From 7162d4523e4b56975c06b34a0b7aedb2914c2980 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 24 Apr 2026 09:06:33 -0400 Subject: [PATCH] feat(dashboard): restructure home page as status headline + 3 cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per plan.md §8e information architecture: - index.html (109 lines) is now a home page with status headline, 3 cards (People & Zones, Devices & Fleet Health, Recent Events), optional extras row, and mobile bottom nav - live.html serves the full 3D viewer at /live route - home-cards.js connects to /ws/dashboard for snapshot + incremental updates - tokens.css provides the Radix dark design system - layout.css provides the CSS Grid app shell with responsive breakpoints - home.css provides card grid, status banner, responsive mobile layout Co-Authored-By: Claude Opus 4.7 --- dashboard/css/home.css | 270 +++ dashboard/css/layout.css | 239 ++ dashboard/css/tokens.css | 111 + dashboard/index.html | 4563 +----------------------------------- dashboard/js/home-cards.js | 231 ++ dashboard/live.html | 4489 +++++++++++++++++++++++++++++++++++ 6 files changed, 5431 insertions(+), 4472 deletions(-) create mode 100644 dashboard/css/home.css create mode 100644 dashboard/css/layout.css create mode 100644 dashboard/css/tokens.css create mode 100644 dashboard/js/home-cards.js create mode 100644 dashboard/live.html diff --git a/dashboard/css/home.css b/dashboard/css/home.css new file mode 100644 index 0000000..4cb9495 --- /dev/null +++ b/dashboard/css/home.css @@ -0,0 +1,270 @@ +/* ─────────────────────────────────────────────────────────────── + Spaxel Home Page — Status headline + 3 cards (§8e) + ─────────────────────────────────────────────────────────────── */ + +.home-body { + font-family: var(--font-body); + background: var(--bg-page); + color: var(--text-primary); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Top nav ── */ +.home-nav { + background: var(--slate-3); + border-bottom: 1px solid var(--border-default); + padding: 0 var(--space-6); + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; +} +.home-nav__logo { + font-size: var(--text-lg); + font-weight: var(--fw-heading); + color: var(--text-primary); + text-decoration: none; + display: flex; + align-items: center; + gap: var(--space-2); +} +.home-nav__links { + display: flex; + gap: var(--space-4); +} +.home-nav__link { + color: var(--text-secondary); + text-decoration: none; + font-size: var(--text-sm); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-control); + transition: background var(--transition-fast), color var(--transition-fast); +} +.home-nav__link:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +.home-nav__link--active { + color: var(--color-primary); +} + +/* ── Status headline (Row 1) ── */ +.home-status { + padding: var(--space-6); + text-align: center; +} +.home-status__banner { + padding: var(--space-3) var(--space-6); + border-radius: var(--radius-card); + font-size: var(--text-base); + font-weight: var(--fw-heading); + line-height: var(--lh-body); + max-width: 700px; + margin: 0 auto; +} +.home-status__banner--ok { + background: rgba(70, 167, 88, 0.15); + color: var(--ok); + border: 1px solid rgba(70, 167, 88, 0.25); +} +.home-status__banner--warn { + background: rgba(229, 160, 13, 0.15); + color: var(--warn); + border: 1px solid rgba(229, 160, 13, 0.25); +} +.home-status__banner--alert { + background: rgba(229, 72, 77, 0.15); + color: var(--alert); + border: 1px solid rgba(229, 72, 77, 0.25); +} + +/* ── Card grid (Row 2) ── */ +.home-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-6); + padding: 0 var(--space-6) var(--space-6); + max-width: 1440px; + margin: 0 auto; + width: 100%; +} + +/* ── Card ── */ +.home-card { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); + text-decoration: none; + color: inherit; + transition: border-color var(--transition-fast), background var(--transition-fast); + cursor: pointer; +} +.home-card:hover { + border-color: var(--border-strong); + background: var(--bg-hover); +} +.home-card__header { + display: flex; + align-items: center; + justify-content: space-between; +} +.home-card__title { + font-size: var(--text-sm); + font-weight: var(--fw-heading); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.home-card__icon { + font-size: var(--text-lg); + color: var(--text-muted); +} +.home-card__count { + font-size: var(--text-2xl); + font-weight: var(--fw-heading); + line-height: var(--lh-heading); +} +.home-card__detail { + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--lh-body); + min-height: 2.4em; +} +.home-card__meta { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; +} +.home-card__tag { + font-size: var(--text-xs); + padding: 2px var(--space-2); + border-radius: var(--radius-control); + background: var(--bg-active); + color: var(--text-secondary); +} +.home-card__tag--ok { + background: rgba(70, 167, 88, 0.15); + color: var(--ok); +} +.home-card__tag--warn { + background: rgba(229, 160, 13, 0.15); + color: var(--warn); +} +.home-card__tag--alert { + background: rgba(229, 72, 77, 0.15); + color: var(--alert); +} + +/* ── Optional row (Row 3) ── */ +.home-extras { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--space-6); + padding: 0 var(--space-6) var(--space-8); + max-width: 1440px; + margin: 0 auto; + width: 100%; +} +.home-extras__item { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + padding: var(--space-4) var(--space-6); + font-size: var(--text-sm); + color: var(--text-secondary); + display: none; +} +.home-extras__item--visible { + display: block; +} + +/* ── Mobile bottom nav ── */ +.home-bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--slate-3); + border-top: 1px solid var(--border-default); + padding-bottom: env(safe-area-inset-bottom); + z-index: 100; +} +.home-bottom-nav__list { + display: flex; + list-style: none; +} +.home-bottom-nav__item { + flex: 1; + text-align: center; +} +.home-bottom-nav__link { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: var(--space-2) 0; + color: var(--text-muted); + text-decoration: none; + font-size: var(--text-xs); + min-height: 44px; + justify-content: center; +} +.home-bottom-nav__link--active { + color: var(--color-primary); +} + +/* ── Connection indicator ── */ +.home-status__dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + vertical-align: middle; + margin-right: var(--space-2); +} +.home-status__dot--connected { + background: var(--ok); + box-shadow: 0 0 6px var(--ok); +} +.home-status__dot--disconnected { + background: var(--alert); +} + +/* ── Responsive: tablet (single column cards) ── */ +@media (max-width: 1023px) { + .home-cards { + grid-template-columns: 1fr; + max-width: 600px; + } + .home-nav__links { + display: none; + } +} + +/* ── Responsive: mobile (bottom nav) ── */ +@media (max-width: 639px) { + .home-body { + padding-bottom: 60px; + } + .home-bottom-nav { + display: block; + } + .home-status { + padding: var(--space-4); + } + .home-cards { + padding: 0 var(--space-4) var(--space-4); + gap: var(--space-4); + } + .home-extras { + padding: 0 var(--space-4) var(--space-4); + } +} diff --git a/dashboard/css/layout.css b/dashboard/css/layout.css new file mode 100644 index 0000000..22c46d1 --- /dev/null +++ b/dashboard/css/layout.css @@ -0,0 +1,239 @@ +/* ─────────────────────────────────────────────────────────────── + Spaxel App Shell — Page-level CSS Grid (§8e Layout) + Establishes one grid container per page. No absolute-positioned + layout containers — overlays use or fixed slide-ins. + ─────────────────────────────────────────────────────────────── */ + +/* ── App shell grid ── */ +.app-shell { + display: grid; + grid-template-areas: + "header header" + "nav main" + "footer footer"; + grid-template-columns: 260px 1fr; + grid-template-rows: auto 1fr auto; + min-height: 100dvh; + max-width: 1440px; + margin-inline: auto; +} + +/* Named grid areas */ +.app-shell > .app-header { grid-area: header; } +.app-shell > .app-nav { grid-area: nav; } +.app-shell > .app-main { grid-area: main; } +.app-shell > .app-footer { grid-area: footer; } + +/* ── Full-width pages (no sidebar nav) ── */ +.app-shell--full { + grid-template-areas: + "header" + "main" + "footer"; + grid-template-columns: 1fr; +} + +/* ── Live-view shell (3D canvas fills viewport) ── */ +.app-shell--live { + display: grid; + grid-template-areas: + "header" + "main"; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + min-height: 100dvh; + max-width: none; + margin-inline: 0; +} + +/* ── Tablet: collapse sidebar nav ── */ +@media (max-width: 1023px) { + .app-shell { + grid-template-areas: + "header" + "main" + "footer"; + grid-template-columns: 1fr; + } + + .app-shell > .app-nav { + display: none; + } +} + +/* ── Mobile: single column + bottom nav ── */ +@media (max-width: 639px) { + .app-shell, + .app-shell--full, + .app-shell--live { + grid-template-areas: + "header" + "main"; + grid-template-columns: 1fr; + } + + .app-shell > .app-nav, + .app-shell > .app-footer { + display: none; + } + + /* Bottom navigation for mobile */ + .app-mobile-nav { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--slate-3); + border-top: 1px solid var(--border-default); + padding-bottom: env(safe-area-inset-bottom); + z-index: 100; + } + + .app-mobile-nav__list { + display: flex; + list-style: none; + width: 100%; + margin: 0; + padding: 0; + } + + .app-mobile-nav__item { + flex: 1; + text-align: center; + } + + .app-mobile-nav__link { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: var(--space-2) 0; + color: var(--text-muted); + text-decoration: none; + font-size: var(--text-xs); + min-height: 44px; + justify-content: center; + } + + .app-mobile-nav__link--active { + color: var(--color-primary); + } +} + +/* Desktop: hide mobile bottom nav */ +@media (min-width: 640px) { + .app-mobile-nav { + display: none; + } +} + +/* ── Touch targets (all interactive elements) ── */ +@media (max-width: 1023px) { + .app-shell button, + .app-shell--full button, + .app-shell--live button, + .app-shell a, + .app-shell--full a, + .app-shell--live a, + .app-shell input[type="checkbox"], + .app-shell--full input[type="checkbox"], + .app-shell--live input[type="checkbox"], + .app-shell select, + .app-shell--full select, + .app-shell--live select { + min-height: 44px; + min-width: 44px; + } +} + +/* ── Shared header styling ── */ +.app-header { + background: var(--slate-3); + border-bottom: 1px solid var(--border-default); + padding: 0 var(--space-6); + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + z-index: 100; +} + +.app-header__logo { + font-size: var(--text-lg); + font-weight: var(--fw-heading); + color: var(--text-primary); + text-decoration: none; + display: flex; + align-items: center; + gap: var(--space-2); +} + +.app-header__links { + display: flex; + gap: var(--space-4); +} + +.app-header__link { + color: var(--text-secondary); + text-decoration: none; + font-size: var(--text-sm); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-control); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.app-header__link:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.app-header__link--active { + color: var(--color-primary); +} + +@media (max-width: 1023px) { + .app-header__links { + display: none; + } +} + +/* ── Shared sidebar nav ── */ +.app-nav { + background: var(--slate-2); + border-right: 1px solid var(--border-default); + padding: var(--space-4); + overflow-y: auto; +} + +/* ── Main content area ── */ +.app-main { + overflow-y: auto; + overflow-x: hidden; +} + +/* ── Dialog-based modals (replaces fixed-position overlays) ── */ +dialog.app-dialog { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-default); + border-radius: var(--radius-modal); + padding: 0; + max-width: 560px; + width: 92%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +dialog.app-dialog::backdrop { + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); +} + +/* ── Mobile body padding for bottom nav ── */ +@media (max-width: 639px) { + .has-mobile-nav { + padding-bottom: calc(60px + env(safe-area-inset-bottom)); + } +} diff --git a/dashboard/css/tokens.css b/dashboard/css/tokens.css new file mode 100644 index 0000000..50916a5 --- /dev/null +++ b/dashboard/css/tokens.css @@ -0,0 +1,111 @@ +/* ─────────────────────────────────────────────────────────────── + Spaxel Design System — Radix Dark Tokens (§8e) + Single source of truth for all dashboard surfaces. + ─────────────────────────────────────────────────────────────── */ + +:root { + /* ── Radix Slate (dark) ── */ + --slate-1: #111113; + --slate-2: #18191b; + --slate-3: #1e2024; + --slate-4: #262830; + --slate-5: #2e3039; + --slate-6: #363842; + --slate-7: #43454f; + --slate-8: #565867; + --slate-9: #6b6e7b; + --slate-10: #7c7f8c; + --slate-11: #9b9daa; + --slate-12: #ededf0; + + /* ── Radix Blue (accent) ── */ + --blue-1: #11181e; + --blue-2: #152233; + --blue-3: #1a2e47; + --blue-4: #1e3a5f; + --blue-5: #224777; + --blue-6: #26558f; + --blue-7: #2e6aad; + --blue-8: #3579c5; + --blue-9: #3e96e8; + --blue-10: #4daaf5; + --blue-11: #68bdfa; + --blue-12: #aedcff; + + /* ── Semantic state ── */ + --ok: #46a758; /* green-9 */ + --warn: #e5a00d; /* amber-9 */ + --alert: #e5484d; /* red-9 */ + + /* ── Semantic aliases ── */ + --color-success: var(--ok); + --color-warning: var(--warn); + --color-danger: var(--alert); + --color-primary: var(--blue-9); + --color-primary-hover: var(--blue-10); + + /* ── State with opacity (for badges, backgrounds) ── */ + --ok-muted: rgba(70, 167, 88, 0.2); + --ok-border: rgba(70, 167, 88, 0.3); + --warn-muted: rgba(229, 160, 13, 0.2); + --warn-border: rgba(229, 160, 13, 0.3); + --alert-muted: rgba(229, 72, 77, 0.2); + --alert-border: rgba(229, 72, 77, 0.3); + --blue-muted: rgba(62, 150, 232, 0.15); + --blue-border: rgba(62, 150, 232, 0.3); + + /* ── Background steps ── */ + --bg-page: var(--slate-2); + --bg-card: var(--slate-3); + --bg-panel: var(--slate-3); + --bg-hover: var(--slate-4); + --bg-active: var(--slate-5); + --bg-input: var(--slate-4); + + /* ── Text ── */ + --text-primary: var(--slate-12); + --text-secondary: var(--slate-11); + --text-muted: var(--slate-9); + --text-on-accent: #fff; + + /* ── Border ── */ + --border-default: rgba(255, 255, 255, 0.08); + --border-subtle: rgba(255, 255, 255, 0.04); + --border-strong: rgba(255, 255, 255, 0.15); + + /* ── Typography ── */ + --font-body: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif; + --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + + --text-xs: 12px; + --text-sm: 14px; + --text-base: 16px; + --text-lg: 20px; + --text-2xl: 28px; + + --lh-body: 1.5; + --lh-heading: 1.2; + --fw-body: 400; + --fw-heading: 600; + + /* ── Spacing (4 px grid) ── */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + + /* ── Radius ── */ + --radius-control: 6px; + --radius-card: 10px; + --radius-modal: 16px; + + /* ── Transitions ── */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; +} diff --git a/dashboard/index.html b/dashboard/index.html index 8128325..b456587 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -5,4486 +5,105 @@ - Spaxel Dashboard - - - - - - - - - - - - - - - - - - - - - - + Spaxel + + + - - -
- 📅 - Learning your home's daily patterns... -
-
-
- 7 days left - -
+ +
- -
+ + - -
- 🛡 - Learning normal patterns... -
-
-
- 7 days remaining -
+ +
- -
+ +
+
+ + Connecting… +
+
- -
-
-
- Disconnected - -
- -
- Nodes: 0 -
-
- Links: 0 -
-
- CLEAR -
-
- +
+
Waiting for data…
+
+
- -
- - -
+ +
+ + + + + diff --git a/dashboard/js/home-cards.js b/dashboard/js/home-cards.js new file mode 100644 index 0000000..08b0895 --- /dev/null +++ b/dashboard/js/home-cards.js @@ -0,0 +1,231 @@ +/** + * home-cards.js — Populates the Spaxel home page from /ws/dashboard snapshot. + * + * Connects to the dashboard WebSocket, reads the initial snapshot to fill all + * three cards, then applies incremental updates to refresh counts only. + */ +(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'); + + // ── State ── + let ws = null; + let reconnectDelay = 1000; + let recentEvents = []; + + // ── 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; + 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'; + return Math.floor(diff / 86400000) + 'd ago'; + } + + function setBanner(level, html) { + $banner.className = 'home-status__banner home-status__banner--' + level; + $banner.innerHTML = '' + html; + } + + // ── 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'; }); + + 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(', '); + } else { + var occupied = zones.filter(function (z) { return z.count > 0; }); + $peopleDtl.textContent = occupied.length > 0 + ? occupied.map(function (z) { return z.name; }).join(', ') + : 'No one detected'; + } + } + + function updateDevicesCard(snapshot) { + var nodes = snapshot.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'; }); + + $devicesCnt.textContent = online.length + '/' + nodes.length + ' online'; + $devicesMeta.innerHTML = ''; + + if (offline.length > 0) { + $devicesDtl.textContent = offline.length + ' device' + (offline.length > 1 ? 's' : '') + ' offline'; + addTag($devicesMeta, 'alert', offline.length + ' offline'); + } else if (stale.length > 0) { + $devicesDtl.textContent = stale.length + ' device' + (stale.length > 1 ? 's' : '') + ' stale'; + addTag($devicesMeta, 'warn', stale.length + ' stale'); + } else { + $devicesDtl.textContent = nodes.length === 0 + ? 'No devices registered' + : 'All devices healthy'; + } + + var quality = snapshot.confidence; + if (typeof quality === 'number') { + var qLevel = quality >= 80 ? 'ok' : quality >= 60 ? 'warn' : 'alert'; + addTag($devicesMeta, qLevel, quality + '% quality'); + } + } + + function addTag(container, level, text) { + var span = document.createElement('span'); + span.className = 'home-card__tag home-card__tag--' + level; + span.textContent = text; + container.appendChild(span); + } + + function updateEventsCard(snapshot) { + var evts = snapshot.events || []; + if (evts.length > 0) { + recentEvents = evts.slice(0, 5); + } + renderEvents(); + } + + function renderEvents() { + if (recentEvents.length === 0) { + $eventsDtl.textContent = 'No recent events'; + return; + } + var lines = recentEvents.slice(0, 5).map(function (e) { + var label = e.person || e.zone || e.type || 'Event'; + var time = e.timestamp_ms ? relativeTime(e.timestamp_ms) : ''; + return label + (time ? ' — ' + time : ''); + }); + $eventsDtl.innerHTML = lines.join('
'); + } + + function updateExtras(snapshot) { + // Morning briefing + if (snapshot.briefing) { + $briefing.textContent = snapshot.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'); + } + // Anomaly + if (snapshot.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; }); + + 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'); + } + } + + // ── Snapshot processing ── + + function handleSnapshot(snapshot) { + updateBanner(snapshot); + updatePeopleCard(snapshot); + updateDevicesCard(snapshot); + updateEventsCard(snapshot); + updateExtras(snapshot); + } + + function handleIncremental(msg) { + // Lightweight refresh on incremental updates + if (msg.blobs) updatePeopleCard(msg); + if (msg.nodes) { updateDevicesCard(msg); updateBanner(msg); } + if (msg.events && msg.events.length > 0) { + // Prepend new events, keep last 5 + recentEvents = msg.events.concat(recentEvents).slice(0, 5); + renderEvents(); + } + } + + // ── WebSocket ── + + function connect() { + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + var url = proto + '//' + location.host + '/ws/dashboard'; + ws = new WebSocket(url); + + ws.onopen = function () { + reconnectDelay = 1000; + }; + + ws.onmessage = function (evt) { + var msg; + try { msg = JSON.parse(evt.data); } catch (e) { return; } + + if (msg.type === 'snapshot') { + handleSnapshot(msg); + } else { + handleIncremental(msg); + } + }; + + ws.onclose = function () { + setBanner('warn', 'Reconnecting…'); + setTimeout(function () { + reconnectDelay = Math.min(reconnectDelay * 2, 10000); + connect(); + }, reconnectDelay); + }; + + ws.onerror = function () { + ws.close(); + }; + } + + // ── Boot ── + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', connect); + } else { + connect(); + } + + // Refresh relative timestamps every 30s + setInterval(renderEvents, 30000); +})(); diff --git a/dashboard/live.html b/dashboard/live.html new file mode 100644 index 0000000..06ba287 --- /dev/null +++ b/dashboard/live.html @@ -0,0 +1,4489 @@ + + + + + + + + Spaxel Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 📅 + Learning your home's daily patterns... +
+
+
+ 7 days left + +
+ + +
+ + +
+ 🛡 + Learning normal patterns... +
+
+
+ 7 days remaining +
+ + +
+ + +
+
+
+ Disconnected + +
+ +
+ Nodes: 0 +
+
+ Links: 0 +
+
+ CLEAR +
+
+
+ + + + + -- +
+ Quality +
+
Detection Quality
+
Based on 0 active links
+
Weakest: -- at --%
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
+
+ FPS: 0 +
+
+ + +
+ + + + + + + + +
+
+
+ Menu + +
+ +
+ + + + + +
+ +
+
+
+

Nodes

+
+
+
No nodes connected
+
+
+ +
+
+

Presence

+ CLEAR +
+
+
No links active
+
+
+
+
+

Timeline

+
+
+
Loading timeline...
+
+
+
+
+

People & Devices

+
+
+
No devices registered
+
+
+
+
+ + +
+

Nodes

+
+
No nodes connected
+
+ + + +
+ + +
+
Amplitude — no link selected
+ +
+
Mean amplitude (60 s)
+ +
+ + +
+
+

Presence

+ CLEAR +
+
+
No links active
+
+
Delta RMS (10 s)
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Room Dimensions

+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + + + + + + + + +
+

GDOP Coverage

+
Coverage: --%
+ +
+ Excellent + Good + Fair + Poor +
+
+ + + + + + +
+
+
+
Morning Briefing
+
Today
+
+ +
+
+
+
+ Loading briefing... +
+
+ +
+ + +
+ + + +
+ + +
+

Briefing Settings

+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ +