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();