diff --git a/README.md b/README.md index 3d25ce6..0adf230 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,10 @@ Open `http://192.168.1.133:8080` in your browser. - **RSSI history chart** — opens from any Networks row. Shows signal strength over time per node (1h / 2h / 6h / 24h selectable). Each node gets its own coloured line. Useful for spotting interference patterns, seeing how signal fluctuates by time of day, and comparing which node consistently hears a given AP better. Updates live as new scans arrive. - **Cross-node** — side-by-side per-node RSSI for every AP. Shows which node is physically closer to each network. Filter to multi-node only to focus on confirmed cross-node observations. - **Clients** — probe request data: unique MACs, vendor (OUI lookup), what SSIDs they're searching for, signal, which nodes saw them. +- **Presence** — three sections driven entirely by probe data: + - *Present Now* — real (non-randomized) MACs seen 2+ times in the last hour. Filters out the city-center noise of transient devices. + - *New Arrivals* — devices and networks first seen in the last 24 hours, split into two side-by-side tables. + - *Regulars* — devices and networks seen on 2 or more distinct calendar days. Takes at least two days of data to populate. - **Node detail** — click a node in the sidebar for stats: total events, unique SSIDs/BSSIDs, RSSI range, first/last seen, uptime, free heap, AP signal, last 100 events. ### Sidebar @@ -240,7 +244,7 @@ A node is considered **online** if a heartbeat was received within the last 30 s ### Live updates -SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests. +SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node, presence) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests. --- @@ -277,7 +281,10 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa - [x] Deployed to Orange Pi as systemd services (runs 24/7) - [x] All three nodes sending to Orange Pi, confirmed live - [x] Node heartbeat every 10s — uptime, free heap, AP signal, drives online/offline status +- [x] RSSI history chart per network (canvas, per-node coloured lines, 1h/2h/6h/24h range) +- [x] Presence tab — Present Now, New Arrivals, Regulars - [ ] Scan interval control from dashboard +- [ ] DB pruning / retention policy (events.db grows indefinitely) --- diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py index 9cb7c41..5b28884 100644 --- a/coordinator/dashboard.py +++ b/coordinator/dashboard.py @@ -170,14 +170,18 @@ def build_clients() -> list[dict]: GROUP BY src_mac ORDER BY times_seen DESC """) - # Attach probed SSIDs and OUI vendor info to each row + # Fetch all probed SSIDs in one query, group in Python — avoids N+1 + ssid_rows = query(""" + SELECT DISTINCT src_mac, ssid FROM probe_events + WHERE src_mac IS NOT NULL AND ssid IS NOT NULL AND ssid != '' + ORDER BY src_mac, ssid + """) + ssids_by_mac: dict = {} + for s in ssid_rows: + ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"]) + for r in rows: - ssid_rows = query(""" - SELECT DISTINCT ssid FROM probe_events - WHERE src_mac = ? AND ssid IS NOT NULL AND ssid != '' - ORDER BY ssid - """, (r["src_mac"],)) - r["probed_ssids"] = [s["ssid"] for s in ssid_rows] + r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], []) vendor, randomized = oui_lookup(r["src_mac"]) r["vendor"] = vendor r["randomized"] = randomized @@ -237,6 +241,119 @@ def build_cross_node() -> dict: return {"node_ids": node_ids, "networks": result} +def build_presence() -> dict: + now = datetime.datetime.utcnow() + cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds") + cutoff_24h = (now - datetime.timedelta(hours=24)).isoformat(timespec="seconds") + + # ── Present now: real MACs seen 2+ times in the last hour ──────────────── + present_rows = query(""" + SELECT src_mac, + COUNT(*) AS times_seen, + MAX(rssi) AS best_rssi, + ROUND(AVG(rssi), 1) AS avg_rssi, + MAX(received_at) AS last_seen, + COUNT(DISTINCT node_id) AS node_count + FROM probe_events + WHERE src_mac IS NOT NULL AND received_at > ? + GROUP BY src_mac + HAVING COUNT(*) > 1 + ORDER BY last_seen DESC + """, (cutoff_1h,)) + present = [] + for r in present_rows: + vendor, randomized = oui_lookup(r["src_mac"]) + if not randomized: + r["vendor"] = vendor + present.append(r) + + # ── New arrivals: first seen in last 24h ───────────────────────────────── + new_mac_rows = query(""" + SELECT src_mac, + MIN(received_at) AS first_seen, + MAX(rssi) AS best_rssi, + COUNT(*) AS times_seen, + COUNT(DISTINCT node_id) AS node_count + FROM probe_events + WHERE src_mac IS NOT NULL + GROUP BY src_mac + HAVING MIN(received_at) > ? + ORDER BY first_seen DESC + LIMIT 100 + """, (cutoff_24h,)) + new_macs = [] + for r in new_mac_rows: + vendor, randomized = oui_lookup(r["src_mac"]) + r["vendor"] = vendor + r["randomized"] = randomized + new_macs.append(r) + + new_networks = query(""" + SELECT bssid, + MAX(ssid) AS ssid, + MIN(received_at) AS first_seen, + MAX(rssi) AS best_rssi, + MAX(channel) AS channel, + MAX(encryption) AS encryption, + COUNT(DISTINCT node_id) AS node_count + FROM beacon_events + WHERE bssid IS NOT NULL + GROUP BY bssid + HAVING MIN(received_at) > ? + ORDER BY first_seen DESC + LIMIT 100 + """, (cutoff_24h,)) + + # ── Regulars: seen on 2+ distinct calendar days ─────────────────────────── + reg_mac_rows = query(""" + SELECT src_mac, + COUNT(DISTINCT date(received_at)) AS days_seen, + MIN(received_at) AS first_seen, + MAX(received_at) AS last_seen, + MAX(rssi) AS best_rssi, + COUNT(*) AS times_seen, + COUNT(DISTINCT node_id) AS node_count + FROM probe_events + WHERE src_mac IS NOT NULL + GROUP BY src_mac + HAVING COUNT(DISTINCT date(received_at)) >= 2 + ORDER BY days_seen DESC, times_seen DESC + LIMIT 50 + """) + reg_macs = [] + for r in reg_mac_rows: + vendor, randomized = oui_lookup(r["src_mac"]) + r["vendor"] = vendor + r["randomized"] = randomized + reg_macs.append(r) + + reg_networks = query(""" + SELECT bssid, + MAX(ssid) AS ssid, + COUNT(DISTINCT date(received_at)) AS days_seen, + MIN(received_at) AS first_seen, + MAX(received_at) AS last_seen, + MAX(rssi) AS best_rssi, + MAX(channel) AS channel, + MAX(encryption) AS encryption, + COUNT(DISTINCT node_id) AS node_count + FROM beacon_events + WHERE bssid IS NOT NULL + GROUP BY bssid + HAVING COUNT(DISTINCT date(received_at)) >= 2 + ORDER BY days_seen DESC, best_rssi DESC + LIMIT 50 + """) + + return { + "present": present, + "new_macs": new_macs, + "new_networks": new_networks, + "reg_macs": reg_macs, + "reg_networks": reg_networks, + } + + def build_node_detail(node_id: str) -> dict | None: rows = query(""" SELECT @@ -349,6 +466,11 @@ async def api_cross_node(): return build_cross_node() +@app.get("/api/presence") +async def api_presence(): + return build_presence() + + @app.get("/api/rssi-history") async def api_rssi_history(bssid: str, hours: int = 2): hours = min(max(hours, 1), 48) diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css index 4f3c4f6..1e87e2d 100644 --- a/coordinator/static/dashboard.css +++ b/coordinator/static/dashboard.css @@ -360,6 +360,25 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); } letter-spacing: 0.06em; } +/* ── Presence ── */ +.presence-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.presence-sub { + font-size: 10px; + color: var(--text-muted); + margin-left: auto; +} +.presence-sub-label { + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; +} + /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar-track { background: var(--bg); } diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js index 9db39b3..53c6c34 100644 --- a/coordinator/static/dashboard.js +++ b/coordinator/static/dashboard.js @@ -8,6 +8,7 @@ let networksData = []; let netSort = { col: 'times_seen', dir: 'desc' }; let crossNodeData = { node_ids: [], networks: [] }; let clientsData = []; +let presenceData = {}; let chartBssid = null; let chartHours = 2; @@ -316,6 +317,126 @@ function renderClientsTable() { }).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 = (!r.encryption || r.encryption === 'OPEN' || r.encryption === 'None') ? 'enc-open' : 'muted'; + 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.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)} + ${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 = (!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.best_rssi)} dBm + ${fmt(r.channel)} + `; + }).join(''); + } +} + // ── RSSI chart ──────────────────────────────────────────────────────────────── async function showNetworkChart(bssid) { chartBssid = bssid; @@ -344,7 +465,7 @@ 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 nodeIds = Object.keys(series).sort(); const allPts = nodeIds.flatMap(id => series[id]); if (!allPts.length) { @@ -446,10 +567,12 @@ function setView(view) { 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() { @@ -466,6 +589,8 @@ 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'); @@ -517,6 +642,7 @@ function startSSE() { 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])); diff --git a/coordinator/templates/dashboard.html b/coordinator/templates/dashboard.html index 5767e1d..1e6e9cc 100644 --- a/coordinator/templates/dashboard.html +++ b/coordinator/templates/dashboard.html @@ -27,6 +27,7 @@ + @@ -211,6 +212,98 @@ + +
+ + +
+ Present Now + + real MACs · seen 2+ times in last hour +
+
+ + + + + + +
MACVendorBest RSSIAvg RSSITimes SeenNodesLast Seen
+ +
+ + +
+ New Arrivals + + first seen in last 24h +
+
+
+
Devices
+
+ + + + + + +
MACVendorFirst SeenBest RSSITimes Seen
+ +
+
+
+
Networks
+
+ + + + + + +
SSIDBSSIDFirst SeenBest RSSIChEnc
+ +
+
+
+ + +
+ Regulars + + seen on 2+ distinct days +
+
+
+
Devices
+
+ + + + + + +
MACVendorDays SeenFirst SeenLast SeenBest RSSI
+ +
+
+
+
Networks
+
+ + + + + + +
SSIDBSSIDDays SeenFirst SeenBest RSSICh
+ +
+
+
+ +
+