From 76330f6137d6f9a948c1d472c936a62258145fd4 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 21:15:23 +0000 Subject: [PATCH] feat: live monitoring section on internal project Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client portal has a live dashboard but the internal project page only showed static counts. Add a portal-style live section to the Overview tab so operators can see real-time sound levels at a glance. Backend: - New GET /api/projects/{id}/live-stats — resolves each sound NRL to its active SLM unit and returns SLMM's cached /status snapshot (concurrent fetch). Internal-rich: includes battery/power/reachability the portal scrubs. Degrades to no_device/unreachable/no_data per location. Frontend (project detail Overview tab): - Rollup strip (live / offline / loudest-now) + a live tile per NRL with a Live/Stopped/Offline/Wedged badge, color-coded Leq (55/70 thresholds), Lp/Lmax, last-seen, and battery/power. - Self-refreshes every 15s, pauses when the browser tab is hidden, and sits outside the 30s htmx dashboard swap so it never flickers. Polls only for projects with the sound module. Reuses the same SLMM /status source as the portal; no SLMM changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/projects.py | 94 ++++++++++++++++++ templates/projects/detail.html | 175 +++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index b1407de..4c94f2b 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1103,6 +1103,100 @@ async def get_project_dashboard( }) +@router.get("/{project_id}/live-stats") +async def get_project_live_stats(project_id: str, db: Session = Depends(get_db)): + """Live SLM readings for each sound NRL in the project. + + Reads SLMM's cached per-unit status snapshots (the same source the client + portal uses) and returns one entry per active sound location. Powers the + Overview tab's live monitoring section. Internal-only, so it includes + device-health fields (battery, power source, reachability) the portal hides. + """ + import os + import asyncio + import httpx + + project = db.query(Project).filter_by(id=project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + now = datetime.utcnow() + locations = ( + db.query(MonitoringLocation) + .filter( + MonitoringLocation.project_id == project_id, + MonitoringLocation.location_type == "sound", + MonitoringLocation.removed_at.is_(None), + ) + .order_by(MonitoringLocation.sort_order, MonitoringLocation.name) + .all() + ) + + # Active SLM unit per location (mirrors portal.active_unit_for_location). + def _active_unit(loc_id: str): + asg = ( + db.query(UnitAssignment) + .filter( + UnitAssignment.location_id == loc_id, + UnitAssignment.status == "active", + UnitAssignment.device_type == "slm", + or_( + UnitAssignment.assigned_until.is_(None), + UnitAssignment.assigned_until > now, + ), + ) + .order_by(UnitAssignment.assigned_at.desc()) + .first() + ) + return asg.unit_id if asg else None + + loc_units = [(loc, _active_unit(loc.id)) for loc in locations] + + slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + + async def _fetch(unit_id): + if not unit_id: + return None, "no_device" + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{slmm_base}/api/nl43/{unit_id}/status") + except Exception: + return None, "unreachable" + if r.status_code != 200: + return None, "no_data" + return (r.json() or {}).get("data") or {}, None + + results = await asyncio.gather(*[_fetch(u) for (_, u) in loc_units]) + + out = [] + for (loc, unit_id), (data, reason) in zip(loc_units, results): + entry = { + "id": loc.id, + "name": loc.name, + "unit_id": unit_id, + } + if data is None: + entry["reason"] = reason + entry["measurement_state"] = None + else: + entry.update( + { + "measurement_state": data.get("measurement_state"), + "leq": data.get("leq"), + "lp": data.get("lp"), + "lmax": data.get("lmax"), + "last_seen": data.get("last_seen"), + "battery_level": data.get("battery_level"), + "power_source": data.get("power_source"), + "is_reachable": data.get("is_reachable"), + "connection_state": data.get("connection_state"), + } + ) + out.append(entry) + + return {"status": "ok", "locations": out} + + # ============================================================================ # Project Types # ============================================================================ diff --git a/templates/projects/detail.html b/templates/projects/detail.html index b7a35b6..2f214fe 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -85,6 +85,36 @@
+ + +
"']/g, c => ( + {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +function lsNum(v) { const f = parseFloat(v); return isNaN(f) ? null : f; } +function lsFmtAgo(iso) { + if (!iso) return ''; + const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z'); + const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000)); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.round(s / 60) + 'm ago'; + if (s < 86400) return Math.round(s / 3600) + 'h ago'; + return Math.round(s / 86400) + 'd ago'; +} +// Headline Leq color, matched to the portal thresholds. +function lsLeqColor(leq, measuring) { + if (!measuring || leq == null) return 'text-gray-400 dark:text-gray-500'; + if (leq >= LS_LEVEL_RED) return 'text-red-500'; + if (leq >= LS_LEVEL_AMBER) return 'text-amber-500'; + return 'text-green-500'; +} +// Friendly labels for NL-43 battery / power-source codes (fall back to raw). +function lsBattery(code) { + return ({F:'Full', M:'Mid', L:'Low', D:'Dead', E:'Empty'})[code] || (code || ''); +} +function lsPower(code) { + return ({I:'Battery', E:'External', U:'USB'})[code] || (code || ''); +} + +function lsRenderTile(loc) { + const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure'; + const wedged = loc.connection_state === 'wedged'; + const reachable = loc.is_reachable !== false; // null/absent → assume ok + const hasData = loc.measurement_state != null || loc.leq != null; + + // Status badge + let badge; + if (!loc.unit_id) { + badge = 'No unit'; + } else if (wedged) { + badge = 'Wedged'; + } else if (!reachable || !hasData) { + badge = 'Offline'; + } else if (measuring) { + badge = 'Live'; + } else { + badge = 'Stopped'; + } + + const leqNum = lsNum(loc.leq); + const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq; + const leqColor = lsLeqColor(leqNum, measuring); + + // Health line: unit · last-seen · battery/power + const bits = []; + if (loc.unit_id) bits.push(`${lsEsc(loc.unit_id)}`); + if (hasData && loc.last_seen) bits.push(lsEsc(lsFmtAgo(loc.last_seen))); + if (hasData && (loc.battery_level || loc.power_source)) { + const b = lsBattery(loc.battery_level), p = lsPower(loc.power_source); + const low = loc.battery_level === 'L' || loc.battery_level === 'D' || loc.battery_level === 'E'; + bits.push(`${lsEsc([p, b].filter(Boolean).join(' · '))}`); + } + + const levels = (hasData) + ? `
+ Lp ${lsEsc(loc.lp ?? '--')}   Lmax ${lsEsc(loc.lmax ?? '--')} +
` + : ''; + + return ` +
+
+
${lsEsc(loc.name)}
+ ${badge} +
+
+ ${lsEsc(leqStr)} + dB Leq +
+ ${levels} +
+ ${bits.join('·')} +
+
`; +} + +function lsRender(locations) { + const section = document.getElementById('live-stats-section'); + if (!section) return; + if (!locations.length) { section.classList.add('hidden'); return; } + section.classList.remove('hidden'); + + // Rollup + let live = 0, off = 0, peak = null, peakStr = null, peakLoc = null; + for (const l of locations) { + const measuring = l.measurement_state === 'Start' || l.measurement_state === 'Measure'; + const hasData = l.measurement_state != null || l.leq != null; + if (measuring) { + live++; + const n = lsNum(l.leq); + if (n != null && (peak == null || n > peak)) { peak = n; peakStr = l.leq; peakLoc = l.name; } + } else if (!l.unit_id || !hasData || l.is_reachable === false) { + off++; + } + } + document.getElementById('ls-live').textContent = live; + document.getElementById('ls-offline').textContent = off; + const pw = document.getElementById('ls-loudest-wrap'); + if (peak != null) { + pw.classList.remove('hidden'); + document.getElementById('ls-loudest').textContent = peakStr; + document.getElementById('ls-loudest-loc').textContent = peakLoc; + } else { pw.classList.add('hidden'); } + + document.getElementById('ls-tiles').innerHTML = locations.map(lsRenderTile).join(''); +} + +async function loadLiveStats() { + // Skip work while the tab is hidden in the background. + if (document.hidden) return; + try { + const r = await fetch(`/api/projects/${projectId}/live-stats`); + if (!r.ok) return; + const j = await r.json(); + lsRender(j.locations || []); + } catch (e) { /* keep last render */ } +} + +function startLiveStats() { + if (liveStatsTimer) return; // already running + loadLiveStats(); + liveStatsTimer = setInterval(loadLiveStats, 15000); +} + document.addEventListener('DOMContentLoaded', function() { loadProjectDetails();