update to 0.16.0 #72
@@ -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
|
# Project Types
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -85,6 +85,36 @@
|
|||||||
<div id="tab-content">
|
<div id="tab-content">
|
||||||
<!-- Overview Tab -->
|
<!-- Overview Tab -->
|
||||||
<div id="overview-tab" class="tab-panel">
|
<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"
|
<div id="project-dashboard"
|
||||||
hx-get="/api/projects/{{ project_id }}/dashboard"
|
hx-get="/api/projects/{{ project_id }}/dashboard"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
@@ -1009,6 +1039,10 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule);
|
document.getElementById('sound-tab-btn').classList.toggle('hidden', !hasSoundModule);
|
||||||
document.getElementById('sound-settings-section')?.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
|
// 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-units-btn')?.classList.toggle('hidden', !isRemote);
|
||||||
document.getElementById('sound-sub-schedules-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
|
// 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() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadProjectDetails();
|
loadProjectDetails();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user