902 lines
74 KiB
JavaScript
902 lines
74 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',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: [], 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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 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('');
|
|
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 · PCAP is persisted only after a successful RouterOS block; recent packets come from the bounded RAM ring.',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==='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),
|
|
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();
|
|
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();
|
|
});
|
|
})();
|