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:
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user