diff --git a/README.md b/README.md index ba909d0..c3782b8 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,18 @@ This is a learning/research project covering distributed systems, event-driven a - **Coordinator:** Orange Pi at `192.168.1.133`, runs 24/7 as systemd services - **Dev machine:** This PC at `192.168.1.101` — coding and flashing only - **Flashing:** Arduino CLI on this PC +- **Pen testing:** Parrot OS laptop at `192.168.1.124` (user: `keny`) — active investigation rig +- **WiFi adapter:** Alfa MT7612U (`wlx00c0cab67193`) — monitor mode + packet injection, plugged into Parrot laptop +- **Pen testing tools:** airgeddon at `/home/keny/Documents/github_tools/airgeddon` ### Active nodes -| node_id | MAC | Location | -|----------|-------------------|-------------------------| -| F68D6E30 | 44:1b:f6:8d:6e:30 | room 1 (permanent) | -| A1D658D4 | e0:72:a1:d6:58:d4 | room 2 (permanent) | -| A1D700C4 | e0:72:a1:d7:00:c4 | dev machine /dev/ttyACM2| -| A1D6F190 | e0:72:a1:d6:f1:90 | dev machine /dev/ttyACM1| +| node_id | MAC | Location | +|----------|-------------------|------------------------------| +| F68D6E30 | 44:1b:f6:8d:6e:30 | room 1 (permanent) | +| A1D658D4 | e0:72:a1:d6:58:d4 | room 2 (permanent) | +| A1D700C4 | e0:72:a1:d7:00:c4 | room 3 (permanent) | +| A1D6F190 | e0:72:a1:d6:f1:90 | dev machine (desk, USB) | --- @@ -43,6 +46,7 @@ This is a learning/research project covering distributed systems, event-driven a - **Coordinator IP:** `192.168.1.133` (Orange Pi, production) - **UDP port:** `5005` - **Dashboard port:** `8080` +- **Parrot laptop:** `192.168.1.124` — SSH as `keny`, passwordless sudo configured --- @@ -345,12 +349,39 @@ The **Bursts** section is the anomaly detector. A single unique source MAC gener Multi-node confirmation (`node_count > 1`, highlighted red) significantly raises confidence — it means the source is physically close and strong, not a distant weak signal. +### Our own networks + +To avoid investigating our own infrastructure, these SSIDs and BSSIDs are ours: + +| SSID | Band | Purpose | +|-----------|--------|--------------------------------| +| `sandbox` | 2.4GHz | Main network, nodes connect here| +| `botnet` | 2.4GHz | IoT devices | +| `pronet` | 5GHz | Main 5GHz network | +| `mango` | 2.4GHz | Secondary network | + +`sandbox` BSSID `1C:3B:F3:9C:AC:30` appearing in deauth/impersonation data is expected — our own nodes briefly deauth from it during channel hopping and reconnect. + ### OUI lookup The `oui.txt` file is the IEEE public OUI database (39,171 entries as of download). It maps the first 3 bytes of a real MAC to a manufacturer name. Used in the Clients tab. Refresh it occasionally by re-running `deploy.sh` after downloading a fresh copy from `https://standards-oui.ieee.org/oui/oui.txt`. --- +## Active investigations + +### Sustained deauth attack on Tuya device +A persistent automated deauth flood has been running since **2026-04-03**, targeting `38:2C:E5:7E:77:1D` (Tuya Smart Inc. device). Four spoofed source MACs fire simultaneously roughly every hour, all using reason code 2, impersonating real AP BSSIDs in the building. All 4 nodes confirm it. Consistent with an automated WPA2 handshake capture tool. No action taken yet — being passively monitored. + +Attacker src MACs: `82:4E:66:47:09:C1`, `42:8C:46:6E:12:9C`, `62:D9:AA:E8:B4:09`, `62:87:CB:38:2C:20` + +### Living Room speaker (own device) +SSID: `Living Room speaker.n078` · BSSID: `FA:8F:CA:76:06:B2` · ch 6 · OPEN · RSSI -54 dBm (bathroom, same apartment). + +Broadcasting an open unauthenticated hotspot. Next step: connect via Parrot laptop + Alfa adapter, nmap the provisioning interface, document what is exposed. Not yet started. + +--- + ## Current status - [x] Arduino CLI installed, ESP32 core configured @@ -375,13 +406,47 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa - [x] Queue drop counters — tracked per queue in firmware, reported in heartbeat, stored in DB - [x] SQLite indexes — received_at, node_id, bssid, src_mac, src, dst across all event tables - [x] Dashboard query caps — 300-row limits on Clients, Networks, Cross-node to keep UI responsive -- [ ] Attack session reconstruction — group deauth events into discrete sessions by source/time -- [ ] Surface assoc events in dashboard (Sessions tab or Search results) -- [ ] DB pruning / retention policy (events.db grows indefinitely) +- [x] Sessions tab — deauth events grouped into sessions by source MAC + 2-min gap, sortable, expandable rows +- [x] Dashboard performance overhaul — fixed tab switch latency and Networks tab failing to load (see below) +- [x] DB pruning / retention policy — background task deletes events older than HOT_DAYS every 6 hours +- [ ] Surface assoc events in dashboard (Search results or dedicated view) - [ ] Scan interval control from dashboard --- +## Dashboard performance overhaul (2026-04-06) + +### Problem + +Tab switches took up to a minute to load. The Networks tab failed to load entirely. The SSE live feed would stall during tab switches. The root cause was three compounding issues: + +**1. Blocking the async event loop.** +FastAPI routes are `async def`, but the SQLite calls (`sqlite3` module) are fully synchronous. When called directly inside `async def`, they block uvicorn's entire event loop — meaning while one slow query runs, the server cannot serve any other request, including the SSE stream. This is why everything froze together. + +**2. Expensive endpoints had no caching.** +`/api/alerts`, `/api/sessions`, `/api/presence`, and `/api/cross-node` ran full database queries on every single request, with no result caching. The 2-second debounce on the frontend meant these were being called repeatedly. + +`build_sessions()` was the worst: it pulled every deauth event from the last 30 days and grouped them into sessions in a Python loop — O(N) in Python on every call, with no cache. Under a sustained deauth flood (thousands of events/day), this was very slow. + +`build_alerts()` ran several queries against the **entire** `deauth_events` table with no time cutoff — top targets, top targeted devices, reason breakdown, activity heatmap all scanned all-time data. + +**3. Unbounded database growth.** +No pruning meant every query got slower every day as the tables grew. The database had also developed B-tree corruption (double-referenced pages, out-of-order rowids), likely from WAL journal not checkpointing cleanly under sustained write load. The database was rebuilt clean. + +### Fixes applied + +- **`asyncio.to_thread()`** — all `build_*` calls in every route are now dispatched to a thread pool executor. The event loop stays free to handle SSE and other requests while queries run in the background. The SSE generator's inline `query()` calls were also fixed the same way. + +- **Caching added** — alerts (60s TTL), sessions (60s), presence (60s), cross-node (60s) now cache results in memory. The existing nodes/networks/clients caches were kept. Tab switches hit the cache on repeated loads rather than re-querying the DB. + +- **Time bounds on alerts queries** — top targets, top targeted devices, reason breakdown, and activity heatmap are now scoped to the last 7 days instead of all-time. Still meaningful, no longer scanning the full table history. + +- **Composite indexes added** — added `(bssid, received_at)` on `beacon_events` and `deauth_events`, and `(src_mac, received_at)` on `probe_events`. Queries that filter by time and aggregate by BSSID or MAC now use a single composite index instead of two separate ones. + +- **Background pruning task** — at startup, a background coroutine runs every 6 hours and deletes rows older than `HOT_DAYS` (30 days) from all event tables, followed by a WAL checkpoint. The database will no longer grow indefinitely. + +--- + ## Python dependencies ``` diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py index d47c6c8..2b10a5d 100644 --- a/coordinator/dashboard.py +++ b/coordinator/dashboard.py @@ -19,6 +19,7 @@ JS_PATH = os.path.join(os.path.dirname(__file__), "static", "dashboard.js") OUI_PATH = os.path.join(os.path.dirname(__file__), "oui.txt") ONLINE_SECS = 30 # node considered online if heartbeat within this many seconds EVENT_CAP = 100 # max events returned / shown in dashboard +HOT_DAYS = 30 # dashboard queries cover this many days; Search is always all-time # ─── Deauth reason codes ───────────────────────────────────────────────────── @@ -113,6 +114,10 @@ def query(sql: str, params: tuple = ()) -> list[dict]: return [dict(r) for r in rows] +def _hot_cutoff() -> str: + """ISO timestamp HOT_DAYS ago — used to scope dashboard queries.""" + return (datetime.datetime.utcnow() - datetime.timedelta(days=HOT_DAYS)).isoformat(timespec="seconds") + def _node_status(last_seen: str | None, threshold: int = ONLINE_SECS) -> str: if not last_seen: return "offline" @@ -125,7 +130,26 @@ def _node_status(last_seen: str | None, threshold: int = ONLINE_SECS) -> str: # ─── API data builders ──────────────────────────────────────────────────────── +_nodes_cache: tuple[float, list] | None = None +_networks_cache: tuple[float, list] | None = None +_clients_cache: tuple[float, list] | None = None +_cross_node_cache: tuple[float, dict] | None = None +_presence_cache: tuple[float, dict] | None = None +_alerts_cache: tuple[float, dict] | None = None +_sessions_cache: tuple[float, list] | None = None +_NODES_CACHE_TTL = 10 # seconds +_NETWORKS_CACHE_TTL = 30 # seconds +_CLIENTS_CACHE_TTL = 30 # seconds +_CROSS_NODE_CACHE_TTL = 60 # seconds +_PRESENCE_CACHE_TTL = 60 # seconds +_ALERTS_CACHE_TTL = 60 # seconds +_SESSIONS_CACHE_TTL = 60 # seconds + def build_nodes() -> list[dict]: + global _nodes_cache + now = datetime.datetime.utcnow().timestamp() + if _nodes_cache and (now - _nodes_cache[0]) < _NODES_CACHE_TTL: + return _nodes_cache[1] rows = query(""" SELECT node_id, @@ -156,6 +180,7 @@ def build_nodes() -> list[dict]: r["status"] = _node_status(r["last_heartbeat"], threshold=30) else: r["status"] = _node_status(r["last_beacon"], threshold=60) + _nodes_cache = (now, rows) return rows @@ -173,6 +198,10 @@ def build_events(limit: int = EVENT_CAP, node_id: str | None = None) -> list[dic def build_networks() -> list[dict]: + global _networks_cache + now = datetime.datetime.utcnow().timestamp() + if _networks_cache and (now - _networks_cache[0]) < _NETWORKS_CACHE_TTL: + return _networks_cache[1] rows = query(""" SELECT ssid, @@ -185,18 +214,24 @@ def build_networks() -> list[dict]: COUNT(DISTINCT node_id) AS node_count, MAX(received_at) AS last_seen FROM beacon_events - WHERE bssid IS NOT NULL + WHERE bssid IS NOT NULL AND received_at > ? GROUP BY bssid ORDER BY times_seen DESC LIMIT 300 - """) + """, (_hot_cutoff(),)) + _networks_cache = (now, rows) return rows def build_clients() -> list[dict]: + global _clients_cache + now = datetime.datetime.utcnow().timestamp() + if _clients_cache and (now - _clients_cache[0]) < _CLIENTS_CACHE_TTL: + return _clients_cache[1] """ Per unique src_mac: aggregated stats and the SSIDs they were probing for. """ + cutoff = _hot_cutoff() rows = query(""" SELECT src_mac, @@ -208,11 +243,11 @@ def build_clients() -> list[dict]: MIN(received_at) AS first_seen, MAX(received_at) AS last_seen FROM probe_events - WHERE src_mac IS NOT NULL + WHERE src_mac IS NOT NULL AND received_at > ? GROUP BY src_mac ORDER BY times_seen DESC LIMIT 300 - """) + """, (cutoff,)) # Fetch SSIDs only for the MACs we actually returned if not rows: return [] @@ -221,8 +256,9 @@ def build_clients() -> list[dict]: ssid_rows = query(f""" SELECT DISTINCT src_mac, ssid FROM probe_events WHERE src_mac IN ({placeholders}) AND ssid IS NOT NULL AND ssid != '' + AND received_at > ? ORDER BY src_mac, ssid - """, tuple(mac_list)) + """, tuple(mac_list) + (cutoff,)) ssids_by_mac: dict = {} for s in ssid_rows: ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"]) @@ -232,6 +268,7 @@ def build_clients() -> list[dict]: vendor, randomized = oui_lookup(r["src_mac"]) r["vendor"] = vendor r["randomized"] = randomized + _clients_cache = (now, rows) return rows @@ -241,9 +278,15 @@ def build_cross_node() -> dict: Result: { node_ids: [...], networks: [{bssid, ssid, channel, encryption, node_count, nodes: {node_id: {best_rssi, avg_rssi, times_seen}}}] } """ + global _cross_node_cache + now = datetime.datetime.utcnow().timestamp() + if _cross_node_cache and (now - _cross_node_cache[0]) < _CROSS_NODE_CACHE_TTL: + return _cross_node_cache[1] + node_rows = query("SELECT DISTINCT node_id FROM beacon_events ORDER BY node_id") node_ids = [r["node_id"] for r in node_rows] + cutoff = _hot_cutoff() rows = query(""" SELECT b.bssid, @@ -255,16 +298,16 @@ def build_cross_node() -> dict: ROUND(AVG(b.rssi), 1) AS avg_rssi, COUNT(*) AS times_seen FROM beacon_events b - WHERE b.bssid IN ( + WHERE b.received_at > ? AND b.bssid IN ( SELECT bssid FROM beacon_events - WHERE bssid IS NOT NULL + WHERE bssid IS NOT NULL AND received_at > ? GROUP BY bssid ORDER BY COUNT(DISTINCT node_id) DESC, COUNT(*) DESC LIMIT 300 ) GROUP BY b.bssid, b.node_id ORDER BY b.bssid, b.node_id - """) + """, (cutoff, cutoff)) networks: dict = {} for r in rows: @@ -291,10 +334,17 @@ def build_cross_node() -> dict: for net in result: net["node_count"] = len(net["nodes"]) - return {"node_ids": node_ids, "networks": result} + result_dict = {"node_ids": node_ids, "networks": result} + _cross_node_cache = (now, result_dict) + return result_dict def build_presence() -> dict: + global _presence_cache + _now_ts = datetime.datetime.utcnow().timestamp() + if _presence_cache and (_now_ts - _presence_cache[0]) < _PRESENCE_CACHE_TTL: + return _presence_cache[1] + now = datetime.datetime.utcnow() cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds") cutoff_24h = (now - datetime.timedelta(hours=24)).isoformat(timespec="seconds") @@ -341,6 +391,7 @@ def build_presence() -> dict: r["randomized"] = randomized new_macs.append(r) + cutoff_hot = _hot_cutoff() new_networks = query(""" SELECT bssid, MAX(ssid) AS ssid, @@ -350,12 +401,12 @@ def build_presence() -> dict: MAX(encryption) AS encryption, COUNT(DISTINCT node_id) AS node_count FROM beacon_events - WHERE bssid IS NOT NULL + WHERE bssid IS NOT NULL AND received_at > ? GROUP BY bssid HAVING MIN(received_at) > ? ORDER BY first_seen DESC LIMIT 100 - """, (cutoff_24h,)) + """, (cutoff_hot, cutoff_24h,)) # ── Regulars: seen on 2+ distinct calendar days ─────────────────────────── reg_mac_rows = query(""" @@ -398,13 +449,15 @@ def build_presence() -> dict: LIMIT 50 """) - return { + result = { "present": present, "new_macs": new_macs, "new_networks": new_networks, "reg_macs": reg_macs, "reg_networks": reg_networks, } + _presence_cache = (_now_ts, result) + return result SESSION_GAP_SECS = 120 # gap > 2 min between events from same src = new session @@ -450,7 +503,12 @@ def _finalize_session(active: dict) -> dict: def build_sessions() -> list[dict]: - cutoff = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).isoformat(timespec="seconds") + global _sessions_cache + now = datetime.datetime.utcnow().timestamp() + if _sessions_cache and (now - _sessions_cache[0]) < _SESSIONS_CACHE_TTL: + return _sessions_cache[1] + + cutoff = _hot_cutoff() rows = query(""" SELECT src, dst, bssid, reason, rssi, received_at, node_id, subtype @@ -505,13 +563,21 @@ def build_sessions() -> list[dict]: sessions.append(_finalize_session(active)) sessions.sort(key=lambda s: s["start"], reverse=True) - return sessions[:100] + result = sessions[:100] + _sessions_cache = (now, result) + return result def build_alerts() -> dict: + global _alerts_cache + _now_ts = datetime.datetime.utcnow().timestamp() + if _alerts_cache and (_now_ts - _alerts_cache[0]) < _ALERTS_CACHE_TTL: + return _alerts_cache[1] + now = datetime.datetime.utcnow() cutoff_5m = (now - datetime.timedelta(minutes=5)).isoformat(timespec="seconds") cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds") + cutoff_7d = (now - datetime.timedelta(days=7)).isoformat(timespec="seconds") # Recent raw deauth/disassoc events recent = query(""" @@ -564,7 +630,7 @@ def build_alerts() -> dict: WHERE received_at > ? """, (cutoff_1h,)) - # Most targeted BSSIDs — all time + # Most targeted BSSIDs — last 7 days top_targets = query(""" SELECT bssid, @@ -574,10 +640,11 @@ def build_alerts() -> dict: MIN(received_at) AS first_seen, MAX(received_at) AS last_seen FROM deauth_events + WHERE received_at > ? GROUP BY bssid ORDER BY total_frames DESC LIMIT 10 - """) + """, (cutoff_7d,)) if top_targets: bssid_list = [t["bssid"] for t in top_targets] placeholders = ",".join("?" * len(bssid_list)) @@ -590,7 +657,7 @@ def build_alerts() -> dict: for t in top_targets: t["ssid"] = ssid_map.get(t["bssid"]) - # Most targeted devices — all time (grouped by dst, the actual victim) + # Most targeted devices — last 7 days (grouped by dst, the actual victim) top_targeted_devices = query(""" SELECT dst, @@ -600,16 +667,17 @@ def build_alerts() -> dict: MIN(received_at) AS first_seen, MAX(received_at) AS last_seen FROM deauth_events + WHERE received_at > ? GROUP BY dst ORDER BY total_frames DESC LIMIT 10 - """) + """, (cutoff_7d,)) for d in top_targeted_devices: vendor, randomized = oui_lookup(d["dst"]) d["vendor"] = vendor d["randomized"] = randomized - # Reason code breakdown — all time + # Reason code breakdown — last 7 days reason_rows = query(""" SELECT reason, COUNT(*) AS total_frames, @@ -618,25 +686,27 @@ def build_alerts() -> dict: MIN(received_at) AS first_seen, MAX(received_at) AS last_seen FROM deauth_events + WHERE received_at > ? GROUP BY reason ORDER BY total_frames DESC - """) + """, (cutoff_7d,)) for r in reason_rows: desc, classification = deauth_reason_info(r["reason"]) r["description"] = desc r["classification"] = classification - # Activity heatmap — frame count per day × hour + # Activity heatmap — frame count per day × hour, last 7 days heatmap_rows = query(""" SELECT date(received_at) AS day, CAST(strftime('%H', received_at) AS INTEGER) AS hour, COUNT(*) AS frames FROM deauth_events + WHERE received_at > ? GROUP BY day, hour ORDER BY day, hour - """) + """, (cutoff_7d,)) - return { + result = { "recent": recent, "bursts": bursts, "summary": summary[0] if summary else {}, @@ -645,6 +715,8 @@ def build_alerts() -> dict: "reason_stats": reason_rows, "heatmap": heatmap_rows, } + _alerts_cache = (_now_ts, result) + return result def build_search(q: str) -> dict: @@ -893,30 +965,56 @@ def ensure_schema(): ) """) conn.executescript(""" - CREATE INDEX IF NOT EXISTS idx_beacon_received_at ON beacon_events (received_at); - CREATE INDEX IF NOT EXISTS idx_beacon_node_id ON beacon_events (node_id); - CREATE INDEX IF NOT EXISTS idx_beacon_bssid ON beacon_events (bssid); - CREATE INDEX IF NOT EXISTS idx_probe_received_at ON probe_events (received_at); - CREATE INDEX IF NOT EXISTS idx_probe_node_id ON probe_events (node_id); - CREATE INDEX IF NOT EXISTS idx_probe_src_mac ON probe_events (src_mac); - CREATE INDEX IF NOT EXISTS idx_heartbeat_node_id ON heartbeat_events (node_id); - CREATE INDEX IF NOT EXISTS idx_deauth_received_at ON deauth_events (received_at); - CREATE INDEX IF NOT EXISTS idx_deauth_node_id ON deauth_events (node_id); - CREATE INDEX IF NOT EXISTS idx_deauth_bssid ON deauth_events (bssid); - CREATE INDEX IF NOT EXISTS idx_deauth_src ON deauth_events (src); - CREATE INDEX IF NOT EXISTS idx_deauth_dst ON deauth_events (dst); - CREATE INDEX IF NOT EXISTS idx_assoc_received_at ON assoc_events (received_at); - CREATE INDEX IF NOT EXISTS idx_assoc_node_id ON assoc_events (node_id); - CREATE INDEX IF NOT EXISTS idx_assoc_src ON assoc_events (src); - CREATE INDEX IF NOT EXISTS idx_assoc_bssid ON assoc_events (bssid); + CREATE INDEX IF NOT EXISTS idx_beacon_received_at ON beacon_events (received_at); + CREATE INDEX IF NOT EXISTS idx_beacon_node_id ON beacon_events (node_id); + CREATE INDEX IF NOT EXISTS idx_beacon_bssid ON beacon_events (bssid); + CREATE INDEX IF NOT EXISTS idx_beacon_bssid_time ON beacon_events (bssid, received_at); + CREATE INDEX IF NOT EXISTS idx_probe_received_at ON probe_events (received_at); + CREATE INDEX IF NOT EXISTS idx_probe_node_id ON probe_events (node_id); + CREATE INDEX IF NOT EXISTS idx_probe_src_mac ON probe_events (src_mac); + CREATE INDEX IF NOT EXISTS idx_probe_src_time ON probe_events (src_mac, received_at); + CREATE INDEX IF NOT EXISTS idx_heartbeat_node_id ON heartbeat_events (node_id); + CREATE INDEX IF NOT EXISTS idx_deauth_received_at ON deauth_events (received_at); + CREATE INDEX IF NOT EXISTS idx_deauth_node_id ON deauth_events (node_id); + CREATE INDEX IF NOT EXISTS idx_deauth_bssid ON deauth_events (bssid); + CREATE INDEX IF NOT EXISTS idx_deauth_bssid_time ON deauth_events (bssid, received_at); + CREATE INDEX IF NOT EXISTS idx_deauth_src ON deauth_events (src); + CREATE INDEX IF NOT EXISTS idx_deauth_dst ON deauth_events (dst); + CREATE INDEX IF NOT EXISTS idx_assoc_received_at ON assoc_events (received_at); + CREATE INDEX IF NOT EXISTS idx_assoc_node_id ON assoc_events (node_id); + CREATE INDEX IF NOT EXISTS idx_assoc_src ON assoc_events (src); + CREATE INDEX IF NOT EXISTS idx_assoc_bssid ON assoc_events (bssid); """) conn.commit() +PRUNE_INTERVAL_SECS = 6 * 3600 # run pruning every 6 hours + + +def prune_old_events() -> None: + """Delete events older than HOT_DAYS from all event tables.""" + cutoff = _hot_cutoff() + tables = ["beacon_events", "probe_events", "deauth_events", "assoc_events"] + with get_conn() as conn: + for table in tables: + conn.execute(f"DELETE FROM {table} WHERE received_at < ?", (cutoff,)) + conn.execute("PRAGMA wal_checkpoint(PASSIVE)") + conn.commit() + + app = FastAPI(title="ESP32 Recon Dashboard") ensure_schema() +@app.on_event("startup") +async def start_pruning_task(): + async def _pruner(): + while True: + await asyncio.sleep(PRUNE_INTERVAL_SECS) + await asyncio.to_thread(prune_old_events) + asyncio.create_task(_pruner()) + + @app.get("/", response_class=HTMLResponse) async def index(): return FileResponse(HTML_PATH) @@ -934,73 +1032,77 @@ async def serve_js(): @app.get("/api/nodes") async def api_nodes(): - return build_nodes() + return await asyncio.to_thread(build_nodes) @app.get("/api/events") async def api_events(limit: int = EVENT_CAP): limit = min(limit, EVENT_CAP) - return build_events(limit=limit) + return await asyncio.to_thread(build_events, limit) @app.get("/api/networks") async def api_networks(): - return build_networks() + return await asyncio.to_thread(build_networks) @app.get("/api/clients") async def api_clients(): - return build_clients() + return await asyncio.to_thread(build_clients) @app.get("/api/cross-node") async def api_cross_node(): - return build_cross_node() + return await asyncio.to_thread(build_cross_node) @app.get("/api/presence") async def api_presence(): - return build_presence() + return await asyncio.to_thread(build_presence) @app.get("/api/rssi-history") async def api_rssi_history(bssid: str, hours: int = 2): hours = min(max(hours, 1), 48) cutoff = (datetime.datetime.utcnow() - datetime.timedelta(hours=hours)).isoformat(timespec="seconds") - rows = query(""" - SELECT node_id, received_at, rssi - FROM beacon_events - WHERE bssid = ? AND received_at >= ? - ORDER BY received_at ASC - """, (bssid, cutoff)) - series: dict = {} - for r in rows: - nid = r["node_id"] - if nid not in series: - series[nid] = [] - series[nid].append({"t": r["received_at"], "rssi": r["rssi"]}) - return series + + def _fetch(): + rows = query(""" + SELECT node_id, received_at, rssi + FROM beacon_events + WHERE bssid = ? AND received_at >= ? + ORDER BY received_at ASC + """, (bssid, cutoff)) + series: dict = {} + for r in rows: + nid = r["node_id"] + if nid not in series: + series[nid] = [] + series[nid].append({"t": r["received_at"], "rssi": r["rssi"]}) + return series + + return await asyncio.to_thread(_fetch) @app.get("/api/sessions") async def api_sessions(): - return build_sessions() + return await asyncio.to_thread(build_sessions) @app.get("/api/search") async def api_search(q: str = ""): - return build_search(q) + return await asyncio.to_thread(build_search, q) @app.get("/api/alerts") async def api_alerts(): - return build_alerts() + return await asyncio.to_thread(build_alerts) @app.get("/api/heartbeats") async def api_heartbeats(): """Latest heartbeat per node.""" - return query(""" + return await asyncio.to_thread(query, """ SELECT node_id, received_at, uptime_ms, free_heap, wifi_rssi FROM heartbeat_events WHERE id IN (SELECT MAX(id) FROM heartbeat_events GROUP BY node_id) @@ -1009,7 +1111,7 @@ async def api_heartbeats(): @app.get("/api/nodes/{node_id}") async def api_node_detail(node_id: str): - detail = build_node_detail(node_id) + detail = await asyncio.to_thread(build_node_detail, node_id) if detail is None: raise HTTPException(status_code=404, detail="Node not found") return detail @@ -1022,13 +1124,13 @@ async def api_stream(): try: last_id = 0 # Start from the current latest event so we don't replay history on connect - rows = query("SELECT MAX(id) AS max_id FROM beacon_events") + rows = await asyncio.to_thread(query, "SELECT MAX(id) AS max_id FROM beacon_events") if rows and rows[0]["max_id"]: last_id = rows[0]["max_id"] while True: await asyncio.sleep(2) - new_rows = query(""" + new_rows = await asyncio.to_thread(query, """ SELECT * FROM beacon_events WHERE id > ? ORDER BY id ASC