-
Notifications
You must be signed in to change notification settings - Fork 32
EH/MU/FR-C1: live sensor-health KPIs, stale detection, refresh interval #962
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9b7b3aa
a1e02cd
1635988
d40e9b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 `<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>`; | ||
| } | ||
|
|
@@ -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") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
|
@@ -405,6 +482,7 @@ async function loadSensorHealthPage() { | |
| try { | ||
| const data = await apiFetch("/sensors/updates"); | ||
| lastItems = Array.isArray(data.items) ? data.items : []; | ||
| updateSensorKpis(lastItems); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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),
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
@@ -416,7 +494,7 @@ async function loadSensorHealthPage() { | |
| searchInput?.addEventListener("input", render); | ||
|
|
||
| await refresh(); | ||
| setInterval(refresh, 15000); | ||
| setInterval(refresh, REFRESH_MS); | ||
| } | ||
|
|
||
| // ================================================================ | ||
|
|
||
There was a problem hiding this comment.
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