From 9b7b3aac34c530b934e6bd584066ff0a6a640fa7 Mon Sep 17 00:00:00 2001 From: Mustafa Al Husaini Date: Thu, 6 Aug 2026 14:51:37 +1000 Subject: [PATCH 1/3] FR-C1: live sensor-health KPIs, stale detection, refresh interval - Replace static KPI cards with live Total/Online/Degraded/Offline/Low Battery counts computed from the same data as the table - Surface backend Degraded/Offline status and last-seen time as stale indicators - Add Degraded status pill and filter option - Drive refresh interval from a single REFRESH_MS constant and display it - Map lastAudioMinutesAgo into the Last Audio column --- .../hmi/ui/public/admin/sensor-health.html | 44 +++++++-- .../ui/public/admin/sensor_health/script.js | 96 ++++++++++++++++--- 2 files changed, 119 insertions(+), 21 deletions(-) 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..f72c8ae0e 100644 --- a/src/production/hmi/ui/public/admin/sensor_health/script.js +++ b/src/production/hmi/ui/public/admin/sensor_health/script.js @@ -120,6 +120,10 @@ function pillHtml(status, batteryPct) { return `${s}`; } + if (s === "Degraded") { + return `${s}`; + } + if (s === "Offline" || s === "Failed") { return `${s}`; } @@ -167,12 +171,72 @@ function formatUptime(seconds) { return `${mins}m`; } +function formatMinutesAgo(minutes) { + 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`; +} + +// Single source of truth for the refresh interval. +const REFRESH_MS = 15000; + +function refreshIntervalLabel() { + const seconds = Math.round(REFRESH_MS / 1000); + return seconds >= 60 ? `${Math.round(seconds / 60)}m` : `${seconds}s`; +} + function updateLastUpdated() { const el = document.getElementById("last-updated-at"); if (!el) return; const now = new Date(); - el.textContent = `Last updated at: ${now.toLocaleTimeString()}`; + const intervalSpan = `${refreshIntervalLabel()}`; + el.innerHTML = `Last updated at: ${now.toLocaleTimeString()} · auto-refresh every ${intervalSpan}`; +} + +// Compute status counts from the SAME array that fills the table, +// so the KPI cards can never drift from the table contents. +function updateSensorKpis(items) { + const counts = { total: 0, online: 0, degraded: 0, offline: 0, lowBattery: 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: + 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); } // ================================================================ @@ -356,14 +420,10 @@ async function loadSensorHealthPage() { 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 status pills and KPI counts exactly. + const matchesStatus = + statusVal === "All" ? true : item.status === statusVal; return matchesSearch && matchesStatus; }); @@ -378,20 +438,29 @@ 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"); } + // Flag stale / missing sensors so they stand out in the table. + if (item.status === "Degraded" || item.status === "Offline") { + tr.classList.add("sensor-row-stale"); + } + + // Last-seen context makes staleness legible at a glance. + const lastSeenText = formatMinutesAgo(item.lastSeenMinutesAgo); + const lastAudioText = formatMinutesAgo(item.lastAudioMinutesAgo); + tr.innerHTML = ` ${item.sensorId || "—"} - ${pillHtml(item.status, item.batteryPct)} + ${pillHtml(item.status, item.batteryPct)}
seen ${lastSeenText}
${formatBattery(item.batteryPct)} ${formatPercent(item.cpu)} ${formatPercent(item.ram)} ${formatPercent(item.disk)} ${formatUptime(item.uptime)} ${formatGps(item.gps)} - ${item.lastAudio || "—"} + ${lastAudioText} `; tbody.appendChild(tr); @@ -405,6 +474,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 +486,7 @@ async function loadSensorHealthPage() { searchInput?.addEventListener("input", render); await refresh(); - setInterval(refresh, 15000); + setInterval(refresh, REFRESH_MS); } // ================================================================ From a1e02cd0c438cf1bc51cf4060fb5b58bc5bcc62a Mon Sep 17 00:00:00 2001 From: Mustafa Al Husaini Date: Tue, 18 Aug 2026 16:26:23 +1000 Subject: [PATCH 2/3] =?UTF-8?q?FR-C1:=20address=20review=20=E2=80=94=20sta?= =?UTF-8?q?tus=20as=20source=20of=20truth=20for=20pill/filter,=20KPI=20fal?= =?UTF-8?q?lback=20bucket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/public/admin/sensor_health/script.js | 70 +++++++------------ 1 file changed, 26 insertions(+), 44 deletions(-) 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 f72c8ae0e..d71dd540d 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,10 @@ 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; + const menuToggle = document.getElementById("menu-toggle"); const mobileBackdrop = document.getElementById("mobile-backdrop"); @@ -109,18 +113,18 @@ 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") { + if (s === "Degraded" || s === "Low Battery") { return `${s}`; } @@ -171,41 +175,23 @@ function formatUptime(seconds) { return `${mins}m`; } -function formatMinutesAgo(minutes) { - 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`; -} - -// Single source of truth for the refresh interval. -const REFRESH_MS = 15000; - -function refreshIntervalLabel() { - const seconds = Math.round(REFRESH_MS / 1000); - return seconds >= 60 ? `${Math.round(seconds / 60)}m` : `${seconds}s`; -} - function updateLastUpdated() { const el = document.getElementById("last-updated-at"); if (!el) return; const now = new Date(); - const intervalSpan = `${refreshIntervalLabel()}`; - el.innerHTML = `Last updated at: ${now.toLocaleTimeString()} · auto-refresh every ${intervalSpan}`; + 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. +// 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 }; + const counts = { total: 0, online: 0, degraded: 0, offline: 0, lowBattery: 0, other: 0 }; for (const item of items) { counts.total += 1; @@ -223,6 +209,7 @@ function updateSensorKpis(items) { counts.lowBattery += 1; break; default: + counts.other += 1; break; } } @@ -237,6 +224,8 @@ function updateSensorKpis(items) { set("kpi-degraded", counts.degraded); set("kpi-offline", counts.offline); set("kpi-low-battery", counts.lowBattery); + + return counts; } // ================================================================ @@ -416,12 +405,11 @@ 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); - // Filter on the backend-derived status so the dropdown matches - // the status pills and KPI counts exactly. + // 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; @@ -441,26 +429,20 @@ async function loadSensorHealthPage() { if (item.status === "Low Battery") { tr.classList.add("sensor-row-low-battery"); } - - // Flag stale / missing sensors so they stand out in the table. if (item.status === "Degraded" || item.status === "Offline") { tr.classList.add("sensor-row-stale"); } - // Last-seen context makes staleness legible at a glance. - const lastSeenText = formatMinutesAgo(item.lastSeenMinutesAgo); - const lastAudioText = formatMinutesAgo(item.lastAudioMinutesAgo); - tr.innerHTML = ` ${item.sensorId || "—"} - ${pillHtml(item.status, item.batteryPct)}
seen ${lastSeenText}
+ ${pillHtml(item.status)} ${formatBattery(item.batteryPct)} ${formatPercent(item.cpu)} ${formatPercent(item.ram)} ${formatPercent(item.disk)} ${formatUptime(item.uptime)} ${formatGps(item.gps)} - ${lastAudioText} + ${item.lastAudio || "—"} `; tbody.appendChild(tr); From 163598809281c66f5374f5c66dc9cc4bed788f5b Mon Sep 17 00:00:00 2001 From: Mustafa Al Husaini Date: Wed, 19 Aug 2026 16:15:45 +1000 Subject: [PATCH 3/3] FR-C1: add last-seen label for stale rows and map Last Audio column to real backend field --- .../ui/public/admin/sensor_health/script.js | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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 d71dd540d..58c9e45b9 100644 --- a/src/production/hmi/ui/public/admin/sensor_health/script.js +++ b/src/production/hmi/ui/public/admin/sensor_health/script.js @@ -6,6 +6,16 @@ // 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"); @@ -175,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; @@ -435,14 +461,14 @@ async function loadSensorHealthPage() { tr.innerHTML = ` ${item.sensorId || "—"} - ${pillHtml(item.status)} + ${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);