// ── 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 presenceData = {};
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.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.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.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 = `
| SSID | BSSID | Ch | Enc |
${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.node_count)} |
${fmt(r.times_seen)} |
${shortTime(r.last_seen)} |
`;
}).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.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.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 = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
return `
| ${fmt(r.ssid, '')} |
${fmt(r.bssid)} |
${shortTime(r.first_seen)} |
${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.regular_macs || [];
const regNets = p.regular_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)} |
`).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 = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted';
return `
| ${fmt(r.ssid, '')} |
${fmt(r.bssid)} |
${fmt(r.days_seen)} |
${shortTime(r.first_seen)} |
${fmt(r.channel)} |
`;
}).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) => `
`).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('view-presence').classList.toggle('active', view === 'presence');
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');
}
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 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();
else if (currentView === 'presence') await fetchPresence();
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);