Files
2026-08-17 10:07:19 +02:00

926 lines
76 KiB
JavaScript

(() => {
'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',18000:'Last 5 hours',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, currentThroughput: null, currentThroughputWindow: 0, status: null, config: null, ruleSources: [], ruleSourcesLoaded: false,
selectedRuleSources: new Set(), sourceQueue: null, sourceQueueTimer: null,
ndrIncidents: [], assets: [], iocs: [], pcaps: [], pcapMode: 'blocks', ndrSummary: {},
ruleIntelligence: [], ruleSnapshots: [], mergedRulesOffset: 0, mergedRulesQuery: '', 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); loadMergedRules(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 setSubtab(group, name) {
let found = false;
document.querySelectorAll('[data-subtab-group]').forEach(el => {
if (el.dataset.subtabGroup !== group) return;
const active = el.dataset.subtab === name;
el.classList.toggle('active', active);
el.setAttribute('aria-selected', active ? 'true' : 'false');
if (active) found = true;
});
if (!found) return;
document.querySelectorAll('[data-subtab-panel]').forEach(el => {
const marker = String(el.dataset.subtabPanel || '');
const split = marker.indexOf(':');
if (split < 0 || marker.slice(0, split) !== group) return;
el.classList.toggle('active', marker.slice(split + 1) === name);
});
if (group === 'security' && state.analytics) scheduleChartRender();
}
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 renderRuleUpdateSchedule() { const el=$('ruleUpdateSchedule'); if(!el)return; const hours=Math.max(0,Number(state.config?.rule_update_interval_hours??24)); el.textContent=hours?`Every ${hours}h`:'Disabled'; el.title=hours?'Controlled by RULE_UPDATE_INTERVAL_HOURS. Set 0 to disable scheduled updates.':'Scheduled updates are disabled because RULE_UPDATE_INTERVAL_HOURS=0.'; }
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 renderCurrentThroughput(t) {
const windowSec=Number(t?.window_seconds||selectedWindow());
if(windowSec && windowSec!==selectedWindow())return;
state.currentThroughput=t||{}; state.currentThroughputWindow=selectedWindow();
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 ingress=Math.max(0,Number(t?.current_ingress_bps||0));
const other=Math.max(0,Number(t?.current_other_bps ?? (total-inbound-outbound))), pps=Math.max(0,Number(t?.current_pps||0));
if($('metricThroughput'))$('metricThroughput').textContent=fmtBits(total);
if($('metricThroughputSplit'))$('metricThroughputSplit').textContent=`IN ${fmtBits(inbound)} · OUT ${fmtBits(outbound)}${other>0?` · OTHER ${fmtBits(other)}`:''} · ${Math.round(pps).toLocaleString()} pps`;
const quality=$('metricThroughputQuality');
if(quality){
const age=t?.current_sample_age_ms;
const ageText=Number.isFinite(Number(age))?`${(Number(age)/1000).toFixed(1)}s`:'waiting';
const capture=Math.max(0,Math.min(100,Number(t?.capture_efficiency_pct ?? 0)));
const queue=Math.max(0,Math.min(100,Number(t?.queue_fill_pct||0)));
const loss=Math.max(0,Number(t?.loss_pps||0));
const inspectRatio=Math.max(0,Math.min(100,Number(t?.inspection_ratio_pct ?? 100)));
const kernelDrops=Math.max(0,Number(t?.kernel_udp_drops||0));
const queueDrops=Math.max(0,Number(t?.queue_dropped_datagrams||0));
const truncated=Math.max(0,Number(t?.truncated_datagrams||0));
const buffer=Number(t?.rcvbuf_bytes||0), queueBytes=Number(t?.queue_capacity_bytes||0);
quality.textContent=`TZSP RX ${fmtBits(ingress)} · capture ${capture.toFixed(1)}% · queue ${queue.toFixed(0)}% · loss ${loss.toFixed(loss<10?1:0)}/s`;
quality.title=`sample ${ageText} old · inspected/TZSP ${inspectRatio.toFixed(1)}% · socket ${fmtBytes(buffer)} · userspace queue ${fmtBytes(queueBytes)} · kernel drops ${kernelDrops.toLocaleString()} · queue drops ${queueDrops.toLocaleString()} · truncated ${truncated.toLocaleString()}`;
const pipelineBehind=ingress>10_000_000 && inspectRatio<90;
const unhealthy=loss>0||queue>=80||pipelineBehind||t?.current_sample_fresh===false||t?.rx_thread_alive===false||t?.worker_thread_alive===false;
quality.classList.toggle('metric-sub-bad',unhealthy);
}
}
function renderThroughput(t) {
const windowSec=Number(t?.window_seconds||selectedWindow());
if(windowSec!==selectedWindow())return;
state.throughput=t; state.throughputWindow=windowSec;
renderCurrentThroughput(t);
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;
const live=(state.currentThroughput && state.currentThroughputWindow===windowSec)?state.currentThroughput:a;
renderCurrentThroughput(live);
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==='sqlite-snapshot'?`${refreshing?'refreshing':'SQLite cached'} · ${ageText} · ${completeness}`:`${source} · ${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('');
renderRedisStatus(s.redis || {}, s.traffic_history || {}, s.services?.redis || {});
renderHistoryStatus(s.traffic_history || {}, s.analytics_snapshots || {});
}
function renderRedisStatus(r={}, h={}, service={}) {
const configured=Boolean(h.redis_configured), managed=Boolean(r.managed);
let label='disabled', cls='';
if(managed){
if(r.ready && h.redis_ok!==false){label='ready';cls='ok';}
else if(r.running){label='degraded';cls='warn';}
else{label='down';cls='bad';}
}else if(configured){
if(h.redis_ok){label='external · connected';cls='ok';}
else{label='external · degraded';cls='bad';}
}
const badge=$('redisStateBadge');
if(badge){badge.textContent=label;badge.className=`status-chip ${cls}`.trim();}
const endpoint=managed&&r.port?`127.0.0.1:${r.port}`:(configured?'configured via REDIS_URL':'—');
const rows=[
['Mode',managed?'managed':configured?'external':'disabled'],
['Endpoint',endpoint],
['Backend',h.backend||'—'],
['Process',r.pid?`PID ${r.pid}`:managed?(r.running?'running':'not running'):'—'],
['Restarts',managed?(r.restarts??0):'—'],
['Persistence',r.persistence||'—'],
['Data directory',r.data_dir||'—'],
['Max memory',managed?(Number(r.maxmemory_mb||0)>0?`${r.maxmemory_mb} MB`:'unlimited'):'—'],
['RDB snapshot',r.snapshot_seconds?`every ${r.snapshot_seconds}s`:'—'],
['Stored events',h.redis_events??'—'],
['Throughput samples',h.throughput_samples??'—'],
['Retention',h.retention_hours?`${h.retention_hours} h`:'—'],
['Writer queue',h.writer_queue??0],
['Redis write errors',h.writer_redis_errors??0],
['Last error',r.last_error||h.redis_error||(service.status==='degraded'?service.details:'—')],
];
const el=$('redisStatus'); if(el)el.innerHTML=rows.map(([k,v])=>`<div class="kv-row"><span>${esc(k)}</span><span class="break">${esc(v)}</span></div>`).join('');
}
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>';
const pcapDescriptions={blocks:'Mode: blocks (legacy) · with the Rust data-plane this is converted to Suricata alert capture so Python never processes every packet.',alerts:'Mode: alerts · Suricata persists packets associated with alerts.',all:'Mode: all · Suricata persists all observed packets into the rotating PCAP log.',off:'Mode: off · forensic PCAP persistence is disabled.'};
if($('pcapMeta'))$('pcapMeta').textContent=pcapDescriptions[state.pcapMode]||`Mode: ${state.pcapMode}`;
$('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 forensic PCAP files 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||[]; state.pcapMode=pcaps.mode||'blocks';
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==='throughput')renderCurrentThroughput(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;renderRuleUpdateSchedule();}).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),
loadThroughput(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} sources · ${(r.enabled_sources||[]).length} active · ${state.ruleSources.filter(x=>x.manual).length} manual · vendor rules ${fmtBytes(st.vendor_rules_size_bytes||0)}`; const count=Number(st.vendor_rule_count||0); if($('feedRuleCount'))$('feedRuleCount').textContent=`${count.toLocaleString()} rules`; if($('mergedRuleCount'))$('mergedRuleCount').textContent=`${count.toLocaleString()} active rules`; renderRuleUpdateSchedule(); 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');const toggle=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');const remove=x.manual?` · <button class="link-btn danger-link" data-source-remove="${esc(x.name)}" ${queued?'disabled':''}>remove</button>`:'';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>${toggle}${remove}</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();}
async function addManualSource(){const name=$('manualSourceName').value.trim(),url=$('manualSourceUrl').value.trim();if(!name||!url)return notice('Enter a source name and URL.','bad');try{const r=await adminPost('/api/admin/rules/sources/add',{name,url,no_checksum:$('manualSourceNoChecksum').checked});notice(r.message);$('manualSourceName').value='';$('manualSourceUrl').value='';await loadRuleSources();}catch(e){notice(e.message,'bad');}}
async function removeManualSource(name){if(!confirm(`Remove manual source ${name}? Active feeds will be rebuilt.`))return;try{const r=await adminPost('/api/admin/rules/sources/remove',{source:name});notice(r.message);state.selectedRuleSources.delete(name);await loadRuleSources();}catch(e){notice(e.message,'bad');}}
async function loadMergedRules(reset=true){const q=($('mergedRuleSearch').value||'').trim();if(reset){state.mergedRulesOffset=0;state.mergedRulesQuery=q;$('mergedRules').value='';}const offset=reset?0:state.mergedRulesOffset;if(offset===null)return;try{const r=await api(`/api/rules/merged?q=${encodeURIComponent(state.mergedRulesQuery)}&offset=${Number(offset||0)}&limit=1000`);$('mergedRules').value+=(r.content||'');state.mergedRulesOffset=r.next_offset;const total=Number(r.total_rules||0),matched=Number(r.matched||0);$('mergedRuleMeta').textContent=`${fmtBytes(r.size_bytes||0)}${r.updated_at?` · updated ${fmtTime(r.updated_at)}`:''}`;$('mergedRuleCount').textContent=`${total.toLocaleString()} active rules`;const match=$('mergedRuleMatchCount');match.textContent=`${matched.toLocaleString()} matching`;match.classList.toggle('hidden',!state.mergedRulesQuery);if($('feedRuleCount'))$('feedRuleCount').textContent=`${total.toLocaleString()} rules`;$('loadMoreMergedRules').disabled=r.next_offset===null;}catch(e){$('mergedRuleMeta').textContent='Merged rules are not available yet.';$('mergedRuleCount').textContent='— active rules';$('mergedRuleMatchCount').classList.add('hidden');notice(e.message,'bad');}}
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)));
document.querySelectorAll('[data-subtab-group]').forEach(el=>el.addEventListener('click',()=>setSubtab(el.dataset.subtabGroup,el.dataset.subtab)));
$('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-source-remove],[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.sourceRemove)removeManualSource(t.dataset.sourceRemove);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);
$('loadMergedRules').addEventListener('click',()=>loadMergedRules(true)); $('searchMergedRules').addEventListener('click',()=>loadMergedRules(true)); $('loadMoreMergedRules').addEventListener('click',()=>loadMergedRules(false)); $('mergedRuleSearch').addEventListener('keydown',e=>{if(e.key==='Enter')loadMergedRules(true);});
$('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);
$('addManualSource').addEventListener('click',addManualSource);
$('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();
// Live throughput already arrives over WebSocket. Aggregate SQLite-backed
// cards/charts do not need to be recomputed every 30 seconds.
if (!state.refreshTimer) state.refreshTimer=setInterval(refreshStats,60000);
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();
});
})();