From 115e2c5ed49354436ea3ef4e8d134198d1233c57 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 10 Mar 2026 16:50:59 -0600 Subject: [PATCH 01/11] added column for the device state and some wording next to the icond for the pca state so its not as ambiguous --- package-lock.json | 4 +- package.json | 2 +- src/gate/GateList.tsx | 59 +++++++++++++++++-- .../ComponentTypes/DragerGasDongle.ts | 5 ++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79e2f18..d46565d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11967,7 +11967,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#1c00e059fe16126e85c50581786b7510759920d7", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bfe8ce6f4ee17a178304e17718a8911b060b2e69", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 90ac71b..5f7818c 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/gate/GateList.tsx b/src/gate/GateList.tsx index 2cdec52..5910808 100644 --- a/src/gate/GateList.tsx +++ b/src/gate/GateList.tsx @@ -1,5 +1,5 @@ import { Gate } from "models/Gate"; -import { Box, Card, IconButton, List, ListItem, ListItemText, Theme, Typography } from "@mui/material"; +import { Box, Card, IconButton, List, ListItem, ListItemText, Theme, Tooltip, Typography } from "@mui/material"; import React, { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useMobile } from "hooks"; @@ -20,6 +20,7 @@ import { quack } from "protobuf-ts/quack"; import { getTemperatureUnit } from "utils"; import { teal } from "@mui/material/colors"; import { react } from "@babel/types"; +import { getDeviceStateHelper } from "pbHelpers/DeviceState"; interface Props { //gates: Gate[]; @@ -42,6 +43,12 @@ const useStyles = makeStyles((theme: Theme) => ({ gateCard: { marginBottom: 10, paddingLeft: 15 + }, + cellContainer: { + margin: theme.spacing(1), + marginLeft: theme.spacing(2), + display: "flex", + width: theme.spacing(10) } })); @@ -100,13 +107,33 @@ export default function GateList(props: Props) { const displayPCAStatus = (state: pond.PCAState) => { switch(state){ case pond.PCAState.PCA_STATE_IN_BOUNDS: - return + return ( + + + PCA in threshold + + ) case pond.PCAState.PCA_STATE_OUT_BOUNDS: - return + return ( + + + PCA out of threshold + + ) case pond.PCAState.PCA_STATE_OFF: - return + return ( + + + PCA off + + ) default: - return + return ( + + + PCA device not found + + ) } } @@ -166,7 +193,7 @@ export default function GateList(props: Props) { const conditionDisplay = (gate: Gate) => { let display = "" if(gate.status.pcaState === pond.PCAState.PCA_STATE_OFF){ - display = "Inactive" + display = "PCA Off" } else if (gate.status.pcaState === pond.PCAState.PCA_STATE_UNKNOWN){ display = "--" } else { //the pca is currently active calulate the delta temp (outlet - ambient) @@ -241,6 +268,19 @@ export default function GateList(props: Props) { ) }, + { + title: "Device State", + // cellStyle: {width: 100000}, + // sortKey: "state", + render: gate => { + const deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState); + return ( + + {deviceStateHelper.icon} + + ) + } + }, { title: "PCA Status", render: gate => { @@ -324,6 +364,7 @@ export default function GateList(props: Props) { } const gutter = (gate: Gate) => { + const deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState) return( @@ -334,6 +375,12 @@ export default function GateList(props: Props) { Delta Temperature: {conditionDisplay(gate)} + + Device State: + + {deviceStateHelper.icon} + + {/* taking these out for now because we are not sure if the customer wants them */} {/* Estimated Temp: diff --git a/src/pbHelpers/ComponentTypes/DragerGasDongle.ts b/src/pbHelpers/ComponentTypes/DragerGasDongle.ts index dd28f75..648b01d 100644 --- a/src/pbHelpers/ComponentTypes/DragerGasDongle.ts +++ b/src/pbHelpers/ComponentTypes/DragerGasDongle.ts @@ -92,6 +92,11 @@ export function dragerGasDongle(subtype: number = 0): ComponentTypeExtension { key: quack.DragerGasDongleSubtype.DRAGER_GAS_DONGLE_SUBTYPE_LEL, value: "DRAGER_GAS_DONGLE_SUBTYPE_LEL", friendlyName: "Drager Gas LEL" + }, + { + key: quack.DragerGasDongleSubtype.DRAGER_GAS_DONGLE_SUBTYPE_AMMONIA, + value: "DRAGER_GAS_DONGLE_SUBTYPE_AMMONIA", + friendlyName: "Drager Gas Ammonia" } ], friendlyName: "Drager Gas", From 863f87da1c7dc47168d35615bc2f1227916791fd Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 10 Mar 2026 16:52:02 -0600 Subject: [PATCH 02/11] switch to staging proto --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d46565d..9b726d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11967,7 +11967,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bfe8ce6f4ee17a178304e17718a8911b060b2e69", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#e4a1e598240d504e3ccfe09d6f023c5865ab2c71", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 5f7818c..90ac71b 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From c7016be6cdb783e83ab4d49692fa67aae4c46786 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 11 Mar 2026 11:21:36 -0600 Subject: [PATCH 03/11] fixing bug to make sure controller from other devices dont show as options for interactions when using the nodes on the bin svg --- src/bin/BinVisualizerV2.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index 37ed664..2da6f78 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -290,11 +290,7 @@ export default function BinVisualizer(props: Props) { const [modeChangeInProgress, setModeChangeInProgress] = useState(false) const [newGrainSettings, setNewGrainSettings] = useState() - const componentAPI = useComponentAPI() - const [openControllerDIalog, setOpenControllerDialog] = useState(false) - const [controllerOutputStates, setControllerOutputStates] = useState>(new Map()) - const [selectedController, setSelectedController] = useState() - const [newOutputState, setNewOutputState] = useState() + const [filteredComponents, setFilteredComponents] = useState([]) useEffect(() => { setModeTime(moment(bin.status.lastModeChange)); @@ -1420,13 +1416,21 @@ export default function BinVisualizer(props: Props) { coldestNodeTemp={valueDisplay === "low" ? lowNodeConditions?.tempC : undefined} cableNodeClicked={(cable, fillNode) => { if (cable) { - let devId = componentDevices?.get(cable.key()); + let devId = componentDevices.get(cable.key()); if (devId) { let device = devMap.get(devId); if (device) { setSelectedCable(cable); setSelectedNode(fillNode); setCableDevice(device); + //need to set the filtered components so that components on other devices do not show up as options in interaction settings + let filtered: Component[] = [] + components.forEach((component, key) => { + if(componentDevices.get(key) === devId){ + filtered.push(component) + } + }) + setFilteredComponents(filtered) setOpenNodeDialog(true); } } @@ -1556,7 +1560,7 @@ export default function BinVisualizer(props: Props) { let device = componentDevices.get(fan.key) if(fanComp && device){ return ( - + ) } })} @@ -2075,7 +2079,7 @@ export default function BinVisualizer(props: Props) { {selectedCable && cableDevice && ( Date: Fri, 13 Mar 2026 12:29:06 -0600 Subject: [PATCH 04/11] implemented live web socket on the devices page for status readings --- src/pages/Devices.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index 80a3f0d..d24c829 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -6,7 +6,9 @@ import ResponsiveTable, { Column } from "common/ResponsiveTable"; import ProvisionDevice from "device/ProvisionDevice"; import { pond } from "protobuf-ts/pond"; import { useDeviceAPI, useGlobalState, useGroupAPI, useTeamAPI } from "providers"; -import { useCallback, useEffect, useState } from "react"; +import { useHTTP } from "hooks"; +import { useDeviceStatusStreams } from "hooks/useDeviceStatusStreams"; +import { useCallback, useEffect, useMemo, useState } from "react"; import PageContainer from "./PageContainer"; import { useMobile } from "hooks"; import { useLocation, useNavigate, useParams } from "react-router-dom"; @@ -191,6 +193,26 @@ export default function Devices() { const [preferencesAnchor, setPreferencesAnchor] = useState(null) const [{ as, team }] = useGlobalState() + const { token } = useHTTP() + + const deviceIds = useMemo(() => devices.map((d) => d.id().toString()), [devices]) + const handleStatusUpdate = useCallback((deviceId: string, status: pond.DeviceStatus) => { + setDevices((prev) => + prev.map((d) => { + if (d.id().toString() !== deviceId) return d + const updated = Device.clone(d) + updated.status = status + return updated + }) + ) + }, []) + + useDeviceStatusStreams({ + deviceIds, + onStatusUpdate: handleStatusUpdate, + token, + enabled: devices.length > 0 && !!token, + }) const updateGroups = (newGroups: Group[]) => { setGroups(newGroups); @@ -242,7 +264,7 @@ export default function Devices() { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); if (compareTimestamps(device.status.sen5x?.timestamp, oneDayAgo.toISOString()) > 0) { - // console.log("timestamp success " + device.settings?.deviceId) + // console.log("timestamp success " + device.settings?.deviceId + ": " + device.status.sen5x?.timestamp) return true } else { // console.log("timestamp fail " + device.settings?.deviceId) From 1e2003a5414c78ab175d2293dfda90d654e8774c Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 13:20:51 -0600 Subject: [PATCH 05/11] added optional timestamp --- src/common/RelativeTimestamp.tsx | 38 ++++++++ src/common/StatusDust.tsx | 12 ++- src/common/StatusGas.tsx | 14 ++- src/common/StatusPlenum.tsx | 11 ++- src/common/StatusSen5x.tsx | 11 ++- src/hooks/useDeviceStatusStreams.ts | 138 ++++++++++++++++++++++++++++ src/pages/Devices.tsx | 33 +++++-- 7 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 src/common/RelativeTimestamp.tsx create mode 100644 src/hooks/useDeviceStatusStreams.ts diff --git a/src/common/RelativeTimestamp.tsx b/src/common/RelativeTimestamp.tsx new file mode 100644 index 0000000..811ded6 --- /dev/null +++ b/src/common/RelativeTimestamp.tsx @@ -0,0 +1,38 @@ +import { Typography } from "@mui/material"; +import moment from "moment"; +import { useEffect, useState } from "react"; + +interface Props { + /** RFC3339 timestamp string */ + timestamp: string; +} + +/** + * Displays a timestamp as relative time (e.g. "a minute ago", "5 seconds ago"). + * Uses a subtle, muted typography style. + * Refreshes every 60 seconds to keep the relative text current. + */ +export default function RelativeTimestamp({ timestamp }: Props) { + const [, setTick] = useState(0); + + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 60000); + return () => clearInterval(interval); + }, []); + + const date = moment(timestamp); + if (!date.isValid()) return null; + + return ( + + {date.fromNow()} + + ); +} diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx index a7e8e3a..4963269 100644 --- a/src/common/StatusDust.tsx +++ b/src/common/StatusDust.tsx @@ -3,10 +3,13 @@ import { green } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; 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 + device: Device; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -48,7 +51,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusDust(props: Props) { - const { device } = props; + const { device, showTimestamp } = props; const classes = useStyles() const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); @@ -165,6 +168,11 @@ export default function StatusDust(props: Props) { + {showTimestamp && device.status.sen5x?.timestamp && ( + + + + )} diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx index dcf7ed3..4571278 100644 --- a/src/common/StatusGas.tsx +++ b/src/common/StatusGas.tsx @@ -3,13 +3,14 @@ import { blue, deepOrange, teal } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; 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 - // noDust?: boolean; - gasType: "co2" | "co" | "no2" | "o2" | "all" + device: Device; + gasType: "co2" | "co" | "no2" | "o2" | "all"; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -61,7 +62,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusGas(props: Props) { - const { device, gasType } = props; + const { device, gasType, showTimestamp } = props; const classes = useStyles() // console.log(noDust) let colors: string[] = [] @@ -203,6 +204,11 @@ export default function StatusGas(props: Props) { Volt: {device.status[gas]?.millivolts.toFixed(1)}mV + {showTimestamp && device.status[gas]?.timestamp && ( + + + + )} diff --git a/src/common/StatusPlenum.tsx b/src/common/StatusPlenum.tsx index fe2b00d..250660a 100644 --- a/src/common/StatusPlenum.tsx +++ b/src/common/StatusPlenum.tsx @@ -3,11 +3,13 @@ import { blue, orange } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; 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 + device: Device; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -25,7 +27,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusPlenum(props: Props) { - const { device } = props; + const { device, showTimestamp } = props; const classes = useStyles() const colors = or(device.status.plenum?.overlays.map(overlay => overlay.color), []); @@ -117,6 +119,11 @@ export default function StatusPlenum(props: Props) { {device.status.plenum?.humidity.toFixed(1)}% + {showTimestamp && device.status.plenum?.timestamp && ( + + + + )} diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx index fad0ecb..533fde2 100644 --- a/src/common/StatusSen5x.tsx +++ b/src/common/StatusSen5x.tsx @@ -3,12 +3,14 @@ import { blue, green, orange } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; 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 + device: Device; noDust?: boolean; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -50,7 +52,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusSen5x(props: Props) { - const { device, noDust } = props; + const { device, noDust, showTimestamp } = props; const classes = useStyles() // console.log(noDust) const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); @@ -200,6 +202,11 @@ export default function StatusSen5x(props: Props) { } + {showTimestamp && device.status.sen5x?.timestamp && ( + + + + )} diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts new file mode 100644 index 0000000..24ca8bb --- /dev/null +++ b/src/hooks/useDeviceStatusStreams.ts @@ -0,0 +1,138 @@ +import { pond } from "protobuf-ts/pond"; +import { useEffect, useRef, useCallback, useMemo } from "react"; + +/** + * 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[]; + /** Called when a device status update is received */ + onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void; + /** Auth token passed as ?token= query param */ + token?: string; + /** Whether the connections should be active. Default true. */ + enabled?: boolean; +} + +/** + * Subscribes to live device status streams for multiple devices. + * Uses the /v1/live/devices/:device/status endpoint per device. + * 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 onStatusUpdateRef = useRef(onStatusUpdate); + onStatusUpdateRef.current = onStatusUpdate; + + const wsMapRef = useRef>(new Map()); + const retriesRef = useRef>(new Map()); + const reconnectTimeoutsRef = useRef>>(new Map()); + + const connect = useCallback( + (deviceId: string) => { + if (!token || !deviceId) return; + + const params = new URLSearchParams(); + params.set("token", token); + + const path = `/live/devices/${deviceId}/status`; + const url = `${getWsBaseUrl()}${path}?${params.toString()}`; + const ws = new WebSocket(url); + wsMapRef.current.set(deviceId, ws); + + ws.onopen = () => { + console.debug(`[ws] connected: ${path}`); + retriesRef.current.set(deviceId, 0); + }; + + ws.onmessage = (event) => { + try { + const raw = JSON.parse(event.data); + const status = pond.DeviceStatus.fromObject(raw ?? {}); + onStatusUpdateRef.current(deviceId, status); + } catch (err) { + console.warn(`[ws] failed to parse device status for ${deviceId}:`, err); + } + }; + + ws.onerror = () => { + console.warn(`[ws] error on ${path}`); + }; + + ws.onclose = (event) => { + wsMapRef.current.delete(deviceId); + + // Don't reconnect on clean close or auth rejection + if (event.code === 1000 || event.code === 4001 || event.code === 4003) { + return; + } + + const retries = retriesRef.current.get(deviceId) ?? 0; + const delay = Math.min(1000 * Math.pow(2, retries), 30000); + retriesRef.current.set(deviceId, retries + 1); + console.debug(`[ws] reconnecting ${path} in ${delay}ms...`); + + const timeout = setTimeout(() => connect(deviceId), delay); + reconnectTimeoutsRef.current.set(deviceId, timeout); + }; + }, + [token] + ); + + // Normalize for stable comparison: same set of IDs = same string regardless of order + const deviceIdsKey = useMemo( + () => [...new Set(deviceIds)].sort().join(","), + [deviceIds.join(",")] + ); + + useEffect(() => { + if (!enabled || !token || deviceIds.length === 0) { + wsMapRef.current.forEach((ws) => ws.close(1000, "disabled")); + wsMapRef.current.clear(); + reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); + reconnectTimeoutsRef.current.clear(); + return; + } + + const currentIds = new Set(deviceIds); + + // Connect to new devices + deviceIds.forEach((id) => { + if (!wsMapRef.current.has(id)) { + connect(id); + } + }); + + // Disconnect from devices no longer in the list + wsMapRef.current.forEach((ws, id) => { + if (!currentIds.has(id)) { + ws.close(1000, "device removed"); + wsMapRef.current.delete(id); + const t = reconnectTimeoutsRef.current.get(id); + if (t) { + clearTimeout(t); + reconnectTimeoutsRef.current.delete(id); + } + } + }); + + return () => { + wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup")); + wsMapRef.current.clear(); + reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); + reconnectTimeoutsRef.current.clear(); + }; + }, [enabled, token, deviceIdsKey, deviceIds.length, connect]); +} diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index d24c829..d166134 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -190,6 +190,15 @@ export default function Devices() { localStorage.setItem('groupGas', groupGas.toString()); }, [groupGas]); + const [showTimestamp, setShowTimestamp] = useState(() => { + const stored = localStorage.getItem('showTimestamp'); + return stored !== null ? stored === 'true' : false; + }); + + useEffect(() => { + localStorage.setItem('showTimestamp', showTimestamp.toString()); + }, [showTimestamp]); + const [preferencesAnchor, setPreferencesAnchor] = useState(null) const [{ as, team }] = useGlobalState() @@ -646,7 +655,7 @@ export default function Devices() { if (device.status.plenum?.temperature) { return ( - + ) } else { @@ -666,7 +675,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -682,7 +691,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -700,7 +709,7 @@ export default function Devices() { if (device.status.co) { return ( - + ) } else { @@ -718,7 +727,7 @@ export default function Devices() { if (device.status.co2) { return ( - + ) } else { @@ -736,7 +745,7 @@ export default function Devices() { if (device.status.no2) { return ( - + ) } else { @@ -754,7 +763,7 @@ export default function Devices() { if (device.status.o2) { return ( - + ) } else { @@ -894,6 +903,16 @@ export default function Devices() { + setShowTimestamp(!showTimestamp)} + sx={{ marginLeft: -0.75 }} + dense + > + + + + + ) } From 9e3ce3c00dd4c55185ee12f3ce958c63de82bd4f Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 13:43:05 -0600 Subject: [PATCH 06/11] added h2s lel and o2 to the device status readings on the devices dashboard --- src/common/StatusGas.tsx | 45 +++++++++++---------- src/pages/Devices.tsx | 86 +++++++++++++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx index 4571278..b220d96 100644 --- a/src/common/StatusGas.tsx +++ b/src/common/StatusGas.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from "react"; interface Props { device: Device; - gasType: "co2" | "co" | "no2" | "o2" | "all"; + gasType: "co2" | "co" | "no2" | "o2" | "lel" | "h2s" | "all"; showTimestamp?: boolean; } @@ -24,7 +24,7 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(14), - height: theme.spacing(6), + minHeight: theme.spacing(6), }, boxTall: { display: "inline-block", @@ -34,7 +34,7 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(14), - height: theme.spacing(8.5), + minHeight: theme.spacing(8.5), }, carouselContainer: { position: 'relative', @@ -68,23 +68,27 @@ export default function StatusGas(props: Props) { let colors: string[] = [] let messages: string[] = [] if (gasType === "all") { - colors = or(device.status.o2?.overlays.map(overlay => overlay.color), []); - colors.push(...or(device.status.co2?.overlays.map(overlay => overlay.color), [])); - colors.push(...or(device.status.no2?.overlays.map(overlay => overlay.color), [])); - colors.push(...or(device.status.co?.overlays.map(overlay => overlay.color), [])); - messages = or(device.status.o2?.overlays.map(overlay => overlay.message), []); - messages.push(...or(device.status.co2?.overlays.map(overlay => overlay.message), [])); - messages.push(...or(device.status.no2?.overlays.map(overlay => overlay.message), [])); - messages.push(...or(device.status.co?.overlays.map(overlay => overlay.message), [])); + colors = or(device.status.o2?.overlays?.map(overlay => overlay.color), []); + colors.push(...or(device.status.co2?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.no2?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.co?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.lel?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.h2s?.overlays?.map(overlay => overlay.color), [])); + messages = or(device.status.o2?.overlays?.map(overlay => overlay.message), []); + messages.push(...or(device.status.co2?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.no2?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.co?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.lel?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.h2s?.overlays?.map(overlay => overlay.message), [])); } else { - colors = or(device.status[gasType]?.overlays.map(overlay => overlay.color), []); - messages = or(device.status[gasType]?.overlays.map(overlay => overlay.message), []); + colors = or(device.status[gasType]?.overlays?.map(overlay => overlay.color), []); + messages = or(device.status[gasType]?.overlays?.map(overlay => overlay.message), []); } const [color, setColor] = useState(colors.length > 0 ? colors[0] : ""); const [, setColorIndex] = useState(0) - const gasTypes: ("o2" | "no2" | "co2" | "co")[] = ["o2", "no2", "co2", "co"] + const gasTypes: ("o2" | "no2" | "co2" | "co" | "lel" | "h2s")[] = ["o2", "no2", "co2", "co", "lel", "h2s"] useEffect(() => { const interval = setInterval(() => { @@ -107,8 +111,7 @@ export default function StatusGas(props: Props) { return } const interval = setInterval(() => { - if (gasType === "all") setCurrentIndex((prevIndex) => (prevIndex + 1) % 4); - else setCurrentIndex((prevIndex) => (prevIndex + 1) % 4); + if (gasType === "all") setCurrentIndex((prevIndex) => (prevIndex + 1) % gasTypes.length); }, 6000); return () => clearInterval(interval); // Cleanup on unmount @@ -189,14 +192,14 @@ export default function StatusGas(props: Props) { } - {gas !== "o2" ? - - Gas: {device.status[gas]?.ppm.toFixed(1)}ppm - - : + {(gas === "o2" || gas === "lel") ? Gas: {(device.status[gas]?.ppm!/10000).toFixed(1)}% + : + + Gas: {device.status[gas]?.ppm.toFixed(1)}ppm + } diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index d166134..08fb013 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -143,6 +143,8 @@ export default function Devices() { const [hasCo2, setHasCo2] = useState(false) const [hasNo2, setHasNo2] = useState(false) const [hasO2, setHasO2] = useState(false) + const [hasLel, setHasLel] = useState(false) + const [hasH2S, setHasH2S] = useState(false) const [groupsLoading, setGroupsLoading] = useState(false) const [groups, setGroups] = useState([]); @@ -296,16 +298,34 @@ export default function Devices() { const doesDeviceHaveO2 = (device: pond.Device) => { if (device.status?.o2?.ppm && device.status?.o2?.timestamp) { - if (device.status.sen5x?.timestamp) { - const now = new Date(); - const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); - if (compareTimestamps(device.status.o2?.timestamp, oneDayAgo.toISOString()) > 0) { - return true - } - } else { - return false + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + if (compareTimestamps(device.status.o2?.timestamp, oneDayAgo.toISOString()) > 0) { + return true } - } + } + return false + } + + const doesDeviceHaveLel = (device: pond.Device) => { + if (device.status?.lel?.ppm && device.status?.lel?.timestamp) { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + if (compareTimestamps(device.status.lel?.timestamp, oneDayAgo.toISOString()) > 0) { + return true + } + } + return false + } + + const doesDeviceHaveH2S = (device: pond.Device) => { + if (device.status?.h2s?.ppm && device.status?.h2s?.timestamp) { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + if (compareTimestamps(device.status.h2s?.timestamp, oneDayAgo.toISOString()) > 0) { + return true + } + } return false } @@ -486,6 +506,8 @@ export default function Devices() { let hasCo = false let hasCo2 = false let hasNo2 = false + let hasLel = false + let hasH2S = false resp.data.devices.forEach(device => { setHasPlenums(doesDeviceHavePlenum(device)) if (doesDeviceHavePlenum(device)) hasPlenum = true @@ -495,6 +517,8 @@ export default function Devices() { if (doesDeviceHaveCo(device)) hasCo = true if (doesDeviceHaveCo2(device)) hasCo2 = true if (doesDeviceHaveNo2(device)) hasNo2 = true + if (doesDeviceHaveLel(device)) hasLel = true + if (doesDeviceHaveH2S(device)) hasH2S = true newDevices.push(Device.create(device)) }) setHasPlenums(hasPlenum) @@ -503,6 +527,8 @@ export default function Devices() { setHasCo(hasCo) setHasCo2(hasCo2) setHasNo2(hasNo2) + setHasLel(hasLel) + setHasH2S(hasH2S) setDevices(newDevices) setTotal(resp.data.total) }).finally(() => { @@ -774,6 +800,42 @@ export default function Devices() { } }) } + if (hasLel && !groupGas) { + columns.push({ + title: "LEL", + render: (device: Device) => { + if (device.status.lel) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } + if (hasH2S && !groupGas) { + columns.push({ + title: "H2S", + render: (device: Device) => { + if (device.status.h2s) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } return columns } @@ -1000,6 +1062,8 @@ export default function Devices() { let hasCo = false let hasCo2 = false let hasNo2 = false + let hasLel = false + let hasH2S = false resp.data.devices.forEach(device => { setHasPlenums(doesDeviceHavePlenum(device)) if (doesDeviceHavePlenum(device)) hasPlenum = true @@ -1009,6 +1073,8 @@ export default function Devices() { if (doesDeviceHaveCo(device)) hasCo = true if (doesDeviceHaveCo2(device)) hasCo2 = true if (doesDeviceHaveNo2(device)) hasNo2 = true + if (doesDeviceHaveLel(device)) hasLel = true + if (doesDeviceHaveH2S(device)) hasH2S = true newDevices.push(Device.create(device)) }) setHasPlenums(hasPlenum) @@ -1017,6 +1083,8 @@ export default function Devices() { setHasCo(hasCo) setHasCo2(hasCo2) setHasNo2(hasNo2) + setHasLel(hasLel) + setHasH2S(hasH2S) setDevices(currentRows.concat(newDevices)) }).catch(_err => { From e732cab837e3b7cbec501cf5858e291cfe6f1de2 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 14:57:42 -0600 Subject: [PATCH 07/11] fixed table formatting and added a scroll bar --- src/common/ResponsiveTable.tsx | 93 +++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/src/common/ResponsiveTable.tsx b/src/common/ResponsiveTable.tsx index ef99665..b229475 100644 --- a/src/common/ResponsiveTable.tsx +++ b/src/common/ResponsiveTable.tsx @@ -15,11 +15,10 @@ const useStyles = makeStyles((theme: Theme) => { border: "none" }, tableContainer: { - // margin: theme.spacing(1), - // [theme.breakpoints.up("sm")]: { - // margin: theme.spacing(2) - // }, - // width: "auto" + width: "100%", + maxWidth: "100%", + overflow: "hidden", + minWidth: 0, }, title: { padding: theme.spacing(1), @@ -75,6 +74,53 @@ export interface Column { disableSort?: boolean; } +function ResizableHeaderTitle(props: { + column: Column; + index: number; + columnsLength: number; + resizeable: boolean; + onColumnClick: (column: Column) => void; + showOrderIcon: (column: Column) => React.ReactNode; +}) { + const { column, index, columnsLength, resizeable, onColumnClick, showOrderIcon } = props; + const titleRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useEffect(() => { + const el = titleRef.current; + if (!el) return; + const checkOverflow = () => setIsOverflowing(el.scrollWidth > el.clientWidth); + checkOverflow(); + const ro = new ResizeObserver(checkOverflow); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + return ( + !column.disableSort && onColumnClick(column)} + sx={{ + cursor: column.disableSort ? "default" : "pointer", + minWidth: 0, + overflow: "hidden", + ...(isOverflowing && resizeable && index < columnsLength - 1 + ? { + maskImage: "linear-gradient(to right, black 75%, transparent 100%)", + WebkitMaskImage: "linear-gradient(to right, black 75%, transparent 100%)", + } + : {}), + }} + > + + {column.title} {showOrderIcon(column)} + + + ); +} + interface Props { rows: T[], columns?: Column[] | (() => Column[]), @@ -483,11 +529,15 @@ export default function ResponsiveTable(props: Props) { if (!dragStart) return let newWidths: number[] = [...rowWidths] - newWidths[index] = newWidths[index] - (dragStart - e.clientX) + const delta = dragStart - e.clientX + const newWidthForIndex = newWidths[index] - delta + const clampedWidth = Math.max(MIN_COLUMN_WIDTH, newWidthForIndex) + const actualDelta = newWidths[index] - clampedWidth + newWidths[index] = clampedWidth newWidths.every((width, j) => { if (j <= index || width === 0) return true else if (newWidths[j] !== undefined) { - newWidths[j] = newWidths[j] + (dragStart - e.clientX) + newWidths[j] = newWidths[j] + actualDelta return false } }) @@ -495,8 +545,11 @@ export default function ResponsiveTable(props: Props) { } + const MIN_COLUMN_WIDTH = 24; + return ( + @@ -552,18 +605,23 @@ export default function ResponsiveTable(props: Props) { ) return ( - - !column.disableSort && columnClick(column)} - sx={{ cursor: column.disableSort ? "default" : "pointer" }} - > - {column.title} {showOrderIcon(column)} - + + {index < columns.length-1 && resizeable && resizeClick(event, index)} > | @@ -672,6 +730,7 @@ export default function ResponsiveTable(props: Props) { }
+
{!hidePagination && Date: Fri, 13 Mar 2026 15:23:07 -0600 Subject: [PATCH 08/11] fixed miscoloring of the data section of the table --- src/common/ResponsiveTable.tsx | 81 ++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/common/ResponsiveTable.tsx b/src/common/ResponsiveTable.tsx index b229475..c5d2c82 100644 --- a/src/common/ResponsiveTable.tsx +++ b/src/common/ResponsiveTable.tsx @@ -1,11 +1,11 @@ import { Box, Button, Card, Checkbox, CircularProgress, Collapse, darken, Divider, Grid2, IconButton, InputAdornment, ListItemButton, ListItemIcon, ListItemText, Menu, MenuItem, Paper, SxProps, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Theme, Typography } from "@mui/material"; +import { alpha } from "@mui/system/colorManipulator"; import { makeStyles } from "@mui/styles"; import { ArrowDownward, ArrowUpward, ChevronRight, Close, Search, ViewColumn } from "@mui/icons-material" import { useEffect, useRef, useState } from "react"; import React from "react"; import { useMobile } from "hooks"; import classNames from "classnames"; -import { getThemeType } from "theme"; const useStyles = makeStyles((theme: Theme) => { //const isMobile = useMobile() @@ -58,10 +58,14 @@ const useStyles = makeStyles((theme: Theme) => { }, stickyHeader: { position: "sticky", - backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(40, 40, 40)", + backgroundColor: theme.palette.background.paper, + // Match Paper's elevation overlay so header matches search bar area + backgroundImage: theme.palette.mode === "dark" + ? `linear-gradient(${alpha("#fff", 0.05)}, ${alpha("#fff", 0.05)})` + : undefined, top: 0, zIndex: 300 //giving a really high z-index to make sure nothing is in front of it - } + }, })}, ); @@ -549,44 +553,45 @@ export default function ResponsiveTable(props: Props) { return ( + {(title || setSearchText) && ( + + + + + {renderTitle()} + {renderSubtitle()} + + + + + + {actions && actions} + + + {endTitleElement} + + + {setSearchText && ( + + {searchBar()} + + )} + + + { columns && !hideKeys && + + + + } + + + + + + )} - - - - - - {renderTitle()} - {renderSubtitle()} - - - - - - {actions && actions} - - - {endTitleElement} - - - {setSearchText && - {searchBar()} - } - - - { columns && !hideKeys && - - - - } - - - - - - - {customElement && From 06d7ff4d4a3c80ebfb014bf639c646e039a460d5 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 15:34:43 -0600 Subject: [PATCH 09/11] fixed column resizer being broken --- src/common/ResponsiveTable.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/common/ResponsiveTable.tsx b/src/common/ResponsiveTable.tsx index c5d2c82..4772aff 100644 --- a/src/common/ResponsiveTable.tsx +++ b/src/common/ResponsiveTable.tsx @@ -500,14 +500,12 @@ export default function ResponsiveTable(props: Props) { } const resizeClick = (event: React.MouseEvent, index: number) => { - - let newWidths: number[] = [] - event.currentTarget.parentElement?.parentElement?.parentElement?.childNodes.forEach(node => { - if (node instanceof HTMLElement) { - newWidths.push(node.clientWidth) - } - }) - + const row = event.currentTarget.closest("tr") + if (!row) return + const cells = Array.from(row.querySelectorAll("th, td")) + const offset = (rowSelect ? 1 : 0) + (renderGutter ? 1 : 0) + const newWidths = cells.slice(offset).map((cell) => cell.clientWidth) + rowWidthsRef.current = newWidths dragIndexRef.current = index; dragStartRef.current = event.clientX; From 50103d5a0c4f16d54894e6a2958a3de15fc5640f Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 15:37:36 -0600 Subject: [PATCH 10/11] rendering scroll bar below the table instead of on top of the last row --- src/common/ResponsiveTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/ResponsiveTable.tsx b/src/common/ResponsiveTable.tsx index 4772aff..2a4cf1a 100644 --- a/src/common/ResponsiveTable.tsx +++ b/src/common/ResponsiveTable.tsx @@ -587,7 +587,7 @@ export default function ResponsiveTable(props: Props) { )} - +
{customElement && From a2ac900f3ec5c04d697482b4649f5884466ca421 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 15:47:22 -0600 Subject: [PATCH 11/11] fixed race condition on web socket status updates --- src/pages/Devices.tsx | 48 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index 08fb013..0a6b9af 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -32,6 +32,28 @@ import { cloneDeep } from "lodash"; import LoadingScreen from "app/LoadingScreen"; import TeamDialog from "teams/TeamDialog"; +type TimestampedReading = { + timestamp?: string; +}; + +const getTimestampMillis = (timestamp?: string): number | undefined => { + if (!timestamp) return undefined; + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +const pickNewestReading = ( + current: T | null | undefined, + incoming: T | null | undefined +): T | null | undefined => { + const currentTs = getTimestampMillis(current?.timestamp); + const incomingTs = getTimestampMillis(incoming?.timestamp); + + if (incomingTs === undefined) return current ?? incoming; + if (currentTs === undefined) return incoming ?? current; + return incomingTs >= currentTs ? incoming : current; +}; + const useStyles = makeStyles((theme: Theme) => { return ({ buttonBox: { @@ -212,7 +234,31 @@ export default function Devices() { prev.map((d) => { if (d.id().toString() !== deviceId) return d const updated = Device.clone(d) - updated.status = status + const incomingStatus = pond.DeviceStatus.fromObject(status) + const currentStatus = d.status ?? pond.DeviceStatus.create() + + // Guard against out-of-order websocket events by keeping whichever + // reading has the newest timestamp for each independently-updated sensor. + incomingStatus.plenum = pickNewestReading(currentStatus.plenum, incomingStatus.plenum) + incomingStatus.sen5x = pickNewestReading(currentStatus.sen5x, incomingStatus.sen5x) + incomingStatus.co = pickNewestReading(currentStatus.co, incomingStatus.co) + incomingStatus.co2 = pickNewestReading(currentStatus.co2, incomingStatus.co2) + incomingStatus.no2 = pickNewestReading(currentStatus.no2, incomingStatus.no2) + incomingStatus.o2 = pickNewestReading(currentStatus.o2, incomingStatus.o2) + incomingStatus.lel = pickNewestReading(currentStatus.lel, incomingStatus.lel) + incomingStatus.h2s = pickNewestReading(currentStatus.h2s, incomingStatus.h2s) + + const currentLastActive = getTimestampMillis(currentStatus.lastActive) + const incomingLastActive = getTimestampMillis(incomingStatus.lastActive) + if ( + currentLastActive !== undefined && + incomingLastActive !== undefined && + incomingLastActive < currentLastActive + ) { + incomingStatus.lastActive = currentStatus.lastActive + } + + updated.status = incomingStatus return updated }) )