feat(slm): auto-populate live panel from cache, per-unit refresh, fix badge overlap

Live Measurements panel no longer sits blank until you click Start Live Stream:
- On open it fills the KPI cards from the cached /status snapshot (lp/leq/lmax/
  L1/L10) and backfills the chart from the /history DOD trail — both pure cache
  reads, no device hit.
- Shows measuring state (● Measuring / ■ Stopped) and a freshness stamp
  ("as of 2:14 PM (12m ago)") that turns amber + "cached" when stale, so a cached
  value is never mistaken for a live reading.
- Polls the cache every 15s while open so the cards stay current without opening
  a device stream; Start Live Stream takes over (and no longer wipes the
  backfilled trail). Chart cap raised 60 -> 600 so the 2h backfill isn't truncated.

Refresh buttons (on-demand, user-initiated single device read via GET /live,
which also updates the cache):
- one per device row in the list, and one in the panel header. Spinner while in
  flight; toast on success/failure; reloads the list so badges + last-check update.

Layout fix: the status badge (Measuring/Active/Idle/Benched) was rendered at the
top-right of the card, colliding with the absolutely-positioned chart/gear icons.
Moved it to the bottom meta row next to "Last check", padded the card content
clear of the action icons, and added the refresh icon to that group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:59:42 +00:00
parent e27aef33ac
commit 711ef41e5f
2 changed files with 253 additions and 52 deletions
+37 -29
View File
@@ -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,41 +27,42 @@
</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 %} <span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
{% endif %}
</div>
{% if unit.address %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
{% elif unit.location %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
{% endif %} {% endif %}
</div> </div>
{% if unit.address %}
{% if unit.retired %} <p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
<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> {% elif unit.location %}
{% elif not unit.deployed %} <p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
<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>
{% elif unit.measurement_state == "Start" %}
<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>
{% 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>
{% 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>
{% endif %} {% endif %}
</div> </div>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <!-- Status badge + last-check on one line (moved off the top-right so it
{% if unit.slm_last_check %} no longer collides with the refresh/chart/gear action icons). -->
Last check: {{ unit.slm_last_check|local_datetime }} <div class="mt-2 flex items-center gap-2 flex-wrap">
{% if unit.retired %}
<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 %}
<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" %}
<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 %}
<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 %}
No recent check-in <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 %}
<span class="text-xs text-gray-500 dark:text-gray-400">
{% if unit.slm_last_check %}
Last check: {{ unit.slm_last_check|local_datetime }}
{% else %}
No recent check-in
{% endif %}
</span>
</div> </div>
</a> </a>
</div> </div>
+216 -23
View File
@@ -51,13 +51,31 @@
<!-- 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>
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> Live Measurements
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <span id="panel-unit-id" class="text-seismo-orange"></span>
</svg> </h2>
</button> <!-- 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">
<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>
</svg>
</button>
</div>
</div> </div>
<!-- Current Metrics --> <!-- Current Metrics -->
@@ -244,12 +262,25 @@ 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: [] };
timestamps: [], if (window.dashboardChart) {
lp: [], window.dashboardChart.data.labels = [];
leq: [] 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 // Scroll to chart
panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -257,6 +288,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,14 +302,9 @@ 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}/monitor`; const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/monitor`;
@@ -320,6 +347,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) {
@@ -340,8 +371,8 @@ function updateDashboardChart(data) {
window.dashboardChartData.lp.push(parseFloat(data.lp || 0)); window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
window.dashboardChartData.leq.push(parseFloat(data.leq || 0)); window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
// 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 (window.dashboardChartData.timestamps.length > 600) {
window.dashboardChartData.timestamps.shift(); window.dashboardChartData.timestamps.shift();
window.dashboardChartData.lp.shift(); window.dashboardChartData.lp.shift();
window.dashboardChartData.leq.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 <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