fix(dashboard): improve home-cards.js snapshot caching and alert handling
Cache the full WebSocket snapshot so incremental updates always have complete state for banner and card rendering. Add fall/security alert detection in the status banner with --alert level. Add armed security state styling in home.css. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
814e1b7721
commit
beb6bd2af3
2 changed files with 155 additions and 81 deletions
|
|
@ -30,17 +30,17 @@
|
|||
.home-status__banner--ok {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok);
|
||||
border: 1px solid rgba(70, 167, 88, 0.25);
|
||||
border: 1px solid var(--ok-border);
|
||||
}
|
||||
.home-status__banner--warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
border: 1px solid rgba(229, 160, 13, 0.25);
|
||||
border: 1px solid var(--warn-border);
|
||||
}
|
||||
.home-status__banner--alert {
|
||||
background: var(--alert-bg);
|
||||
color: var(--alert);
|
||||
border: 1px solid rgba(229, 72, 77, 0.25);
|
||||
border: 1px solid var(--alert-border);
|
||||
}
|
||||
|
||||
/* ── Card grid (Row 2) ── */
|
||||
|
|
@ -146,6 +146,11 @@
|
|||
.home-extras__item--visible {
|
||||
display: block;
|
||||
}
|
||||
.home-extras__item--armed {
|
||||
border-color: var(--alert-border);
|
||||
background: var(--alert-bg);
|
||||
color: var(--alert);
|
||||
}
|
||||
|
||||
/* ── Mobile bottom nav (uses shared .app-mobile-nav from layout.css) ── */
|
||||
|
||||
|
|
|
|||
|
|
@ -3,34 +3,35 @@
|
|||
*
|
||||
* Connects to the dashboard WebSocket, reads the initial snapshot to fill all
|
||||
* three cards, then applies incremental updates to refresh counts only.
|
||||
* Caches the full snapshot so incremental merges always have complete state.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── DOM refs ──
|
||||
const $banner = document.getElementById('status-banner');
|
||||
const $dot = document.getElementById('ws-dot');
|
||||
const $peopleCnt = document.getElementById('people-count');
|
||||
const $peopleDtl = document.getElementById('people-detail');
|
||||
const $devicesCnt = document.getElementById('devices-count');
|
||||
const $devicesDtl = document.getElementById('devices-detail');
|
||||
const $devicesMeta= document.getElementById('devices-meta');
|
||||
const $eventsDtl = document.getElementById('events-detail');
|
||||
const $briefing = document.getElementById('briefing-card');
|
||||
const $anomaly = document.getElementById('anomaly-banner');
|
||||
const $security = document.getElementById('security-toggle');
|
||||
var $banner = document.getElementById('status-banner');
|
||||
var $peopleCnt = document.getElementById('people-count');
|
||||
var $peopleDtl = document.getElementById('people-detail');
|
||||
var $devicesCnt = document.getElementById('devices-count');
|
||||
var $devicesDtl = document.getElementById('devices-detail');
|
||||
var $devicesMeta = document.getElementById('devices-meta');
|
||||
var $eventsDtl = document.getElementById('events-detail');
|
||||
var $briefing = document.getElementById('briefing-card');
|
||||
var $anomaly = document.getElementById('anomaly-banner');
|
||||
var $security = document.getElementById('security-toggle');
|
||||
|
||||
// ── State ──
|
||||
let ws = null;
|
||||
let reconnectDelay = 1000;
|
||||
let recentEvents = [];
|
||||
var ws = null;
|
||||
var reconnectDelay = 1000;
|
||||
var recentEvents = [];
|
||||
// Cached snapshot — always holds the latest full state
|
||||
var cached = { blobs: [], nodes: [], zones: [], events: [], triggers: [] };
|
||||
|
||||
// ── Helpers ──
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function relativeTime(isoOrMs) {
|
||||
const ts = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime();
|
||||
const diff = Date.now() - ts;
|
||||
var ts = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime();
|
||||
var diff = Date.now() - ts;
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||||
|
|
@ -42,23 +43,56 @@
|
|||
$banner.innerHTML = '<span class="home-status__dot home-status__dot--connected" id="ws-dot"></span>' + html;
|
||||
}
|
||||
|
||||
// Merge an incremental message's partial arrays into the cached snapshot.
|
||||
// Incremental messages only contain items that changed — replace by id/key.
|
||||
function mergeIncremental(msg) {
|
||||
if (msg.blobs) {
|
||||
if (msg.type !== 'snapshot') {
|
||||
// Replace existing blobs with same id, add new ones
|
||||
var ids = {};
|
||||
for (var i = 0; i < msg.blobs.length; i++) ids[msg.blobs[i].id] = msg.blobs[i];
|
||||
cached.blobs = cached.blobs.filter(function (b) { return !(b.id in ids); }).concat(msg.blobs);
|
||||
} else {
|
||||
cached.blobs = msg.blobs;
|
||||
}
|
||||
}
|
||||
if (msg.nodes) {
|
||||
if (msg.type !== 'snapshot') {
|
||||
var macs = {};
|
||||
for (var i = 0; i < msg.nodes.length; i++) macs[msg.nodes[i].mac] = msg.nodes[i];
|
||||
cached.nodes = cached.nodes.filter(function (n) { return !(n.mac in macs); }).concat(msg.nodes);
|
||||
} else {
|
||||
cached.nodes = msg.nodes;
|
||||
}
|
||||
}
|
||||
if (msg.zones) {
|
||||
if (msg.type !== 'snapshot') {
|
||||
var zids = {};
|
||||
for (var i = 0; i < msg.zones.length; i++) zids[msg.zones[i].id] = msg.zones[i];
|
||||
cached.zones = cached.zones.filter(function (z) { return !(z.id in zids); }).concat(msg.zones);
|
||||
} else {
|
||||
cached.zones = msg.zones;
|
||||
}
|
||||
}
|
||||
if (msg.triggers) cached.triggers = msg.triggers;
|
||||
if (typeof msg.confidence === 'number') cached.confidence = msg.confidence;
|
||||
if (msg.security_mode !== undefined) cached.security_mode = msg.security_mode;
|
||||
}
|
||||
|
||||
// ── Card updaters ──
|
||||
|
||||
function updatePeopleCard(snapshot) {
|
||||
const blobs = snapshot.blobs || [];
|
||||
const zones = snapshot.zones || [];
|
||||
const peopleNames = blobs
|
||||
.map(function (b) { return b.person; })
|
||||
.filter(function (p) { return p && p !== 'Unknown'; });
|
||||
function updatePeopleCard() {
|
||||
var blobs = cached.blobs || [];
|
||||
var zones = cached.zones || [];
|
||||
var peopleNames = [];
|
||||
for (var i = 0; i < blobs.length; i++) {
|
||||
var p = blobs[i].person;
|
||||
if (p && p !== 'Unknown' && peopleNames.indexOf(p) === -1) peopleNames.push(p);
|
||||
}
|
||||
|
||||
const uniquePeople = [];
|
||||
peopleNames.forEach(function (n) {
|
||||
if (uniquePeople.indexOf(n) === -1) uniquePeople.push(n);
|
||||
});
|
||||
|
||||
$peopleCnt.textContent = uniquePeople.length + ' people';
|
||||
if (uniquePeople.length > 0) {
|
||||
$peopleDtl.textContent = uniquePeople.join(', ');
|
||||
$peopleCnt.textContent = peopleNames.length + (peopleNames.length === 1 ? ' person' : ' people');
|
||||
if (peopleNames.length > 0) {
|
||||
$peopleDtl.textContent = peopleNames.join(', ');
|
||||
} else {
|
||||
var occupied = zones.filter(function (z) { return z.count > 0; });
|
||||
$peopleDtl.textContent = occupied.length > 0
|
||||
|
|
@ -67,11 +101,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
function updateDevicesCard(snapshot) {
|
||||
var nodes = snapshot.nodes || [];
|
||||
function updateDevicesCard() {
|
||||
var nodes = cached.nodes || [];
|
||||
var online = nodes.filter(function (n) { return n.status === 'online'; });
|
||||
var stale = nodes.filter(function (n) { return n.status === 'stale'; });
|
||||
var offline= nodes.filter(function (n) { return n.status === 'offline'; });
|
||||
var offline = nodes.filter(function (n) { return n.status === 'offline'; });
|
||||
|
||||
$devicesCnt.textContent = online.length + '/' + nodes.length + ' online';
|
||||
$devicesMeta.innerHTML = '';
|
||||
|
|
@ -88,7 +122,7 @@
|
|||
: 'All devices healthy';
|
||||
}
|
||||
|
||||
var quality = snapshot.confidence;
|
||||
var quality = cached.confidence;
|
||||
if (typeof quality === 'number') {
|
||||
var qLevel = quality >= 80 ? 'ok' : quality >= 60 ? 'warn' : 'alert';
|
||||
addTag($devicesMeta, qLevel, quality + '% quality');
|
||||
|
|
@ -102,15 +136,7 @@
|
|||
container.appendChild(span);
|
||||
}
|
||||
|
||||
function updateEventsCard(snapshot) {
|
||||
var evts = snapshot.events || [];
|
||||
if (evts.length > 0) {
|
||||
recentEvents = evts.slice(0, 5);
|
||||
}
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
function updateEventsCard() {
|
||||
if (recentEvents.length === 0) {
|
||||
$eventsDtl.textContent = 'No recent events';
|
||||
return;
|
||||
|
|
@ -123,65 +149,108 @@
|
|||
$eventsDtl.innerHTML = lines.join('<br>');
|
||||
}
|
||||
|
||||
function updateExtras(snapshot) {
|
||||
function updateExtras() {
|
||||
// Morning briefing
|
||||
if (snapshot.briefing) {
|
||||
$briefing.textContent = snapshot.briefing;
|
||||
if (cached.briefing) {
|
||||
$briefing.textContent = cached.briefing;
|
||||
$briefing.classList.add('home-extras__item--visible');
|
||||
}
|
||||
// Security mode
|
||||
if (snapshot.security_mode) {
|
||||
$security.textContent = 'Security mode: ARMED';
|
||||
$security.classList.add('home-extras__item--visible');
|
||||
if (cached.security_mode) {
|
||||
$security.innerHTML = '<strong>Security mode: ARMED</strong>';
|
||||
$security.classList.add('home-extras__item--visible', 'home-extras__item--armed');
|
||||
} else {
|
||||
$security.classList.remove('home-extras__item--visible', 'home-extras__item--armed');
|
||||
}
|
||||
// Anomaly
|
||||
if (snapshot.anomaly_active) {
|
||||
if (cached.anomaly_active) {
|
||||
$anomaly.textContent = 'Anomaly detected';
|
||||
$anomaly.classList.add('home-extras__item--visible');
|
||||
}
|
||||
}
|
||||
|
||||
function updateBanner(snapshot) {
|
||||
var nodes = snapshot.nodes || [];
|
||||
var blobs = snapshot.blobs || [];
|
||||
var offline = nodes.filter(function (n) { return n.status === 'offline'; });
|
||||
var people = blobs.filter(function (b) { return b.person; });
|
||||
function updateBanner() {
|
||||
var nodes = cached.nodes || [];
|
||||
var blobs = cached.blobs || [];
|
||||
var offline = nodes.filter(function (n) { return n.status === 'offline'; });
|
||||
var online = nodes.filter(function (n) { return n.status === 'online'; });
|
||||
|
||||
if (offline.length > 0) {
|
||||
// Check for active alerts (fall, security) in recent events
|
||||
var now = Date.now();
|
||||
var recentAlert = null;
|
||||
for (var i = 0; i < recentEvents.length; i++) {
|
||||
var e = recentEvents[i];
|
||||
var age = now - (e.timestamp_ms || 0);
|
||||
if (age > 300000) break; // only consider last 5 min
|
||||
if (e.type === 'fall_alert') {
|
||||
recentAlert = 'Fall alert' + (e.zone ? ' in ' + e.zone : '') +
|
||||
(e.person ? ': ' + e.person : '');
|
||||
break;
|
||||
}
|
||||
if (e.type === 'security_alert') {
|
||||
recentAlert = 'Security alert' + (e.zone ? ' in ' + e.zone : '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (recentAlert) {
|
||||
setBanner('alert', recentAlert);
|
||||
} else if (offline.length > 0) {
|
||||
setBanner('warn', offline.length + ' device' + (offline.length > 1 ? 's' : '') + ' offline');
|
||||
} else if (people.length > 0) {
|
||||
var names = [];
|
||||
people.forEach(function (b) {
|
||||
if (b.person && names.indexOf(b.person) === -1) names.push(b.person);
|
||||
});
|
||||
var online = nodes.filter(function (n) { return n.status === 'online'; });
|
||||
setBanner('ok', 'All clear — ' + names.length + ' people home, ' +
|
||||
online.length + ' devices online');
|
||||
} else {
|
||||
var onlineN = nodes.filter(function (n) { return n.status === 'online'; });
|
||||
setBanner('ok', 'All clear — No one detected, ' + onlineN.length + ' devices online');
|
||||
var names = [];
|
||||
for (var i = 0; i < blobs.length; i++) {
|
||||
var p = blobs[i].person;
|
||||
if (p && names.indexOf(p) === -1) names.push(p);
|
||||
}
|
||||
if (names.length > 0) {
|
||||
setBanner('ok', 'All clear — ' + names.length + ' people home, ' +
|
||||
online.length + ' devices online');
|
||||
} else {
|
||||
setBanner('ok', 'All clear — No one detected, ' + online.length + ' devices online');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot processing ──
|
||||
|
||||
function handleSnapshot(snapshot) {
|
||||
updateBanner(snapshot);
|
||||
updatePeopleCard(snapshot);
|
||||
updateDevicesCard(snapshot);
|
||||
updateEventsCard(snapshot);
|
||||
updateExtras(snapshot);
|
||||
cached = {
|
||||
blobs: snapshot.blobs || [],
|
||||
nodes: snapshot.nodes || [],
|
||||
zones: snapshot.zones || [],
|
||||
triggers: snapshot.triggers || [],
|
||||
events: snapshot.events || [],
|
||||
confidence: snapshot.confidence,
|
||||
security_mode: snapshot.security_mode,
|
||||
briefing: snapshot.briefing,
|
||||
anomaly_active: snapshot.anomaly_active,
|
||||
};
|
||||
|
||||
if (snapshot.events && snapshot.events.length > 0) {
|
||||
recentEvents = snapshot.events.slice(0, 5);
|
||||
}
|
||||
|
||||
updateBanner();
|
||||
updatePeopleCard();
|
||||
updateDevicesCard();
|
||||
updateEventsCard();
|
||||
updateExtras();
|
||||
}
|
||||
|
||||
function handleIncremental(msg) {
|
||||
// Lightweight refresh on incremental updates
|
||||
if (msg.blobs) updatePeopleCard(msg);
|
||||
if (msg.nodes) { updateDevicesCard(msg); updateBanner(msg); }
|
||||
mergeIncremental(msg);
|
||||
|
||||
if (msg.events && msg.events.length > 0) {
|
||||
// Prepend new events, keep last 5
|
||||
recentEvents = msg.events.concat(recentEvents).slice(0, 5);
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
// Always refresh all cards from cached state
|
||||
updateBanner();
|
||||
updatePeopleCard();
|
||||
updateDevicesCard();
|
||||
updateEventsCard();
|
||||
updateExtras();
|
||||
}
|
||||
|
||||
// ── WebSocket ──
|
||||
|
|
@ -227,5 +296,5 @@
|
|||
}
|
||||
|
||||
// Refresh relative timestamps every 30s
|
||||
setInterval(renderEvents, 30000);
|
||||
setInterval(updateEventsCard, 30000);
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue