From 4a489fd67fa51bfd7d480816c7c35643c18e813a Mon Sep 17 00:00:00 2001 From: Robert Barnett Date: Sun, 4 Oct 2026 15:12:03 -0400 Subject: [PATCH] Add Airtime by Band, RF Spectrum and Signal Scope widgets for the NOC Builder Publishes three Dashboard visuals that had no widget: fleet airtime per band over time, the radios placed in the 2.4, 5 and 6 GHz spectrum, and every client by access point and signal strength. RF Spectrum and Signal Scope share app/wifi/rf.py with the Dashboard. --- README.md | 4 ++ app/api/widgets.py | 157 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/README.md b/README.md index a09a607..5b6ef81 100644 --- a/README.md +++ b/README.md @@ -637,6 +637,10 @@ this app's own role check against the `X-Suite-Role` pktHub asserts. which resolves against the parent's height — and collapses to zero against an auto-height parent, rendering blank. Maps and canvases hit this first. +### RF widgets for the NOC Builder + +Three Dashboard visuals are now NOC widgets: **Airtime by Band** (average channel utilization per band over time, across every radio), **RF Spectrum** (each radio placed by channel and width in the 2.4, 5 and 6 GHz bands, height showing utilization; dashed marks a width that was not reported, an approximate position, or no utilization reading) and **Signal Scope** (every connected client by the access point it sits on, range showing signal strength). They share `app/wifi/rf.py` with the Dashboard, so channel placement and signal thresholds cannot drift between the two. + ### Widget endpoints now require the suite token `app/api/widgets.py` previously mounted its router with a bare `APIRouter()`, diff --git a/app/api/widgets.py b/app/api/widgets.py index a07c51c..5eede7d 100644 --- a/app/api/widgets.py +++ b/app/api/widgets.py @@ -181,6 +181,26 @@ async def _capture_refresh(request: Request) -> None: "params": [_WINDOW_PARAM], }, + { + "id": "airtime_trend", "title": "Airtime by Band", "category": "Trends", + "description": "Average channel utilization per band over time, across all radios", + "view_path": "/api/widgets/airtime_trend", + "default_w": 680, "default_h": 320, "min_w": 320, "min_h": 180, + "params": [_WINDOW_PARAM], + }, + { + "id": "rf_spectrum", "title": "RF Spectrum", "category": "Radios", + "description": "Where each radio sits in the 2.4, 5 and 6 GHz bands; height is channel utilization", + "view_path": "/api/widgets/rf_spectrum", + "default_w": 760, "default_h": 420, "min_w": 400, "min_h": 260, + }, + { + "id": "signal_scope", "title": "Signal Scope", "category": "Clients", + "description": "Every connected client by the access point it is on; range is signal strength", + "view_path": "/api/widgets/signal_scope", + "default_w": 520, "default_h": 460, "min_w": 300, "min_h": 300, + }, + # ── Alerts ──────────────────────────────────────────────────────────────── { "id": "active_alerts", "title": "Active Alerts", "category": "Alerts", @@ -832,6 +852,143 @@ async def widget_radio_trend( return HTMLResponse(_page(f"{label} — last {hours}h", body)) +# ── Airtime by Band widget (chart) ──────────────────────────────────────────── +@router.get("/airtime_trend", response_class=HTMLResponse, include_in_schema=False) +async def widget_airtime_trend(hours: int = 6): + hours = max(1, min(int(hours or 6), 720)) + bucket = max(60, (hours * 3600) // 120) + rows = await _rows( + """SELECT (CAST(strftime('%s', m.ts) AS INTEGER) / ?) AS b, r.band AS band, + AVG(m.utilization_pct) AS util + FROM radio_metrics m JOIN radios r ON r.id = m.radio_id + WHERE m.ts >= ? AND m.utilization_pct IS NOT NULL + AND r.band IN ('2.4GHz','5GHz','6GHz') + GROUP BY b, r.band ORDER BY b""", + (bucket, _since(hours)), + ) + if not rows: + return HTMLResponse(_page("Airtime by Band", _empty('No radio reported utilization in this window'))) + buckets = sorted({r["b"] for r in rows}) + series = [] + for band in ("2.4GHz", "5GHz", "6GHz"): + have = {r["b"]: r["util"] for r in rows if r["band"] == band} + if not have: + continue + # Carry the last reading across a gap (and the first one back to the + # start) so every band spans the same buckets. + first = have[min(have)] + vals, last = [], first + for b in buckets: + last = have.get(b, last) + vals.append(float(last)) + series.append((band, vals)) + body = _line_chart(series, fmt=lambda v: f"{v:.0f}%") + return HTMLResponse(_page(f"Airtime by Band — last {hours}h", body)) + + +# ── RF Spectrum widget ──────────────────────────────────────────────────────── +@router.get("/rf_spectrum", response_class=HTMLResponse, include_in_schema=False) +async def widget_rf_spectrum(): + from app.wifi.rf import BANDS, band_axis, occupied_span + rows = await _rows( + """SELECT ap.name AS ap_name, r.band, r.channel, r.channel_width_mhz, r.utilization_pct + FROM radios r JOIN access_points ap ON ap.id = r.access_point_id + WHERE r.band IN ('2.4GHz','5GHz','6GHz')""" + ) + placed = [] + for r in rows: + span = occupied_span(r["band"], r["channel"], r["channel_width_mhz"]) + if span: + placed.append({**r, **span}) + if not placed: + return HTMLResponse(_page("RF Spectrum", _empty('No radio has a usable channel'))) + + colors = {"2.4GHz": "#fb923c", "5GHz": "#60a5fa", "6GHz": "#a78bfa"} + W, LANE, GAP = 600, 96, 22 + lanes, y0 = [], 0 + for band in BANDS: + mine = [p for p in placed if p["band"] == band] + if not mine: + continue + ax = band_axis(band) + lo, hi = ax["lo_mhz"], ax["hi_mhz"] + X = lambda mhz: (mhz - lo) / (hi - lo) * W + base = y0 + LANE + parts = [f'{html.escape(band)}', + f''] + for t in ax["ticks"]: + if t["major"]: + x = X(t["mhz"]) + parts.append(f'' + f'{t["channel"]}') + for p in mine: + util = p["utilization_pct"] + h = max(4.0, (float(util) if util is not None else 8.0) / 100 * (LANE - 18)) + x, w = X(p["lo_mhz"]), max(2.0, X(p["hi_mhz"]) - X(p["lo_mhz"])) + dash = ' stroke-dasharray="3 2" stroke="#e2e8f0" stroke-width="1"' \ + if (p["approximate"] or not p["width_reported"] or util is None) else "" + tip = f'{p["ap_name"]} · ch {p["channel"]} · ' + (f'{float(util):.0f}%' if util is not None else 'no utilization') + parts.append(f'{html.escape(tip)}') + lanes.append("".join(parts)) + y0 += LANE + GAP + body = (f'' + f'{"".join(lanes)}' + '
height = channel utilization · ' + 'dashed = width not reported, position approximate or no utilization
') + return HTMLResponse(_page("RF Spectrum", body)) + + +# ── Signal Scope widget ─────────────────────────────────────────────────────── +@router.get("/signal_scope", response_class=HTMLResponse, include_in_schema=False) +async def widget_signal_scope(): + import math + import zlib + from app.wifi.rf import SIGNAL_FAIR_DBM, SIGNAL_GOOD_DBM + rows = await _rows( + """SELECT ap.id AS ap_id, ap.name AS ap_name, c.rssi_dbm, c.mac_address + FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id + WHERE c.rssi_dbm IS NOT NULL AND c.last_seen >= datetime(ap.last_seen, '-60 seconds')""" + ) + if not rows: + return HTMLResponse(_page("Signal Scope", _empty('No client is reporting a signal'))) + by_ap: dict[int, list[dict]] = {} + for r in rows: + by_ap.setdefault(r["ap_id"], []).append(r) + ranked = sorted(by_ap.values(), key=lambda m: (-len(m), m[0]["ap_name"]))[:16] + + CX = CY = 200 + R_CORE, R_MAX, STRONG, WEAK = 20, 150, -30, -95 + rng = lambda v: R_CORE + (R_MAX - R_CORE) * (STRONG - max(WEAK, min(STRONG, v))) / (STRONG - WEAK) + n = len(ranked) + parts = [] + for dbm, lbl in ((SIGNAL_GOOD_DBM, "good"), (SIGNAL_FAIR_DBM, "fair")): + parts.append(f'{dbm} dBm') + for i, members in enumerate(ranked): + a0, a1 = 360 * i / n, 360 * (i + 1) / n + mid = math.radians((a0 + a1) / 2) + sx, sy = CX + R_MAX * math.sin(math.radians(a0)), CY - R_MAX * math.cos(math.radians(a0)) + parts.append(f'') + lx, ly = CX + (R_MAX + 14) * math.sin(mid), CY - (R_MAX + 14) * math.cos(mid) + name = str(members[0]["ap_name"]) + parts.append(f'' + f'{html.escape(name[:14])} · {len(members)}') + for j, c in enumerate(sorted(members, key=lambda m: m["rssi_dbm"])[:120]): + # Where a client sits around its sector means nothing; it is spread + # out only so the dots do not stack. + frac = ((zlib.crc32(f'{c["mac_address"]}|{i}'.encode()) % 1000) / 1000) * 0.8 + 0.1 + ang = math.radians(a0 + (a1 - a0) * frac) + r = rng(float(c["rssi_dbm"])) + col = ("#4ade80" if c["rssi_dbm"] >= SIGNAL_GOOD_DBM + else "#fbbf24" if c["rssi_dbm"] >= SIGNAL_FAIR_DBM else "#f87171") + parts.append(f'') + body = ('{"".join(parts)}') + return HTMLResponse(_page("Signal Scope", body)) + + # ── Client Trend widget (chart) ─────────────────────────────────────────────── @router.get("/client_trend", response_class=HTMLResponse, include_in_schema=False) async def widget_client_trend(hours: int = 6):