feat(dashboard): restructure home page as status headline + 3 cards
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 <noreply@anthropic.com>
This commit is contained in:
parent
756faec80f
commit
7162d4523e
6 changed files with 5431 additions and 4472 deletions
270
dashboard/css/home.css
Normal file
270
dashboard/css/home.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
239
dashboard/css/layout.css
Normal file
239
dashboard/css/layout.css
Normal file
|
|
@ -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 <dialog> 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));
|
||||
}
|
||||
}
|
||||
111
dashboard/css/tokens.css
Normal file
111
dashboard/css/tokens.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
4563
dashboard/index.html
4563
dashboard/index.html
File diff suppressed because it is too large
Load diff
231
dashboard/js/home-cards.js
Normal file
231
dashboard/js/home-cards.js
Normal file
|
|
@ -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 = '<span class="home-status__dot home-status__dot--connected" id="ws-dot"></span>' + 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('<br>');
|
||||
}
|
||||
|
||||
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);
|
||||
})();
|
||||
4489
dashboard/live.html
Normal file
4489
dashboard/live.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue