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
|
||||
# ============================================================================
|
||||
|
||||
@@ -85,6 +85,36 @@
|
||||
<div id="tab-content">
|
||||
<!-- Overview Tab -->
|
||||
<div id="overview-tab" class="tab-panel">
|
||||
<!-- Live monitoring (sound) — self-contained, refreshes itself every 15s.
|
||||
Kept OUTSIDE #project-dashboard so the 30s htmx swap below never
|
||||
clobbers its DOM/timer. Shown only for projects with sound NRLs. -->
|
||||
<div id="live-stats-section" class="hidden mb-6">
|
||||
<div class="flex flex-wrap items-center gap-2.5 mb-4">
|
||||
<span class="text-[10px] uppercase tracking-[0.18em] text-seismo-orange/90 font-semibold">Live monitoring</span>
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-seismo-orange"></span>
|
||||
</span>
|
||||
<b id="ls-live" class="text-lg font-semibold text-seismo-orange tabular-nums">–</b>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">live</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
|
||||
<b id="ls-offline" class="text-lg font-semibold text-gray-700 dark:text-gray-200 tabular-nums">–</b>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">offline</span>
|
||||
</div>
|
||||
<div id="ls-loudest-wrap" class="hidden inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm">
|
||||
<span class="text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Loudest now</span>
|
||||
<b id="ls-loudest" class="text-lg font-semibold text-seismo-orange tabular-nums">–</b>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">dB</span>
|
||||
<span id="ls-loudest-loc" class="text-xs text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500 ml-auto">auto-refresh 15s</span>
|
||||
</div>
|
||||
<div id="ls-tiles" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="project-dashboard"
|
||||
hx-get="/api/projects/{{ project_id }}/dashboard"
|
||||
hx-trigger="load, every 30s"
|
||||
@@ -1009,6 +1039,10 @@ async function loadProjectDetails() {
|
||||
document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule);
|
||||
document.getElementById('sound-settings-section')?.classList.toggle('hidden', !hasSoundModule);
|
||||
|
||||
// Live monitoring section: only for sound projects (idempotent).
|
||||
if (hasSoundModule) startLiveStats();
|
||||
else document.getElementById('live-stats-section')?.classList.add('hidden');
|
||||
|
||||
// Within Sound: show Assigned Units + Schedules sub-tabs only for remote projects
|
||||
document.getElementById('sound-sub-units-btn')?.classList.toggle('hidden', !isRemote);
|
||||
document.getElementById('sound-sub-schedules-btn')?.classList.toggle('hidden', !isRemote);
|
||||
@@ -2075,6 +2109,147 @@ function submitUploadAll() {
|
||||
}
|
||||
|
||||
// Load project details on page load and restore active tab from URL hash
|
||||
// ── Live monitoring section (sound) ──────────────────────────────────────
|
||||
// Self-contained: fetches /live-stats every 15s and renders a rollup + a
|
||||
// live tile per NRL. Started from loadProjectDetails() once we know the
|
||||
// project has the sound module, so vibration-only projects don't poll.
|
||||
let liveStatsTimer = null;
|
||||
const LS_LEVEL_AMBER = 55, LS_LEVEL_RED = 70;
|
||||
|
||||
function lsEsc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/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 = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">No unit</span>';
|
||||
} else if (wedged) {
|
||||
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>';
|
||||
} else if (!reachable || !hasData) {
|
||||
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>';
|
||||
} else if (measuring) {
|
||||
badge = '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] bg-orange-100 dark:bg-orange-900/30 text-seismo-orange"><span class="relative flex h-1.5 w-1.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-seismo-orange opacity-75"></span><span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-seismo-orange"></span></span>Live</span>';
|
||||
} else {
|
||||
badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>';
|
||||
}
|
||||
|
||||
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(`<span class="font-mono text-seismo-orange">${lsEsc(loc.unit_id)}</span>`);
|
||||
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(`<span class="${low ? 'text-red-500' : ''}">${lsEsc([p, b].filter(Boolean).join(' · '))}</span>`);
|
||||
}
|
||||
|
||||
const levels = (hasData)
|
||||
? `<div class="mt-1.5 text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
Lp ${lsEsc(loc.lp ?? '--')} Lmax ${lsEsc(loc.lmax ?? '--')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="font-semibold text-gray-900 dark:text-white truncate">${lsEsc(loc.name)}</div>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="mt-3 flex items-baseline gap-1.5">
|
||||
<span class="text-4xl leading-none font-semibold tabular-nums ${leqColor}">${lsEsc(leqStr)}</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">dB Leq</span>
|
||||
</div>
|
||||
${levels}
|
||||
<div class="mt-2 text-[11px] text-gray-400 dark:text-gray-500 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
${bits.join('<span class="opacity-40">·</span>')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user