From c56b7f6c99b45b7ab7316a3aa11a9d9cea5a74f5 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 18:13:17 +0000 Subject: [PATCH 01/13] feat(slm): wire unit live view to the /monitor fan-out feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SLM live view now consumes SLMM's shared DOD /monitor feed instead of the per-client DRD /stream. This fixes the single-connection contention (many viewers share one device feed) and finally puts L1/L10 in the live chart (DRD couldn't carry percentiles). - New WS proxy handler /api/slmm/{unit}/monitor -> SLMM /api/nl43/{unit}/monitor. Uses asyncio.wait(FIRST_COMPLETED) + cancel-sibling instead of gather(), so it doesn't leave a task sending into a closed socket ("Unexpected ASGI message after close"). - Live view JS points at /monitor; onmessage reflects feed_status and ignores heartbeat / unreachable frames so they don't blank the cards or zero-spike the chart. Adds a small Live/Device-offline badge. Still on the old /live (DRD): the dashboard live tile (sound_level_meters.html) — next slice. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/slmm.py | 65 +++++++++++++++++++++++++++ templates/partials/slm_live_view.html | 29 ++++++++++-- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/backend/routers/slmm.py b/backend/routers/slmm.py index 1c73f5e..b7d3e48 100644 --- a/backend/routers/slmm.py +++ b/backend/routers/slmm.py @@ -231,6 +231,71 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str): logger.info(f"WebSocket proxy closed for {unit_id} (live)") +@router.websocket("/{unit_id}/monitor") +async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str): + """ + Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed). + + This is the shared ~1Hz DOD feed: many clients subscribe to one device feed + (no single-connection contention) and it carries L1/L10 (which the DRD + /stream cannot). Preferred over /stream for the live view. + """ + await websocket.accept() + logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)") + + target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor" + backend_ws = None + + try: + backend_ws = await websockets.connect(target_ws_url) + logger.info(f"Connected to SLMM monitor feed for {unit_id}") + + async def forward_to_client(): + """Backend monitor frames -> browser.""" + async for message in backend_ws: + await websocket.send_text(message) + + async def watch_client(): + """Drain client frames; raises WebSocketDisconnect on close so we can + tear the pair down (the monitor feed is server->client only).""" + while True: + await websocket.receive_text() + + # When EITHER side ends (browser disconnects or backend closes), cancel the + # other immediately — avoids sending into a closed socket (the + # "Unexpected ASGI message after close" race that asyncio.gather leaves open). + tasks = [asyncio.ensure_future(forward_to_client()), + asyncio.ensure_future(watch_client())] + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for t in pending: + t.cancel() + for t in pending: + try: + await t + except Exception: + pass + + except websockets.exceptions.WebSocketException as e: + logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}") + try: + await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)}) + except Exception: + pass + except Exception as e: + logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}") + finally: + if backend_ws: + try: + await backend_ws.close() + except Exception: + pass + try: + await websocket.close() + except Exception: + pass + logger.info(f"WebSocket monitor proxy closed for {unit_id}") + + # HTTP catch-all route MUST come after specific routes (including WebSocket routes) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def proxy_to_slmm(path: str, request: Request): diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index 0779dac..aa19a3b 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -143,6 +143,8 @@ Stop Live Stream + + @@ -512,9 +514,11 @@ function initLiveDataStream(unitId) { window.liveChart.update(); } - // WebSocket URL for SLMM backend via proxy + // WebSocket URL for SLMM backend via proxy. + // /monitor = the shared fan-out DOD feed (many viewers, one device connection, + // and it carries L1/L10 which the DRD /stream cannot). const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`; + const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`; window.currentWebSocket = new WebSocket(wsUrl); @@ -530,7 +534,11 @@ function initLiveDataStream(unitId) { window.currentWebSocket.onmessage = function(event) { try { const data = JSON.parse(event.data); - console.log('WebSocket data received:', data); + // The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a + // 'feed_status' on each frame. Reflect status, but don't let a heartbeat + // or an 'unreachable' frame blank the cards / spike the chart with zeros. + updateFeedStatus(data.feed_status); + if (data.heartbeat || data.feed_status === 'unreachable') return; updateLiveMetrics(data); updateLiveChart(data); } catch (error) { @@ -559,6 +567,21 @@ function stopLiveDataStream() { } } +// Reflect device reachability from the monitor feed's feed_status. Safe no-op +// if the badge element isn't on the page. +function updateFeedStatus(status) { + const el = document.getElementById('live-feed-status'); + if (!el || status == null) return; + if (status === 'unreachable') { + el.textContent = 'Device offline'; + el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; + } else { + el.textContent = 'Live'; + el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; + } + el.style.display = ''; +} + // Update metrics display function updateLiveMetrics(data) { if (document.getElementById('live-lp')) { From 61b144efd235b0ea71651cb32e15cc301e090109 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 19:07:42 +0000 Subject: [PATCH 02/13] feat(slm): plot L1/L10 lines on the live chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L1/L10 cards populated, but the chart only had Lp + Leq datasets, so the percentiles weren't drawn. Add L1 (violet) and L10 (amber) lines — pushed/shifted/cleared alongside Lp/Leq — so the chart shows all four. (Legend labels are hardcoded L1/L10, matching the default percentile slots; dynamic ln1_label/ln2_label on the chart is a follow-up if a job reconfigures the device's Ln slots.) Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/partials/slm_live_view.html | 33 ++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index aa19a3b..e9d8a0f 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -434,6 +434,24 @@ function initializeChart() { tension: 0.3, borderWidth: 2, pointRadius: 0 + }, + { + label: 'L1', + data: [], + borderColor: 'rgb(139, 92, 246)', + backgroundColor: 'rgba(139, 92, 246, 0.1)', + tension: 0.3, + borderWidth: 2, + pointRadius: 0 + }, + { + label: 'L10', + data: [], + borderColor: 'rgb(245, 158, 11)', + backgroundColor: 'rgba(245, 158, 11, 0.1)', + tension: 0.3, + borderWidth: 2, + pointRadius: 0 } ] }, @@ -506,11 +524,12 @@ function initLiveDataStream(unitId) { window.chartData.timestamps = []; window.chartData.lp = []; window.chartData.leq = []; + window.chartData.ln1 = []; + window.chartData.ln2 = []; } if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) { window.liveChart.data.labels = []; - window.liveChart.data.datasets[0].data = []; - window.liveChart.data.datasets[1].data = []; + window.liveChart.data.datasets.forEach(ds => ds.data = []); window.liveChart.update(); } @@ -615,7 +634,9 @@ if (typeof window.chartData === 'undefined') { window.chartData = { timestamps: [], lp: [], - leq: [] + leq: [], + ln1: [], + ln2: [] }; } @@ -625,12 +646,16 @@ function updateLiveChart(data) { window.chartData.timestamps.push(now.toLocaleTimeString()); window.chartData.lp.push(parseFloat(data.lp || 0)); window.chartData.leq.push(parseFloat(data.leq || 0)); + window.chartData.ln1.push(parseFloat(data.ln1 || 0)); + window.chartData.ln2.push(parseFloat(data.ln2 || 0)); // Keep only last 60 data points if (window.chartData.timestamps.length > 60) { window.chartData.timestamps.shift(); window.chartData.lp.shift(); window.chartData.leq.shift(); + window.chartData.ln1.shift(); + window.chartData.ln2.shift(); } // Update chart if available @@ -638,6 +663,8 @@ function updateLiveChart(data) { window.liveChart.data.labels = window.chartData.timestamps; window.liveChart.data.datasets[0].data = window.chartData.lp; window.liveChart.data.datasets[1].data = window.chartData.leq; + window.liveChart.data.datasets[2].data = window.chartData.ln1; + window.liveChart.data.datasets[3].data = window.chartData.ln2; window.liveChart.update('none'); } } From 3b818dcd97e5a9f05a9338baa4ea6a92f2fb000e Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 19:12:34 +0000 Subject: [PATCH 03/13] fix(slm): stop monitor proxy leaking CancelledError on stream stop The /monitor WS proxy cancelled its sibling task on disconnect but then `except Exception` failed to swallow the resulting CancelledError (a BaseException), so stopping the stream raised "Exception in ASGI application". It also only awaited the pending task, leaving the done task's WebSocketDisconnect unretrieved ("Task exception was never retrieved"). Await all tasks and catch (CancelledError, Exception). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/slmm.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/routers/slmm.py b/backend/routers/slmm.py index b7d3e48..62a0385 100644 --- a/backend/routers/slmm.py +++ b/backend/routers/slmm.py @@ -269,10 +269,15 @@ async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str): done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) for t in pending: t.cancel() - for t in pending: + # Await ALL tasks (the done one AND the cancelled one) and swallow both + # the expected WebSocketDisconnect and CancelledError. CancelledError is a + # BaseException, so a bare `except Exception` misses it — that's what leaked + # the traceback on stop; and awaiting only `pending` left the done task's + # exception unretrieved. + for t in tasks: try: await t - except Exception: + except (asyncio.CancelledError, Exception): pass except websockets.exceptions.WebSocketException as e: From bdc91177e2c5b1664527c52c5fdb988daefe463d Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 19:28:55 +0000 Subject: [PATCH 04/13] feat(admin): per-unit live-monitoring (keepalive) toggle on /admin/slmm Adds a "Live Monitoring (keepalive)" card listing each SLMM device with its monitor_enabled state and an Enable/Disable toggle. Reads from /api/slmm/roster (now includes monitor_enabled) and POSTs to /api/slmm/{unit}/monitor/{start,stop}, which persist the flag in SLMM (survives restarts; auto-started on boot). Shows a reachability dot + 24/7 ON/OFF badge. Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/admin_slmm.html | 67 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/templates/admin_slmm.html b/templates/admin_slmm.html index c9056b1..2b88dcd 100644 --- a/templates/admin_slmm.html +++ b/templates/admin_slmm.html @@ -42,6 +42,18 @@ + +
+

Live Monitoring (keepalive)

+

+ Keepalive runs the 1 Hz DOD feed 24/7 (even with no viewer), which powers the live-chart + trail and continuous threshold alerts. Toggling persists and survives restarts. +

+
+

Loading…

+
+
+

Raw API Tester

@@ -132,7 +144,60 @@ async function sendRaw() { } } +async function loadMonitors() { + const el = document.getElementById('monitor-list'); + try { + const r = await fetch('/api/slmm/roster'); + if (!r.ok) throw new Error('HTTP ' + r.status); + const d = await r.json(); + const devices = d.devices || []; + if (!devices.length) { + el.innerHTML = '

No devices configured.

'; + return; + } + el.innerHTML = devices.map(dev => { + const on = !!dev.monitor_enabled; + const reach = dev.status ? dev.status.is_reachable : null; + const reachDot = reach === false + ? '' + : ''; + return ` +
+
+ ${reachDot} + ${_esc(dev.unit_id)} + ${_esc(dev.host)}:${_esc(dev.tcp_port)} +
+
+ ${on ? '24/7 ON' : 'OFF'} + +
+
`; + }).join(''); + } catch (e) { + el.innerHTML = `

Failed to load devices: ${_esc(e.message)}

`; + } +} + +async function toggleMonitor(unitId, enable) { + const action = enable ? 'start' : 'stop'; + try { + const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/monitor/${action}`, { method: 'POST' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + await loadMonitors(); + } catch (e) { + alert('Toggle failed: ' + e.message); + } +} + loadSlmmOverview(); -setInterval(loadSlmmOverview, 30000); +loadMonitors(); +setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000); {% endblock %} From f5e93d56129a144a0e85de0fd5d20fcd335e8b59 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 20:00:41 +0000 Subject: [PATCH 05/13] feat(slm): backfill the live chart from the DOD trail on open On opening the live view, fetch GET /api/slmm/{unit}/history?hours=2 and seed the chart with the recent trend BEFORE connecting the live socket, so it opens with context instead of blank. Live frames then append in order. - backfillChart() populates all four series (Lp/Leq/L1/L10) from the trail. - initLiveDataStream is async and awaits the backfill before opening the WS. - Chart rolling window raised 60 -> 600 points so the ~2h backfill (1/min) isn't immediately shifted out. - Trail timestamps are naive UTC -> append 'Z' so they localize consistently with the live frames. Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/partials/slm_live_view.html | 41 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/templates/partials/slm_live_view.html b/templates/partials/slm_live_view.html index e9d8a0f..70ceefb 100644 --- a/templates/partials/slm_live_view.html +++ b/templates/partials/slm_live_view.html @@ -513,7 +513,37 @@ if (typeof window.currentWebSocket === 'undefined') { window.currentWebSocket = null; } -function initLiveDataStream(unitId) { +// Backfill the chart with the recent DOD trail so it opens with context. +async function backfillChart(unitId) { + try { + const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`); + if (!r.ok) return; + const d = await r.json(); + const readings = d.readings || []; + if (!window.chartData) return; + for (const row of readings) { + // Trail timestamps are naive UTC; append 'Z' so they convert to local + // consistently with the live frames (which use local Date.now()). + window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : ''); + window.chartData.lp.push(parseFloat(row.lp || 0)); + window.chartData.leq.push(parseFloat(row.leq || 0)); + window.chartData.ln1.push(parseFloat(row.ln1 || 0)); + window.chartData.ln2.push(parseFloat(row.ln2 || 0)); + } + if (window.liveChart) { + window.liveChart.data.labels = window.chartData.timestamps; + window.liveChart.data.datasets[0].data = window.chartData.lp; + window.liveChart.data.datasets[1].data = window.chartData.leq; + window.liveChart.data.datasets[2].data = window.chartData.ln1; + window.liveChart.data.datasets[3].data = window.chartData.ln2; + window.liveChart.update('none'); + } + } catch (e) { + console.warn('Chart backfill failed:', e); + } +} + +async function initLiveDataStream(unitId) { // Close existing connection if any if (window.currentWebSocket) { window.currentWebSocket.close(); @@ -533,6 +563,10 @@ function initLiveDataStream(unitId) { window.liveChart.update(); } + // Seed the chart with recent history BEFORE opening the live socket, so live + // frames append after the backfill (right order) and the chart isn't blank. + await backfillChart(unitId); + // WebSocket URL for SLMM backend via proxy. // /monitor = the shared fan-out DOD feed (many viewers, one device connection, // and it carries L1/L10 which the DRD /stream cannot). @@ -649,8 +683,9 @@ function updateLiveChart(data) { window.chartData.ln1.push(parseFloat(data.ln1 || 0)); window.chartData.ln2.push(parseFloat(data.ln2 || 0)); - // Keep only last 60 data points - if (window.chartData.timestamps.length > 60) { + // Keep a rolling window large enough to hold the ~2h backfill (one point/min) + // plus a good run of live points before the oldest scroll off. + if (window.chartData.timestamps.length > 600) { window.chartData.timestamps.shift(); window.chartData.lp.shift(); window.chartData.leq.shift(); From 17a1a83bdf5767039a48f8d87497ce82b3975bbd Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 21:29:50 +0000 Subject: [PATCH 06/13] feat(slm): point dashboard live tile at /monitor too Finishes the live-view pivot: the SLM dashboard's live-chart tile now uses the fan-out /monitor feed (multi-viewer, L1/L10) instead of the DRD /stream, and skips heartbeat / unreachable frames so they don't blank the metrics or spike the chart. Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/sound_level_meters.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/sound_level_meters.html b/templates/sound_level_meters.html index 697f6e7..3055db9 100644 --- a/templates/sound_level_meters.html +++ b/templates/sound_level_meters.html @@ -280,7 +280,7 @@ function startDashboardStream() { } const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`; + const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`; window.dashboardWebSocket = new WebSocket(wsUrl); @@ -293,6 +293,10 @@ function startDashboardStream() { window.dashboardWebSocket.onmessage = function(event) { try { const data = JSON.parse(event.data); + // /monitor sends keepalive 'heartbeat' frames (no metrics) and a per-frame + // 'feed_status'; skip heartbeats and offline frames so they don't blank the + // metrics or spike the chart with zeros. + if (data.heartbeat || data.feed_status === 'unreachable') return; updateDashboardMetrics(data); updateDashboardChart(data); } catch (error) { From d92d01dc564b76fb1747439902e46467a564bbd6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 22:27:01 +0000 Subject: [PATCH 07/13] fix(slm): dashboard status from SLMM's cached roster, not a device call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "No recent check-in" read a roster field (slm_last_check) that nothing stamps, and the live-status fetch hit /measurement-state — which sends Measure? to the DEVICE every refresh, competing with DOD polling. Now read SLMM's /roster once: it carries each unit's cached NL43Status (last_seen, measurement_state) — a cache read, no device call. is_recent is derived from last_seen (advances only on a successful monitor poll, so staleness == not being reached) within 5 min, for all non-retired units (benched units can still be monitored). Net: fewer device calls AND the dashboard reflects the live monitor. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/slm_dashboard.py | 48 ++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index 3b93488..dfcdb80 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -91,29 +91,41 @@ async def get_slm_units( one_hour_ago = datetime.utcnow() - timedelta(hours=1) for unit in units: + # Legacy default from the roster field; refined from SLMM's cached status below. unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago) + unit.measurement_state = None if include_measurement: - async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None: - try: - response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state") - if response.status_code == 200: - return response.json().get("measurement_state") - except Exception: - return None - return None - - deployed_units = [unit for unit in units if unit.deployed and not unit.retired] - if deployed_units: + # SLMM's /roster carries each unit's CACHED status (last_seen, + # measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device + # call. The live monitor refreshes that cache ~every 1.3s, so this reflects + # real monitoring without sending Measure? to the device (which the old + # /measurement-state did) and competing with DOD polling. One call covers all. + slmm_status = {} + try: async with httpx.AsyncClient(timeout=3.0) as client: - tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units] - results = await asyncio.gather(*tasks, return_exceptions=True) + r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster") + if r.status_code == 200: + for dev in (r.json().get("devices") or []): + slmm_status[dev.get("unit_id")] = dev.get("status") or {} + except Exception: + slmm_status = {} - for unit, state in zip(deployed_units, results): - if isinstance(state, Exception): - unit.measurement_state = None - else: - unit.measurement_state = state + # "Recent" = the monitor has a fresh successful read. last_seen only advances + # on a successful poll, so staleness == the device isn't being reached. + recent_cutoff = datetime.utcnow() - timedelta(minutes=5) + for unit in units: + st = slmm_status.get(unit.id) + if not st: + continue + unit.measurement_state = st.get("measurement_state") + last_seen = st.get("last_seen") + if last_seen: + try: + ls = datetime.fromisoformat(last_seen.replace("Z", "")) + unit.is_recent = ls > recent_cutoff + except Exception: + pass return templates.TemplateResponse("partials/slm_device_list.html", { "request": request, From 170dedb138fcc4fbc64d8cbc14685e65b769456b Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 9 Jun 2026 22:57:45 +0000 Subject: [PATCH 08/13] perf(slm): command center loads from cached status, no device pings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_live_view fired two device calls on every command-center load: /measurement-state (sends Measure?) and /live (fresh DOD read) — competing with the monitor's DOD polling. Both are now redundant: the keepalive monitor keeps NL43Status fresh (~1.3s) and the live-stream WS handles ongoing updates. Read the cached /status once instead (no device call); derive is_measuring from measurement_state. Command center opens instantly without poking the device. (Relies on monitor_start_time now being in /status.) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/slm_dashboard.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index dfcdb80..f3d3fcd 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -169,25 +169,18 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge is_measuring = False try: - async with httpx.AsyncClient(timeout=10.0) as client: - # Get measurement state - state_response = await client.get( - f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state" - ) - if state_response.status_code == 200: - state_data = state_response.json() - measurement_state = state_data.get("measurement_state", "Unknown") - is_measuring = state_data.get("is_measuring", False) - - # Get live status (measurement_start_time is already stored in SLMM database) - status_response = await client.get( - f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live" - ) - if status_response.status_code == 200: - status_data = status_response.json() - current_status = status_data.get("data", {}) + # Read SLMM's CACHED status (NL43Status) — no device call. The live monitor + # keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we + # no longer fire Measure? + a fresh DOD read at the device on every command- + # center load (which competed with DOD polling for the single connection). + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status") + if r.status_code == 200: + current_status = r.json().get("data", {}) + measurement_state = current_status.get("measurement_state") + is_measuring = measurement_state in ("Start", "Measure") except Exception as e: - logger.error(f"Failed to get status for {unit_id}: {e}") + logger.error(f"Failed to get cached status for {unit_id}: {e}") return templates.TemplateResponse("partials/slm_live_view.html", { "request": request, From e27aef33ac8b698b620fd975d03bfc66f65ba4bc Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 18:34:15 +0000 Subject: [PATCH 09/13] fix(slm): guard htmx.trigger so deploy/bench doesn't throw on pages without #slm-list toggleSLMDeployed() and the save-config success path both called htmx.trigger('#slm-list', 'load') guarded only by `typeof htmx !== 'undefined'`. No page actually has a #slm-list element, so htmx resolved the selector to null and called null.dispatchEvent(...) -> "can't access property dispatchEvent, e is null". The deploy POST had already succeeded and the green success message had already rendered, so the user saw both "Unit marked as deployed." and a red error. Guard the trigger on the element existing so it's a harmless no-op. Co-Authored-By: Claude Opus 4.8 --- templates/partials/slm_settings_modal.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/templates/partials/slm_settings_modal.html b/templates/partials/slm_settings_modal.html index 02e9ac6..0b89025 100644 --- a/templates/partials/slm_settings_modal.html +++ b/templates/partials/slm_settings_modal.html @@ -528,7 +528,7 @@ async function saveSLMSettings(event) { if (typeof checkFTPStatus === 'function') { checkFTPStatus(unitId); } - if (typeof htmx !== 'undefined') { + if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) { htmx.trigger('#slm-list', 'load'); } }, 1500); @@ -604,8 +604,10 @@ async function toggleSLMDeployed() { successDiv.classList.remove('hidden'); setTimeout(() => successDiv.classList.add('hidden'), 3000); - // Refresh any SLM list on the page - if (typeof htmx !== 'undefined') { + // Refresh any SLM list on the page (only if one is actually present — + // the detail/dashboard pages have no #slm-list, and htmx.trigger on a + // null target throws "can't access property dispatchEvent, e is null"). + if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) { htmx.trigger('#slm-list', 'load'); } } catch (error) { From 711ef41e5f932e49a6176d4fad58970e33b8bc5b Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 18:59:42 +0000 Subject: [PATCH 10/13] feat(slm): auto-populate live panel from cache, per-unit refresh, fix badge overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Measurements panel no longer sits blank until you click Start Live Stream: - On open it fills the KPI cards from the cached /status snapshot (lp/leq/lmax/ L1/L10) and backfills the chart from the /history DOD trail — both pure cache reads, no device hit. - Shows measuring state (● Measuring / ■ Stopped) and a freshness stamp ("as of 2:14 PM (12m ago)") that turns amber + "cached" when stale, so a cached value is never mistaken for a live reading. - Polls the cache every 15s while open so the cards stay current without opening a device stream; Start Live Stream takes over (and no longer wipes the backfilled trail). Chart cap raised 60 -> 600 so the 2h backfill isn't truncated. Refresh buttons (on-demand, user-initiated single device read via GET /live, which also updates the cache): - one per device row in the list, and one in the panel header. Spinner while in flight; toast on success/failure; reloads the list so badges + last-check update. Layout fix: the status badge (Measuring/Active/Idle/Benched) was rendered at the top-right of the card, colliding with the absolutely-positioned chart/gear icons. Moved it to the bottom meta row next to "Last check", padded the card content clear of the action icons, and added the refresh icon to that group. Co-Authored-By: Claude Opus 4.8 --- templates/partials/slm_device_list.html | 66 ++++--- templates/sound_level_meters.html | 239 +++++++++++++++++++++--- 2 files changed, 253 insertions(+), 52 deletions(-) diff --git a/templates/partials/slm_device_list.html b/templates/partials/slm_device_list.html index 117decb..810bd0f 100644 --- a/templates/partials/slm_device_list.html +++ b/templates/partials/slm_device_list.html @@ -2,7 +2,14 @@ {% if units %} {% for unit in units %}
-
+
+
- -
-
-
- {{ unit.id }} - {% if unit.slm_model %} - • {{ unit.slm_model }} - {% endif %} -
- {% if unit.address %} -

{{ unit.address }}

- {% elif unit.location %} -

{{ unit.location }}

+
+
+
+ {{ unit.id }} + {% if unit.slm_model %} + • {{ unit.slm_model }} {% endif %}
- - {% if unit.retired %} - Retired - {% elif not unit.deployed %} - Benched - {% elif unit.measurement_state == "Start" %} - Measuring - {% elif unit.is_recent %} - Active - {% else %} - Idle + {% if unit.address %} +

{{ unit.address }}

+ {% elif unit.location %} +

{{ unit.location }}

{% endif %}
-
diff --git a/templates/sound_level_meters.html b/templates/sound_level_meters.html index 3055db9..360ddbd 100644 --- a/templates/sound_level_meters.html +++ b/templates/sound_level_meters.html @@ -51,13 +51,31 @@