update to 0.16.0 #72

Merged
serversdown merged 32 commits from dev into main 2026-06-23 00:59:46 -04:00
2 changed files with 269 additions and 0 deletions
Showing only changes of commit 76330f6137 - Show all commits
+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
# ============================================================================
+175
View File
@@ -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 => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 ?? '--')} &nbsp; 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();