From dc8b2617e6b5f93f56b3d7740753b18ab99ff5bf Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 12:35:09 -0600 Subject: [PATCH] merge web socket changes --- .env | 3 + src/common/StatusDust.tsx | 130 +++++++++---------- src/common/StatusSen5x.tsx | 191 ++++++++++++++-------------- src/hooks/useDeviceStatusStreams.ts | 30 ++--- src/hooks/useWebSocket.ts | 30 ++--- src/pages/Device.tsx | 22 +++- src/pages/Devices.tsx | 40 ++++++ src/utils/getWsBaseUrl.ts | 16 +++ 8 files changed, 262 insertions(+), 200 deletions(-) create mode 100644 src/utils/getWsBaseUrl.ts diff --git a/.env b/.env index 6e8f870..9fdc8f3 100644 --- a/.env +++ b/.env @@ -20,3 +20,6 @@ VITE_APP_WEBSITE_TITLE="Adaptive Dashboard" VITE_APP_PRIMARY_COLOUR=blue VITE_APP_SECONDARY_COLOUR=blueGrey VITE_APP_SIGNATURE_COLOUR="#323232" + +# Live device/component WebSocket streams (must match string "true" in code) +VITE_ENABLE_WEBSOCKETS=true diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx index c157501..b45e930 100644 --- a/src/common/StatusDust.tsx +++ b/src/common/StatusDust.tsx @@ -5,7 +5,6 @@ import { Device } from "models"; import PulseBox from "./PulseBox"; import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; -import { useEffect, useState } from "react"; interface Props { device: Device; @@ -23,56 +22,47 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(16), - height: theme.spacing(11), + minHeight: theme.spacing(11), + }, + readingsWrapper: { + position: "relative", + width: "100%", + minHeight: theme.spacing(10), }, carouselContainer: { - position: 'relative', - height: '40px', - overflow: 'hidden', + position: "relative", + height: "40px", + overflow: "hidden", textAlign: "center", }, carouselItem: { opacity: 0, - transform: 'translateX(100%)', - transition: 'opacity 0.5s ease, transform 0.5s ease', - position: 'absolute', - width: '100%', + transform: "translateX(100%)", + transition: "opacity 0.5s ease, transform 0.5s ease", + position: "absolute", + width: "100%", overflow: "hidden", - // height: '100%', }, active: { opacity: 1, - transform: 'translateX(0)', + transform: "translateX(0)", }, inactive: { - transform: 'translateX(-100%)', + transform: "translateX(-100%)", }, - }) -}) + }); +}); export default function StatusDust(props: Props) { const { device, showTimestamp } = props; - const classes = useStyles() + const classes = useStyles(); - const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); - const messages = or(device.status.sen5x?.overlays.map(overlay => overlay.message), []); - - // const [currentIndex, setCurrentIndex] = useState(0); - - // Auto-cycle through measurements every 3 seconds - // useEffect(() => { - // const interval = setInterval(() => { - // setCurrentIndex((prevIndex) => (prevIndex + 1) % 2); - // }, 3000); // Adjust timing as needed (3000ms = 3s) - - // return () => clearInterval(interval); // Cleanup on unmount - // }, []); + const colors = or(device.status.sen5x?.overlays.map((overlay) => overlay.color), []); + const messages = or(device.status.sen5x?.overlays.map((overlay) => overlay.message), []); const tooltip = () => { - if (messages.length === 0) return null - if (messages.length < 2) return ( - messages[0] - ) + if (messages.length === 0) return null; + if (messages.length < 2) return messages[0]; return ( @@ -85,10 +75,10 @@ export default function StatusDust(props: Props) { @@ -96,22 +86,20 @@ export default function StatusDust(props: Props) { {message} - ) + ); })} - ) - } + ); + }; function compareTimestamps(timestamp1: string, timestamp2: string): number { const date1 = new Date(timestamp1); const date2 = new Date(timestamp2); - // Check if dates are valid if (isNaN(date1.getTime()) || isNaN(date2.getTime())) { throw new Error("Invalid RFC3339 timestamp format"); } - // Compare using getTime() for millisecond precision return date1.getTime() - date2.getTime(); } @@ -119,47 +107,49 @@ export default function StatusDust(props: Props) { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); if (compareTimestamps(device.status.sen5x?.timestamp, oneDayAgo.toISOString()) < 0) { - return null - } + return null; + } } else { - return null + return null; } return ( - - - - - - {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 - - - - - {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 - - - - - {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 - - - - - {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 - + + + + + + + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 + + + + + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 + + + + + {"<4um: "} + {device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + + + + + {"<10um: "} + {device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + + {showTimestamp && device.status.sen5x?.timestamp && ( - + )} - ) -} \ No newline at end of file + ); +} diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx index 4e3d316..1c34214 100644 --- a/src/common/StatusSen5x.tsx +++ b/src/common/StatusSen5x.tsx @@ -24,60 +24,61 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(16), - height: theme.spacing(11), + minHeight: theme.spacing(11), + }, + /** Reserves vertical space so absolute carousel slides do not overlap the timestamp below. */ + readingsWrapper: { + position: "relative", + width: "100%", + minHeight: theme.spacing(10), }, carouselContainer: { - position: 'relative', - height: '40px', - overflow: 'hidden', + position: "relative", + height: "40px", + overflow: "hidden", textAlign: "center", }, carouselItem: { opacity: 0, - transform: 'translateX(100%)', - transition: 'opacity 0.5s ease, transform 0.5s ease', - position: 'absolute', - width: '100%', + transform: "translateX(100%)", + transition: "opacity 0.5s ease, transform 0.5s ease", + position: "absolute", + width: "100%", overflow: "hidden", - // height: '100%', }, active: { opacity: 1, - transform: 'translateX(0)', + transform: "translateX(0)", }, inactive: { - transform: 'translateX(-100%)', + transform: "translateX(-100%)", }, - }) -}) + }); +}); export default function StatusSen5x(props: Props) { const { device, noDust, showTimestamp } = props; - const classes = useStyles() - // console.log(noDust) - const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); - const messages = or(device.status.sen5x?.overlays.map(overlay => overlay.message), []); + const classes = useStyles(); + const colors = or(device.status.sen5x?.overlays.map((overlay) => overlay.color), []); + const messages = or(device.status.sen5x?.overlays.map((overlay) => overlay.message), []); const [currentIndex, setCurrentIndex] = useState(0); - // Auto-cycle through measurements every 5 seconds useEffect(() => { if (noDust) { - setCurrentIndex(0) - return + setCurrentIndex(0); + return; } const interval = setInterval(() => { if (!noDust) setCurrentIndex((prevIndex) => (prevIndex + 1) % 2); - }, 5000); + }, 5000); - return () => clearInterval(interval); // Cleanup on unmount + return () => clearInterval(interval); }, [noDust]); const tooltip = () => { - if (messages.length === 0) return null - if (messages.length < 2) return ( - messages[0] - ) + if (messages.length === 0) return null; + if (messages.length < 2) return messages[0]; return ( @@ -90,10 +91,10 @@ export default function StatusSen5x(props: Props) { @@ -101,22 +102,20 @@ export default function StatusSen5x(props: Props) { {message} - ) + ); })} - ) - } + ); + }; function compareTimestamps(timestamp1: string, timestamp2: string): number { const date1 = new Date(timestamp1); const date2 = new Date(timestamp2); - // Check if dates are valid if (isNaN(date1.getTime()) || isNaN(date2.getTime())) { throw new Error("Invalid RFC3339 timestamp format"); } - // Compare using getTime() for millisecond precision return date1.getTime() - date2.getTime(); } @@ -124,76 +123,82 @@ export default function StatusSen5x(props: Props) { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); if (compareTimestamps(device.status.sen5x?.timestamp, oneDayAgo.toISOString()) < 0) { - return null - } + return null; + } } else { - return null + return null; } return ( - - - - - - Temp: {device.status.sen5x?.temperature.toFixed(1)}°C - - - - - Humidity: {device.status.sen5x?.humidity.toFixed(1)}% - - - - - Voc: {device.status.sen5x?.voc.toFixed(1)}% - - - - - Nox: {device.status.sen5x?.nox.toFixed(1)}% - + + + + + + + Temp: {device.status.sen5x?.temperature.toFixed(1)}°C + + + + + Humidity: {device.status.sen5x?.humidity.toFixed(1)}% + + + + + Voc: {device.status.sen5x?.voc.toFixed(1)}% + + + + + Nox: {device.status.sen5x?.nox.toFixed(1)}% + + + + {!noDust && ( + + + + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 + + + + + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 + + + + + {"<4um: "} + {device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + + + + + {"<10um: "} + {device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + + + + )} - - {!noDust && - - - {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 - - - - - {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 - - - - - {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 - - - - - {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 - - - } {showTimestamp && device.status.sen5x?.timestamp && ( - + )} - ) -} \ No newline at end of file + ); +} diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts index 5c38058..d04d663 100644 --- a/src/hooks/useDeviceStatusStreams.ts +++ b/src/hooks/useDeviceStatusStreams.ts @@ -1,22 +1,10 @@ import { pond } from "protobuf-ts/pond"; import { useEffect, useRef, useCallback, useMemo } from "react"; +import { getWsBaseUrl } from "utils/getWsBaseUrl"; /** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ const WEBSOCKETS_ENABLED = import.meta.env.VITE_ENABLE_WEBSOCKETS === "true"; -/** - * Derives the WebSocket base URL from VITE_APP_API_URL. - * Matches the logic in useWebSocket.ts. - */ -function getWsBaseUrl(): string { - const apiUrl = import.meta.env.VITE_APP_API_URL; - if (apiUrl) { - return apiUrl.replace(/^http/, "ws"); - } - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${window.location.host}`; -} - export interface UseDeviceStatusStreamsOptions { /** Device IDs to stream status for (e.g. from the visible devices list) */ deviceIds: string[]; @@ -24,6 +12,12 @@ export interface UseDeviceStatusStreamsOptions { onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void; /** Auth token passed as ?token= query param */ token?: string; + /** Same as REST/listDevices (?keys=) — required when listing under a context path or group tab */ + keys?: string[]; + /** Same as REST (?types=) */ + types?: string[]; + /** View-as / impersonation (?as=), same as device list when not already team-first in URL */ + as?: string; /** Whether the connections should be active. Default true. */ enabled?: boolean; } @@ -34,7 +28,7 @@ export interface UseDeviceStatusStreamsOptions { * Readings (plenum, sen5x, co, co2, no2, o2, etc.) stream in real time. */ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { - const { deviceIds, onStatusUpdate, token, enabled = true } = options; + const { deviceIds, onStatusUpdate, token, keys, types, as, enabled = true } = options; const onStatusUpdateRef = useRef(onStatusUpdate); onStatusUpdateRef.current = onStatusUpdate; @@ -49,8 +43,12 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { const params = new URLSearchParams(); params.set("token", token); + if (keys?.length) params.set("keys", keys.toString()); + if (types?.length) params.set("types", types.toString()); + if (as) params.set("as", as); - const path = `/live/devices/${deviceId}/status`; + // Match useWebSocket / pond routes: /v1/live/devices/:id/status + const path = `/v1/live/devices/${deviceId}/status`; const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const ws = new WebSocket(url); wsMapRef.current.set(deviceId, ws); @@ -91,7 +89,7 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { reconnectTimeoutsRef.current.set(deviceId, timeout); }; }, - [token] + [token, keys?.join(), types?.join(), as] ); // Normalize for stable comparison: same set of IDs = same string regardless of order diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index ff6092c..9945f2d 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,24 +1,11 @@ import { useEffect, useRef, useCallback } from "react"; +import { getWsBaseUrl } from "utils/getWsBaseUrl"; /** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */ const WEBSOCKETS_ENABLED = import.meta.env.VITE_ENABLE_WEBSOCKETS === "true"; -/** - * Derives the WebSocket base URL from VITE_APP_API_URL. - * e.g. "https://api.brandxtech.ca" -> "wss://api.brandxtech.ca" - * Falls back to current page host for local dev. - */ -function getWsBaseUrl(): string { - const apiUrl = import.meta.env.VITE_APP_API_URL; - if (apiUrl) { - return apiUrl.replace(/^http/, "ws"); - } - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${window.location.host}`; -} - interface UseWebSocketOptions { - /** The API path, e.g. "/v1/live/devices/123/components" */ + /** The API path including /v1, e.g. "/v1/live/devices/123/components" */ path: string; /** Transform the raw MessageEvent into your domain type */ parse: (event: MessageEvent) => T; @@ -28,6 +15,12 @@ interface UseWebSocketOptions { token?: string; /** Minimum seconds between updates, passed as ?rate= to the backend */ rate?: number; + /** Context keys, same as REST device APIs (?keys=) */ + keys?: string[]; + /** Context types, same as REST device APIs (?types=) */ + types?: string[]; + /** Impersonation / team view key, same as REST (?as=) */ + as?: string; /** Whether the connection should be active. Default true. */ enabled?: boolean; } @@ -44,7 +37,7 @@ interface UseWebSocketOptions { * }); */ export function useWebSocket(options: UseWebSocketOptions) { - const { path, parse, onMessage, token, rate = 0, enabled = true } = options; + const { path, parse, onMessage, token, rate = 0, keys, types, as, enabled = true } = options; // Keep latest callbacks in refs so reconnects don't use stale closures const onMessageRef = useRef(onMessage); @@ -62,6 +55,9 @@ export function useWebSocket(options: UseWebSocketOptions) { const params = new URLSearchParams(); params.set("token", token); if (rate > 0) params.set("rate", rate.toString()); + if (keys?.length) params.set("keys", keys.toString()); + if (types?.length) params.set("types", types.toString()); + if (as) params.set("as", as); const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const ws = new WebSocket(url); @@ -100,7 +96,7 @@ export function useWebSocket(options: UseWebSocketOptions) { console.debug(`[ws] reconnecting in ${delay}ms...`); reconnectTimeoutRef.current = setTimeout(connect, delay); }; - }, [path, token, rate]); + }, [path, token, rate, keys?.join(), types?.join(), as]); useEffect(() => { if (!WEBSOCKETS_ENABLED || !enabled || !token) { diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index db9914e..6dd2623 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -3,7 +3,7 @@ import Grid from '@mui/material/Grid2'; import { Component, Device, Interaction, User } from "models"; import { getContextKeys, getContextTypes } from "pbHelpers/Context"; import { useDeviceAPI, useGlobalState, useSnackbar } from "providers"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import PageContainer from "./PageContainer"; import { useHTTP, useMobile } from "hooks"; @@ -38,7 +38,7 @@ export default function DevicePage() { const isMobile = useMobile() const deviceID = useParams<{ deviceID: string }>()?.deviceID ?? ""; const groupID = useParams<{ groupID: string }>()?.groupID ?? ""; - const { state } = useLocation(); + const { state, pathname: locationPathname } = useLocation(); const [{ as, team, user }] = useGlobalState() // console.log(state) const [device, setDevice] = useState(state?.device ? Device.create(state.device) : Device.create()) @@ -68,6 +68,14 @@ export default function DevicePage() { const { token } = useHTTP(); + const liveContextKeys = useMemo(() => getContextKeys(), [locationPathname]); + const liveContextTypes = useMemo(() => getContextTypes(), [locationPathname]); + /** Matches getDevicePageData: omit ?as= when first context type is "team". */ + const liveAs = + as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team") + ? as + : undefined; + const loadDevice = () => { if (loading) return if (state?.devicePageData) { @@ -173,7 +181,7 @@ export default function DevicePage() { // When the backend receives a new reading and updates a component, // this fires and updates the component in state without a page refresh. useWebSocket<{ key: string; component: Component } | null>({ - path: `/live/devices/${deviceID}/components`, + path: `/v1/live/devices/${deviceID}/components`, parse: (e) => { try { const raw = JSON.parse(e.data); @@ -197,6 +205,9 @@ export default function DevicePage() { return updated; }); }, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, token, enabled: !!deviceID, }); @@ -204,7 +215,7 @@ export default function DevicePage() { // --- Real-time device updates (optional) --- // Updates device-level info (name, status, lastActive, etc.) useWebSocket({ - path: `/live/devices/${deviceID}`, + path: `/v1/live/devices/${deviceID}`, parse: (e) => { try { const raw = JSON.parse(e.data); @@ -223,6 +234,9 @@ export default function DevicePage() { if (!updatedDevice) return; setDevice(updatedDevice); }, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, token, enabled: !!deviceID, }); diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index 0a6b9af..ea31e7f 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -264,10 +264,50 @@ export default function Devices() { ) }, []) + /** Same context chain as deviceAPI.list / GetContext on the backend (path + group tab + ?as=). */ + const statusStreamKeys = useMemo(() => { + if (tab !== "all") { + const g = groups[parseInt(tab)] + if (g) { + const keys = [...getContextKeys()] + keys.push(g.id().toString()) + return keys + } + } + return getContextKeys() + }, [tab, groups, location.pathname]) + + const statusStreamTypes = useMemo(() => { + if (tab !== "all") { + const g = groups[parseInt(tab)] + if (g) { + const types = [...getContextTypes()] + types.push("group") + return types + } + } + return getContextTypes() + }, [tab, groups, location.pathname]) + + /** Match Device page / getDevicePageData: omit ?as= when URL context already starts with team. */ + const statusStreamAs = useMemo(() => { + const types = + tab !== "all" && groups[parseInt(tab)] + ? [...getContextTypes(), "group"] + : getContextTypes() + if (as && !(types.length > 0 && types[0] === "team")) { + return as + } + return undefined + }, [as, tab, groups, location.pathname]) + useDeviceStatusStreams({ deviceIds, onStatusUpdate: handleStatusUpdate, token, + keys: statusStreamKeys, + types: statusStreamTypes, + as: statusStreamAs, enabled: devices.length > 0 && !!token, }) diff --git a/src/utils/getWsBaseUrl.ts b/src/utils/getWsBaseUrl.ts new file mode 100644 index 0000000..331b78a --- /dev/null +++ b/src/utils/getWsBaseUrl.ts @@ -0,0 +1,16 @@ +/** + * Origin for Pond WebSocket URLs. + * + * VITE_APP_API_URL usually ends with /v1 (same base axios uses for REST). WS paths are + * written as /v1/live/..., so we strip a trailing /v1 from the env URL to avoid /v1/v1/. + */ +export function getWsBaseUrl(): string { + const apiUrl = import.meta.env.VITE_APP_API_URL; + if (apiUrl) { + let ws = apiUrl.replace(/^http/, "ws"); + ws = ws.replace(/\/v1\/?$/, ""); + return ws.replace(/\/$/, "") || ws; + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}`; +}