+
+ {{ unit.id }}
+ {% if unit.slm_model %}
+ • {{ unit.slm_model }}
{% endif %}
-
- {% if unit.retired %}
-
Retired
- {% elif not unit.deployed %}
-
Benched
- {% elif unit.measurement_state == "Start" %}
-
Measuring
- {% elif unit.is_recent %}
-
Active
- {% else %}
-
Idle
+ {% if unit.address %}
+
{{ unit.address }}
+ {% elif unit.location %}
+
{{ unit.location }}
{% endif %}
-
- {% if unit.slm_last_check %}
- Last check: {{ unit.slm_last_check|local_datetime }}
+
+
+ {% if unit.retired %}
+ Retired
+ {% elif not unit.deployed %}
+ Benched
+ {% elif unit.measurement_state == "Start" %}
+ Measuring
+ {% elif unit.is_recent %}
+ Active
{% else %}
- No recent check-in
+ Idle
{% endif %}
+
+ {% if unit.slm_last_check %}
+ Last check: {{ unit.slm_last_check|local_datetime }}
+ {% else %}
+ No recent check-in
+ {% endif %}
+
diff --git a/templates/sound_level_meters.html b/templates/sound_level_meters.html
index 3055db9..360ddbd 100644
--- a/templates/sound_level_meters.html
+++ b/templates/sound_level_meters.html
@@ -51,13 +51,31 @@
-
-
Live Measurements
-
-
-
-
-
+
+
+
+ Live Measurements
+
+
+
+
+
+
+
+
+
@@ -244,12 +262,25 @@ function showLiveChart(unitId) {
initializeDashboardChart();
}
- // Reset data
- window.dashboardChartData = {
- timestamps: [],
- lp: [],
- leq: []
- };
+ // Reset data for the newly-selected unit (clears any prior unit's line)
+ window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
+ if (window.dashboardChart) {
+ window.dashboardChart.data.labels = [];
+ window.dashboardChart.data.datasets[0].data = [];
+ window.dashboardChart.data.datasets[1].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
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -257,6 +288,7 @@ function showLiveChart(unitId) {
function closeLiveChart() {
stopDashboardStream();
+ stopPanelCachePolling();
document.getElementById('live-chart-panel').classList.add('hidden');
window.selectedUnitId = null;
}
@@ -270,14 +302,9 @@ function startDashboardStream() {
window.dashboardWebSocket.close();
}
- // Reset chart data
- window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
- if (window.dashboardChart) {
- window.dashboardChart.data.labels = [];
- window.dashboardChart.data.datasets[0].data = [];
- window.dashboardChart.data.datasets[1].data = [];
- window.dashboardChart.update();
- }
+ // The live WS takes over from the cache poller; keep the backfilled trail on
+ // the chart so the live frames continue the line instead of blanking it.
+ stopPanelCachePolling();
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`;
@@ -320,6 +347,10 @@ function stopDashboardStream() {
window.dashboardWebSocket.close();
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) {
@@ -340,8 +371,8 @@ function updateDashboardChart(data) {
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
- // Keep only last 60 data points
- if (window.dashboardChartData.timestamps.length > 60) {
+ // Keep a generous window (backfill seeds up to ~120 points from the 2h trail).
+ if (window.dashboardChartData.timestamps.length > 600) {
window.dashboardChartData.timestamps.shift();
window.dashboardChartData.lp.shift();
window.dashboardChartData.leq.shift();
@@ -355,6 +386,168 @@ function updateDashboardChart(data) {
}
}
+// ---- 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(parseFloat(row.lp || 0));
+ cd.leq.push(parseFloat(row.leq || 0));
+ }
+ 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.update('none');
+ }
+ } catch (e) {
+ console.warn('Panel chart backfill failed:', e);
+ }
+}
+
+// Measuring badge + "as of
(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 'no cached reading yet ';
+ 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()} (${ago}${tag}) `;
+}
+
+// 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
function openDeviceConfigModal(unitId) {
// Call the unified modal function from slm_settings_modal.html