diff --git a/src/production/hmi/ui/public/admin/sensor-health.html b/src/production/hmi/ui/public/admin/sensor-health.html index dae0e64cb..8fb1ba5b2 100644 --- a/src/production/hmi/ui/public/admin/sensor-health.html +++ b/src/production/hmi/ui/public/admin/sensor-health.html @@ -9,6 +9,23 @@ + + @@ -71,21 +88,31 @@

Sensor Health

- +
-

SENSOR MONITORING

-

Live

+

TOTAL SENSORS

+

--

+
+ +
+

ONLINE

+

--

+
+ +
+

DEGRADED

+

--

-

DATA SOURCE

-

MQTT / Seeded

+

OFFLINE

+

--

-

AUTO REFRESH

-

15s

+

LOW BATTERY

+

--

@@ -111,6 +138,7 @@

Sensor Overview

@@ -122,7 +150,7 @@

Sensor Overview

class="card-subtitle" style="margin-bottom: 12px;" > - Last updated at: -- + Last updated at: -- · auto-refresh every --
diff --git a/src/production/hmi/ui/public/admin/sensor_health/script.js b/src/production/hmi/ui/public/admin/sensor_health/script.js index a0e471d1a..58c9e45b9 100644 --- a/src/production/hmi/ui/public/admin/sensor_health/script.js +++ b/src/production/hmi/ui/public/admin/sensor_health/script.js @@ -2,6 +2,20 @@ Sprint 2: UI enhancement and live status monitoring */ +// Single source of truth for the auto-refresh interval. Drives both the +// setInterval timer and the interval shown to the user, so they cannot drift. +const REFRESH_MS = 15000; + +// Small muted style for the "seen X ago" line under each status pill. +(function injectSensorHealthStyles() { + if (document.getElementById("sensor-health-inline-styles")) return; + const style = document.createElement("style"); + style.id = "sensor-health-inline-styles"; + style.textContent = + ".cell-subtext{font-size:11px;color:var(--muted,#888);margin-top:2px;}"; + document.head.appendChild(style); +})(); + const menuToggle = document.getElementById("menu-toggle"); const mobileBackdrop = document.getElementById("mobile-backdrop"); @@ -109,17 +123,21 @@ async function apiFetch(path, options = {}) { return response.text(); } -function pillHtml(status, batteryPct) { +function pillHtml(status) { + // Status is the single source of truth (derived by the backend). We do not + // re-derive Low Battery from a raw battery threshold here, otherwise a + // Degraded/Offline sensor that also has low battery would show the wrong + // pill while the KPI cards and row highlight correctly show its real status. const s = String(status || "").trim(); - if (typeof batteryPct === "number" && batteryPct < 20) { - return `Low Battery`; - } - if (s === "Online" || s === "Success") { return `${s}`; } + if (s === "Degraded" || s === "Low Battery") { + return `${s}`; + } + if (s === "Offline" || s === "Failed") { return `${s}`; } @@ -167,6 +185,22 @@ function formatUptime(seconds) { return `${mins}m`; } +// Turn a "minutes ago" number into readable text for last-seen / last-audio. +function formatMinutesAgo(minutes) { + if (minutes === null || minutes === undefined || minutes === "") return "—"; + const num = Number(minutes); + if (!Number.isFinite(num) || num < 0) return "—"; + if (num === 0) return "just now"; + if (num < 60) return `${num}m ago`; + + const hours = Math.floor(num / 60); + const mins = num % 60; + if (hours < 24) return mins ? `${hours}h ${mins}m ago` : `${hours}h ago`; + + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + function updateLastUpdated() { const el = document.getElementById("last-updated-at"); if (!el) return; @@ -175,6 +209,51 @@ function updateLastUpdated() { el.textContent = `Last updated at: ${now.toLocaleTimeString()}`; } +// Compute status counts from the SAME array that fills the table, so the KPI +// cards can never drift from the table contents. +// +// The backend currently derives exactly four statuses (Online, Degraded, +// Offline, Low Battery). The `other` bucket is a safety net: if the backend +// ever returns a status outside that set, it is still counted so the buckets +// always reconcile with the total and the cards can never silently under-count. +function updateSensorKpis(items) { + const counts = { total: 0, online: 0, degraded: 0, offline: 0, lowBattery: 0, other: 0 }; + + for (const item of items) { + counts.total += 1; + switch (item.status) { + case "Online": + counts.online += 1; + break; + case "Degraded": + counts.degraded += 1; + break; + case "Offline": + counts.offline += 1; + break; + case "Low Battery": + counts.lowBattery += 1; + break; + default: + counts.other += 1; + break; + } + } + + const set = (id, value) => { + const el = document.getElementById(id); + if (el) el.textContent = value; + }; + + set("kpi-total", counts.total); + set("kpi-online", counts.online); + set("kpi-degraded", counts.degraded); + set("kpi-offline", counts.offline); + set("kpi-low-battery", counts.lowBattery); + + return counts; +} + // ================================================================ // Reboot sensors // ================================================================ @@ -352,18 +431,13 @@ async function loadSensorHealthPage() { const filtered = lastItems.filter((item) => { const sensorId = String(item.sensorId || "").toLowerCase(); - const batteryPct = Number(item.batteryPct); const matchesSearch = !searchVal || sensorId.includes(searchVal); - let matchesStatus = true; - if (statusVal === "Online") { - matchesStatus = item.status === "Online"; - } else if (statusVal === "Offline") { - matchesStatus = item.status === "Offline"; - } else if (statusVal === "Low Battery") { - matchesStatus = Number.isFinite(batteryPct) && batteryPct < 20; - } + // Filter on the backend-derived status so the dropdown matches the pills + // and KPI counts exactly (same source of truth everywhere). + const matchesStatus = + statusVal === "All" ? true : item.status === statusVal; return matchesSearch && matchesStatus; }); @@ -378,20 +452,23 @@ async function loadSensorHealthPage() { for (const item of filtered) { const tr = document.createElement("tr"); - if (typeof item.batteryPct === "number" && item.batteryPct < 20) { + if (item.status === "Low Battery") { tr.classList.add("sensor-row-low-battery"); } + if (item.status === "Degraded" || item.status === "Offline") { + tr.classList.add("sensor-row-stale"); + } tr.innerHTML = ` ${item.sensorId || "—"} - ${pillHtml(item.status, item.batteryPct)} + ${pillHtml(item.status)}
seen ${formatMinutesAgo(item.lastSeenMinutesAgo)}
${formatBattery(item.batteryPct)} ${formatPercent(item.cpu)} ${formatPercent(item.ram)} ${formatPercent(item.disk)} ${formatUptime(item.uptime)} ${formatGps(item.gps)} - ${item.lastAudio || "—"} + ${formatMinutesAgo(item.lastAudioMinutesAgo)} `; tbody.appendChild(tr); @@ -405,6 +482,7 @@ async function loadSensorHealthPage() { try { const data = await apiFetch("/sensors/updates"); lastItems = Array.isArray(data.items) ? data.items : []; + updateSensorKpis(lastItems); updateLastUpdated(); render(); } catch (e) { @@ -416,7 +494,7 @@ async function loadSensorHealthPage() { searchInput?.addEventListener("input", render); await refresh(); - setInterval(refresh, 15000); + setInterval(refresh, REFRESH_MS); } // ================================================================