// ── State ──────────────────────────────────────────────────────────────────── const MAX_ROWS = 100; let generalEvents = []; let currentNode = null; let currentView = 'feed'; let nodeData = {}; let networksData = []; let netSort = { col: 'times_seen', dir: 'desc' }; let crossNodeData = { node_ids: [], networks: [] }; let clientsData = []; let clientsGroupBy = null; // null | 'vendor' | 'ssid' let presenceData = {}; let alertsData = {}; let sessionsData = []; let sessionSort = { col: 'frame_count', dir: 'desc' }; let chartBssid = null; let chartHours = 2; const NODE_COLORS = ['#4ec96a', '#e67e22', '#7eb3ff', '#c678dd']; // ── Helpers ────────────────────────────────────────────────────────────────── function fmt(val, fallback = '—') { return (val !== null && val !== undefined && val !== '') ? val : fallback; } function rssiClass(rssi) { if (rssi >= -50) return 'rssi-hi'; if (rssi >= -80) return 'rssi-mid'; return 'rssi-low'; } function impBadge(imp) { const cls = ['high', 'normal', 'low'].includes(imp) ? imp : 'normal'; return `${fmt(imp)}`; } function reasonCell(code, desc, cls) { if (code == null) return ''; const color = cls === 'attack' ? '#e74c3c' : cls === 'suspicious' ? '#e67e22' : 'var(--text-muted)'; return `${code}` + `${desc || ''}`; } function encClass(enc) { if (!enc || enc === 'OPEN' || enc === 'None') return 'enc-open'; if (enc === 'WPA3' || enc === 'WPA2/WPA3') return 'enc-secure'; return 'muted'; } function shortTime(iso) { if (!iso) return '—'; return iso.replace('T', ' '); } function fmtUptime(ms) { if (ms == null) return '—'; const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const h = Math.floor(m / 60); const d = Math.floor(h / 24); if (d > 0) return `${d}d ${h % 24}h`; if (h > 0) return `${h}h ${m % 60}m`; if (m > 0) return `${m}m`; return `${s}s`; } // ── Sidebar ────────────────────────────────────────────────────────────────── function renderSidebar(nodes) { const online = nodes.filter(n => n.status === 'online').length; document.getElementById('sum-nodes').textContent = nodes.length; document.getElementById('sum-online').textContent = online; document.getElementById('node-list').innerHTML = nodes.map(n => { const hbParts = []; if (n.uptime_ms != null) hbParts.push(`↑${fmtUptime(n.uptime_ms)}`); if (n.free_heap != null) hbParts.push(`${Math.round(n.free_heap / 1024)}K`); if (n.wifi_rssi != null) hbParts.push(`${n.wifi_rssi}dBm`); const hbLine = hbParts.join(' · '); return `
${n.node_id}
${shortTime(n.last_seen)}
${hbLine ? `
${hbLine}
` : ''}
${n.total_events}
`; }).join(''); } // ── Feed view ───────────────────────────────────────────────────────────────── function renderGeneralTable(animate = false) { const tbody = document.getElementById('general-tbody'); const empty = document.getElementById('general-empty'); document.getElementById('general-count').textContent = `${generalEvents.length} events`; if (!generalEvents.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; if (animate) { const tr = document.createElement('tr'); tr.className = 'new-row'; tr.innerHTML = generalRowHTML(generalEvents[0]); tbody.prepend(tr); while (tbody.rows.length > MAX_ROWS) tbody.deleteRow(tbody.rows.length - 1); } else { tbody.innerHTML = generalEvents.map(generalRowHTML).join(''); } } function generalRowHTML(r) { return ` ${shortTime(r.received_at)} ${fmt(r.node_id)} ${fmt(r.ssid, '')} ${fmt(r.bssid)} ${fmt(r.rssi)} dBm ${fmt(r.channel)} ${fmt(r.encryption)} ${impBadge(r.importance)} `; } // ── Node detail view ────────────────────────────────────────────────────────── function renderDetail(d) { document.getElementById('detail-node-id').textContent = d.node_id; document.getElementById('ds-status').textContent = d.status.toUpperCase(); document.getElementById('ds-status').style.color = d.status === 'online' ? 'var(--green-live)' : 'var(--text-muted)'; document.getElementById('ds-total').textContent = d.total_events; document.getElementById('ds-ssids').textContent = d.unique_ssids; document.getElementById('ds-bssids').textContent = d.unique_bssids; document.getElementById('ds-rssi-avg').textContent = d.avg_rssi !== null ? `${d.avg_rssi} dBm` : '—'; document.getElementById('ds-rssi-range').textContent = d.min_rssi !== null ? `${d.min_rssi} / ${d.max_rssi} dBm` : ''; document.getElementById('ds-first').textContent = shortTime(d.first_seen); document.getElementById('ds-last').textContent = shortTime(d.last_seen); document.getElementById('ds-uptime').textContent = d.uptime_ms != null ? fmtUptime(d.uptime_ms) : '—'; document.getElementById('ds-heap').textContent = d.free_heap != null ? Math.round(d.free_heap / 1024) + ' KB' : '—'; document.getElementById('ds-ap-rssi').textContent = d.wifi_rssi != null ? d.wifi_rssi + ' dBm' : '—'; document.getElementById('detail-count').textContent = `${d.events.length} events (last ${MAX_ROWS})`; document.getElementById('detail-tbody').innerHTML = d.events.map(r => ` ${shortTime(r.received_at)} ${fmt(r.ssid, '')} ${fmt(r.bssid)} ${fmt(r.rssi)} dBm ${fmt(r.channel)} ${fmt(r.encryption)} ${impBadge(r.importance)} ${fmt(r.confidence)} `).join(''); } // ── Networks view ───────────────────────────────────────────────────────────── async function fetchNetworks() { networksData = await fetch('/api/networks').then(r => r.json()); renderNetworksTable(); } function renderNetworksTable() { const tbody = document.getElementById('networks-tbody'); const empty = document.getElementById('networks-empty'); const count = document.getElementById('networks-count'); if (!networksData.length) { tbody.innerHTML = ''; empty.style.display = 'block'; count.textContent = ''; return; } empty.style.display = 'none'; const sorted = [...networksData].sort((a, b) => { let av = a[netSort.col], bv = b[netSort.col]; if (av == null) av = netSort.dir === 'asc' ? Infinity : -Infinity; if (bv == null) bv = netSort.dir === 'asc' ? Infinity : -Infinity; if (typeof av === 'string') av = av.toLowerCase(); if (typeof bv === 'string') bv = bv.toLowerCase(); if (av < bv) return netSort.dir === 'asc' ? -1 : 1; if (av > bv) return netSort.dir === 'asc' ? 1 : -1; return 0; }); count.textContent = `${sorted.length} unique networks`; tbody.innerHTML = sorted.map(r => { const encCls = encClass(r.encryption); const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted'; return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} ${fmt(r.best_rssi)} dBm ${fmt(r.avg_rssi)} dBm ${fmt(r.channel)} ${r.encryption || 'OPEN'} ${fmt(r.times_seen)} ${fmt(r.node_count)} ${shortTime(r.last_seen)} `; }).join(''); } function initNetworkSort() { document.querySelectorAll('#view-networks thead th.sortable').forEach(th => { th.addEventListener('click', () => { const col = th.dataset.col; if (netSort.col === col) { netSort.dir = netSort.dir === 'desc' ? 'asc' : 'desc'; } else { netSort.col = col; netSort.dir = ['ssid', 'bssid', 'encryption'].includes(col) ? 'asc' : 'desc'; } document.querySelectorAll('#view-networks thead th').forEach(h => { h.classList.remove('sort-active'); h.dataset.arrow = ''; }); th.classList.add('sort-active'); th.dataset.arrow = netSort.dir === 'desc' ? '↓' : '↑'; renderNetworksTable(); }); }); } // ── Cross-node view ─────────────────────────────────────────────────────────── async function fetchCrossNode() { crossNodeData = await fetch('/api/cross-node').then(r => r.json()); renderCrossNodeTable(); } function renderCrossNodeTable() { const thead = document.getElementById('crossnode-thead'); const tbody = document.getElementById('crossnode-tbody'); const empty = document.getElementById('crossnode-empty'); const count = document.getElementById('crossnode-count'); const filter = document.getElementById('crossnode-filter').checked; const nodeIds = crossNodeData.node_ids || []; let networks = crossNodeData.networks || []; if (filter) networks = networks.filter(n => n.node_count > 1); if (!networks.length) { thead.innerHTML = tbody.innerHTML = ''; empty.style.display = 'block'; count.textContent = ''; return; } empty.style.display = 'none'; count.textContent = `${networks.length} networks`; thead.innerHTML = ` SSIDBSSIDChEnc ${nodeIds.map(id => `${id}`).join('')} Nodes ${nodeIds.map(() => ` best avg `).join('')} `; tbody.innerHTML = networks.map(net => { const seenRssis = nodeIds.filter(id => net.nodes[id]).map(id => net.nodes[id].best_rssi); const globalBest = seenRssis.length ? Math.max(...seenRssis) : null; const encCls = encClass(net.encryption); const nodeCls = net.node_count > 1 ? 'nodes-multi' : 'muted'; const nodeCells = nodeIds.map(id => { if (!net.nodes[id]) return `——`; const n = net.nodes[id]; const isBest = n.best_rssi === globalBest && net.node_count > 1; const bestCls = isBest ? 'cn-best' : rssiClass(n.best_rssi); return `${n.best_rssi} dBm ${n.avg_rssi} dBm`; }).join(''); return ` ${fmt(net.ssid, '')} ${fmt(net.bssid)} ${fmt(net.channel)} ${net.encryption || 'OPEN'} ${nodeCells} ${net.node_count} / ${nodeIds.length} `; }).join(''); } // ── Clients view ────────────────────────────────────────────────────────────── async function fetchClients() { clientsData = await fetch('/api/clients').then(r => r.json()); renderClientsTable(); } function clientsRow(r) { const probing = r.probed_ssids.length ? r.probed_ssids.map(s => `${s}`).join(', ') : 'wildcard only'; const vendorCls = r.randomized ? 'muted' : 'dim'; const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted'; return ` ${fmt(r.src_mac)} ${r.vendor || '—'} ${probing} ${fmt(r.best_rssi)} dBm ${fmt(r.avg_rssi)} dBm ${fmt(r.node_count)} ${fmt(r.times_seen)} ${shortTime(r.last_seen)} `; } function toggleClientsGroup(by) { clientsGroupBy = (clientsGroupBy === by) ? null : by; renderClientsTable(); } function renderClientsTable() { const tbody = document.getElementById('clients-tbody'); const empty = document.getElementById('clients-empty'); const count = document.getElementById('clients-count'); const thVendor = document.getElementById('clients-th-vendor'); const thProbing = document.getElementById('clients-th-probing'); if (thVendor) thVendor.className = clientsGroupBy === 'vendor' ? 'group-active' : 'group-col'; if (thProbing) thProbing.className = clientsGroupBy === 'ssid' ? 'group-active' : 'group-col'; if (!clientsData.length) { tbody.innerHTML = ''; empty.style.display = 'block'; count.textContent = ''; return; } empty.style.display = 'none'; count.textContent = `${clientsData.length} unique MACs`; if (!clientsGroupBy) { tbody.innerHTML = clientsData.map(clientsRow).join(''); return; } // Build groups const groups = {}; clientsData.forEach(r => { let key; if (clientsGroupBy === 'vendor') { key = r.vendor || 'Unknown'; } else { key = r.probed_ssids.length ? r.probed_ssids[0] : '(wildcard only)'; } if (!groups[key]) groups[key] = []; groups[key].push(r); }); const sorted = Object.keys(groups).sort((a, b) => a.localeCompare(b)); tbody.innerHTML = sorted.map(key => { const rows = groups[key]; const hdr = `${key} — ${rows.length} device${rows.length > 1 ? 's' : ''}`; return hdr + rows.map(clientsRow).join(''); }).join(''); } // ── Presence view ───────────────────────────────────────────────────────────── async function fetchPresence() { presenceData = await fetch('/api/presence').then(r => r.json()); renderPresenceView(); } function renderPresenceView() { const p = presenceData; // Present Now const presentTbody = document.getElementById('present-tbody'); const presentEmpty = document.getElementById('present-empty'); const presentCount = document.getElementById('present-count'); const present = p.present || []; presentCount.textContent = `${present.length} devices`; if (!present.length) { presentTbody.innerHTML = ''; presentEmpty.style.display = 'block'; } else { presentEmpty.style.display = 'none'; presentTbody.innerHTML = present.map(r => { const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted'; return ` ${fmt(r.src_mac)} ${r.vendor || '—'} ${fmt(r.best_rssi)} dBm ${fmt(r.avg_rssi)} dBm ${fmt(r.times_seen)} ${fmt(r.node_count)} ${shortTime(r.last_seen)} `; }).join(''); } // New Arrivals — devices const arrMacsTbody = document.getElementById('arrivals-macs-tbody'); const arrMacsEmpty = document.getElementById('arrivals-macs-empty'); const arrCount = document.getElementById('arrivals-count'); const newMacs = p.new_macs || []; const newNets = p.new_networks || []; arrCount.textContent = `${newMacs.length} devices · ${newNets.length} networks`; if (!newMacs.length) { arrMacsTbody.innerHTML = ''; arrMacsEmpty.style.display = 'block'; } else { arrMacsEmpty.style.display = 'none'; arrMacsTbody.innerHTML = newMacs.map(r => ` ${fmt(r.src_mac)} ${r.vendor || '—'} ${shortTime(r.first_seen)} ${fmt(r.best_rssi)} dBm ${fmt(r.times_seen)} `).join(''); } // New Arrivals — networks const arrNetsTbody = document.getElementById('arrivals-nets-tbody'); const arrNetsEmpty = document.getElementById('arrivals-nets-empty'); if (!newNets.length) { arrNetsTbody.innerHTML = ''; arrNetsEmpty.style.display = 'block'; } else { arrNetsEmpty.style.display = 'none'; arrNetsTbody.innerHTML = newNets.map(r => { const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} ${shortTime(r.first_seen)} ${fmt(r.best_rssi)} dBm ${fmt(r.channel)} ${r.encryption || 'OPEN'} `; }).join(''); } // Regulars — devices const regMacsTbody = document.getElementById('regulars-macs-tbody'); const regMacsEmpty = document.getElementById('regulars-macs-empty'); const regCount = document.getElementById('regulars-count'); const regMacs = p.reg_macs || []; const regNets = p.reg_networks || []; regCount.textContent = `${regMacs.length} devices · ${regNets.length} networks`; if (!regMacs.length) { regMacsTbody.innerHTML = ''; regMacsEmpty.style.display = 'block'; } else { regMacsEmpty.style.display = 'none'; regMacsTbody.innerHTML = regMacs.map(r => ` ${fmt(r.src_mac)} ${r.vendor || '—'} ${fmt(r.days_seen)} ${shortTime(r.first_seen)} ${shortTime(r.last_seen)} ${fmt(r.best_rssi)} dBm `).join(''); } // Regulars — networks const regNetsTbody = document.getElementById('regulars-nets-tbody'); const regNetsEmpty = document.getElementById('regulars-nets-empty'); if (!regNets.length) { regNetsTbody.innerHTML = ''; regNetsEmpty.style.display = 'block'; } else { regNetsEmpty.style.display = 'none'; regNetsTbody.innerHTML = regNets.map(r => { const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} ${fmt(r.days_seen)} ${shortTime(r.first_seen)} ${fmt(r.best_rssi)} dBm ${fmt(r.channel)} `; }).join(''); } } // ── Deauth heatmap ──────────────────────────────────────────────────────────── function renderDeauthHeatmap(rows) { const canvas = document.getElementById('deauth-heatmap'); const empty = document.getElementById('heatmap-empty'); const subtitle = document.getElementById('heatmap-subtitle'); if (!rows.length) { canvas.style.display = 'none'; empty.style.display = 'block'; subtitle.textContent = ''; return; } canvas.style.display = 'block'; empty.style.display = 'none'; // Build lookup const lookup = {}; let maxFrames = 0; rows.forEach(r => { if (!lookup[r.day]) lookup[r.day] = {}; lookup[r.day][r.hour] = r.frames; if (r.frames > maxFrames) maxFrames = r.frames; }); const days = Object.keys(lookup).sort(); subtitle.textContent = `${days.length} days`; const dpr = window.devicePixelRatio || 1; const padL = 100, padT = 36, padR = 16, padB = 12; const cellH = 42; // Fill container width exactly const containerW = canvas.parentElement.clientWidth; const cellW = Math.floor((containerW - padL - padR) / 24); const W = padL + 24 * cellW + padR; const H = padT + days.length * cellH + padB; // Set physical pixel size, CSS display size canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H); // Hour labels ctx.font = '11px Roboto, sans-serif'; ctx.fillStyle = '#5a7a60'; ctx.textAlign = 'center'; for (let h = 0; h < 24; h++) { ctx.fillText(String(h).padStart(2, '0'), padL + h * cellW + cellW / 2, padT - 10); } // Day rows days.forEach((day, di) => { const y = padT + di * cellH; // Day label ctx.font = '11px Roboto, sans-serif'; ctx.fillStyle = '#5a7a60'; ctx.textAlign = 'right'; ctx.fillText(day.slice(5), padL - 8, y + cellH / 2 + 4); for (let h = 0; h < 24; h++) { const frames = (lookup[day] && lookup[day][h]) || 0; const x = padL + h * cellW; if (frames === 0) { ctx.fillStyle = '#111a14'; } else { const t = Math.min(frames / maxFrames, 1); const r = Math.round(80 + t * 151); const g = Math.round(20 + t * 10); const b = Math.round(20 + t * 10); ctx.fillStyle = `rgb(${r},${g},${b})`; } ctx.fillRect(x + 1, y + 1, cellW - 2, cellH - 2); if (frames > 0) { ctx.font = '11px Roboto, sans-serif'; ctx.fillStyle = frames > maxFrames * 0.4 ? '#fff' : '#e74c3c'; ctx.textAlign = 'center'; ctx.fillText(frames > 999 ? '999+' : frames, x + cellW / 2, y + cellH / 2 + 4); } } }); } // ── Sessions view ───────────────────────────────────────────────────────────── function initSessionSort() { document.querySelectorAll('#sessions-table thead th.sortable').forEach(th => { th.style.cursor = 'pointer'; th.addEventListener('click', () => { const col = th.dataset.col; if (sessionSort.col === col) { sessionSort.dir = sessionSort.dir === 'desc' ? 'asc' : 'desc'; } else { sessionSort.col = col; sessionSort.dir = 'desc'; } document.querySelectorAll('#sessions-table thead th.sortable').forEach(h => { h.classList.remove('sort-active'); h.dataset.arrow = ''; }); th.classList.add('sort-active'); th.dataset.arrow = sessionSort.dir === 'desc' ? '↓' : '↑'; renderSessionsView(); }); }); } async function fetchSessions() { sessionsData = await fetch('/api/sessions').then(r => r.json()); renderSessionsView(); } function renderSessionsView() { const tbody = document.getElementById('sessions-tbody'); const empty = document.getElementById('sessions-empty'); const count = document.getElementById('sessions-count'); // Sort const { col, dir } = sessionSort; const sevOrder = { attack: 0, suspicious: 1, normal: 2 }; const data = [...sessionsData].sort((a, b) => { let av, bv; if (col === 'bssids') { av = a.unique_bssids.length; bv = b.unique_bssids.length; } else if (col === 'targets') { av = a.unique_targets.length; bv = b.unique_targets.length; } else if (col === 'severity') { av = sevOrder[a.severity] ?? 9; bv = sevOrder[b.severity] ?? 9; } else if (col === 'peak_rssi') { av = a.peak_rssi ?? -200; bv = b.peak_rssi ?? -200; } else { av = a[col]; bv = b[col]; } if (av < bv) return dir === 'desc' ? 1 : -1; if (av > bv) return dir === 'desc' ? -1 : 1; return 0; }); count.textContent = `${data.length} session${data.length !== 1 ? 's' : ''}`; if (!data.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; tbody.innerHTML = ''; data.forEach((s, idx) => { const sevClass = `sev-${s.severity}`; const duration = s.duration_secs < 60 ? `${s.duration_secs}s` : `${Math.floor(s.duration_secs / 60)}m ${s.duration_secs % 60}s`; const vendor = s.src_randomized ? 'Randomized' : (s.src_vendor || 'Unknown'); const mainRow = document.createElement('tr'); mainRow.className = `session-row ${sevClass}`; mainRow.dataset.idx = idx; mainRow.innerHTML = ` ${s.src} ${vendor} ${s.start.replace('T', ' ')} ${duration} ${s.frame_count} ${s.unique_bssids.length} ${s.unique_targets.length} ${s.node_count} ${s.peak_rssi != null ? s.peak_rssi + ' dBm' : '—'} ${reasonCell(s.dominant_reason, s.dominant_reason_desc, s.dominant_reason_class)} ${s.severity} `; tbody.appendChild(mainRow); // Build detail row const detailRow = document.createElement('tr'); detailRow.className = 'session-detail'; detailRow.dataset.sessionIdx = idx; const bssidList = s.unique_bssids.join(', ') || '—'; const targetList = s.unique_targets.join(', ') || '—'; const nodeList = (s.nodes || []).join(', ') || '—'; let evRows = ''; (s.events || []).forEach(ev => { evRows += ` ${ev.received_at.replace('T', ' ')} ${ev.node_id} ${ev.subtype} ${ev.src} ${ev.dst} ${ev.bssid} ${ev.reason} ${ev.rssi} dBm `; }); const truncNote = s.frame_count > 50 ? `
Showing first 50 of ${s.frame_count} events
` : ''; detailRow.innerHTML = `
BSSIDs: ${bssidList} Targets: ${targetList} Nodes: ${nodeList} Avg RSSI: ${s.avg_rssi != null ? s.avg_rssi + ' dBm' : '—'} Dominant Reason: ${reasonCell(s.dominant_reason, s.dominant_reason_desc, s.dominant_reason_class)}
${evRows}
TimeNodeTypeSrc DstBSSIDReasonRSSI
${truncNote}
`; detailRow.style.display = 'none'; tbody.appendChild(detailRow); mainRow.addEventListener('click', () => { const isOpen = detailRow.style.display !== 'none'; detailRow.style.display = isOpen ? 'none' : ''; mainRow.querySelector('.expand-btn').textContent = isOpen ? '▶' : '▼'; }); }); } // ── Alerts view ─────────────────────────────────────────────────────────────── async function fetchAlerts() { alertsData = await fetch('/api/alerts').then(r => r.json()); renderAlertsView(); } function renderAlertsView() { const a = alertsData; const sum = a.summary || {}; // Summary strip document.getElementById('alerts-summary').innerHTML = `
${sum.total ?? 0} events (last 1h)
${sum.deauth_count ?? 0} deauth
${sum.disassoc_count ?? 0} disassoc
${sum.unique_bssids ?? 0} unique BSSIDs
${sum.unique_srcs ?? 0} unique sources
`; // Reason code breakdown const reasonsTbody = document.getElementById('reasons-tbody'); const reasonsEmpty = document.getElementById('reasons-empty'); const reasonsCount = document.getElementById('reasons-count'); const reasons = a.reason_stats || []; reasonsCount.textContent = `${reasons.length} codes`; if (!reasons.length) { reasonsTbody.innerHTML = ''; reasonsEmpty.style.display = 'block'; } else { reasonsEmpty.style.display = 'none'; reasonsTbody.innerHTML = reasons.map(r => { const cls = `reason-${r.classification}`; const label = r.classification === 'attack' ? 'ATTACK' : r.classification === 'suspicious' ? 'SUSPICIOUS' : 'normal'; return ` ${r.reason} ${r.description} ${label} ${r.total_frames} ${r.unique_bssids} ${r.unique_targets} ${shortTime(r.last_seen)} `; }).join(''); } // Most targeted networks const targetsTbody = document.getElementById('targets-tbody'); const targetsEmpty = document.getElementById('targets-empty'); const targetsCount = document.getElementById('targets-count'); const topTargets = a.top_targets || []; targetsCount.textContent = `${topTargets.length}`; if (!topTargets.length) { targetsTbody.innerHTML = ''; targetsEmpty.style.display = 'block'; } else { targetsEmpty.style.display = 'none'; targetsTbody.innerHTML = topTargets.map((t, i) => ` ${fmt(t.bssid)} ${fmt(t.ssid, '')} ${t.total_frames} ${reasonCell(t.dominant_reason, t.dominant_reason_desc, t.dominant_reason_class)} ${t.unique_srcs} ${t.node_count} ${shortTime(t.last_seen)} `).join(''); } // Most active attackers const attackersTbody = document.getElementById('attackers-tbody'); const attackersEmpty = document.getElementById('attackers-empty'); const attackersCount = document.getElementById('attackers-count'); const topTargetedDevices = a.top_targeted_devices || []; attackersCount.textContent = `${topTargetedDevices.length}`; if (!topTargetedDevices.length) { attackersTbody.innerHTML = ''; attackersEmpty.style.display = 'block'; } else { attackersEmpty.style.display = 'none'; attackersTbody.innerHTML = topTargetedDevices.map(d => { const vendorCls = d.randomized ? 'muted' : 'dim'; return ` ${fmt(d.dst)} ${d.vendor || '—'} ${d.total_frames} ${reasonCell(d.dominant_reason, d.dominant_reason_desc, d.dominant_reason_class)} ${d.networks_used} ${d.node_count} ${shortTime(d.last_seen)} `; }).join(''); } // Bursts const burstsTbody = document.getElementById('bursts-tbody'); const burstsEmpty = document.getElementById('bursts-empty'); const burstsCount = document.getElementById('bursts-count'); const bursts = a.bursts || []; burstsCount.textContent = bursts.length ? `${bursts.length} active` : ''; if (!bursts.length) { burstsTbody.innerHTML = ''; burstsEmpty.style.display = 'block'; } else { burstsEmpty.style.display = 'none'; burstsTbody.innerHTML = bursts.map(b => { const multiNode = b.node_count > 1; const rowCls = multiNode ? 'burst-row burst-multi' : 'burst-row'; const nodeCls = multiNode ? 'burst-multi' : 'muted'; const typeCls = b.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type'; return ` ${fmt(b.bssid)} ${fmt(b.ssid, '')} ${b.subtype} ${b.count} ${b.unique_srcs} ${b.node_count} ${shortTime(b.first_seen)} ${shortTime(b.last_seen)} `; }).join(''); } // Raw deauth feed const deauthTbody = document.getElementById('deauth-tbody'); const deauthEmpty = document.getElementById('deauth-empty'); const deauthCount = document.getElementById('deauth-count'); const recent = a.recent || []; deauthCount.textContent = `${recent.length} events`; if (!recent.length) { deauthTbody.innerHTML = ''; deauthEmpty.style.display = 'block'; } else { deauthEmpty.style.display = 'none'; deauthTbody.innerHTML = recent.map(r => { const typeCls = r.subtype === 'deauth' ? 'deauth-type' : 'disassoc-type'; return ` ${shortTime(r.received_at)} ${fmt(r.node_id)} ${r.subtype} ${fmt(r.src)} ${fmt(r.dst)} ${fmt(r.bssid)} ${fmt(r.reason)} ${fmt(r.rssi)} dBm `; }).join(''); } } // ── RSSI chart ──────────────────────────────────────────────────────────────── async function showNetworkChart(bssid) { chartBssid = bssid; const net = networksData.find(n => n.bssid === bssid); document.getElementById('chart-title').textContent = fmt(net?.ssid, ''); document.getElementById('chart-subtitle').textContent = bssid; setView('network-chart'); // requestAnimationFrame ensures the view is laid out before we read canvas.offsetWidth requestAnimationFrame(() => loadAndRenderChart()); } async function setChartRange(hours) { chartHours = hours; document.querySelectorAll('.range-btn').forEach(b => { b.classList.toggle('active', parseInt(b.dataset.hours) === hours); }); await loadAndRenderChart(); } async function loadAndRenderChart() { const data = await fetch(`/api/rssi-history?bssid=${encodeURIComponent(chartBssid)}&hours=${chartHours}`).then(r => r.json()); renderRssiChart(data); } function renderRssiChart(series) { const canvas = document.getElementById('rssi-chart'); const empty = document.getElementById('chart-empty'); const legend = document.getElementById('chart-legend'); const nodeIds = Object.keys(series).sort(); const allPts = nodeIds.flatMap(id => series[id]); if (!allPts.length) { canvas.style.display = 'none'; empty.style.display = 'block'; legend.innerHTML = ''; return; } canvas.style.display = 'block'; empty.style.display = 'none'; const W = canvas.offsetWidth; const H = canvas.height; canvas.width = W; const ctx = canvas.getContext('2d'); const pad = { top: 20, right: 16, bottom: 36, left: 52 }; const pw = W - pad.left - pad.right; const ph = H - pad.top - pad.bottom; const now = Date.now(); const tStart = now - chartHours * 3600000; const rssis = allPts.map(p => p.rssi); const minR = Math.floor((Math.min(...rssis) - 5) / 5) * 5; const maxR = Math.ceil ((Math.max(...rssis) + 5) / 5) * 5; const rng = maxR - minR || 1; const xMap = t => pad.left + (new Date(t + 'Z').getTime() - tStart) / (now - tStart) * pw; const yMap = r => pad.top + (1 - (r - minR) / rng) * ph; ctx.clearRect(0, 0, W, H); ctx.font = '10px IBM Plex Mono'; // Horizontal gridlines + Y labels const step = rng <= 20 ? 5 : rng <= 50 ? 10 : 20; for (let r = minR; r <= maxR; r += step) { const y = yMap(r); ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + pw, y); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = '#3a5040'; ctx.textAlign = 'right'; ctx.fillText(`${r}`, pad.left - 6, y + 3); } // X axis time ticks const ticks = Math.min(6, Math.floor(pw / 80)); ctx.fillStyle = '#3a5040'; ctx.textAlign = 'center'; for (let i = 0; i <= ticks; i++) { const t = tStart + (i / ticks) * (now - tStart); const x = pad.left + (i / ticks) * pw; ctx.fillText(new Date(t).toTimeString().slice(0, 5), x, H - 8); ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pad.top + ph); ctx.lineTo(x, pad.top + ph + 4); ctx.stroke(); } // Plot border ctx.strokeStyle = '#1e3024'; ctx.lineWidth = 1; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(pad.left, pad.top); ctx.lineTo(pad.left, pad.top + ph); ctx.lineTo(pad.left + pw, pad.top + ph); ctx.stroke(); // Series nodeIds.forEach((nid, i) => { const pts = series[nid]; const color = NODE_COLORS[i % NODE_COLORS.length]; ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); ctx.beginPath(); pts.forEach((p, j) => { const x = xMap(p.t), y = yMap(p.rssi); j === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); ctx.fillStyle = color; pts.forEach(p => { ctx.beginPath(); ctx.arc(xMap(p.t), yMap(p.rssi), 2.5, 0, Math.PI * 2); ctx.fill(); }); }); // Legend legend.innerHTML = nodeIds.map((nid, i) => `
${nid}
`).join(''); } // ── Search view ─────────────────────────────────────────────────────────────── async function doSearch() { const q = document.getElementById('search-input').value.trim(); if (q.length < 2) return; const data = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then(r => r.json()); renderSearchResults(data, q); } function renderSearchResults(data, q) { const container = document.getElementById('search-results'); const empty = document.getElementById('search-empty'); container.querySelectorAll('.search-section').forEach(el => el.remove()); if (!Object.keys(data).length) { empty.textContent = `No data found for "${q}".`; empty.style.display = 'block'; return; } empty.style.display = 'none'; // Helper to append a section function addSection(title, html) { const div = document.createElement('div'); div.className = 'search-section'; div.innerHTML = `${html}`; container.appendChild(div); } // As a network if (data.as_network) { const rows = data.as_network.map(r => { const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} ${fmt(r.best_rssi)} dBm ${fmt(r.channel)} ${r.encryption || 'OPEN'} ${fmt(r.times_seen)} ${r.node_count} ${shortTime(r.first_seen)} ${shortTime(r.last_seen)} `; }).join(''); addSection('As a Network', `
${rows}
SSIDBSSIDBest RSSIChEncTimes SeenNodesFirst SeenLast Seen
`); } // As a client device if (data.as_client) { const rows = data.as_client.map(r => { const probing = r.probed_ssids.length ? r.probed_ssids.join(', ') : 'wildcard only'; return ` ${fmt(r.src_mac)} ${r.vendor || '—'} ${probing} ${fmt(r.best_rssi)} dBm ${fmt(r.times_seen)} ${shortTime(r.first_seen)} ${shortTime(r.last_seen)} `; }).join(''); addSection('As a Client Device (Probe Requests)', `
${rows}
MACVendorProbing ForBest RSSITimes SeenFirst SeenLast Seen
`); } // Devices probing for this SSID if (data.probing_for) { const rows = data.probing_for.map(r => ` ${fmt(r.src_mac)} ${r.vendor || '—'} ${fmt(r.best_rssi)} dBm ${fmt(r.times_seen)} ${shortTime(r.first_seen)} ${shortTime(r.last_seen)} `).join(''); addSection('Devices Probing for This SSID', `
${rows}
MACVendorBest RSSITimes SeenFirst SeenLast Seen
`); } // As a deauth victim if (data.as_victim) { const rows = data.as_victim.map(r => ` ${fmt(r.dst)} ${r.vendor || '—'} ${r.total_frames} ${r.networks_used} ${r.node_count} ${shortTime(r.first_seen)} ${shortTime(r.last_seen)} `).join(''); addSection('As a Deauth Victim', `
${rows}
MACVendorFrames ReceivedNetworks UsedNodesFirst SeenLast Seen
`); } // Vendor — client devices if (data.vendor_clients) { const label = data.vendor_name || q; const rows = data.vendor_clients.map(r => { const probing = r.probed_ssids.length ? r.probed_ssids.join(', ') : 'wildcard only'; return ` ${fmt(r.src_mac)} ${r.vendor || '—'} ${probing} ${fmt(r.best_rssi)} dBm ${fmt(r.times_seen)} ${shortTime(r.last_seen)} `; }).join(''); addSection(`Vendor Match — Client Devices (${label})`, `
${rows}
MACVendorProbing ForBest RSSITimes SeenLast Seen
`); } // Vendor — networks / APs if (data.vendor_networks) { const label = data.vendor_name || q; const rows = data.vendor_networks.map(r => { const encCls = encClass(r.encryption); return ` ${fmt(r.ssid, '')} ${fmt(r.bssid)} ${fmt(r.best_rssi)} dBm ${fmt(r.channel)} ${r.encryption || 'OPEN'} ${fmt(r.times_seen)} ${r.node_count} ${shortTime(r.last_seen)} `; }).join(''); addSection(`Vendor Match — Networks / APs (${label})`, `
${rows}
SSIDBSSIDBest RSSIChEncTimes SeenNodesLast Seen
`); } // As an impersonated AP if (data.as_impersonated) { const rows = data.as_impersonated.map(r => ` ${fmt(r.bssid)} ${r.total_frames} ${r.unique_targets} ${r.node_count} ${shortTime(r.first_seen)} ${shortTime(r.last_seen)} `).join(''); addSection('As an Impersonated AP (Deauth Source)', `
${rows}
BSSIDFrames SentUnique TargetsNodesFirst SeenLast Seen
`); } } // ── View switching ──────────────────────────────────────────────────────────── function setView(view) { currentView = view; document.getElementById('view-general').classList.toggle('active', view === 'feed'); document.getElementById('view-networks').classList.toggle('active', view === 'networks'); document.getElementById('view-network-chart').classList.toggle('active', view === 'network-chart'); document.getElementById('view-crossnode').classList.toggle('active', view === 'crossnode'); document.getElementById('view-clients').classList.toggle('active', view === 'clients'); document.getElementById('view-detail').classList.toggle('active', view === 'detail'); document.getElementById('view-presence').classList.toggle('active', view === 'presence'); document.getElementById('view-alerts').classList.toggle('active', view === 'alerts'); document.getElementById('view-sessions').classList.toggle('active', view === 'sessions'); document.getElementById('view-analysis').classList.toggle('active', view === 'analysis'); document.getElementById('view-search').classList.toggle('active', view === 'search'); document.getElementById('tab-feed').classList.toggle('active', view === 'feed' || view === 'detail'); document.getElementById('tab-networks').classList.toggle('active', view === 'networks' || view === 'network-chart'); document.getElementById('tab-crossnode').classList.toggle('active', view === 'crossnode'); document.getElementById('tab-clients').classList.toggle('active', view === 'clients'); document.getElementById('tab-presence').classList.toggle('active', view === 'presence'); document.getElementById('tab-alerts').classList.toggle('active', view === 'alerts'); document.getElementById('tab-sessions').classList.toggle('active', view === 'sessions'); document.getElementById('tab-analysis').classList.toggle('active', view === 'analysis'); document.getElementById('tab-search').classList.toggle('active', view === 'search'); document.getElementById('view-ble').classList.toggle('active', view === 'ble'); document.getElementById('tab-ble').classList.toggle('active', view === 'ble'); if (view !== 'ble' && _blePoll) { clearInterval(_blePoll); _blePoll = null; } } function showFeed() { currentNode = null; setView('feed'); document.querySelectorAll('.node-item').forEach(el => el.classList.remove('active')); } function showGeneral() { showFeed(); } function showNetworks() { setView('networks'); fetchNetworks(); } async function showCrossNode() { setView('crossnode'); await fetchCrossNode(); } async function showClients() { setView('clients'); await fetchClients(); } async function showPresence() { setView('presence'); await fetchPresence(); } async function showAlerts() { setView('alerts'); await fetchAlerts(); } async function showSessions() { setView('sessions'); initSessionSort(); await fetchSessions(); } async function showAnalysis() { setView('analysis'); const data = await fetch('/api/alerts').then(r => r.json()); requestAnimationFrame(() => renderDeauthHeatmap(data.heatmap || [])); } function showSearch() { setView('search'); document.getElementById('search-input').focus(); } async function showDetail(node_id) { currentNode = node_id; setView('detail'); document.querySelectorAll('.node-item').forEach(el => { el.classList.toggle('active', el.dataset.nodeId === node_id); }); const data = await fetch(`/api/nodes/${node_id}`).then(r => r.json()); renderDetail(data); } // ── Init ───────────────────────────────────────────────────────────────────── async function init() { const [nodes, events] = await Promise.all([ fetch('/api/nodes').then(r => r.json()), fetch(`/api/events?limit=${MAX_ROWS}`).then(r => r.json()), ]); generalEvents = events; nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n])); document.getElementById('sum-events').textContent = generalEvents.length; renderSidebar(nodes); renderGeneralTable(false); } // ── SSE ─────────────────────────────────────────────────────────────────────── let _currentES = null; // track active EventSource so we can close it on reconnect let _nodesFetching = false; // in-flight guard — prevents concurrent /api/nodes requests function startSSE() { if (_currentES) { _currentES.close(); _currentES = null; } const es = new EventSource('/api/stream'); _currentES = es; const dot = document.getElementById('live-dot'); let sseTimer = null; es.onopen = () => dot.classList.add('alive'); es.onmessage = (e) => { dot.classList.remove('alive'); requestAnimationFrame(() => dot.classList.add('alive')); // Update the feed array immediately — this is just JS, no API call needed const ev = JSON.parse(e.data); generalEvents.unshift(ev); if (generalEvents.length > MAX_ROWS) generalEvents.pop(); document.getElementById('sum-events').textContent = generalEvents.length; if (currentView === 'feed') renderGeneralTable(true); // Debounce all API-backed refreshes: a scan fires 20+ events in rapid // succession — only act on the trailing edge, at most once per 2 seconds. if (sseTimer) return; sseTimer = setTimeout(async () => { sseTimer = null; if (currentView === 'networks') await fetchNetworks(); else if (currentView === 'network-chart') await loadAndRenderChart(); else if (currentView === 'crossnode') await fetchCrossNode(); else if (currentView === 'presence') await fetchPresence(); else if (currentView === 'alerts') await fetchAlerts(); else if (currentView === 'analysis') await showAnalysis(); else if (currentView === 'ble') await fetchBle(); if (!_nodesFetching) { _nodesFetching = true; try { const nodes = await fetch('/api/nodes').then(r => r.json()); nodeData = Object.fromEntries(nodes.map(n => [n.node_id, n])); renderSidebar(nodes); if (currentView === 'detail' && currentNode) { const data = await fetch(`/api/nodes/${currentNode}`).then(r => r.json()); renderDetail(data); } } finally { _nodesFetching = false; } } }, 2000); }; es.onerror = () => { dot.classList.remove('alive'); es.close(); _currentES = null; setTimeout(startSSE, 3000); }; } // ── BLE ─────────────────────────────────────────────────────────────────────── let bleData = { devices: [], feed: [], breakdown: [] }; let _blePoll = null; function showBle() { setView('ble'); fetchBle(); if (_blePoll) clearInterval(_blePoll); _blePoll = setInterval(fetchBle, 5000); } async function fetchBle() { try { bleData = await fetch('/api/ble').then(r => r.json()); renderBle(); } catch (e) { /* network hiccup */ } } function bleTypeClass(type) { if (!type) return 'ble-type-other'; if (type.startsWith('Apple') || type === 'iBeacon' || type.includes('AirPods') || type.includes('FindMy') || type.includes('AirTag') || type.includes('HomeKit') || type.includes('AirDrop') || type.includes('Proximity')) return 'ble-type-apple'; if (type.startsWith('Samsung')) return 'ble-type-samsung'; return 'ble-type-other'; } function renderBle() { // Breakdown chips const bd = bleData.breakdown || []; const bdEl = document.getElementById('ble-breakdown'); document.getElementById('ble-breakdown-count').textContent = bd.length ? `${bd.length} manufacturers` : ''; bdEl.innerHTML = bd.map(b => { const label = b.vendor && b.vendor !== 'Unknown' && b.vendor !== 'No mfr data' ? b.vendor : b.mfr_id != null ? `0x${b.mfr_id.toString(16).padStart(4,'0').toUpperCase()}` : 'No mfr data'; return `
${b.unique_devices}${label}
`; }).join(''); // Devices table const devs = bleData.devices || []; const devsEl = document.getElementById('ble-devices-body'); const emptyEl = document.getElementById('ble-devices-empty'); const tableEl = document.getElementById('ble-devices-table'); document.getElementById('ble-devices-count').textContent = devs.length ? `${devs.length} unique` : ''; if (!devs.length) { emptyEl.style.display = ''; tableEl.style.display = 'none'; } else { emptyEl.style.display = 'none'; tableEl.style.display = ''; devsEl.innerHTML = devs.map(d => { const typeClass = bleTypeClass(d.type); const addrClass = d.addr_type === 'public' ? 'ble-addr-public' : 'ble-addr-random'; const name = d.name || ''; const vendor = d.vendor && d.vendor !== 'Unknown' ? d.vendor : ''; return ` ${fmt(d.type)} ${fmt(d.mac)} ${name} ${vendor} ${d.addr_type || '—'} ${fmt(d.best_rssi)} dBm ${fmt(d.times_seen)} ${fmt(d.node_count)} ${shortTime(d.last_seen)} `; }).join(''); } // Feed const feed = bleData.feed || []; document.getElementById('ble-feed-count').textContent = feed.length ? `last ${feed.length}` : ''; document.getElementById('ble-feed-body').innerHTML = feed.map(f => { const typeClass = bleTypeClass(f.type); const addrClass = f.addr_type === 'public' ? 'ble-addr-public' : 'ble-addr-random'; return ` ${shortTime(f.received_at)} ${fmt(f.node_id)} ${fmt(f.type)} ${fmt(f.mac)} ${f.name || ''} ${fmt(f.rssi)} dBm ${f.addr_type || '—'} `; }).join(''); } // ── Boot ────────────────────────────────────────────────────────────────────── initNetworkSort(); init().then(startSSE);