Feat: add SLM live monitoring improvements #60
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user