From 31f7e8ee074a86bdeadc54586fe60edd5d035c85 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 20 Mar 2026 11:16:59 -0600 Subject: [PATCH] fixed devices page websocket context --- src/hooks/useDeviceStatusStreams.ts | 16 +++++++++--- src/hooks/useWebSocket.ts | 15 ++++++++--- src/pages/Device.tsx | 22 +++++++++++++--- src/pages/Devices.tsx | 40 +++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts index 5c38058..e235ebc 100644 --- a/src/hooks/useDeviceStatusStreams.ts +++ b/src/hooks/useDeviceStatusStreams.ts @@ -24,6 +24,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 +40,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 +55,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 +101,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..93d3aa2 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -18,7 +18,7 @@ function getWsBaseUrl(): string { } 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 +28,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 +50,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 +68,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 +109,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, })