poc2_worked

This commit is contained in:
Mateusz Gruszczyński
2026-08-15 18:29:36 +02:00
parent fc3a2944b2
commit 71b6c0d86f
62 changed files with 9112 additions and 375 deletions
File diff suppressed because one or more lines are too long
+838
View File
@@ -0,0 +1,838 @@
(() => {
'use strict';
const MAX_BUFFERED_EVENTS = 1000;
const LIVE_RENDER_INTERVAL_MS = 350;
const VIEW_PATHS = {
overview:'/', live:'/live', security:'/security', intelligence:'/intelligence', blocks:'/blocks',
reports:'/reports', feeds:'/feeds', rules:'/rules', system:'/system'
};
const PATH_VIEWS = Object.fromEntries(Object.entries(VIEW_PATHS).map(([view,path])=>[path,view]));
const WINDOW_LABELS = {900:'Last 15 minutes',3600:'Last 1 hour',21600:'Last 6 hours',86400:'Last 24 hours'};
const state = {
view: 'overview', ws: null, reconnectTimer: null, reconnectDelay: 1000,
liveEnabled: false, paused: false, live: [], liveById: new Map(), liveSequence: 0,
liveRenderTimer: null, liveFilterTimer: null, historyLoaded: false, snapshot: [],
batchTimes: [], uiDropped: 0, serverDropped: 0,
incidents: [], analytics: null, analyticsWindow: 0, throughput: null, throughputWindow: 0, status: null, config: null, ruleSources: [], ruleSourcesLoaded: false,
selectedRuleSources: new Set(), sourceQueue: null, sourceQueueTimer: null,
ndrIncidents: [], assets: [], iocs: [], pcaps: [], ndrSummary: {},
ruleIntelligence: [], ruleSnapshots: [], backups: [], audit: [],
authEnabled: false, authenticated: false, username: '', csrfToken: '', appStarted: false,
refreshTimer: null, chartRenderTimer: null, analyticsPollTimer: null, analyticsRequest: 0,
};
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
async function api(url, options = {}) {
const headers = {'Accept':'application/json'};
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
const method = String(options.method || 'GET').toUpperCase();
if (!['GET','HEAD','OPTIONS'].includes(method) && state.csrfToken && url !== '/api/auth/login') headers['X-CSRF-Token'] = state.csrfToken;
const res = await fetch(url, {...options, credentials:'same-origin', headers:{...headers, ...(options.headers || {})}});
let data = {};
try { data = await res.json(); } catch (_) {}
if (res.status === 401 && url !== '/api/auth/login') {
state.authenticated = false; state.csrfToken = ''; updateSessionUI(); showAuthModal('Your session expired. Sign in again.');
if (state.ws) { try { state.ws.close(); } catch (_) {} state.ws = null; }
}
if (!res.ok) throw new Error(data.error || data.message || `HTTP ${res.status}`);
return data;
}
function notice(message, kind='ok') {
const box = $('notice'); box.textContent = message; box.className = `notice ${kind}`;
clearTimeout(notice.timer); notice.timer = setTimeout(() => box.classList.add('hidden'), 4500);
}
function updateSessionUI() {
const label = state.authenticated ? state.username || 'Signed in' : 'Sign in';
const account = $('accountButton'); if (account) { account.textContent = label; account.classList.toggle('signed-in', state.authenticated); }
const status = $('sessionStatus'); if (status) { status.textContent = state.authenticated ? `Signed in as ${state.username}` : (state.authEnabled ? 'Authentication required' : 'Login not configured'); status.classList.toggle('ok', state.authenticated); }
for (const id of ['systemLoginButton','feedLoginButton']) {
const button = $(id); if (button) button.textContent = state.authenticated ? 'Sign out' : 'Sign in';
}
}
function showAuthModal(message='') {
if (!state.authEnabled) return;
const modal = $('authModal'); if (!modal) return;
$('loginError').textContent = message; $('loginError').classList.toggle('hidden', !message);
if (!$('loginUsername').value) $('loginUsername').value = state.username || 'admin';
modal.classList.remove('hidden');
setTimeout(() => (state.username ? $('loginPassword') : $('loginUsername')).focus(), 0);
}
function hideAuthModal() { $('authModal')?.classList.add('hidden'); $('loginError')?.classList.add('hidden'); }
async function loadSession() {
try {
const session = await api('/api/auth/session');
state.authEnabled = Boolean(session.auth_enabled); state.authenticated = Boolean(session.authenticated);
state.username = session.username || ''; state.csrfToken = session.csrf_token || '';
updateSessionUI();
if (state.authEnabled && !state.authenticated) showAuthModal();
return session;
} catch (e) {
state.authEnabled = true; state.authenticated = false; updateSessionUI(); showAuthModal(e.message); return null;
}
}
async function login(event) {
event?.preventDefault();
const username = $('loginUsername').value.trim(), password = $('loginPassword').value;
const submit = $('loginSubmit'); submit.disabled = true; $('loginError').classList.add('hidden');
try {
const session = await api('/api/auth/login', {method:'POST', body:JSON.stringify({username,password})});
state.authEnabled = true; state.authenticated = true; state.username = session.username || username; state.csrfToken = session.csrf_token || '';
$('loginPassword').value = ''; updateSessionUI(); hideAuthModal();
if (!state.appStarted) await startApplication(); else { await initialLoad(); restartWebSocket(0); }
} catch (e) {
$('loginError').textContent = e.message; $('loginError').classList.remove('hidden');
} finally { submit.disabled = false; }
}
async function logout() {
if (!state.authenticated) { showAuthModal(); return; }
try { await api('/api/auth/logout', {method:'POST', body:'{}'}); } catch (_) {}
state.authenticated = false; state.csrfToken = ''; updateSessionUI();
if (state.ws) { state.ws.onclose = null; try { state.ws.close(); } catch (_) {} state.ws = null; }
showAuthModal('Signed out.');
}
function accountAction() { if (state.authenticated) logout(); else showAuthModal(); }
function openMobileNav() { document.body.classList.add('mobile-nav-open'); $('mobileMenu')?.setAttribute('aria-expanded','true'); }
function closeMobileNav() { document.body.classList.remove('mobile-nav-open'); $('mobileMenu')?.setAttribute('aria-expanded','false'); }
function viewFromLocation() {
const path=(location.pathname||'/').replace(/\/+$/,'')||'/';
return PATH_VIEWS[path] || 'overview';
}
function selectedWindow() { return Number($('windowSelect')?.value || 3600); }
function syncUrl(view=state.view, mode='replace') {
const path=VIEW_PATHS[view]||'/';
const url=new URL(location.href); url.pathname=path;
const windowSec=selectedWindow();
if(windowSec!==3600)url.searchParams.set('window',String(windowSec)); else url.searchParams.delete('window');
const target=`${url.pathname}${url.search}${url.hash}`;
if(mode==='push')history.pushState({view,window:windowSec},'',target); else history.replaceState({view,window:windowSec},'',target);
}
function setView(name, historyMode='push') {
if(!VIEW_PATHS[name])name='overview';
const leavingLive = state.view === 'live' && name !== 'live' && state.liveEnabled;
state.view = name;
if (leavingLive) {
state.liveEnabled = false;
state.paused = false;
state.batchTimes = [];
updateLiveModeControls();
restartWebSocket(0);
}
document.querySelectorAll('.view').forEach(el => el.classList.toggle('active', el.id === `view-${name}`));
document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.view === name));
const labels = {overview:'Overview',live:'Live Sessions',security:'Security',intelligence:'Intelligence',blocks:'Blocks',reports:'Reports',feeds:'Signature Feeds',rules:'Rules',system:'System'};
$('pageTitle').textContent = labels[name] || name;
if(historyMode!=='none')syncUrl(name,historyMode);
closeMobileNav();
if (name === 'blocks') loadBlocks();
if (name === 'intelligence') loadIntelligence(true);
if (name === 'feeds' && !state.ruleSourcesLoaded) loadRuleSources();
if (name === 'rules') loadRuleOperations(true);
if (name === 'system') loadSystemState(true);
if (['overview','reports','security'].includes(name) && state.analytics) scheduleChartRender();
if (name === 'reports') updateReportWindowState(state.analytics);
if (name === 'live') {
if (!state.historyLoaded) loadHistory(true);
else scheduleLiveRender(0);
}
}
function fmtTime(value) {
if (!value) return '—'; const d = new Date(value); if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'2-digit', minute:'2-digit', hour12:false});
}
function fmtShortTime(ms) { const d = new Date(Number(ms || 0)); return Number.isNaN(d.getTime()) ? '—' : d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); }
function fmtBytes(value) { let n=Number(value||0); const u=['B','KB','MB','GB','TB']; let i=0; while(n>=1024&&i<u.length-1){n/=1024;i++;} return `${n<10&&i? n.toFixed(1):Math.round(n)} ${u[i]}`; }
function fmtBits(value) { let n=Math.max(0,Number(value||0)); const u=['bps','Kbps','Mbps','Gbps','Tbps']; let i=0; while(n>=1000&&i<u.length-1){n/=1000;i++;} return `${n<10&&i? n.toFixed(1):Math.round(n)} ${u[i]}`; }
function fmtDuration(sec) { sec=Math.max(0,Number(sec||0)); const d=Math.floor(sec/86400),h=Math.floor(sec%86400/3600),m=Math.floor(sec%3600/60); return d?`${d}d ${h}h`:h?`${h}h ${m}m`:`${m}m`; }
function endpoint(ip, port) { return `<span class="mono">${esc(ip || '—')}${port ? ':'+esc(port) : ''}</span>`; }
function saveBlob(blob, filename) {
const url=URL.createObjectURL(blob), link=document.createElement('a');
link.href=url; link.download=filename||'download'; document.body.appendChild(link); link.click(); link.remove();
setTimeout(()=>URL.revokeObjectURL(url),1000);
}
async function downloadUrl(url) {
try {
const res=await fetch(url,{credentials:'same-origin'});
if(!res.ok){let message=`HTTP ${res.status}`;try{const data=await res.json();message=data.error||data.message||message;}catch(_){}throw new Error(message);}
const blob=await res.blob(), disposition=res.headers.get('Content-Disposition')||'';
const match=disposition.match(/filename="?([^";]+)"?/i), fallback=new URL(url,location.href).searchParams.get('name')||'download';
saveBlob(blob,match?.[1]||fallback);
} catch(e) { notice(`Download failed: ${e.message}`,'bad'); }
}
const csvCell=value=>`"${String(value??'').replace(/"/g,'""')}"`;
function reportCsv(a) {
const rows=[['section','name','count','timestamp','events','bytes','alerts']];
for(const [name,value] of [['window_seconds',a.window_seconds],['events',a.events],['bytes',a.bytes],['alerts',a.alerts],['blocked',a.blocked],['local_clients',a.unique_local_clients],['remote_peers',a.unique_remote_peers]])rows.push(['summary',name,value,'','','','']);
for(const row of a.timeline||[])rows.push(['timeline','', '',new Date(Number(row.ts_ms||0)).toISOString(),row.events||0,row.bytes||0,row.alerts||0]);
for(const [section,items] of [['applications',a.top_apps],['protocols',a.protocols],['directions',a.directions],['event_types',a.event_types],['local_clients',a.top_local_clients],['remote_peers',a.top_remote_peers],['signatures',a.top_signatures]])for(const row of items||[])rows.push([section,row.name,row.count,'','','','']);
return rows.map(row=>row.map(csvCell).join(',')).join('\r\n');
}
async function downloadCurrentReport() {
let data=state.analytics;
if(!data || state.analyticsWindow!==selectedWindow()){
try{data=await api(`/api/traffic/analytics?window=${selectedWindow()}`);}catch(e){notice(`Report download: ${e.message}`,'bad');return;}
}
if(data?.snapshot_loading){notice('Report is still being generated in the background. Try again in a moment.','bad');scheduleAnalyticsPoll(selectedWindow());return;}
const stamp=new Date().toISOString().slice(0,16).replace(/[:T]/g,'-');
saveBlob(new Blob([reportCsv(data)],{type:'text/csv;charset=utf-8'}),`mikrosuricata-report-${selectedWindow()}s-${stamp}.csv`);
}
function eventDetails(ev) {
if (ev.type === 'alert') return ev.signature || ev.category || 'Suricata alert';
if (ev.type === 'dns') return ev.dns_query ? `${ev.dns_query}${ev.dns_type ? ' · '+ev.dns_type : ''}` : 'DNS';
if (ev.type === 'http') return `${ev.http_method || ''} ${ev.http_host || ''}${ev.http_url || ''}`.trim() || 'HTTP';
if (ev.type === 'tls') return ev.tls_sni || ev.tls_subject || ev.tls_version || 'TLS';
if (ev.type === 'ssh') return [ev.ssh_client,ev.ssh_server,ev.ssh_proto,ev.ssh_hassh_client && 'HASSH '+ev.ssh_hassh_client].filter(Boolean).join(' · ') || 'SSH session';
if (ev.type === 'rdp') return [ev.rdp_event_type,ev.rdp_client_name,ev.rdp_client_build,ev.rdp_protocol,ev.rdp_cookie].filter(Boolean).join(' · ') || 'RDP session';
if (ev.type === 'smb') return [ev.smb_command,ev.smb_share,ev.smb_filename,ev.smb_user,ev.smb_status].filter(Boolean).join(' · ') || 'SMB activity';
if (ev.type === 'quic') return [ev.quic_sni,ev.quic_version,ev.quic_ja4 && 'JA4 '+ev.quic_ja4].filter(Boolean).join(' · ') || 'QUIC session';
if (ev.type === 'dhcp') return [ev.dhcp_event_type,ev.dhcp_type,ev.dhcp_hostname,ev.dhcp_assigned_ip,ev.dhcp_client_mac].filter(Boolean).join(' · ') || 'DHCP';
if (ev.type === 'arp') return [ev.arp_opcode,ev.arp_src_ip,ev.arp_src_mac,ev.arp_dest_ip].filter(Boolean).join(' · ') || 'ARP';
if (ev.type === 'fileinfo') return [ev.filename,ev.file_sha256||ev.file_sha1||ev.file_md5].filter(Boolean).join(' · ') || ev.file_state || 'File';
if (ev.type === 'anomaly') return ev.anomaly_event || 'Protocol anomaly';
if (ev.app_summary) return ev.app_summary;
return ev.flow_state ? `Flow ${ev.flow_state}${ev.flow_reason ? ' · '+ev.flow_reason : ''}` : 'Flow event';
}
function eventRow(ev, compact=false) {
const detail = esc(eventDetails(ev));
if (compact) return `<tr><td>${fmtShortTime(ev.ts_ms)}</td><td><span class="event-type ${esc(ev.type)}">${esc(ev.type)}</span></td><td>${endpoint(ev.src_ip,ev.src_port)}</td><td>${endpoint(ev.dest_ip,ev.dest_port)}</td><td>${esc(ev.app_proto || '—')}</td><td class="details-cell" title="${detail}">${detail}</td><td class="right">${fmtBytes(ev.bytes)}</td></tr>`;
const blockIp = candidateBlockIp(ev);
const blockBtn = blockIp ? `<button class="link-btn" data-block-ip="${esc(blockIp)}">block</button>` : '';
return `<tr><td>${fmtShortTime(ev.ts_ms)}</td><td><span class="event-type ${esc(ev.type)}">${esc(ev.type)}</span></td><td>${esc(ev.direction || '—')}</td><td>${endpoint(ev.src_ip,ev.src_port)}</td><td>${endpoint(ev.dest_ip,ev.dest_port)}</td><td>${esc(ev.proto || '—')}</td><td>${esc(ev.app_proto || '—')}</td><td class="details-cell" title="${detail}">${detail}</td><td class="right">${fmtBytes(ev.bytes)}</td><td>${blockBtn}</td></tr>`;
}
function candidateBlockIp(ev) {
if (ev.direction === 'outbound') return ev.dest_ip || '';
if (ev.direction === 'inbound') return ev.src_ip || '';
if (ev.direction === 'external') return ev.src_ip || ev.dest_ip || '';
return '';
}
function currentLiveFilters() {
return {
q: ($('liveSearch')?.value || '').trim().toLowerCase(),
type: $('liveType')?.value || '', proto: $('liveProto')?.value || '', direction: $('liveDirection')?.value || ''
};
}
function eventMatchesLive(ev, filters=currentLiveFilters()) {
if (filters.type && ev.type !== filters.type) return false;
if (filters.proto && ev.proto !== filters.proto) return false;
if (filters.direction && ev.direction !== filters.direction) return false;
if (!filters.q) return true;
return [ev.id,ev.flow_id,ev.community_id,ev.tx_id,ev.src_ip,ev.src_port,ev.dest_ip,ev.dest_port,ev.ether_src,ev.ether_dest,ev.app_proto,ev.signature,ev.signature_id,ev.category,ev.dns_query,ev.http_host,ev.http_url,ev.tls_sni,ev.tls_ja3,ev.tls_ja4,ev.ssh_client,ev.ssh_server,ev.ssh_hassh_client,ev.ssh_hassh_server,ev.rdp_client_name,ev.rdp_client_build,ev.smb_share,ev.smb_filename,ev.smb_user,ev.quic_sni,ev.quic_ja3,ev.quic_ja4,ev.dhcp_hostname,ev.dhcp_client_mac,ev.arp_src_mac,ev.filename,ev.app_summary]
.some(v => String(v||'').toLowerCase().includes(filters.q));
}
function filteredLive() {
const filters = currentLiveFilters();
return state.live.filter(ev => eventMatchesLive(ev, filters)).sort((a,b) => Number(b._uiSeq || b.ts_ms || 0) - Number(a._uiSeq || a.ts_ms || 0));
}
function setLiveEvents(events) {
state.live = [];
state.liveById = new Map();
const rows = Array.isArray(events) ? events.slice(0, MAX_BUFFERED_EVENTS) : [];
for (const raw of rows.reverse()) mergeLiveEvent(raw);
}
function mergeLiveEvent(raw) {
if (!raw || !raw.id) return;
const existing = state.liveById.get(raw.id);
const seq = ++state.liveSequence;
if (existing) {
Object.assign(existing, raw, {_uiSeq:seq});
return;
}
const row = {...raw, _uiSeq:seq};
state.live.push(row);
state.liveById.set(row.id, row);
}
function trimLiveBuffer() {
if (state.live.length <= MAX_BUFFERED_EVENTS) return;
state.live.sort((a,b) => Number(b._uiSeq||0) - Number(a._uiSeq||0));
const removed = state.live.splice(MAX_BUFFERED_EVENTS);
for (const item of removed) state.liveById.delete(item.id);
state.uiDropped += removed.length;
}
function handleLiveBatch(events) {
if (!Array.isArray(events) || !events.length) return;
for (const ev of events) mergeLiveEvent(ev);
trimLiveBuffer();
const now = performance.now();
state.batchTimes.push(now);
while (state.batchTimes.length && state.batchTimes[0] < now - 5000) state.batchTimes.shift();
if (state.view === 'live' && !state.paused) scheduleLiveRender();
}
function scheduleLiveRender(delay=LIVE_RENDER_INTERVAL_MS) {
if (state.liveRenderTimer !== null) return;
state.liveRenderTimer = setTimeout(() => {
state.liveRenderTimer = null;
if (state.view === 'live') renderLive();
}, Math.max(0, delay));
}
function renderLive() {
const limit = Math.min(500, Math.max(50, Number($('liveLimit')?.value || 200)));
const matches = filteredLive();
const rows = matches.slice(0, limit);
$('liveRows').innerHTML = rows.length ? rows.map(ev => eventRow(ev)).join('') : '<tr><td colspan="10" class="empty">No matching sessions/events. Use Search history or Start live.</td></tr>';
const rate = state.batchTimes.length ? state.batchTimes.length / 5 : 0;
$('liveVisibleCount').textContent = `${rows.length.toLocaleString()} visible`;
$('liveBufferedCount').textContent = `${state.live.length.toLocaleString()} buffered`;
$('liveRate').textContent = `${rate.toFixed(rate < 10 ? 1 : 0)} batches/s`;
$('liveDropped').textContent = `${(state.uiDropped + state.serverDropped).toLocaleString()} dropped/coalesced`;
}
function renderOverviewSnapshot() {
const recent = state.snapshot.slice(0, 12);
$('overviewLiveRows').innerHTML = recent.length ? recent.map(ev => eventRow(ev,true)).join('') : '<tr><td colspan="7" class="empty">No recent history yet.</td></tr>';
}
function renderRank(targetId, rows, label='name') {
const el = $(targetId); if (!el) return; const items = rows || []; const max = Math.max(1, ...items.map(x => Number(x.count||0)));
el.innerHTML = items.length ? items.map(row => `<div class="rank-row"><div class="rank-main"><div class="rank-label"><span title="${esc(row[label] || row.name || 'unknown')}">${esc(row[label] || row.name || 'unknown')}</span><span>${Number(row.count||0).toLocaleString()}</span></div><progress class="rank-bar" max="${Math.max(1,max)}" value="${Math.max(0,Number(row.count||0))}" aria-label="${esc(row[label] || row.name || 'unknown')}"></progress></div></div>`).join('') : '<div class="empty">No data in this window.</div>';
}
function renderRankBytes(targetId, rows, label='name') {
const el=$(targetId); if(!el)return; const items=rows||[]; const max=Math.max(1,...items.map(x=>Number(x.bytes||0)));
el.innerHTML=items.length?items.map(row=>`<div class="rank-row"><div class="rank-main"><div class="rank-label"><span title="${esc(row[label]||row.name||'unknown')}">${esc(row[label]||row.name||'unknown')}</span><span>${fmtBytes(row.bytes||0)}</span></div><progress class="rank-bar" max="${Math.max(1,max)}" value="${Math.max(0,Number(row.bytes||0))}" aria-label="${esc(row[label]||row.name||'unknown')}"></progress></div></div>`).join(''):'<div class="empty">No data in this window.</div>';
}
function windowLabel(windowSec=selectedWindow()) { return WINDOW_LABELS[Number(windowSec)] || `${Math.round(Number(windowSec||0)/60)} minutes`; }
function updateReportWindowState(a=null) {
const badge=$('reportWindowBadge'), status=$('reportState');
if(badge)badge.textContent=windowLabel(selectedWindow());
if(!status)return;
if(a?.snapshot_error){status.textContent='Redis unavailable';status.className='status-chip bad';return;}
if(a?.snapshot_loading){status.textContent='building in background';status.className='status-chip warn';return;}
if(a?.snapshot_refreshing){status.textContent='cached · refreshing';status.className='status-chip warn';return;}
if(a){status.textContent=a.analytics_complete===false?'fallback history':'ready · full retained range';status.className=`status-chip ${a.analytics_complete===false?'warn':'ok'}`;return;}
status.textContent='loading'; status.className='status-chip';
}
function markAnalyticsLoading(windowSec=selectedWindow()) {
if(state.analyticsWindow && state.analyticsWindow!==Number(windowSec))state.analytics=null;
state.analyticsWindow=Number(windowSec);
const meta=$('snapshotMeta'); if(meta){meta.textContent='building in background';meta.className='status-chip warn';}
updateReportWindowState({snapshot_loading:true});
const keepThroughput=state.throughput && state.throughputWindow===Number(windowSec);
for(const id of ['metricEvents','metricAlerts','metricBlocked','metricAnomalies','metricNxdomain','metricEncrypted','metricCleartext','metricLocalClients','metricRemotePeers'])if($(id))$(id).textContent='…';
if(!keepThroughput){for(const id of ['metricThroughput','metricBytes','metricPeakThroughput'])if($(id))$(id).textContent='…';if($('metricThroughputSplit'))$('metricThroughputSplit').textContent='IN … · OUT …';}
for(const id of ['reportEvents','reportBytes','reportAlerts','reportClients'])if($(id))$(id).textContent='…';
for(const id of ['topApps','topClients','topSources','reportSources','reportDestinations','eventTypes','securitySignatures','fingerprintRank','assetRank','fileRank'])if($(id))$(id).innerHTML='<div class="empty">Building the selected time range in the background…</div>';
const charts=window.MikroSuricataCharts; if(charts?.drawLoading){if(!keepThroughput)charts.drawLoading($('throughputChart'));for(const id of ['trafficChart','eventsChart','directionDonut','eventTypeDonut','protocolDonut','reportDirectionDonut','appDonut','reportEventDonut','severityDonut'])charts.drawLoading($(id));}
}
function scheduleAnalyticsPoll(windowSec, delay=1200) {
clearTimeout(state.analyticsPollTimer);
state.analyticsPollTimer=setTimeout(()=>{if(Number(windowSec)===selectedWindow())loadAnalytics(windowSec,true);},delay);
}
function renderThroughput(t) {
const windowSec=Number(t?.window_seconds||selectedWindow());
if(windowSec!==selectedWindow())return;
state.throughput=t; state.throughputWindow=windowSec;
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(t.current_bps||0);
if($('metricThroughputSplit')){
const total=Math.max(0,Number(t.current_bps||0)), inbound=Math.max(0,Number(t.current_in_bps||0)), outbound=Math.max(0,Number(t.current_out_bps||0));
const other=Math.max(0,Number(t.current_other_bps ?? (total-inbound-outbound)));
$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''}`;
}
if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(t.peak_bps||0);
if($('metricBytes'))$('metricBytes').textContent=fmtBytes(t.bytes||0);
const charts=window.MikroSuricataCharts; if(charts?.drawThroughput)charts.drawThroughput($('throughputChart'),t.timeline||[]);
}
async function loadThroughput(windowSec=selectedWindow(), silent=true) {
const requested=Number(windowSec||3600);
try {
const data=await api(`/api/traffic/throughput?window=${requested}`);
if(requested!==selectedWindow())return;
renderThroughput(data);
} catch(e) { if(!silent)notice(`Traffic throughput: ${e.message}`,'bad'); }
}
async function loadAnalytics(windowSec=selectedWindow(), silent=false, forceLoading=false) {
const requested=Number(windowSec||3600), requestId=++state.analyticsRequest;
if(forceLoading || state.analyticsWindow!==requested)markAnalyticsLoading(requested);
try {
const data=await api(`/api/traffic/analytics?window=${requested}`);
if(requestId!==state.analyticsRequest || requested!==selectedWindow())return;
if(data.snapshot_loading){markAnalyticsLoading(requested);scheduleAnalyticsPoll(requested);return;}
renderAnalytics(data);
if(data.snapshot_refreshing)scheduleAnalyticsPoll(requested,1800);
} catch(e) { if(!silent)notice(`Traffic analytics: ${e.message}`,'bad'); }
}
function renderAnalytics(a) {
const windowSec=Number(a?.window_seconds||selectedWindow());
if(windowSec!==selectedWindow())return;
if(a?.snapshot_loading){markAnalyticsLoading(windowSec);scheduleAnalyticsPoll(windowSec);return;}
clearTimeout(state.analyticsPollTimer);
state.analytics = a;
state.analyticsWindow = windowSec;
if(!state.throughput || state.throughputWindow!==windowSec){state.throughput=a;state.throughputWindow=windowSec;}
$('metricEvents').textContent = Number(a.events||0).toLocaleString();
const traffic=(state.throughput && state.throughputWindow===windowSec)?state.throughput:a;
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(traffic.current_bps||0);
if($('metricThroughputSplit')){
const total=Math.max(0,Number(traffic.current_bps||0)), inbound=Math.max(0,Number(traffic.current_in_bps||0)), outbound=Math.max(0,Number(traffic.current_out_bps||0));
const other=Math.max(0,Number(traffic.current_other_bps ?? (total-inbound-outbound)));
$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''}`;
}
if($('metricPeakThroughput'))$('metricPeakThroughput').textContent=fmtBits(traffic.peak_bps||0);
$('metricBytes').textContent = fmtBytes(traffic.bytes||0); $('metricAlerts').textContent = Number(a.alerts||0).toLocaleString(); $('metricBlocked').textContent = Number(a.blocked||0).toLocaleString();
$('metricEventRate').textContent = `${Math.round(Number(a.events||0)/(Number(a.window_seconds||3600)/60)).toLocaleString()} / min`;
$('metricAnomalies').textContent = Number(a.anomalies||0).toLocaleString(); $('metricNxdomain').textContent = Number(a.dns_nxdomain||0).toLocaleString();
$('metricEncrypted').textContent = Number(a.encrypted_sessions||0).toLocaleString(); $('metricCleartext').textContent = Number(a.cleartext_sessions||0).toLocaleString();
$('metricLocalClients').textContent = Number(a.unique_local_clients||0).toLocaleString(); $('metricRemotePeers').textContent = Number(a.unique_remote_peers||0).toLocaleString();
renderRank('topApps',a.top_apps);
renderRankBytes('topClients',a.top_local_clients_by_bytes||[]);
renderRankBytes('topSources',a.top_remote_peers_by_bytes||[]);
renderRank('reportSources',a.top_local_clients || a.top_sources);
renderRank('reportDestinations',a.top_remote_peers || a.top_destinations);
renderRank('eventTypes',a.top_sources);
renderRank('securitySignatures',a.top_signatures);
renderRank('fingerprintRank',a.top_fingerprints);
renderRank('assetRank',a.top_assets);
renderRank('fileRank',a.top_files);
$('reportEvents').textContent=Number(a.events||0).toLocaleString(); $('reportBytes').textContent=fmtBytes(traffic.bytes||0); $('reportAlerts').textContent=Number(a.alerts||0).toLocaleString(); $('reportClients').textContent=Number(a.unique_local_clients||0).toLocaleString();
const coverage=[['Alerts',Number(a.alerts||0)],['Anomalies',Number(a.anomalies||0)],['DNS',Number((a.event_types||[]).find(x=>x.name==='dns')?.count||0)],['TLS / QUIC / SSH',Number(a.encrypted_sessions||0)],['Files',Number(a.files||0)]];
$('coverageStatus').innerHTML=coverage.map(([k,v])=>`<div class="kv-row"><span>${esc(k)}</span><span>${Number(v).toLocaleString()}</span></div>`).join('');
const age=Number(a.snapshot_age_seconds||0), source=a.snapshot_source||'live', stale=Boolean(a.snapshot_stale), refreshing=Boolean(a.snapshot_refreshing); const ageText=age<60?Math.round(age)+'s':age<3600?Math.round(age/60)+'m':Math.round(age/3600)+'h';
const completeness=a.analytics_complete===false?'fallback':`all ${Number(a.retained_events_scanned??a.events??0).toLocaleString()} retained`;
$('snapshotMeta').textContent=source==='redis-cache'?`${refreshing?'refreshing':'Redis cached'} · ${ageText} · ${completeness}`:`Redis · ${completeness}`;
$('snapshotMeta').className=`status-chip ${a.analytics_complete===false||stale?'warn':'ok'}`;
updateReportWindowState(a);
scheduleChartRender();
}
function scheduleChartRender() {
if (!state.analytics) return;
clearTimeout(state.chartRenderTimer);
state.chartRenderTimer=setTimeout(()=>requestAnimationFrame(()=>requestAnimationFrame(drawVisibleCharts)),20);
}
function drawVisibleCharts() {
const a=state.analytics, charts=window.MikroSuricataCharts; if (!a || !charts) return;
const t=(state.throughput && state.throughputWindow===selectedWindow())?state.throughput:a;
charts.drawThroughput?.($('throughputChart'),t.timeline||[]); charts.drawEvents($('trafficChart'),a.timeline||[]); charts.drawEvents($('eventsChart'),a.timeline||[]);
charts.drawDonut($('directionDonut'),a.directions||[]); charts.drawDonut($('eventTypeDonut'),a.event_types||[]);
charts.drawDonut($('protocolDonut'),a.protocols||[]); charts.drawDonut($('reportDirectionDonut'),a.directions||[]);
charts.drawDonut($('appDonut'),a.top_apps||[]); charts.drawDonut($('reportEventDonut'),a.event_types||[]); charts.drawDonut($('severityDonut'),a.severities||[]);
}
function renderStatus(s) {
state.status = s; const ok = s.status === 'ok'; $('sideHealth').textContent = ok ? 'Operational' : 'Degraded'; $('sideHealthDot').className=`status-dot ${ok?'ok':'bad'}`; $('sideUptime').textContent=`Uptime ${fmtDuration(s.uptime_seconds)}`;
const rt=s.runtime||{}; $('filteredCount').textContent=Number(rt.alerts_filtered||0).toLocaleString();
if (s.services) $('serviceRows').innerHTML = Object.values(s.services).map(x=>`<tr><td>${esc(x.name)}</td><td><span class="status-chip ${x.status==='up'||x.status==='configured'?'ok':x.status==='disabled'?'':'bad'}">${esc(x.status)}</span></td><td class="break">${esc(x.details)}</td></tr>`).join('');
if (s.ports) $('portRows').innerHTML=s.ports.map(x=>`<tr><td>${esc(x.name)}</td><td>${esc(x.direction)}</td><td>${esc(x.protocol)}</td><td class="mono">${esc(x.address)}</td><td>${esc(x.port)}</td><td><span class="status-chip ${x.status==='up'||x.status==='configured'?'ok':''}">${esc(x.status)}</span></td></tr>`).join('');
renderHistoryStatus(s.traffic_history || {}, s.analytics_snapshots || {});
}
function renderHistoryStatus(h, snapshots={}) {
state.serverDropped = Number(h.subscriber_dropped_events || 0);
const rows=[['Backend',h.backend||'redis'],['Redis',h.redis_configured?(h.redis_ok?'connected':'degraded'):'disabled'],['Redis events',h.redis_events ?? '—'],['Throughput samples',h.throughput_samples ?? '—'],['RAM history','disabled'],['Retention',`${h.retention_hours||0} h`],['Event count cap','none'],['Chart snapshots',`${(snapshots.persisted||[]).length}/4 in Redis`],['Snapshot refresh',snapshots.interval_seconds?`${snapshots.interval_seconds}s`:'—'],['Writer queue',h.writer_queue??0],['Writer Redis errors',h.writer_redis_errors??0],['Writer dropped',h.writer_dropped??0],['WS dropped',h.subscriber_dropped_events??0]];
$('historyStatus').innerHTML=rows.map(([k,v])=>`<div class="kv-row"><span>${esc(k)}</span><span>${esc(v)}</span></div>`).join('');
}
function renderIncidents() {
const q=($('incidentSearch')?.value||'').trim().toLowerCase(), sev=$('severityFilter')?.value||'';
const rows=state.incidents.filter(x=>(!sev||String(x.severity)===sev)&&(!q||[x.signature,x.category,x.src_ip,x.dest_ip,x.block_target].some(v=>String(v||'').toLowerCase().includes(q))));
$('incidentRows').innerHTML=rows.length?rows.map(x=>`<tr><td>${fmtTime(x.last_seen||x.timestamp)}</td><td>${Number(x.hit_count||1).toLocaleString()}</td><td><span class="severity s${esc(x.severity)}">S${esc(x.severity||'—')}</span></td><td class="details-cell" title="${esc(x.signature)}">${esc(x.signature||'—')}</td><td>${endpoint(x.src_ip,x.src_port)}</td><td>${endpoint(x.dest_ip,x.dest_port)}</td><td>${x.blocked?'<span class="status-chip bad">blocked</span>':esc(x.action||'observe')}</td><td>${x.signature_id?`<button class="link-btn" data-suppress="${esc(x.signature_id)}">suppress</button>`:''}</td></tr>`).join(''):'<tr><td colspan="8" class="empty">No matching incidents.</td></tr>';
}
function riskClass(value) {
const risk=Number(value||0); return risk>=80?'risk-critical':risk>=55?'risk-high':risk>=30?'risk-medium':'risk-low';
}
function renderAttack(mitre) {
const items=Array.isArray(mitre)?mitre:[];
if(!items.length)return '<span class="muted">—</span>';
return `<div class="attack-list">${items.slice(0,4).map(x=>`<span class="attack-chip" title="${esc(`${x.tactic_id||''} ${x.tactic||''}`)}">${esc(x.technique_id||x.tactic_id||'ATT&CK')}<small>${esc(x.technique||x.tactic||'')}</small></span>`).join('')}</div>`;
}
function renderIntelligence() {
const summary=state.ndrSummary||{};
$('ndrOpen').textContent=Number(summary.open_incidents||0).toLocaleString();
$('ndrHighRisk').textContent=Number(summary.high_risk_incidents||0).toLocaleString();
$('ndrAssets').textContent=Number(summary.assets||0).toLocaleString();
$('ndrIocHits').textContent=Number(summary.ioc_hits||0).toLocaleString();
$('ndrIncidentRows').innerHTML=state.ndrIncidents.length?state.ndrIncidents.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td>${fmtTime(x.last_seen)}</td><td class="mono">${esc(x.subject_ip||'—')}</td><td class="break stages-col">${esc((x.stages||[]).join(' → ')||'detection')}</td><td>${renderAttack(x.mitre)}</td><td class="details-cell" title="${esc(x.summary||x.title||'')}">${esc(x.summary||x.title||'—')}</td><td>${Number(x.event_count||0).toLocaleString()}${x.blocked?' · blocked':''}</td><td><span class="status-chip ${x.status==='open'?'bad':''}">${esc(x.status||'open')}</span></td><td><button class="link-btn" data-ndr-incident="${Number(x.id)}">evidence</button> · <button class="link-btn" data-ndr-status="${Number(x.id)}" data-status="${x.status==='closed'?'open':'closed'}">${x.status==='closed'?'reopen':'close'}</button></td></tr>`).join(''):'<tr><td colspan="9" class="empty">No correlated NDR incidents yet.</td></tr>';
$('assetRows').innerHTML=state.assets.length?state.assets.map(x=>`<tr><td><span class="risk-score ${riskClass(x.risk_score)}">${Number(x.risk_score||0)}</span></td><td class="mono">${esc(x.ip)}</td><td><strong>${esc(x.hostname||'—')}</strong><div class="muted mono">${esc(x.mac||x.identity_source||'—')}</div></td><td class="break">${esc((x.protocols||[]).slice(0,8).join(', ')||'—')}</td><td class="break">${esc((x.ports||[]).slice(0,12).join(', ')||'—')}</td><td>${Number(x.alert_count||0).toLocaleString()}</td><td>${fmtTime(x.last_seen)}</td></tr>`).join(''):'<tr><td colspan="7" class="empty">Assets appear after traffic or RouterOS inventory sync.</td></tr>';
$('iocRows').innerHTML=state.iocs.length?state.iocs.map(x=>`<tr><td><span class="status-chip">${esc(x.indicator_type)}</span></td><td class="mono break">${esc(x.indicator)}</td><td>${Number(x.confidence||0)}%</td><td>S${esc(x.severity||'—')}</td><td>${esc(x.source||'—')}</td><td>${Number(x.hit_count||0).toLocaleString()}</td><td>${fmtTime(x.last_hit_at)}</td><td><button class="link-btn danger-link" data-delete-ioc="${Number(x.id)}">delete</button></td></tr>`).join(''):'<tr><td colspan="8" class="empty">No local IOCs configured.</td></tr>';
$('pcapRows').innerHTML=state.pcaps.length?state.pcaps.map(x=>{const url=`/api/forensics/pcap?name=${encodeURIComponent(x.name)}`;return `<tr><td class="mono">${esc(x.name)}</td><td>${fmtBytes(x.size_bytes)}</td><td>${fmtTime(Number(x.modified_at||0)*1000)}</td><td><a class="link-btn" href="${url}" data-download-url="${url}">download</a></td></tr>`;}).join(''):'<tr><td colspan="4" class="empty">No alert PCAP has rotated yet.</td></tr>';
}
async function loadIntelligence(silent=false) {
try {
const [ndr,incidents,assets,iocs,pcaps]=await Promise.all([api('/api/ndr/summary'),api('/api/ndr/incidents?limit=150'),api('/api/assets?limit=300'),api('/api/threat-intel?limit=1000'),api('/api/forensics/pcaps')]);
state.ndrSummary=ndr.summary||{}; state.ndrIncidents=incidents.incidents||[]; state.assets=assets.assets||[]; state.iocs=iocs.iocs||[]; state.pcaps=pcaps.files||[];
renderIntelligence();
if (!silent) notice('Intelligence data refreshed.');
} catch(e) { if(!silent)notice(e.message,'bad'); }
}
async function loadNdrIncident(id) {
try {
const data=await api(`/api/ndr/incidents/${Number(id)}`), incident=data.incident||{}, events=data.events||[];
$('ndrEvidenceTitle').textContent=`#${incident.id||id} · ${incident.subject_ip||'asset'} · risk ${incident.risk_score||0}`;
$('ndrEvidenceRows').innerHTML=events.length?events.map(x=>`<tr><td>${fmtTime(x.timestamp)}</td><td><span class="status-chip">${esc(x.stage||x.kind||'signal')}</span></td><td><span class="risk-score ${riskClass(x.risk)}">${Number(x.risk||0)}</span></td><td>${renderAttack(x.mitre)}</td><td class="break">${esc(x.summary||'—')}</td></tr>`).join(''):'<tr><td colspan="5" class="empty">No evidence rows.</td></tr>';
} catch(e) { notice(e.message,'bad'); }
}
async function addIoc() {
const indicator=$('iocIndicator').value.trim(); if(!indicator)return notice('Enter an IOC indicator.','bad');
try { const r=await adminPost('/api/admin/threat-intel/add',{type:$('iocType').value,indicator,confidence:Number($('iocConfidence').value||80),source:$('iocSource').value.trim()||'manual'}); $('iocIndicator').value=''; notice(r.message); await loadIntelligence(true); }
catch(e){ notice(e.message,'bad'); }
}
async function importIocs() {
const text=$('iocBulk').value.trim(); if(!text)return notice('Paste IOC entries first.','bad');
try { const r=await adminPost('/api/admin/threat-intel/import',{text}); notice(`${r.message}${r.errors?.length?` · ${r.errors.length} rejected`:''}`); if(r.added)$('iocBulk').value=''; await loadIntelligence(true); }
catch(e){ notice(e.message,'bad'); }
}
async function deleteIoc(id) {
if(!confirm('Delete this IOC and rebuild Suricata datasets?'))return;
try { const r=await adminPost('/api/admin/threat-intel/delete',{id:Number(id)}); notice(r.message); await loadIntelligence(true); }
catch(e){ notice(e.message,'bad'); }
}
async function setNdrStatus(id,status) {
try { const r=await adminPost('/api/admin/ndr/incidents/status',{id:Number(id),status}); notice(r.message); await loadIntelligence(true); }
catch(e){ notice(e.message,'bad'); }
}
function recommendationChip(row) {
const rec=String(row.recommendation||'keep');
const cls=rec==='limit'?'bad':rec==='review'?'warn':'ok';
return `<span class="status-chip ${cls}" title="${esc(row.recommendation_reason||'')}">${esc(rec)}</span>`;
}
function renderRuleIntelligence() {
const rows=state.ruleIntelligence||[];
$('ruleIntelRows').innerHTML=rows.length?rows.map(x=>{
const proposed=x.proposed_threshold||null;
const action=proposed?`<button class="link-btn" data-rule-threshold="${Number(x.signature_id)}" data-count="${Number(proposed.count||5)}" data-seconds="${Number(proposed.seconds||60)}" data-track="${esc(proposed.track||'by_src')}">apply limit</button>`:'<span class="muted">—</span>';
return `<tr><td><span class="noise-score ${Number(x.noise_score||0)>=70?'risk-critical':Number(x.noise_score||0)>=55?'risk-medium':'risk-low'}">${Number(x.noise_score||0)}</span></td><td class="mono">${esc(x.signature_id||'—')}</td><td>${Number(x.hits||0).toLocaleString()}</td><td>${Number(x.incidents||0).toLocaleString()}</td><td class="details-cell" title="${esc(x.signature||'')}">${esc(x.signature||'—')}</td><td>${recommendationChip(x)}<div class="muted text-xs">${esc(x.recommendation_reason||'')}</div></td><td>${action}</td></tr>`;
}).join(''):'<tr><td colspan="7" class="empty">No signature observations in this window.</td></tr>';
}
async function loadRuleIntelligence(silent=false) {
try {
const hours=Number($('ruleIntelHours')?.value||24), data=await api(`/api/rules/intelligence?hours=${hours}&limit=150`);
state.ruleIntelligence=data.rules||[]; renderRuleIntelligence();
if(!silent)notice(`Analyzed ${state.ruleIntelligence.length} signatures · ${Number(data.noisy||0)} limit candidates.`);
} catch(e){if(!silent)notice(e.message,'bad');}
}
function renderRuleSnapshots() {
const rows=state.ruleSnapshots||[];
$('ruleSnapshotRows').innerHTML=rows.length?rows.map(x=>`<tr><td>${fmtTime(x.created_at)}</td><td class="break">${esc(String(x.id||'').replace(/^rules-[^-]+-|-[0-9a-f]{6}\.tar\.gz$/g,''))}</td><td>${fmtBytes(x.size_bytes||0)}</td><td><button class="link-btn" data-rule-rollback="${esc(x.id)}">rollback</button></td></tr>`).join(''):'<tr><td colspan="4" class="empty">No ruleset snapshots yet.</td></tr>';
}
async function loadRuleSnapshots(silent=false) {
if(state.authEnabled&&!state.authenticated){if(!silent)showAuthModal();return;}
try { const data=await api('/api/admin/rules/snapshots'); state.ruleSnapshots=data.snapshots||[]; renderRuleSnapshots(); }
catch(e){if(!silent)notice(e.message,'bad');}
}
async function loadRuleOperations(silent=false) {
await Promise.all([loadRuleIntelligence(silent),loadRuleSnapshots(silent)]);
}
async function applyRecommendedThreshold(target) {
const sid=Number(target.dataset.ruleThreshold||0), count=Number(target.dataset.count||5), seconds=Number(target.dataset.seconds||60), track=target.dataset.track||'by_src';
if(!sid)return;
if(!confirm(`Apply Suricata limit to SID ${sid}: ${count} alert(s) / ${seconds}s, ${track}? Detection remains active; only alert frequency is limited.`))return;
try { const r=await adminPost('/api/admin/rules/threshold',{sid,type:'limit',track,count,seconds}); notice(r.message); await Promise.all([loadRules(),loadRuleIntelligence(true),loadRuleSnapshots(true)]); }
catch(e){notice(e.message,'bad');}
}
async function createRuleSnapshot() {
try { const r=await adminPost('/api/admin/rules/snapshot',{reason:'manual'}); notice(r.message); await loadRuleSnapshots(true); }
catch(e){notice(e.message,'bad');}
}
async function rollbackRuleSnapshot(id) {
if(!confirm(`Rollback Suricata rules and source state to ${id}? A safety snapshot of the current state is created first.`))return;
try { const r=await adminPost('/api/admin/rules/rollback',{id}); notice(r.message); await Promise.all([loadRuleSnapshots(true),loadRuleIntelligence(true)]); }
catch(e){notice(e.message,'bad');}
}
function renderBackups() {
const rows=state.backups||[];
$('backupRows').innerHTML=rows.length?rows.map(x=>{const url=`/api/system/backup?name=${encodeURIComponent(x.id)}`;return `<tr><td>${fmtTime(x.created_at)}</td><td class="mono break">${esc(x.id)}</td><td>${fmtBytes(x.size_bytes||0)}</td><td><a class="link-btn" href="${url}" data-download-url="${url}">download</a> · <button class="link-btn danger-link" data-backup-delete="${esc(x.id)}">delete</button></td></tr>`;}).join(''):'<tr><td colspan="4" class="empty">No persistent backups yet.</td></tr>';
}
function renderAudit() {
const rows=state.audit||[];
$('auditRows').innerHTML=rows.length?rows.map(x=>`<tr><td>${fmtTime(x.timestamp)}</td><td>${esc(x.username||'system')}</td><td class="mono break">${esc(x.action||'—')}</td><td class="break">${esc(x.target||'—')}</td><td><span class="status-chip ${x.result==='ok'?'ok':x.result==='error'?'bad':'warn'}">${esc(x.result||'—')}</span></td></tr>`).join(''):'<tr><td colspan="5" class="empty">No administrative audit events yet.</td></tr>';
}
async function loadSystemState(silent=false) {
try { const [b,a]=await Promise.all([api('/api/system/backups'),api('/api/audit?limit=100')]); state.backups=b.backups||[]; state.audit=a.events||[]; renderBackups(); renderAudit(); if(!silent)notice('Backup and audit state refreshed.'); }
catch(e){if(!silent)notice(e.message,'bad');}
}
async function createBackup() {
try { const r=await adminPost('/api/admin/system/backups/create',{label:'manual'}); notice(r.message); await loadSystemState(true); }
catch(e){notice(e.message,'bad');}
}
async function deleteBackup(id) {
if(!confirm(`Delete backup ${id}?`))return;
try { const r=await adminPost('/api/admin/system/backups/delete',{id}); notice(r.message); await loadSystemState(true); }
catch(e){notice(e.message,'bad');}
}
async function loadHistory(silent=false) {
const limit=Math.min(500,Math.max(50,Number($('liveLimit').value||200)));
const params=new URLSearchParams({limit:String(limit),window:String($('windowSelect').value||3600)});
const f=currentLiveFilters(); if(f.q)params.set('q',f.q); if(f.type)params.set('type',f.type); if(f.proto)params.set('proto',f.proto); if(f.direction)params.set('direction',f.direction);
try {
const data=await api(`/api/traffic?${params}`); setLiveEvents(data.events||[]); state.historyLoaded=true; renderLive();
if (!silent) notice(`Loaded ${state.live.length} matching historical events.`);
} catch(e){ if (!silent) notice(e.message,'bad'); }
}
function websocketUrl() {
const scheme=location.protocol==='https:'?'wss':'ws';
const streamActive = state.liveEnabled && state.view === 'live' && !document.hidden;
const params=new URLSearchParams({window:String($('windowSelect').value||3600),stream:streamActive?'1':'0'});
if (streamActive) {
const f=currentLiveFilters(); if(f.q)params.set('q',f.q); if(f.type)params.set('type',f.type); if(f.proto)params.set('proto',f.proto); if(f.direction)params.set('direction',f.direction);
}
return `${scheme}://${location.host}/ws/live?${params}`;
}
function connectWebSocket() {
clearTimeout(state.reconnectTimer);
if (state.authEnabled && !state.authenticated) return;
const ws=new WebSocket(websocketUrl()); state.ws=ws;
ws.onopen=()=>{
state.reconnectDelay=1000;
const liveActive=state.liveEnabled&&state.view==='live'&&!document.hidden; $('wsBadge').className='connection-badge online'; $('wsBadge').innerHTML=`<span class="status-dot"></span>${liveActive?'Live':'Connected'}`;
updateLiveModeControls();
};
ws.onmessage=e=>{
let msg; try{msg=JSON.parse(e.data)}catch(_){return}
if(msg.type==='event') handleLiveBatch([msg.data]);
else if(msg.type==='events') handleLiveBatch(msg.data||[]);
else if(msg.type==='bootstrap') {
if(state.liveEnabled && msg.data?.events?.length) handleLiveBatch(msg.data.events);
if(msg.data?.status)renderStatus(msg.data.status);
if(msg.data?.analytics)renderAnalytics(msg.data.analytics);
} else if(msg.type==='status')renderStatus(msg.data||{});
else if(msg.type==='analytics')renderAnalytics(msg.data||{});
};
ws.onclose=()=>{ if (!state.authEnabled || state.authenticated) scheduleReconnect(); };
ws.onerror=()=>{try{ws.close();}catch(_){}};
}
function restartWebSocket(delay=0) {
clearTimeout(state.reconnectTimer);
if (state.ws) {
state.ws.onclose = null;
try { state.ws.close(); } catch (_) {}
state.ws = null;
}
if (state.authEnabled && !state.authenticated) {
$('wsBadge').className='connection-badge offline'; $('wsBadge').innerHTML='<span class="status-dot"></span>Sign in';
return;
}
state.reconnectTimer=setTimeout(connectWebSocket,delay);
}
function scheduleReconnect(){
if (state.authEnabled && !state.authenticated) return;
$('wsBadge').className='connection-badge offline'; $('wsBadge').innerHTML='<span class="status-dot"></span>Reconnecting';
clearTimeout(state.reconnectTimer); state.reconnectTimer=setTimeout(connectWebSocket,state.reconnectDelay); state.reconnectDelay=Math.min(state.reconnectDelay*1.7,15000);
}
function updateLiveModeControls() {
const badge=$('liveModeBadge'), toggle=$('toggleLive'), pause=$('pauseLive');
toggle.textContent=state.liveEnabled?'Stop live':'Start live';
pause.disabled=!state.liveEnabled; pause.textContent=state.paused?'Resume display':'Pause display'; pause.classList.toggle('paused',state.paused);
const suspended = state.liveEnabled && (document.hidden || state.view !== 'live');
badge.className=`connection-badge ${state.liveEnabled&&!suspended?'online':'idle'}`;
badge.innerHTML=`<span class="status-dot"></span>${state.liveEnabled?(suspended?'Live suspended':state.paused?'Live · display paused':'Live streaming'):'Live off'}`;
}
function toggleLive() {
state.liveEnabled=!state.liveEnabled; state.paused=false; state.batchTimes=[]; updateLiveModeControls(); restartWebSocket(0);
if(state.liveEnabled) notice('Live streaming enabled. Events are server-filtered, batched and coalesced.');
else notice('Live streaming stopped. Capture and traffic history remain active.');
}
function liveFilterChanged() {
clearTimeout(state.liveFilterTimer);
state.liveFilterTimer=setTimeout(()=>{
scheduleLiveRender(0);
if(state.liveEnabled) restartWebSocket(0);
},250);
}
async function loadOverviewSnapshot(windowSec=selectedWindow(), silent=true) {
try {
const traffic=await api(`/api/traffic?limit=12&window=${Number(windowSec)}`);
if(Number(windowSec)!==selectedWindow())return;
state.snapshot=traffic.events||[]; renderOverviewSnapshot();
} catch(e) { if(!silent)notice(`Recent activity: ${e.message}`,'bad'); }
}
async function initialLoad() {
const windowSec=selectedWindow();
markAnalyticsLoading(windowSec);
const tasks=[
api('/api/status').then(renderStatus).catch(e=>notice(`Status: ${e.message}`,'bad')),
api('/api/stats').then(renderStats).catch(e=>notice(`Stats: ${e.message}`,'bad')),
api('/api/alerts?limit=250').then(alerts=>{state.incidents=alerts.alerts||[];renderIncidents();}).catch(e=>notice(`Incidents: ${e.message}`,'bad')),
api('/api/config').then(config=>{state.config=config;}).catch(e=>notice(`Config: ${e.message}`,'bad')),
loadOverviewSnapshot(windowSec,true),
loadThroughput(windowSec,true),
loadAnalytics(windowSec,true,true),
];
await Promise.allSettled(tasks);
}
function renderStats(data) {
const summary=data.summary||{}, a=data.analytics||{}, ndr=data.ndr||{}; $('metricIncidents').textContent=`${Number(ndr.open_incidents ?? summary.incidents ?? 0).toLocaleString()} open NDR incidents`; $('alerts24h').textContent=Number(a.alerts_24h||0).toLocaleString(); $('uniqueSignatures').textContent=Number(a.signatures_24h||0).toLocaleString(); $('sources24h').textContent=Number(a.sources_24h||0).toLocaleString(); $('metricBlockRate').textContent=`${Number(summary.blocked_alerts||0).toLocaleString()} durable incidents`;
}
async function refreshStats() {
const windowSec=selectedWindow();
await Promise.allSettled([
api('/api/stats').then(renderStats),
api('/api/alerts?limit=250').then(alerts=>{state.incidents=alerts.alerts||[];renderIncidents();}),
loadOverviewSnapshot(windowSec,true),
loadAnalytics(windowSec,true),
state.view==='intelligence'?loadIntelligence(true):Promise.resolve(),
]);
}
async function loadBlocks() {
try { const data=await api('/api/blocks'); $('blocksMeta').textContent=data.configured?`${data.blocks.length} entries in ${data.address_list}`:'RouterOS REST is not configured.'; $('blockRows').innerHTML=data.blocks.length?data.blocks.map(x=>`<tr><td class="mono">${esc(x.address)}</td><td>${esc(x.timeout||'—')}</td><td>${esc(x.creation_time||'—')}</td><td class="details-cell">${esc(x.comment||'')}</td><td>${x.dynamic?'dynamic':'static'}</td><td><button class="link-btn" data-unblock="${esc(x.address)}">unblock</button></td></tr>`).join(''):'<tr><td colspan="6" class="empty">No active blocks or RouterOS unavailable.</td></tr>'; } catch(e){ notice(e.message,'bad'); }
}
async function adminPost(url, body={}) { return api(url,{method:'POST',body:JSON.stringify(body)}); }
async function addBlock() { const address=$('blockAddress').value.trim(); if(!address)return notice('Enter an IP address.','bad'); try{const r=await adminPost('/api/admin/blocks/add',{address,timeout:$('blockTimeout').value.trim(),comment:$('blockComment').value.trim()});notice(r.message);await loadBlocks();}catch(e){notice(e.message,'bad');} }
async function unblock(address){if(!confirm(`Remove ${address} from the RouterOS block list?`))return;try{const r=await adminPost('/api/admin/blocks/remove',{address});notice(r.message);await loadBlocks();}catch(e){notice(e.message,'bad');}}
async function suppress(sid){if(!confirm(`Globally suppress Suricata SID ${sid}?`))return;try{const r=await adminPost('/api/admin/rules/suppress',{sid:Number(sid)});notice(r.message);}catch(e){notice(e.message,'bad');}}
async function loadRules(){if(state.authEnabled&&!state.authenticated){showAuthModal();return;}try{const r=await api('/api/admin/rules');$('customRules').value=r.custom_rules||'';$('thresholdConfig').value=r.threshold_config||'';notice('Rule editors loaded.');}catch(e){notice(e.message,'bad');}}
async function saveRuleFile(url,content){try{const r=await adminPost(url,{content});notice(r.message);}catch(e){notice(e.message,'bad');}}
async function ruleAction(url,body={},confirmText=''){if(confirmText&&!confirm(confirmText))return;try{const r=await adminPost(url,body);notice(r.message);return r;}catch(e){notice(e.message,'bad');return null;}}
async function loadRuleSources(){
try{
const r=await api('/api/rules/sources'); state.ruleSources=r.sources||[]; state.ruleSourcesLoaded=true; const st=r.status||{};
state.sourceQueue=r.queue||state.sourceQueue; const known=new Set(state.ruleSources.map(x=>x.name)); state.selectedRuleSources=new Set([...state.selectedRuleSources].filter(name=>known.has(name)));
$('sourceMeta').textContent=`${state.ruleSources.length} free sources · ${(r.enabled_sources||[]).length} active · persistent state ${r.data_dir||'/data/lib/suricata'} · vendor rules ${fmtBytes(st.vendor_rules_size_bytes||0)}`; renderRuleSources(); renderSourceQueue(state.sourceQueue);
}catch(e){ $('sourceMeta').textContent='Could not load source catalog.'; notice(e.message,'bad'); }
}
function filteredRuleSources(){const q=($('sourceFilter')?.value||'').trim().toLowerCase();return state.ruleSources.filter(x=>!q||[x.name,x.vendor,x.license,(x.tags||[]).join(' ')].some(v=>String(v||'').toLowerCase().includes(q)));}
function sourceQueueItemMap(){return new Map(((state.sourceQueue&&state.sourceQueue.items)||[]).map(item=>[item.source,item]));}
function renderRuleSources(){
const rows=filteredRuleSources(), queueItems=sourceQueueItemMap();
$('ruleSourceRows').innerHTML=rows.length?rows.map(x=>{const item=queueItems.get(x.name),selectable=x.can_toggle&&!x.enabled,queued=item&&['pending','running'].includes(item.status);const status=item?`${x.enabled?'enabled · ':''}${item.status}`:(x.enabled?'enabled':'disabled');return `<tr><td class="select-col"><input type="checkbox" class="source-checkbox" data-source-select="${esc(x.name)}" ${state.selectedRuleSources.has(x.name)?'checked':''} ${selectable&&!queued?'':'disabled'} aria-label="Select ${esc(x.name)}"></td><td><strong>${esc(x.name)}</strong>${x.summary?`<div class="muted">${esc(x.summary)}</div>`:''}${item&&item.message?`<div class="muted queue-item-message">${esc(item.message)}</div>`:''}</td><td>${esc(x.vendor||'—')}</td><td>${esc(x.license||'—')}</td><td>${esc((x.tags||[]).join(', ')||'—')}</td><td><span class="status-chip ${x.enabled||item?.status==='done'?'ok':''} ${item?.status==='failed'?'bad':''}">${esc(status)}</span></td><td>${x.can_toggle?`<button class="link-btn" data-source="${esc(x.name)}" data-enable="${x.enabled?'0':'1'}" ${queued?'disabled':''}>${x.enabled?'disable':'enable & download'}</button>`:x.default?'default / active':'parameters required'}</td></tr>`;}).join(''):'<tr><td colspan="7" class="empty">No matching signature sources.</td></tr>';
updateSourceSelectionButtons();
}
async function toggleSource(name,enable){const action=enable?'enable':'disable';if(!confirm(`${action} ${name}? Active feeds are rebuilt and validated before reload.`))return;const r=await ruleAction(`/api/admin/rules/sources/${action}`,{source:name});if(r)await loadRuleSources();}
function updateSourceSelectionButtons(){const running=['queued','running'].includes(state.sourceQueue?.status);const count=state.selectedRuleSources.size;$('queueSelectedSources').textContent=count?`Queue selected (${count})`:'Queue selected';$('queueSelectedSources').disabled=running||count===0;$('selectVisibleSources').disabled=running;$('selectAllFreeSources').disabled=running;$('clearSourceSelection').disabled=running||count===0;}
function renderSourceQueue(queue){state.sourceQueue=queue||{status:'idle'};const box=$('sourceQueueStatus');if(!box)return;const q=state.sourceQueue,running=['queued','running'].includes(q.status),total=Number(q.total||0),completed=Number(q.completed||0),failed=Number(q.failed||0);box.className=`source-queue-status ${running?'running':''} ${q.status==='failed'?'bad':''}`;box.textContent=running?`${q.phase==='download'?'Downloading feeds':'Source queue'}: ${completed}/${total}${failed?` · ${failed} failed`:''} · ${q.message||''}`:(q.status&&q.status!=='idle'?`${q.status}: ${q.message||''}`:'Queue idle');updateSourceSelectionButtons();if(running)pollSourceQueue();}
function pollSourceQueue(){clearTimeout(state.sourceQueueTimer);state.sourceQueueTimer=setTimeout(async()=>{try{const q=await api('/api/admin/rules/sources/queue');const wasRunning=['queued','running'].includes(state.sourceQueue?.status);renderSourceQueue(q);renderRuleSources();if(wasRunning&&!['queued','running'].includes(q.status)){state.selectedRuleSources.clear();await loadRuleSources();notice(q.message,q.status==='failed'?'bad':'ok');}}catch(e){clearTimeout(state.sourceQueueTimer);notice(`Source queue: ${e.message}`,'bad');}},1000);}
function selectVisibleSources(){for(const x of filteredRuleSources())if(x.can_toggle&&!x.enabled)state.selectedRuleSources.add(x.name);renderRuleSources();}
function selectAllFreeSources(){for(const x of state.ruleSources)if(x.can_toggle&&!x.enabled)state.selectedRuleSources.add(x.name);renderRuleSources();}
function clearSourceSelection(){state.selectedRuleSources.clear();renderRuleSources();}
async function queueSelectedSources(){const sources=[...state.selectedRuleSources];if(!sources.length)return;if(!confirm(`Queue ${sources.length} selected source(s)? They will be enabled sequentially, then all active feeds will be downloaded, merged, validated and reloaded once.`))return;try{const r=await adminPost('/api/admin/rules/sources/queue',{sources});notice(r.message);state.sourceQueue={status:'queued',phase:'waiting',total:sources.length,completed:0,failed:0,message:r.message,items:sources.map(source=>({source,status:'pending',message:'Waiting'}))};renderSourceQueue(state.sourceQueue);renderRuleSources();}catch(e){notice(e.message,'bad');}}
function bind() {
document.querySelectorAll('.nav-item').forEach(el=>el.addEventListener('click',()=>setView(el.dataset.view)));
document.querySelectorAll('[data-nav]').forEach(el=>el.addEventListener('click',()=>setView(el.dataset.nav)));
$('liveSearch').addEventListener('input',liveFilterChanged); ['liveType','liveProto','liveDirection'].forEach(id=>$(id).addEventListener('change',liveFilterChanged)); $('liveLimit').addEventListener('change',()=>scheduleLiveRender(0));
$('incidentSearch').addEventListener('input',renderIncidents); $('severityFilter').addEventListener('change',renderIncidents);
$('toggleLive').addEventListener('click',toggleLive);
$('pauseLive').addEventListener('click',()=>{if(!state.liveEnabled)return;state.paused=!state.paused;updateLiveModeControls();if(!state.paused)scheduleLiveRender(0);});
$('clearLiveView').addEventListener('click',()=>{setLiveEvents([]);renderLive();}); $('loadHistory').addEventListener('click',()=>loadHistory(false));
$('windowSelect').addEventListener('change',async()=>{
const w=selectedWindow(); syncUrl(state.view,'replace'); markAnalyticsLoading(w);
state.throughput=null; state.throughputWindow=0;
await Promise.allSettled([loadThroughput(w,false),loadAnalytics(w,false,true),loadOverviewSnapshot(w,false)]);
restartWebSocket(0);
});
$('globalSearch').addEventListener('keydown',e=>{if(e.key==='Enter'){setView('live');$('liveSearch').value=e.currentTarget.value;loadHistory(false);}});
document.addEventListener('keydown',e=>{if(e.key==='/'&&!/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName||'')){e.preventDefault();$('globalSearch').focus();}});
document.addEventListener('click',e=>{const link=e.target.closest('[data-download-url]');if(!link)return;e.preventDefault();downloadUrl(link.dataset.downloadUrl);});
document.addEventListener('click',e=>{const t=e.target.closest('[data-block-ip],[data-unblock],[data-suppress],[data-source],[data-ndr-incident],[data-ndr-status],[data-delete-ioc],[data-rule-threshold],[data-rule-rollback],[data-backup-delete]');if(!t)return;if(t.dataset.blockIp){setView('blocks');$('blockAddress').value=t.dataset.blockIp;}else if(t.dataset.unblock)unblock(t.dataset.unblock);else if(t.dataset.suppress)suppress(t.dataset.suppress);else if(t.dataset.source)toggleSource(t.dataset.source,t.dataset.enable==='1');else if(t.dataset.ndrIncident)loadNdrIncident(t.dataset.ndrIncident);else if(t.dataset.ndrStatus)setNdrStatus(t.dataset.ndrStatus,t.dataset.status);else if(t.dataset.deleteIoc)deleteIoc(t.dataset.deleteIoc);else if(t.dataset.ruleThreshold)applyRecommendedThreshold(t);else if(t.dataset.ruleRollback)rollbackRuleSnapshot(t.dataset.ruleRollback);else if(t.dataset.backupDelete)deleteBackup(t.dataset.backupDelete);});
$('refreshBlocks').addEventListener('click',loadBlocks); $('addBlock').addEventListener('click',addBlock);
$('refreshIntelligence').addEventListener('click',()=>loadIntelligence(false)); $('addIoc').addEventListener('click',addIoc); $('importIocs').addEventListener('click',importIocs);
$('refreshReports').addEventListener('click',()=>{loadThroughput(selectedWindow(),true);loadAnalytics(selectedWindow(),false,true);}); $('downloadReport').addEventListener('click',downloadCurrentReport);
$('loginForm').addEventListener('submit',login); $('accountButton').addEventListener('click',accountAction); $('systemLoginButton').addEventListener('click',accountAction); $('feedLoginButton').addEventListener('click',accountAction);
$('mobileMenu').addEventListener('click',()=>document.body.classList.contains('mobile-nav-open')?closeMobileNav():openMobileNav()); $('mobileBackdrop').addEventListener('click',closeMobileNav);
$('loadRules').addEventListener('click',loadRules); $('reloadRules').addEventListener('click',()=>ruleAction('/api/admin/rules/reload')); $('saveCustomRules').addEventListener('click',()=>saveRuleFile('/api/admin/rules/custom',$('customRules').value)); $('saveThresholds').addEventListener('click',()=>saveRuleFile('/api/admin/rules/thresholds',$('thresholdConfig').value));
$('loadRuleIntelligence').addEventListener('click',()=>loadRuleIntelligence(false)); $('ruleIntelHours').addEventListener('change',()=>loadRuleIntelligence(true)); $('createRuleSnapshot').addEventListener('click',createRuleSnapshot);
$('loadRuleSources').addEventListener('click',loadRuleSources); $('refreshRuleSources').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/rules/sources/refresh',{},'Refresh the OISF provider catalog now?');if(r)await loadRuleSources();}); $('updateRules').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/rules/update',{},'Download all active feeds, validate the merged ruleset and reload Suricata?');if(r)await loadRuleSources();}); $('sourceFilter').addEventListener('input',renderRuleSources);
$('selectVisibleSources').addEventListener('click',selectVisibleSources); $('selectAllFreeSources').addEventListener('click',selectAllFreeSources); $('clearSourceSelection').addEventListener('click',clearSourceSelection); $('queueSelectedSources').addEventListener('click',queueSelectedSources); $('ruleSourceRows').addEventListener('change',e=>{const box=e.target.closest('[data-source-select]');if(!box)return;box.checked?state.selectedRuleSources.add(box.dataset.sourceSelect):state.selectedRuleSources.delete(box.dataset.sourceSelect);updateSourceSelectionButtons();});
$('resetCounters').addEventListener('click',()=>ruleAction('/api/admin/runtime/reset')); $('clearTraffic').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/traffic/clear',{},'Clear traffic history from RAM/Redis and remove persisted chart snapshots?');if(r){setLiveEvents([]);state.snapshot=[];renderLive();renderOverviewSnapshot();}}); $('vacuumDb').addEventListener('click',()=>ruleAction('/api/admin/database/vacuum')); $('clearAlerts').addEventListener('click',async()=>{const r=await ruleAction('/api/admin/alerts/clear',{},'Delete all durable incident rows from SQLite?');if(r)await refreshStats();});
$('refreshSystemState').addEventListener('click',()=>loadSystemState(false)); $('createBackup').addEventListener('click',createBackup);
window.addEventListener('popstate',()=>{
const value=new URL(location.href).searchParams.get('window'); if(WINDOW_LABELS[value])$('windowSelect').value=value;
setView(viewFromLocation(),'none'); loadThroughput(selectedWindow(),true); loadAnalytics(selectedWindow(),true,true); loadOverviewSnapshot(selectedWindow(),true); restartWebSocket(0);
});
window.addEventListener('resize',()=>{if(state.analytics){clearTimeout(bind.resizeTimer);bind.resizeTimer=setTimeout(scheduleChartRender,150);}});
document.addEventListener('visibilitychange',()=>{
if (!document.hidden && state.analytics) scheduleChartRender();
if (!state.liveEnabled) return;
updateLiveModeControls();
restartWebSocket(document.hidden ? 0 : 100);
});
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeMobileNav(); if(state.authenticated)hideAuthModal();}});
}
async function startApplication() {
if (!state.appStarted) state.appStarted=true;
await initialLoad();
connectWebSocket();
if (!state.refreshTimer) state.refreshTimer=setInterval(refreshStats,30000);
if ('ResizeObserver' in window && !startApplication.observer) {
startApplication.observer=new ResizeObserver(()=>scheduleChartRender());
document.querySelectorAll('.view,.chart-panel,.donut-panel').forEach(el=>startApplication.observer.observe(el));
}
if (document.fonts?.ready) document.fonts.ready.then(scheduleChartRender).catch(()=>{});
}
document.addEventListener('DOMContentLoaded', async () => {
const requestedWindow=new URL(location.href).searchParams.get('window'); if(WINDOW_LABELS[requestedWindow])$('windowSelect').value=requestedWindow;
bind(); setView(viewFromLocation(),'replace'); updateLiveModeControls(); renderIncidents(); renderRuleSources();
const session=await loadSession();
if (session?.default_username && !state.authenticated) $('loginUsername').value=session.default_username;
if (!state.authEnabled || state.authenticated) await startApplication();
});
})();
+207
View File
@@ -0,0 +1,207 @@
(() => {
'use strict';
const COLORS = {
grid:'#202126', text:'#777780', strong:'#d4d4d8', green:'#3ecf8e', blue:'#60a5fa', red:'#f87171', amber:'#fbbf24',
palette:['#3ecf8e','#60a5fa','#fbbf24','#a78bfa','#f87171','#22d3ee','#fb7185','#94a3b8']
};
function setup(canvas) {
if (!canvas || !canvas.isConnected || canvas.offsetParent === null) return null;
const rect = canvas.getBoundingClientRect();
if (rect.width < 20) return null;
const DPR = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
const width = Math.max(1, Math.floor(rect.width));
const cssHeight = Number.parseFloat(getComputedStyle(canvas).height) || 0;
const height = Math.max(170, Math.floor(cssHeight || Number(canvas.getAttribute('height')) || 220));
canvas.width = Math.floor(width * DPR);
canvas.height = Math.floor(height * DPR);
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
return {ctx, width, height};
}
function seriesMax(rows, key) {
return Math.max(1, ...rows.map(row => Number(row?.[key] || 0)));
}
function formatTick(ts) {
const d = new Date(Number(ts || 0));
return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
}
function compactNumber(value) {
const n = Number(value || 0);
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(Math.round(n));
}
function formatRate(value) {
let n = Math.max(0, Number(value || 0));
const units = ['bps','Kbps','Mbps','Gbps','Tbps'];
let i = 0;
while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; }
return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
function drawGrid(ctx, width, height, pad, rows) {
ctx.clearRect(0, 0, width, height);
ctx.lineWidth = 1;
ctx.strokeStyle = COLORS.grid;
ctx.fillStyle = COLORS.text;
ctx.font = '10px ui-sans-serif, system-ui';
for (let i = 0; i <= 4; i++) {
const y = pad.t + ((height - pad.t - pad.b) / 4) * i;
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(width - pad.r, y); ctx.stroke();
}
const labelIndexes = [0, Math.floor((rows.length - 1) / 2), rows.length - 1];
ctx.textBaseline = 'bottom';
labelIndexes.forEach((idx, i) => {
if (!rows[idx]) return;
const x = pad.l + ((width - pad.l - pad.r) * idx / Math.max(rows.length - 1, 1));
ctx.textAlign = i === 0 ? 'left' : (i === labelIndexes.length - 1 ? 'right' : 'center');
ctx.fillText(formatTick(rows[idx].ts_ms), x, height - 2);
});
}
function drawLine(ctx, rows, key, max, width, height, pad, color, fillAlpha=0) {
if (!rows.length || !(Number(max) > 0)) return;
const iw = width - pad.l - pad.r;
const ih = height - pad.t - pad.b;
const points = rows.map((row, idx) => ({
x: pad.l + iw * idx / Math.max(rows.length - 1, 1),
y: pad.t + ih - (Number(row[key] || 0) / Number(max)) * ih,
}));
if (fillAlpha) {
const grad = ctx.createLinearGradient(0, pad.t, 0, height - pad.b);
grad.addColorStop(0, hexToRgba(color, fillAlpha)); grad.addColorStop(1, hexToRgba(color, 0));
ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(points[0].x, height - pad.b);
points.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(points.at(-1).x, height - pad.b); ctx.closePath(); ctx.fill();
}
ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); points.forEach((p, idx) => idx ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke();
}
function hexToRgba(hex, alpha) {
const value = hex.replace('#','');
const n = parseInt(value, 16);
return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${alpha})`;
}
function normalizedDonutRows(rows) {
const positive = (rows || []).map(row => ({
name:String(row?.name || 'unknown'), count:Math.max(0, Number(row?.count || 0))
})).filter(row => row.count > 0);
if (positive.length <= 6) return positive;
const head = positive.slice(0, 5);
const other = positive.slice(5).reduce((sum, row) => sum + row.count, 0);
if (other) head.push({name:'other', count:other});
return head;
}
function drawDonut(canvas, rows) {
if (!canvas) return;
const prepared = setup(canvas); if (!prepared) return;
const {ctx,width,height} = prepared;
ctx.clearRect(0, 0, width, height);
const items = normalizedDonutRows(rows);
const total = items.reduce((sum, row) => sum + row.count, 0);
if (!total) {
ctx.fillStyle = COLORS.text; ctx.font = '11px ui-sans-serif, system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('No data in this window', width / 2, height / 2);
return;
}
const wide = width >= 360;
const cx = wide ? Math.min(width * .34, 135) : width / 2;
const cy = wide ? height / 2 : Math.min(88, height * .42);
const radius = Math.min(70, Math.max(48, Math.min(width * .22, height * .32)));
const inner = radius * .64;
let angle = -Math.PI / 2;
items.forEach((row, idx) => {
const portion = row.count / total;
const end = angle + portion * Math.PI * 2;
ctx.beginPath(); ctx.arc(cx, cy, radius, angle, end); ctx.arc(cx, cy, inner, end, angle, true); ctx.closePath();
ctx.fillStyle = COLORS.palette[idx % COLORS.palette.length]; ctx.fill();
angle = end;
});
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.strong; ctx.font = '600 18px ui-sans-serif, system-ui'; ctx.fillText(compactNumber(total), cx, cy - 5);
ctx.fillStyle = COLORS.text; ctx.font = '9px ui-sans-serif, system-ui'; ctx.fillText('events', cx, cy + 14);
const legendX = wide ? Math.min(width * .60, cx + radius + 35) : 14;
const legendY = wide ? Math.max(18, cy - Math.min(items.length * 16, 84) / 2) : Math.min(height - 74, cy + radius + 16);
const legendWidth = wide ? Math.max(90, width - legendX - 12) : width - 28;
items.forEach((row, idx) => {
const y = legendY + idx * 18;
ctx.fillStyle = COLORS.palette[idx % COLORS.palette.length]; ctx.fillRect(legendX, y + 3, 7, 7);
ctx.fillStyle = COLORS.text; ctx.font = '10px ui-sans-serif, system-ui'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const label = row.name.length > 18 ? `${row.name.slice(0,17)}` : row.name;
ctx.fillText(label, legendX + 13, y, Math.max(40, legendWidth - 45));
ctx.textAlign = 'right';
ctx.fillText(`${Math.round(row.count / total * 100)}%`, legendX + legendWidth, y);
});
}
function drawLoading(canvas, label='Building selected range…') {
if (!canvas) return;
const prepared = setup(canvas); if (!prepared) return;
const {ctx,width,height} = prepared;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = COLORS.grid; ctx.lineWidth = 1;
for (let i = 1; i <= 3; i++) {
const y = height * i / 4;
ctx.beginPath(); ctx.moveTo(12, y); ctx.lineTo(width - 12, y); ctx.stroke();
}
ctx.fillStyle = COLORS.text; ctx.font = '11px ui-sans-serif, system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, width / 2, height / 2);
}
function drawTraffic(canvas, rows) {
if (!canvas) return;
const prepared = setup(canvas); if (!prepared) return;
const {ctx,width,height} = prepared; const pad={l:8,r:8,t:12,b:22};
drawGrid(ctx,width,height,pad,rows);
drawLine(ctx, rows, 'bytes', seriesMax(rows,'bytes'), width,height,pad,COLORS.blue,.10);
drawLine(ctx, rows, 'events', seriesMax(rows,'events'), width,height,pad,COLORS.green,.12);
}
function drawThroughput(canvas, rows) {
if (!canvas) return;
const prepared = setup(canvas); if (!prepared) return;
const {ctx,width,height} = prepared; const pad={l:56,r:10,t:12,b:22};
drawGrid(ctx,width,height,pad,rows);
const max = Math.max(seriesMax(rows,'bps'), seriesMax(rows,'in_bps'), seriesMax(rows,'out_bps'));
ctx.fillStyle = COLORS.text; ctx.font = '10px ui-sans-serif, system-ui'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
if (!(max > 0)) {
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('No TZSP throughput samples in this window', width / 2, height / 2);
return;
}
for (let i = 0; i <= 4; i++) {
const value = max * (4 - i) / 4;
const y = pad.t + ((height - pad.t - pad.b) / 4) * i;
ctx.fillText(formatRate(value), pad.l - 7, y);
}
// Total is always available from raw TZSP byte counters, even when LAN
// direction classification is not configured correctly.
drawLine(ctx, rows, 'bps', max, width,height,pad,COLORS.amber,.04);
drawLine(ctx, rows, 'in_bps', max, width,height,pad,COLORS.blue,.05);
drawLine(ctx, rows, 'out_bps', max, width,height,pad,COLORS.green,.04);
}
function drawEvents(canvas, rows) {
if (!canvas) return;
const prepared = setup(canvas); if (!prepared) return;
const {ctx,width,height} = prepared; const pad={l:8,r:8,t:12,b:22};
drawGrid(ctx,width,height,pad,rows);
const max = Math.max(seriesMax(rows,'events'), seriesMax(rows,'alerts'));
drawLine(ctx, rows, 'events', max, width,height,pad,COLORS.green,.10);
drawLine(ctx, rows, 'alerts', max, width,height,pad,COLORS.red,0);
}
window.MikroSuricataCharts = {drawTraffic, drawThroughput, drawEvents, drawDonut, drawLoading};
})();
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Tailwind Labs, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
4.1.10
+228
View File
@@ -0,0 +1,228 @@
/*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--color-zinc-100: oklch(96.7% 0.001 286.375);
--color-zinc-400: oklch(70.5% 0.015 286.067);
--color-zinc-500: oklch(55.2% 0.016 285.938);
--color-zinc-950: oklch(14.1% 0.005 285.823);
--spacing: 0.25rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
.mb-3 {
margin-bottom: calc(var(--spacing) * 3);
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.hidden {
display: none;
}
.w-full {
width: 100%;
}
.grow {
flex-grow: 1;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.gap-3 {
gap: calc(var(--spacing) * 3);
}
.gap-4 {
gap: calc(var(--spacing) * 4);
}
.bg-zinc-950 {
background-color: var(--color-zinc-950);
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.text-xs {
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
.text-zinc-100 {
color: var(--color-zinc-100);
}
.text-zinc-400 {
color: var(--color-zinc-400);
}
.text-zinc-500 {
color: var(--color-zinc-500);
}
.antialiased {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}