// ── 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 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 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 = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted'; 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 = (!net.encryption || net.encryption === 'OPEN' || net.encryption === 'None') ? 'enc-open' : 'muted'; 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 renderClientsTable() { const tbody = document.getElementById('clients-tbody'); const empty = document.getElementById('clients-empty'); const count = document.getElementById('clients-count'); if (!clientsData.length) { tbody.innerHTML = ''; empty.style.display = 'block'; count.textContent = ''; return; } empty.style.display = 'none'; count.textContent = `${clientsData.length} unique MACs`; tbody.innerHTML = clientsData.map(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)} `; }).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); 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(''); } // ── 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('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'); } 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 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 ─────────────────────────────────────────────────────────────────────── function startSSE() { const es = new EventSource('/api/stream'); 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(); 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); } }, 2000); }; es.onerror = () => { dot.classList.remove('alive'); setTimeout(startSSE, 3000); }; } // ── Boot ────────────────────────────────────────────────────────────────────── initNetworkSort(); init().then(startSSE);