Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions src/production/hmi/ui/public/admin/sensor-health.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@
<link rel="stylesheet" href="/admin/css/icons/tabler-icons/tabler-icons.css" />
<link rel="stylesheet" href="/admin/sensor_health/style.css" />

<style>
/* FR-C1 additions: KPI accents, stale rows, last-seen subtext */
#kpi-online { color: var(--primary, #2e7d32); }
#kpi-degraded { color: var(--warning, #b8860b); }
#kpi-offline { color: var(--danger, #c62828); }
#kpi-low-battery { color: var(--danger, #c62828); }

.sensor-row-stale td { background: rgba(184, 134, 11, 0.06); }
.sensor-row-low-battery td { background: rgba(198, 40, 40, 0.06); }

.cell-subtext {
font-size: 11px;
color: var(--muted, #888);
margin-top: 2px;
}
</style>

<script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script type="text/javascript" src="/admin/js/app.min.js"></script>

Expand Down Expand Up @@ -71,21 +88,31 @@ <h1 style="margin-bottom: 4px;">Sensor Health</h1>
<div id="view-root">
<div class="dashboard-shell">

<!-- Summary cards -->
<!-- Summary cards (populated from the same data as the table) -->
<section class="kpi-grid">
<div class="kpi-card">
<p>SENSOR MONITORING</p>
<h3>Live</h3>
<p>TOTAL SENSORS</p>
<h3 id="kpi-total">--</h3>
</div>

<div class="kpi-card">
<p>ONLINE</p>
<h3 id="kpi-online" class="kpi-online">--</h3>
</div>

<div class="kpi-card">
<p>DEGRADED</p>
<h3 id="kpi-degraded" class="kpi-degraded">--</h3>
</div>

<div class="kpi-card">
<p>DATA SOURCE</p>
<h3>MQTT / Seeded</h3>
<p>OFFLINE</p>
<h3 id="kpi-offline" class="kpi-offline">--</h3>
</div>

<div class="kpi-card">
<p>AUTO REFRESH</p>
<h3>15s</h3>
<p>LOW BATTERY</p>
<h3 id="kpi-low-battery" class="kpi-low-battery">--</h3>
</div>
</section>

Expand All @@ -111,6 +138,7 @@ <h3>Sensor Overview</h3>
<select id="sensor-status-filter">
<option value="All">All</option>
<option value="Online">Online</option>
<option value="Degraded">Degraded</option>
<option value="Offline">Offline</option>
<option value="Low Battery">Low Battery</option>
</select>
Expand All @@ -122,7 +150,7 @@ <h3>Sensor Overview</h3>
class="card-subtitle"
style="margin-bottom: 12px;"
>
Last updated at: --
Last updated at: -- &middot; auto-refresh every <span id="refresh-interval-label">--</span>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interval is inside #last-updated-at, but updateLastUpdated() replaces the parent’s entire textContent at script.js (line 209). Nothing assigns REFRESH_MS to the span, so the required visible interval changes from -- to nothing

</div>

<div class="table-responsive">
Expand Down
114 changes: 96 additions & 18 deletions src/production/hmi/ui/public/admin/sensor_health/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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 `<span class="pill pill-warning">Low Battery</span>`;
}

if (s === "Online" || s === "Success") {
return `<span class="pill pill-success">${s}</span>`;
}

if (s === "Degraded" || s === "Low Battery") {
return `<span class="pill pill-warning">${s}</span>`;
}

if (s === "Offline" || s === "Failed") {
return `<span class="pill pill-danger">${s}</span>`;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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
// ================================================================
Expand Down Expand Up @@ -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;
});
Expand All @@ -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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend overwrites Offline/Degraded with Low Battery when the battery is below its threshold.The frontend then adds the stale treatment only for Degraded or Offline. So a low-battery sensor whose heartbeat is old or missing appears only as low battery?

tr.classList.add("sensor-row-low-battery");
}
if (item.status === "Degraded" || item.status === "Offline") {
tr.classList.add("sensor-row-stale");
}

tr.innerHTML = `
<td>${item.sensorId || "—"}</td>
<td>${pillHtml(item.status, item.batteryPct)}</td>
<td>${pillHtml(item.status)}<div class="cell-subtext">seen ${formatMinutesAgo(item.lastSeenMinutesAgo)}</div></td>
<td>${formatBattery(item.batteryPct)}</td>
<td>${formatPercent(item.cpu)}</td>
<td>${formatPercent(item.ram)}</td>
<td>${formatPercent(item.disk)}</td>
<td>${formatUptime(item.uptime)}</td>
<td>${formatGps(item.gps)}</td>
<td>${item.lastAudio || "—"}</td>
<td>${formatMinutesAgo(item.lastAudioMinutesAgo)}</td>
`;

tbody.appendChild(tr);
Expand All @@ -405,6 +482,7 @@ async function loadSensorHealthPage() {
try {
const data = await apiFetch("/sensors/updates");
lastItems = Array.isArray(data.items) ? data.items : [];
updateSensorKpis(lastItems);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When backend status is something other than Online/Degraded/Offline/Low Battery (your testing notes mention nodes coming back Offline/null), updateSensorKpis counts it toward Total but doesn't add it to any bucket. Can you confirm the backend always returns one of those four values, or should this have a fallback bucket so the cards can't silently under-count?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend derives exactly four statuses (Online / Degraded / Offline / Low Battery), so those are the full set today, but you are right that the cards shouldn't silently under-count if that ever changes. I've added an other fallback bucket so every sensor is counted and the buckets always reconcile with the total. Pushed in the latest commit.

updateLastUpdated();
render();
} catch (e) {
Expand All @@ -416,7 +494,7 @@ async function loadSensorHealthPage() {
searchInput?.addEventListener("input", render);

await refresh();
setInterval(refresh, 15000);
setInterval(refresh, REFRESH_MS);
}

// ================================================================
Expand Down
Loading