Feat: add SLM live monitoring improvements #60
@@ -5,6 +5,50 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
SLM live monitoring — fan-out feed + cache-first reads. Targets **0.14.0**. The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed. This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand. Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Fan-out `/monitor` feed consumption.** The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream. Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone. A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
|
||||||
|
- **L1/L10 percentile lines + cards.** Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10. Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can). Missing/`-.-` values leave a gap rather than dropping the line to 0.
|
||||||
|
- **Live-chart backfill on open.** Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
|
||||||
|
- **Live Measurements panel auto-populates from cache.** Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit. Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale). Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
|
||||||
|
- **Refresh buttons** — one per device-list row, one in the panel header. On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
|
||||||
|
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Dashboard device list + command center read SLMM's cache, not the device.** `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load. This stops dashboard loads from stealing the device's single connection from the live monitor. The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
|
||||||
|
- **Device-list freshness reflects real monitoring.** The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates. The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
|
||||||
|
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.** `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`. The deploy POST had already succeeded, so the operator saw both the green success **and** a red error. Both call sites now guard on the element existing (`slm_settings_modal.html`).
|
||||||
|
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
|
||||||
|
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
|
||||||
|
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
|
||||||
|
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
|
||||||
|
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
|
||||||
|
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py
|
||||||
|
|
||||||
|
# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
|
||||||
|
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
The two builds must ship **together**. Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.13.3] - 2026-06-05
|
## [0.13.3] - 2026-06-05
|
||||||
|
|
||||||
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
|
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
|
||||||
|
|||||||
@@ -91,29 +91,43 @@ async def get_slm_units(
|
|||||||
|
|
||||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||||
for unit in units:
|
for unit in units:
|
||||||
|
# Legacy default from the roster field; refined from SLMM's cached status below.
|
||||||
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
|
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
|
||||||
|
unit.measurement_state = None
|
||||||
|
unit.cache_last_seen = None # SLMM cache last_seen (real monitoring freshness)
|
||||||
|
|
||||||
if include_measurement:
|
if include_measurement:
|
||||||
async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
|
# SLMM's /roster carries each unit's CACHED status (last_seen,
|
||||||
|
# measurement_state) from NL43Status — a DB read on SLMM's side, NOT a device
|
||||||
|
# call. The live monitor refreshes that cache ~every 1.3s, so this reflects
|
||||||
|
# real monitoring without sending Measure? to the device (which the old
|
||||||
|
# /measurement-state did) and competing with DOD polling. One call covers all.
|
||||||
|
slmm_status = {}
|
||||||
try:
|
try:
|
||||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json().get("measurement_state")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
|
|
||||||
if deployed_units:
|
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
|
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/roster")
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
if r.status_code == 200:
|
||||||
|
for dev in (r.json().get("devices") or []):
|
||||||
|
slmm_status[dev.get("unit_id")] = dev.get("status") or {}
|
||||||
|
except Exception:
|
||||||
|
slmm_status = {}
|
||||||
|
|
||||||
for unit, state in zip(deployed_units, results):
|
# "Recent" = the monitor has a fresh successful read. last_seen only advances
|
||||||
if isinstance(state, Exception):
|
# on a successful poll, so staleness == the device isn't being reached.
|
||||||
unit.measurement_state = None
|
recent_cutoff = datetime.utcnow() - timedelta(minutes=5)
|
||||||
else:
|
for unit in units:
|
||||||
unit.measurement_state = state
|
st = slmm_status.get(unit.id)
|
||||||
|
if not st:
|
||||||
|
continue
|
||||||
|
unit.measurement_state = st.get("measurement_state")
|
||||||
|
last_seen = st.get("last_seen")
|
||||||
|
if last_seen:
|
||||||
|
try:
|
||||||
|
ls = datetime.fromisoformat(last_seen.replace("Z", ""))
|
||||||
|
unit.is_recent = ls > recent_cutoff
|
||||||
|
unit.cache_last_seen = ls # the real freshness the monitor updates
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/slm_device_list.html", {
|
return templates.TemplateResponse("partials/slm_device_list.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -157,25 +171,18 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
is_measuring = False
|
is_measuring = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
# Read SLMM's CACHED status (NL43Status) — no device call. The live monitor
|
||||||
# Get measurement state
|
# keeps it fresh (~1.3s) and the live-stream WS provides ongoing updates, so we
|
||||||
state_response = await client.get(
|
# no longer fire Measure? + a fresh DOD read at the device on every command-
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
# center load (which competed with DOD polling for the single connection).
|
||||||
)
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
if state_response.status_code == 200:
|
r = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||||
state_data = state_response.json()
|
if r.status_code == 200:
|
||||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
current_status = r.json().get("data", {})
|
||||||
is_measuring = state_data.get("is_measuring", False)
|
measurement_state = current_status.get("measurement_state")
|
||||||
|
is_measuring = measurement_state in ("Start", "Measure")
|
||||||
# Get live status (measurement_start_time is already stored in SLMM database)
|
|
||||||
status_response = await client.get(
|
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
|
||||||
)
|
|
||||||
if status_response.status_code == 200:
|
|
||||||
status_data = status_response.json()
|
|
||||||
current_status = status_data.get("data", {})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get status for {unit_id}: {e}")
|
logger.error(f"Failed to get cached status for {unit_id}: {e}")
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/slm_live_view.html", {
|
return templates.TemplateResponse("partials/slm_live_view.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
|
|||||||
@@ -231,6 +231,76 @@ async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
|
|||||||
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{unit_id}/monitor")
|
||||||
|
async def proxy_websocket_monitor(websocket: WebSocket, unit_id: str):
|
||||||
|
"""
|
||||||
|
Proxy WebSocket connections to SLMM's /monitor (fan-out DOD feed).
|
||||||
|
|
||||||
|
This is the shared ~1Hz DOD feed: many clients subscribe to one device feed
|
||||||
|
(no single-connection contention) and it carries L1/L10 (which the DRD
|
||||||
|
/stream cannot). Preferred over /stream for the live view.
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"WebSocket accepted for SLMM unit {unit_id} (monitor)")
|
||||||
|
|
||||||
|
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor"
|
||||||
|
backend_ws = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend_ws = await websockets.connect(target_ws_url)
|
||||||
|
logger.info(f"Connected to SLMM monitor feed for {unit_id}")
|
||||||
|
|
||||||
|
async def forward_to_client():
|
||||||
|
"""Backend monitor frames -> browser."""
|
||||||
|
async for message in backend_ws:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
|
||||||
|
async def watch_client():
|
||||||
|
"""Drain client frames; raises WebSocketDisconnect on close so we can
|
||||||
|
tear the pair down (the monitor feed is server->client only)."""
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
|
||||||
|
# When EITHER side ends (browser disconnects or backend closes), cancel the
|
||||||
|
# other immediately — avoids sending into a closed socket (the
|
||||||
|
# "Unexpected ASGI message after close" race that asyncio.gather leaves open).
|
||||||
|
tasks = [asyncio.ensure_future(forward_to_client()),
|
||||||
|
asyncio.ensure_future(watch_client())]
|
||||||
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
# Await ALL tasks (the done one AND the cancelled one) and swallow both
|
||||||
|
# the expected WebSocketDisconnect and CancelledError. CancelledError is a
|
||||||
|
# BaseException, so a bare `except Exception` misses it — that's what leaked
|
||||||
|
# the traceback on stop; and awaiting only `pending` left the done task's
|
||||||
|
# exception unretrieved.
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
except websockets.exceptions.WebSocketException as e:
|
||||||
|
logger.error(f"WebSocket error connecting to SLMM monitor for {unit_id}: {e}")
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"error": "Failed to connect to SLMM monitor", "detail": str(e)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in monitor proxy for {unit_id}: {e}")
|
||||||
|
finally:
|
||||||
|
if backend_ws:
|
||||||
|
try:
|
||||||
|
await backend_ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(f"WebSocket monitor proxy closed for {unit_id}")
|
||||||
|
|
||||||
|
|
||||||
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
||||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy_to_slmm(path: str, request: Request):
|
async def proxy_to_slmm(path: str, request: Request):
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
terra-view:
|
web-app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
|
|||||||
@@ -42,6 +42,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Monitoring (keepalive) -->
|
||||||
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-1">Live Monitoring (keepalive)</h2>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Keepalive runs the 1 Hz DOD feed 24/7 (even with no viewer), which powers the live-chart
|
||||||
|
trail and continuous threshold alerts. Toggling persists and survives restarts.
|
||||||
|
</p>
|
||||||
|
<div id="monitor-list" class="text-sm">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Raw API tester -->
|
<!-- Raw API tester -->
|
||||||
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
<div class="rounded-xl bg-white dark:bg-slate-800 shadow-lg p-4 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3">Raw API Tester</h2>
|
||||||
@@ -132,7 +144,60 @@ async function sendRaw() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMonitors() {
|
||||||
|
const el = document.getElementById('monitor-list');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/slmm/roster');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const d = await r.json();
|
||||||
|
const devices = d.devices || [];
|
||||||
|
if (!devices.length) {
|
||||||
|
el.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No devices configured.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = devices.map(dev => {
|
||||||
|
const on = !!dev.monitor_enabled;
|
||||||
|
const reach = dev.status ? dev.status.is_reachable : null;
|
||||||
|
const reachDot = reach === false
|
||||||
|
? '<span class="w-2 h-2 rounded-full bg-red-500 inline-block" title="unreachable"></span>'
|
||||||
|
: '<span class="w-2 h-2 rounded-full bg-green-500 inline-block" title="reachable"></span>';
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-100 dark:border-gray-700 py-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${reachDot}
|
||||||
|
<span class="font-mono text-gray-900 dark:text-white">${_esc(dev.unit_id)}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(dev.host)}:${_esc(dev.tcp_port)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs font-medium px-2 py-0.5 rounded ${on
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400'}">${on ? '24/7 ON' : 'OFF'}</span>
|
||||||
|
<button onclick="toggleMonitor('${_esc(dev.unit_id)}', ${!on})"
|
||||||
|
class="px-3 py-1 text-xs rounded text-white ${on
|
||||||
|
? 'bg-red-600 hover:bg-red-700' : 'bg-seismo-orange hover:bg-orange-600'}">
|
||||||
|
${on ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<p class="text-red-600 dark:text-red-400">Failed to load devices: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMonitor(unitId, enable) {
|
||||||
|
const action = enable ? 'start' : 'stop';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/monitor/${action}`, { method: 'POST' });
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
await loadMonitors();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Toggle failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadSlmmOverview();
|
loadSlmmOverview();
|
||||||
setInterval(loadSlmmOverview, 30000);
|
loadMonitors();
|
||||||
|
setInterval(() => { loadSlmmOverview(); loadMonitors(); }, 30000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
{% if units %}
|
{% if units %}
|
||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
||||||
<div class="absolute top-3 right-3 flex gap-2">
|
<div class="absolute top-3 right-3 flex gap-2 z-10">
|
||||||
|
<button onclick="event.preventDefault(); event.stopPropagation(); refreshSlmUnit('{{ unit.id }}', this);"
|
||||||
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
|
title="Refresh {{ unit.id }} from device">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
||||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||||
title="View live chart">
|
title="View live chart">
|
||||||
@@ -20,9 +27,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/slm/{{ unit.id }}" class="block">
|
<a href="/slm/{{ unit.id }}" class="block pr-24">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="min-w-0">
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||||
{% if unit.slm_model %}
|
{% if unit.slm_model %}
|
||||||
@@ -36,25 +42,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badge + last-check on one line (moved off the top-right so it
|
||||||
|
no longer collides with the refresh/chart/gear action icons). -->
|
||||||
|
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
{% if unit.retired %}
|
{% if unit.retired %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||||
{% elif not unit.deployed %}
|
{% elif not unit.deployed %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
||||||
{% elif unit.measurement_state == "Start" %}
|
{% elif unit.measurement_state in ["Start", "Measure"] %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
||||||
{% elif unit.is_recent %}
|
{% elif unit.is_recent %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.cache_last_seen %}
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
Last check: {{ unit.cache_last_seen|local_datetime }}
|
||||||
{% if unit.slm_last_check %}
|
{% elif unit.slm_last_check %}
|
||||||
Last check: {{ unit.slm_last_check|local_datetime }}
|
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||||
{% else %}
|
{% else %}
|
||||||
No recent check-in
|
No recent check-in
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,6 +143,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Stop Live Stream
|
Stop Live Stream
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -432,6 +434,24 @@ function initializeChart() {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L1',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(139, 92, 246)',
|
||||||
|
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L10',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(245, 158, 11)',
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -493,7 +513,37 @@ if (typeof window.currentWebSocket === 'undefined') {
|
|||||||
window.currentWebSocket = null;
|
window.currentWebSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initLiveDataStream(unitId) {
|
// Backfill the chart with the recent DOD trail so it opens with context.
|
||||||
|
async function backfillChart(unitId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
const readings = d.readings || [];
|
||||||
|
if (!window.chartData) return;
|
||||||
|
for (const row of readings) {
|
||||||
|
// Trail timestamps are naive UTC; append 'Z' so they convert to local
|
||||||
|
// consistently with the live frames (which use local Date.now()).
|
||||||
|
window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
||||||
|
window.chartData.lp.push(parseFloat(row.lp || 0));
|
||||||
|
window.chartData.leq.push(parseFloat(row.leq || 0));
|
||||||
|
window.chartData.ln1.push(parseFloat(row.ln1 || 0));
|
||||||
|
window.chartData.ln2.push(parseFloat(row.ln2 || 0));
|
||||||
|
}
|
||||||
|
if (window.liveChart) {
|
||||||
|
window.liveChart.data.labels = window.chartData.timestamps;
|
||||||
|
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||||
|
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||||
|
window.liveChart.data.datasets[2].data = window.chartData.ln1;
|
||||||
|
window.liveChart.data.datasets[3].data = window.chartData.ln2;
|
||||||
|
window.liveChart.update('none');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Chart backfill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initLiveDataStream(unitId) {
|
||||||
// Close existing connection if any
|
// Close existing connection if any
|
||||||
if (window.currentWebSocket) {
|
if (window.currentWebSocket) {
|
||||||
window.currentWebSocket.close();
|
window.currentWebSocket.close();
|
||||||
@@ -504,17 +554,24 @@ function initLiveDataStream(unitId) {
|
|||||||
window.chartData.timestamps = [];
|
window.chartData.timestamps = [];
|
||||||
window.chartData.lp = [];
|
window.chartData.lp = [];
|
||||||
window.chartData.leq = [];
|
window.chartData.leq = [];
|
||||||
|
window.chartData.ln1 = [];
|
||||||
|
window.chartData.ln2 = [];
|
||||||
}
|
}
|
||||||
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||||
window.liveChart.data.labels = [];
|
window.liveChart.data.labels = [];
|
||||||
window.liveChart.data.datasets[0].data = [];
|
window.liveChart.data.datasets.forEach(ds => ds.data = []);
|
||||||
window.liveChart.data.datasets[1].data = [];
|
|
||||||
window.liveChart.update();
|
window.liveChart.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket URL for SLMM backend via proxy
|
// Seed the chart with recent history BEFORE opening the live socket, so live
|
||||||
|
// frames append after the backfill (right order) and the chart isn't blank.
|
||||||
|
await backfillChart(unitId);
|
||||||
|
|
||||||
|
// WebSocket URL for SLMM backend via proxy.
|
||||||
|
// /monitor = the shared fan-out DOD feed (many viewers, one device connection,
|
||||||
|
// and it carries L1/L10 which the DRD /stream cannot).
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`;
|
||||||
|
|
||||||
window.currentWebSocket = new WebSocket(wsUrl);
|
window.currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -530,7 +587,11 @@ function initLiveDataStream(unitId) {
|
|||||||
window.currentWebSocket.onmessage = function(event) {
|
window.currentWebSocket.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log('WebSocket data received:', data);
|
// The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a
|
||||||
|
// 'feed_status' on each frame. Reflect status, but don't let a heartbeat
|
||||||
|
// or an 'unreachable' frame blank the cards / spike the chart with zeros.
|
||||||
|
updateFeedStatus(data.feed_status);
|
||||||
|
if (data.heartbeat || data.feed_status === 'unreachable') return;
|
||||||
updateLiveMetrics(data);
|
updateLiveMetrics(data);
|
||||||
updateLiveChart(data);
|
updateLiveChart(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -559,6 +620,21 @@ function stopLiveDataStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflect device reachability from the monitor feed's feed_status. Safe no-op
|
||||||
|
// if the badge element isn't on the page.
|
||||||
|
function updateFeedStatus(status) {
|
||||||
|
const el = document.getElementById('live-feed-status');
|
||||||
|
if (!el || status == null) return;
|
||||||
|
if (status === 'unreachable') {
|
||||||
|
el.textContent = 'Device offline';
|
||||||
|
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Live';
|
||||||
|
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
|
||||||
|
}
|
||||||
|
el.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Update metrics display
|
// Update metrics display
|
||||||
function updateLiveMetrics(data) {
|
function updateLiveMetrics(data) {
|
||||||
if (document.getElementById('live-lp')) {
|
if (document.getElementById('live-lp')) {
|
||||||
@@ -592,7 +668,9 @@ if (typeof window.chartData === 'undefined') {
|
|||||||
window.chartData = {
|
window.chartData = {
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: []
|
leq: [],
|
||||||
|
ln1: [],
|
||||||
|
ln2: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,12 +680,17 @@ function updateLiveChart(data) {
|
|||||||
window.chartData.timestamps.push(now.toLocaleTimeString());
|
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||||
window.chartData.lp.push(parseFloat(data.lp || 0));
|
window.chartData.lp.push(parseFloat(data.lp || 0));
|
||||||
window.chartData.leq.push(parseFloat(data.leq || 0));
|
window.chartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
window.chartData.ln1.push(parseFloat(data.ln1 || 0));
|
||||||
|
window.chartData.ln2.push(parseFloat(data.ln2 || 0));
|
||||||
|
|
||||||
// Keep only last 60 data points
|
// Keep a rolling window large enough to hold the ~2h backfill (one point/min)
|
||||||
if (window.chartData.timestamps.length > 60) {
|
// plus a good run of live points before the oldest scroll off.
|
||||||
|
if (window.chartData.timestamps.length > 600) {
|
||||||
window.chartData.timestamps.shift();
|
window.chartData.timestamps.shift();
|
||||||
window.chartData.lp.shift();
|
window.chartData.lp.shift();
|
||||||
window.chartData.leq.shift();
|
window.chartData.leq.shift();
|
||||||
|
window.chartData.ln1.shift();
|
||||||
|
window.chartData.ln2.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update chart if available
|
// Update chart if available
|
||||||
@@ -615,6 +698,8 @@ function updateLiveChart(data) {
|
|||||||
window.liveChart.data.labels = window.chartData.timestamps;
|
window.liveChart.data.labels = window.chartData.timestamps;
|
||||||
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||||
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||||
|
window.liveChart.data.datasets[2].data = window.chartData.ln1;
|
||||||
|
window.liveChart.data.datasets[3].data = window.chartData.ln2;
|
||||||
window.liveChart.update('none');
|
window.liveChart.update('none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
|
|||||||
if (typeof checkFTPStatus === 'function') {
|
if (typeof checkFTPStatus === 'function') {
|
||||||
checkFTPStatus(unitId);
|
checkFTPStatus(unitId);
|
||||||
}
|
}
|
||||||
if (typeof htmx !== 'undefined') {
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
||||||
htmx.trigger('#slm-list', 'load');
|
htmx.trigger('#slm-list', 'load');
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -604,8 +604,10 @@ async function toggleSLMDeployed() {
|
|||||||
successDiv.classList.remove('hidden');
|
successDiv.classList.remove('hidden');
|
||||||
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
setTimeout(() => successDiv.classList.add('hidden'), 3000);
|
||||||
|
|
||||||
// Refresh any SLM list on the page
|
// Refresh any SLM list on the page (only if one is actually present —
|
||||||
if (typeof htmx !== 'undefined') {
|
// the detail/dashboard pages have no #slm-list, and htmx.trigger on a
|
||||||
|
// null target throws "can't access property dispatchEvent, e is null").
|
||||||
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
|
||||||
htmx.trigger('#slm-list', 'load');
|
htmx.trigger('#slm-list', 'load');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -51,14 +51,32 @@
|
|||||||
|
|
||||||
<!-- Live Measurement Chart - shows when a device is selected -->
|
<!-- Live Measurement Chart - shows when a device is selected -->
|
||||||
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-start justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Live Measurements
|
||||||
|
<span id="panel-unit-id" class="text-seismo-orange"></span>
|
||||||
|
</h2>
|
||||||
|
<!-- Measuring state + cache freshness (populated from cached /status, no device hit) -->
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-sm">
|
||||||
|
<span id="panel-measuring-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
|
||||||
|
<span id="panel-freshness" class="text-gray-500 dark:text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="refreshDashboardPanel()" title="Refresh from device"
|
||||||
|
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange">
|
||||||
|
<svg id="panel-refresh-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
<!-- Current Metrics -->
|
||||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||||
@@ -150,9 +168,18 @@ window.selectedUnitId = null;
|
|||||||
window.dashboardChartData = {
|
window.dashboardChartData = {
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: []
|
leq: [],
|
||||||
|
ln1: [],
|
||||||
|
ln2: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Parse a metric to a number, or null (so a missing/"-.-" percentile leaves a gap
|
||||||
|
// in the line instead of dropping it to 0).
|
||||||
|
function numOrNull(v) {
|
||||||
|
const f = parseFloat(v);
|
||||||
|
return isNaN(f) ? null : f;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Chart.js
|
// Initialize Chart.js
|
||||||
function initializeDashboardChart() {
|
function initializeDashboardChart() {
|
||||||
if (typeof Chart === 'undefined') {
|
if (typeof Chart === 'undefined') {
|
||||||
@@ -194,6 +221,26 @@ function initializeDashboardChart() {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L1',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(168, 85, 247)',
|
||||||
|
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
spanGaps: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'L10',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(249, 115, 22)',
|
||||||
|
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
spanGaps: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -244,12 +291,24 @@ function showLiveChart(unitId) {
|
|||||||
initializeDashboardChart();
|
initializeDashboardChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset data
|
// Reset data for the newly-selected unit (clears any prior unit's line)
|
||||||
window.dashboardChartData = {
|
window.dashboardChartData = { timestamps: [], lp: [], leq: [], ln1: [], ln2: [] };
|
||||||
timestamps: [],
|
if (window.dashboardChart) {
|
||||||
lp: [],
|
window.dashboardChart.data.labels = [];
|
||||||
leq: []
|
window.dashboardChart.data.datasets.forEach(ds => ds.data = []);
|
||||||
};
|
window.dashboardChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name the unit; clear stale status until the cache read returns
|
||||||
|
const unitLabel = document.getElementById('panel-unit-id');
|
||||||
|
if (unitLabel) unitLabel.textContent = '· ' + unitId;
|
||||||
|
setPanelStatus(null, null);
|
||||||
|
|
||||||
|
// Populate immediately from CACHE (no device hit): KPI cards + chart trail.
|
||||||
|
prefillDashboardPanel(unitId);
|
||||||
|
backfillDashboardChart(unitId);
|
||||||
|
// Keep the cards updating from cache (~15s) without opening a device stream.
|
||||||
|
startPanelCachePolling(unitId);
|
||||||
|
|
||||||
// Scroll to chart
|
// Scroll to chart
|
||||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -257,6 +316,7 @@ function showLiveChart(unitId) {
|
|||||||
|
|
||||||
function closeLiveChart() {
|
function closeLiveChart() {
|
||||||
stopDashboardStream();
|
stopDashboardStream();
|
||||||
|
stopPanelCachePolling();
|
||||||
document.getElementById('live-chart-panel').classList.add('hidden');
|
document.getElementById('live-chart-panel').classList.add('hidden');
|
||||||
window.selectedUnitId = null;
|
window.selectedUnitId = null;
|
||||||
}
|
}
|
||||||
@@ -270,17 +330,12 @@ function startDashboardStream() {
|
|||||||
window.dashboardWebSocket.close();
|
window.dashboardWebSocket.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset chart data
|
// The live WS takes over from the cache poller; keep the backfilled trail on
|
||||||
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
// the chart so the live frames continue the line instead of blanking it.
|
||||||
if (window.dashboardChart) {
|
stopPanelCachePolling();
|
||||||
window.dashboardChart.data.labels = [];
|
|
||||||
window.dashboardChart.data.datasets[0].data = [];
|
|
||||||
window.dashboardChart.data.datasets[1].data = [];
|
|
||||||
window.dashboardChart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`;
|
||||||
|
|
||||||
window.dashboardWebSocket = new WebSocket(wsUrl);
|
window.dashboardWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -293,6 +348,10 @@ function startDashboardStream() {
|
|||||||
window.dashboardWebSocket.onmessage = function(event) {
|
window.dashboardWebSocket.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
// /monitor sends keepalive 'heartbeat' frames (no metrics) and a per-frame
|
||||||
|
// 'feed_status'; skip heartbeats and offline frames so they don't blank the
|
||||||
|
// metrics or spike the chart with zeros.
|
||||||
|
if (data.heartbeat || data.feed_status === 'unreachable') return;
|
||||||
updateDashboardMetrics(data);
|
updateDashboardMetrics(data);
|
||||||
updateDashboardChart(data);
|
updateDashboardChart(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -316,6 +375,10 @@ function stopDashboardStream() {
|
|||||||
window.dashboardWebSocket.close();
|
window.dashboardWebSocket.close();
|
||||||
window.dashboardWebSocket = null;
|
window.dashboardWebSocket = null;
|
||||||
}
|
}
|
||||||
|
// Fall back to cache polling so the cards keep refreshing while the panel is open.
|
||||||
|
if (window.selectedUnitId && !document.getElementById('live-chart-panel').classList.contains('hidden')) {
|
||||||
|
startPanelCachePolling(window.selectedUnitId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardMetrics(data) {
|
function updateDashboardMetrics(data) {
|
||||||
@@ -331,26 +394,200 @@ function updateDashboardMetrics(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardChart(data) {
|
function updateDashboardChart(data) {
|
||||||
|
const cd = window.dashboardChartData;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
cd.timestamps.push(now.toLocaleTimeString());
|
||||||
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
cd.lp.push(numOrNull(data.lp));
|
||||||
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
cd.leq.push(numOrNull(data.leq));
|
||||||
|
// /monitor (DOD) frames carry ln1/ln2; a DRD frame would omit them -> null gap.
|
||||||
|
cd.ln1.push(numOrNull(data.ln1));
|
||||||
|
cd.ln2.push(numOrNull(data.ln2));
|
||||||
|
|
||||||
// Keep only last 60 data points
|
// Keep a generous window (backfill seeds up to ~120 points from the 2h trail).
|
||||||
if (window.dashboardChartData.timestamps.length > 60) {
|
if (cd.timestamps.length > 600) {
|
||||||
window.dashboardChartData.timestamps.shift();
|
cd.timestamps.shift();
|
||||||
window.dashboardChartData.lp.shift();
|
cd.lp.shift();
|
||||||
window.dashboardChartData.leq.shift();
|
cd.leq.shift();
|
||||||
|
cd.ln1.shift();
|
||||||
|
cd.ln2.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.dashboardChart) {
|
if (window.dashboardChart) {
|
||||||
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
window.dashboardChart.data.labels = cd.timestamps;
|
||||||
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
window.dashboardChart.data.datasets[0].data = cd.lp;
|
||||||
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
window.dashboardChart.data.datasets[1].data = cd.leq;
|
||||||
|
window.dashboardChart.data.datasets[2].data = cd.ln1;
|
||||||
|
window.dashboardChart.data.datasets[3].data = cd.ln2;
|
||||||
window.dashboardChart.update('none');
|
window.dashboardChart.update('none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Cached-data panel population (no device hit) -----------------------
|
||||||
|
|
||||||
|
// Fill the KPI cards + measuring/freshness from the cached NL43Status snapshot.
|
||||||
|
async function prefillDashboardPanel(unitId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/status`);
|
||||||
|
if (!r.ok) { // 404 = device has never reported yet
|
||||||
|
setPanelStatus(null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = (await r.json()).data || {};
|
||||||
|
updateDashboardMetrics(d); // lp/leq/lmax/ln1/ln2 (ln guards keep cached percentiles)
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
setPanelStatus(measuring, d.last_seen);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Panel cache prefill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the chart from the downsampled DOD trail so it shows recent trend on open.
|
||||||
|
async function backfillDashboardChart(unitId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const readings = (await r.json()).readings || [];
|
||||||
|
const cd = window.dashboardChartData;
|
||||||
|
if (!cd) return;
|
||||||
|
for (const row of readings) {
|
||||||
|
// Trail timestamps are naive UTC; append 'Z' to render in local time
|
||||||
|
// consistently with the live frames (which use local Date.now()).
|
||||||
|
cd.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
||||||
|
cd.lp.push(numOrNull(row.lp));
|
||||||
|
cd.leq.push(numOrNull(row.leq));
|
||||||
|
cd.ln1.push(numOrNull(row.ln1));
|
||||||
|
cd.ln2.push(numOrNull(row.ln2));
|
||||||
|
}
|
||||||
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.data.labels = cd.timestamps;
|
||||||
|
window.dashboardChart.data.datasets[0].data = cd.lp;
|
||||||
|
window.dashboardChart.data.datasets[1].data = cd.leq;
|
||||||
|
window.dashboardChart.data.datasets[2].data = cd.ln1;
|
||||||
|
window.dashboardChart.data.datasets[3].data = cd.ln2;
|
||||||
|
window.dashboardChart.update('none');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Panel chart backfill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measuring badge + "as of <time> (Xm ago)" freshness, so a cached value is never
|
||||||
|
// mistaken for a live one. measuring: true | false | null(unknown).
|
||||||
|
function setPanelStatus(measuring, lastSeenIso) {
|
||||||
|
const badge = document.getElementById('panel-measuring-badge');
|
||||||
|
const fresh = document.getElementById('panel-freshness');
|
||||||
|
if (badge) {
|
||||||
|
if (measuring === null) {
|
||||||
|
badge.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full';
|
||||||
|
badge.textContent = '';
|
||||||
|
} else if (measuring) {
|
||||||
|
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
badge.textContent = '● Measuring';
|
||||||
|
} else {
|
||||||
|
badge.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
badge.textContent = '■ Stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fresh) fresh.innerHTML = fmtFreshness(lastSeenIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human "x ago" with a staleness hint. Cached timestamps are naive UTC.
|
||||||
|
function fmtFreshness(lastSeenIso) {
|
||||||
|
if (!lastSeenIso) return '<span class="text-gray-400">no cached reading yet</span>';
|
||||||
|
const t = new Date(lastSeenIso.endsWith('Z') ? lastSeenIso : lastSeenIso + 'Z');
|
||||||
|
const secs = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
||||||
|
let ago, stale = false;
|
||||||
|
if (secs < 10) ago = 'just now';
|
||||||
|
else if (secs < 60) ago = secs + 's ago';
|
||||||
|
else if (secs < 3600) { ago = Math.round(secs / 60) + 'm ago'; stale = secs >= 300; }
|
||||||
|
else { ago = Math.round(secs / 3600) + 'h ago'; stale = true; }
|
||||||
|
const cls = stale ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500 dark:text-gray-400';
|
||||||
|
const tag = stale ? ' · cached' : '';
|
||||||
|
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${tag})</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache polling: refresh the cards from cache every 15s while the panel is open
|
||||||
|
// and not live-streaming. Pure cache reads — no device contention.
|
||||||
|
function startPanelCachePolling(unitId) {
|
||||||
|
stopPanelCachePolling();
|
||||||
|
window.panelCacheTimer = setInterval(() => {
|
||||||
|
if (window.selectedUnitId) prefillDashboardPanel(window.selectedUnitId);
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
function stopPanelCachePolling() {
|
||||||
|
if (window.panelCacheTimer) { clearInterval(window.panelCacheTimer); window.panelCacheTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- On-demand device refresh (the per-unit + panel refresh buttons) -----
|
||||||
|
|
||||||
|
// One bounded, user-initiated device read: hits the device, updates the cache,
|
||||||
|
// returns the fresh data. Throws on unreachable/disabled.
|
||||||
|
async function forceDeviceRead(unitId) {
|
||||||
|
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/live`);
|
||||||
|
if (!r.ok) {
|
||||||
|
let detail = 'device unreachable';
|
||||||
|
try { detail = (await r.json()).detail || detail; } catch (e) {}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return (await r.json()).data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spinIcon(el, on) {
|
||||||
|
if (el) el.classList.toggle('animate-spin', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFreshReadToPanel(unitId, d) {
|
||||||
|
if (window.selectedUnitId !== unitId) return;
|
||||||
|
updateDashboardMetrics(d);
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
// The read just happened, so "now" is the accurate freshness even if the
|
||||||
|
// /live payload doesn't echo last_seen.
|
||||||
|
setPanelStatus(measuring, d.last_seen || new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-list row refresh button.
|
||||||
|
async function refreshSlmUnit(unitId, btn) {
|
||||||
|
const icon = btn ? btn.querySelector('svg') : null;
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
spinIcon(icon, true);
|
||||||
|
try {
|
||||||
|
const d = await forceDeviceRead(unitId);
|
||||||
|
applyFreshReadToPanel(unitId, d);
|
||||||
|
// Reload the list so the row's badge + last-check reflect the new cache.
|
||||||
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
|
||||||
|
htmx.trigger('#slm-devices-list', 'load');
|
||||||
|
}
|
||||||
|
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
|
||||||
|
else console.warn('refresh failed', e);
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
spinIcon(icon, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel header refresh button (refreshes the unit the panel is showing).
|
||||||
|
async function refreshDashboardPanel() {
|
||||||
|
const unitId = window.selectedUnitId;
|
||||||
|
if (!unitId) return;
|
||||||
|
const icon = document.getElementById('panel-refresh-icon');
|
||||||
|
spinIcon(icon, true);
|
||||||
|
try {
|
||||||
|
const d = await forceDeviceRead(unitId);
|
||||||
|
applyFreshReadToPanel(unitId, d);
|
||||||
|
updateDashboardChart(d); // append the fresh point to the chart
|
||||||
|
if (typeof htmx !== 'undefined' && document.getElementById('slm-devices-list')) {
|
||||||
|
htmx.trigger('#slm-devices-list', 'load');
|
||||||
|
}
|
||||||
|
if (window.showToast) window.showToast(`${unitId} refreshed`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
if (window.showToast) window.showToast(`${unitId}: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
spinIcon(icon, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration modal - use unified SLM settings modal
|
// Configuration modal - use unified SLM settings modal
|
||||||
function openDeviceConfigModal(unitId) {
|
function openDeviceConfigModal(unitId) {
|
||||||
// Call the unified modal function from slm_settings_modal.html
|
// Call the unified modal function from slm_settings_modal.html
|
||||||
|
|||||||
Reference in New Issue
Block a user