From ccdbaac09a17436b5959d6b5c3ec86832bc5bd91 Mon Sep 17 00:00:00 2001 From: ohwenyi Date: Sun, 17 May 2026 01:52:51 +1000 Subject: [PATCH] Enhance sensor health UI for Sprint 2 --- .../HMI/ui/public/admin/sensor-health.html | 244 ++++++++++-------- .../ui/public/admin/sensor_health/script.js | 123 +++++++-- src/Components/HMI/ui/routes/map.routes.js | 161 +++++++++--- 3 files changed, 352 insertions(+), 176 deletions(-) diff --git a/src/Components/HMI/ui/public/admin/sensor-health.html b/src/Components/HMI/ui/public/admin/sensor-health.html index 8fdc55cd4..083482e8b 100644 --- a/src/Components/HMI/ui/public/admin/sensor-health.html +++ b/src/Components/HMI/ui/public/admin/sensor-health.html @@ -14,133 +14,161 @@ -
+
+
+
- - -
- - -
- - - -
-
- - -
-
-

SENSOR MONITORING

-

Live

-
- -
-

DATA SOURCE

-

MQTT

-
- -
-

AUTO REFRESH

-

15s

-
-
- - - -
-

Sensor Overview

-

- This table displays the latest live sensor information received from the MQTT broker. -

- -
-
- - -
- -
-
- - - - - - - - - - - - - - - - - - -
Sensor IDStatusCPURAMDiskUptimeGPSLast Audio
Loading sensor data...
+ +
+ + +
+
- -
-

Live Data Notes

-

- The current dashboard is connected to real-time MQTT-fed sensor data supplied by the IoT team. -

- -
-
-

Current Available Fields

-

- The live payload currently includes CPU usage, RAM usage, disk usage, uptime, GPS coordinates, and the most recent saved audio file. +

+ + + +
+
+ + +
+
+

SENSOR MONITORING

+

Live

+
+ +
+

DATA SOURCE

+

MQTT / Seeded

+
+ +
+

AUTO REFRESH

+

15s

+
+
+ + +
+

Sensor Overview

+

+ Search, filter, and monitor live sensor status in one dashboard view.

-
- -
-

Future Extensions

-

- Additional sensor fields such as ambient light, temperature, battery, connectivity, or other health indicators can be added later as the IoT pipeline expands. + +

+
+ + +
+ +
+ + +
+
+ +
+ Last updated at: -- +
+ +
+ + + + + + + + + + + + + + + + + + + +
Sensor IDStatusBatteryCPURAMDiskUptimeGPSLast Audio
Loading sensor data...
+
+
+ +
+

Monitoring Notes

+

+ The dashboard is designed for live monitoring, filtering, and quick identification of low-battery or offline devices.

-
+ +
+
+

Status Monitoring

+

+ Sensors are visually marked by status so operators can quickly distinguish active devices, offline devices, and low-battery devices. +

+
+ +
+

Future Extensions

+

+ The page can be further extended with more sensor metadata, additional alert rules, and larger-scale monitoring support. +

+
+
+ +
-
-
- + +
-
- + + - - + \ No newline at end of file diff --git a/src/Components/HMI/ui/public/admin/sensor_health/script.js b/src/Components/HMI/ui/public/admin/sensor_health/script.js index 1688a2437..8d151a910 100644 --- a/src/Components/HMI/ui/public/admin/sensor_health/script.js +++ b/src/Components/HMI/ui/public/admin/sensor_health/script.js @@ -1,5 +1,5 @@ /* Project Echo — Sensor Health script.js - Handles sidebar toggle, theme, actions and live sensor UI helpers + Sprint 2: UI enhancement and live status monitoring */ const menuToggle = document.getElementById("menu-toggle"); @@ -18,14 +18,15 @@ if (mobileBackdrop) { } const themeToggle = document.getElementById("theme-toggle"); + function setTheme(darkMode) { if (darkMode) { document.documentElement.setAttribute("data-theme", "dark"); - themeToggle && (themeToggle.textContent = "☀️"); + if (themeToggle) themeToggle.textContent = "☀️"; localStorage.setItem("echo-theme", "dark"); } else { document.documentElement.removeAttribute("data-theme"); - themeToggle && (themeToggle.textContent = "🌙"); + if (themeToggle) themeToggle.textContent = "🌙"; localStorage.setItem("echo-theme", "light"); } } @@ -42,13 +43,23 @@ if (themeToggle) { function showMessage(elementId, message, type = "success") { const el = document.getElementById(elementId); if (!el) return; - const colors = {success: "var(--primary)", warning: "var(--warning)", danger: "var(--danger)"}; + + const colors = { + success: "var(--primary)", + warning: "var(--warning)", + danger: "var(--danger)" + }; + el.style.color = colors[type] || colors.success; el.style.marginTop = "10px"; el.style.fontWeight = "600"; el.textContent = message; el.style.opacity = 0; - setTimeout(() => { el.style.opacity = 1; el.style.transition = "opacity .4s"; }, 30); + + setTimeout(() => { + el.style.opacity = 1; + el.style.transition = "opacity .4s"; + }, 30); } async function apiFetch(path, options = {}) { @@ -66,24 +77,26 @@ async function apiFetch(path, options = {}) { if (contentType.includes("application/json")) { return response.json(); } + return response.text(); } -function pillHtml(status) { +function pillHtml(status, batteryPct) { const s = String(status || "").trim(); - let cls = "pill-warning"; - if (s === "Online" || s === "Success") cls = "pill-success"; - else if (s === "Offline" || s === "Failed") cls = "pill-danger"; - else if ( - s === "Warning" || - s === "Queued" || - s === "High CPU" || - s === "High RAM" || - s === "High Disk" - ) { - cls = "pill-warning"; + + if (typeof batteryPct === "number" && batteryPct < 20) { + return `Low Battery`; + } + + if (s === "Online" || s === "Success") { + return `${s}`; + } + + if (s === "Offline" || s === "Failed") { + return `${s}`; } - return `${s || "Unknown"}`; + + return `${s || "Unknown"}`; } function formatPercent(value) { @@ -92,27 +105,48 @@ function formatPercent(value) { return `${num}%`; } +function formatBattery(value) { + const num = Number(value); + if (!Number.isFinite(num)) return "—"; + + const lowClass = num < 20 ? "battery-low" : ""; + return `${num}%`; +} + function formatGps(gps) { if (!gps || typeof gps !== "object") return "—"; + const lat = gps.lat; const lon = gps.lon; + if (lat === null || lat === undefined || lon === null || lon === undefined) { return "—"; } + return `${lat}, ${lon}`; } function formatUptime(seconds) { const total = Number(seconds); if (!Number.isFinite(total) || total < 0) return "—"; + const days = Math.floor(total / 86400); const hours = Math.floor((total % 86400) / 3600); const mins = Math.floor((total % 3600) / 60); + if (days > 0) return `${days}d ${hours}h ${mins}m`; if (hours > 0) return `${hours}h ${mins}m`; return `${mins}m`; } +function updateLastUpdated() { + const el = document.getElementById("last-updated-at"); + if (!el) return; + + const now = new Date(); + el.textContent = `Last updated at: ${now.toLocaleTimeString()}`; +} + async function rebootSensors() { const sensorsRaw = document.getElementById("reboot-sensor")?.value.trim(); const reason = document.getElementById("reboot-reason")?.value.trim(); @@ -214,10 +248,10 @@ if (shell) { }, 80); } -// Ensure sidebar nav always contains expected links function ensureSensorSidebar() { const sidebar = document.querySelector(".sidebar .nav-section"); if (!sidebar) return; + const required = [ { href: "/admin/dashboard.html", icon: "▦", text: "Dashboard" }, { href: "/admin/sensor-health.html", icon: "📡", text: "Sensor Health" }, @@ -253,28 +287,50 @@ async function loadSensorHealthPage() { if (!tbody) return; const statusFilter = document.getElementById("sensor-status-filter"); + const searchInput = document.getElementById("sensor-search-input"); + let lastItems = []; function render() { const statusVal = statusFilter?.value || "All"; + const searchVal = (searchInput?.value || "").trim().toLowerCase(); const filtered = lastItems.filter((item) => { - if (statusVal !== "All" && item.status !== statusVal) return false; - return true; + 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; + } + + return matchesSearch && matchesStatus; }); tbody.innerHTML = ""; if (!filtered.length) { - tbody.innerHTML = 'No live sensor data available.'; + tbody.innerHTML = 'No sensors match the current search or filter.'; return; } for (const item of filtered) { const tr = document.createElement("tr"); + + if (typeof item.batteryPct === "number" && item.batteryPct < 20) { + tr.classList.add("sensor-row-low-battery"); + } + tr.innerHTML = ` ${item.sensorId || "—"} - ${pillHtml(item.status)} + ${pillHtml(item.status, item.batteryPct)} + ${formatBattery(item.batteryPct)} ${formatPercent(item.cpu)} ${formatPercent(item.ram)} ${formatPercent(item.disk)} @@ -282,6 +338,7 @@ async function loadSensorHealthPage() { ${formatGps(item.gps)} ${item.lastAudio || "—"} `; + tbody.appendChild(tr); } } @@ -290,13 +347,15 @@ async function loadSensorHealthPage() { try { const data = await apiFetch("/sensors/updates"); lastItems = Array.isArray(data.items) ? data.items : []; + updateLastUpdated(); render(); } catch (e) { - tbody.innerHTML = `Failed to load sensors: ${e.message}`; + tbody.innerHTML = `Failed to load sensors: ${e.message}`; } } statusFilter?.addEventListener("change", render); + searchInput?.addEventListener("input", render); await refresh(); setInterval(refresh, 15000); @@ -309,19 +368,23 @@ async function loadAlertsPage() { const data = await apiFetch("/sensors/alerts"); const items = Array.isArray(data.items) ? data.items : []; tbody.innerHTML = ""; + if (!items.length) { tbody.innerHTML = 'No active alerts.'; return; } + for (const alert of items) { const tr = document.createElement("tr"); const sevPill = pillHtml(alert.issue); + tr.innerHTML = ` ${alert.sensorId || "—"} ${sevPill} ${alert.details || ""} ${alert.lastAudioMinutesAgo !== undefined ? alert.lastAudioMinutesAgo : "—"} `; + tbody.appendChild(tr); } } catch (e) { @@ -336,14 +399,21 @@ async function loadRecentRebootHistory() { const data = await apiFetch("/sensors/reboots/recent?limit=50"); const items = Array.isArray(data.items) ? data.items : []; tbody.innerHTML = ""; + if (!items.length) { tbody.innerHTML = 'No reboot history yet.'; return; } + for (const r of items) { const t = r.requestedAt ? new Date(r.requestedAt).toLocaleString() : "—"; const status = String(r.status || "Queued"); - const cls = status.toLowerCase().includes("fail") ? "pill-danger" : status.toLowerCase().includes("success") ? "pill-success" : "pill-warning"; + const cls = status.toLowerCase().includes("fail") + ? "pill-danger" + : status.toLowerCase().includes("success") + ? "pill-success" + : "pill-warning"; + const tr = document.createElement("tr"); tr.innerHTML = ` ${r.sensorId} @@ -376,7 +446,6 @@ async function loadSettingsPage() { } } -// Keep existing onclick bindings working window.fakeReboot = rebootSensors; window.fakeSaveSettings = saveSettings; @@ -385,4 +454,4 @@ document.addEventListener("DOMContentLoaded", () => { loadAlertsPage(); loadRecentRebootHistory(); loadSettingsPage(); -}); +}); \ No newline at end of file diff --git a/src/Components/HMI/ui/routes/map.routes.js b/src/Components/HMI/ui/routes/map.routes.js index 5df10e5b0..d4559e665 100644 --- a/src/Components/HMI/ui/routes/map.routes.js +++ b/src/Components/HMI/ui/routes/map.routes.js @@ -170,27 +170,61 @@ module.exports = function (app) { } }); + // Sprint 2 demo/test seed route app.get("/sensors/test-seed", (req, res) => { - latestSensorData = { - health_data: { - cpu: 37, - ram: 48, - disk: 62, - uptime: 15432 - }, - gps_data: { - lat: -38.1499, - lon: 144.3617 - }, - savedAudio: "test_audio.wav" - }; - - res.json({ - success: true, - message: "Test sensor payload seeded successfully.", - latestSensorData + latestSensorData = { + items: [ + { + sensorId: "LIVE-001", + status: "Online", + batteryPct: 78, + cpu: 37, + ram: 48, + disk: 62, + uptime: 15432, + gps: { + lat: -38.1499, + lon: 144.3617, + }, + lastAudio: "audio_sensor_001.wav", + }, + { + sensorId: "LIVE-002", + status: "Offline", + batteryPct: 55, + cpu: 0, + ram: 0, + disk: 0, + uptime: 0, + gps: { + lat: -38.1605, + lon: 144.3502, + }, + lastAudio: "—", + }, + { + sensorId: "LIVE-003", + status: "Online", + batteryPct: 15, + cpu: 52, + ram: 63, + disk: 70, + uptime: 28765, + gps: { + lat: -38.1422, + lon: 144.3728, + }, + lastAudio: "audio_sensor_003.wav", + }, + ], + }; + + res.json({ + success: true, + message: "Test sensor payload seeded successfully.", + latestSensorData, + }); }); -}); // Sensor Health live routes app.get("/sensors/updates", async (req, res) => { @@ -199,11 +233,18 @@ module.exports = function (app) { return res.json({ items: [] }); } - res.json({ + // If Sprint 2 seeded/demo data exists, return directly + if (Array.isArray(latestSensorData.items)) { + return res.json({ items: latestSensorData.items }); + } + + // Otherwise treat it as live MQTT payload structure + return res.json({ items: [ { sensorId: "LIVE-001", status: "Online", + batteryPct: 18, // temporary fallback until real battery field is available cpu: latestSensorData.health_data?.cpu, ram: latestSensorData.health_data?.ram, disk: latestSensorData.health_data?.disk, @@ -225,31 +266,69 @@ module.exports = function (app) { return res.json({ items: [] }); } - const items = []; + let sourceItems = []; - if (latestSensorData.health_data?.cpu > 90) { - items.push({ - sensorId: "LIVE-001", - issue: "High CPU", - details: `CPU usage ${latestSensorData.health_data.cpu}%`, - }); + if (Array.isArray(latestSensorData.items)) { + sourceItems = latestSensorData.items; + } else { + sourceItems = [ + { + sensorId: "LIVE-001", + status: "Online", + batteryPct: 18, + cpu: latestSensorData.health_data?.cpu, + ram: latestSensorData.health_data?.ram, + disk: latestSensorData.health_data?.disk, + uptime: latestSensorData.health_data?.uptime, + gps: latestSensorData.gps_data, + lastAudio: latestSensorData.savedAudio || null, + }, + ]; } - if (latestSensorData.health_data?.ram > 90) { - items.push({ - sensorId: "LIVE-001", - issue: "High RAM", - details: `RAM usage ${latestSensorData.health_data.ram}%`, - }); - } + const items = []; - if (latestSensorData.health_data?.disk > 90) { - items.push({ - sensorId: "LIVE-001", - issue: "High Disk", - details: `Disk usage ${latestSensorData.health_data.disk}%`, - }); - } + sourceItems.forEach((sensor) => { + if (sensor.status === "Offline") { + items.push({ + sensorId: sensor.sensorId, + issue: "Offline", + details: "Sensor is currently offline.", + }); + } + + if (typeof sensor.batteryPct === "number" && sensor.batteryPct < 20) { + items.push({ + sensorId: sensor.sensorId, + issue: "Low Battery", + details: `Battery level is ${sensor.batteryPct}%.`, + }); + } + + if (typeof sensor.cpu === "number" && sensor.cpu > 90) { + items.push({ + sensorId: sensor.sensorId, + issue: "High CPU", + details: `CPU usage ${sensor.cpu}%`, + }); + } + + if (typeof sensor.ram === "number" && sensor.ram > 90) { + items.push({ + sensorId: sensor.sensorId, + issue: "High RAM", + details: `RAM usage ${sensor.ram}%`, + }); + } + + if (typeof sensor.disk === "number" && sensor.disk > 90) { + items.push({ + sensorId: sensor.sensorId, + issue: "High Disk", + details: `Disk usage ${sensor.disk}%`, + }); + } + }); res.json({ items }); } catch (error) {