feat: live monitoring section on internal project Overview

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:15:23 +00:00
parent c049ac8a41
commit 76330f6137
2 changed files with 269 additions and 0 deletions
+94
View File
@@ -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
# ============================================================================