merge web socket changes
This commit is contained in:
parent
8ecb9e0ca0
commit
dc8b2617e6
8 changed files with 262 additions and 200 deletions
3
.env
3
.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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Grid2 container direction={"column"}>
|
||||
<Grid2 container direction="row">
|
||||
|
|
@ -85,10 +75,10 @@ export default function StatusDust(props: Props) {
|
|||
<Grid2 key={"tooltip-overly-message-" + message} container direction="row" alignItems={"center"}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10, // Size of the square
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: colors[index], // Passed color prop
|
||||
borderRadius: 1, // Optional: slight rounding, remove for sharp square
|
||||
backgroundColor: colors[index],
|
||||
borderRadius: 1,
|
||||
border: "1px solid black",
|
||||
}}
|
||||
/>
|
||||
|
|
@ -96,22 +86,20 @@ export default function StatusDust(props: Props) {
|
|||
{message}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Grid2>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Tooltip title={tooltip()}>
|
||||
<PulseBox color={colors[0] ?? ""} colors={colors} className={classes.box} >
|
||||
<Grid2 container direction="column" >
|
||||
<Grid2 container direction="column"
|
||||
className={`${classes.carouselItem} ${classes.active }`}
|
||||
>
|
||||
<PulseBox color={colors[0] ?? ""} colors={colors} className={classes.box}>
|
||||
<Grid2 container direction="column" spacing={0.25}>
|
||||
<Grid2 className={classes.readingsWrapper}>
|
||||
<Grid2 container direction="column" className={`${classes.carouselItem} ${classes.active}`}>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[300]}}>
|
||||
<Typography variant="body2" style={{ color: green[300] }}>
|
||||
{"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[400]}}>
|
||||
<Typography variant="body2" style={{ color: green[400] }}>
|
||||
{"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[500]}}>
|
||||
{"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3
|
||||
<Typography variant="body2" style={{ color: green[500] }}>
|
||||
{"<4um: "}
|
||||
{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[600]}}>
|
||||
{"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3
|
||||
<Typography variant="body2" style={{ color: green[600] }}>
|
||||
{"<10um: "}
|
||||
{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
{showTimestamp && device.status.sen5x?.timestamp && (
|
||||
<Grid2>
|
||||
<Grid2 sx={{ pt: 0.25 }}>
|
||||
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
</PulseBox>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
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 (
|
||||
<Grid2 container direction={"column"}>
|
||||
<Grid2 container direction="row">
|
||||
|
|
@ -90,10 +91,10 @@ export default function StatusSen5x(props: Props) {
|
|||
<Grid2 key={"tooltip-overly-message-" + message} container direction="row" alignItems={"center"}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10, // Size of the square
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: colors[index], // Passed color prop
|
||||
borderRadius: 1, // Optional: slight rounding, remove for sharp square
|
||||
backgroundColor: colors[index],
|
||||
borderRadius: 1,
|
||||
border: "1px solid black",
|
||||
}}
|
||||
/>
|
||||
|
|
@ -101,22 +102,20 @@ export default function StatusSen5x(props: Props) {
|
|||
{message}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Grid2>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Tooltip title={tooltip()}>
|
||||
<PulseBox color={colors[0] ?? ""} colors={colors} className={classes.box} >
|
||||
<Grid2 container direction="column" >
|
||||
<Grid2 container direction="column"
|
||||
className={`${classes.carouselItem} ${
|
||||
0 === currentIndex ? classes.active : classes.inactive
|
||||
}`}
|
||||
<PulseBox color={colors[0] ?? ""} colors={colors} className={classes.box}>
|
||||
<Grid2 container direction="column" spacing={0.25}>
|
||||
<Grid2 className={classes.readingsWrapper}>
|
||||
<Grid2
|
||||
container
|
||||
direction="column"
|
||||
className={`${classes.carouselItem} ${0 === currentIndex ? classes.active : classes.inactive}`}
|
||||
>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: orange[700]}}>
|
||||
<Typography variant="body2" style={{ color: orange[700] }}>
|
||||
Temp: {device.status.sen5x?.temperature.toFixed(1)}°C
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: blue[700]}}>
|
||||
<Typography variant="body2" style={{ color: blue[700] }}>
|
||||
Humidity: {device.status.sen5x?.humidity.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: blue[300]}}>
|
||||
<Typography variant="body2" style={{ color: blue[300] }}>
|
||||
Voc: {device.status.sen5x?.voc.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: blue[400]}}>
|
||||
<Typography variant="body2" style={{ color: blue[400] }}>
|
||||
Nox: {device.status.sen5x?.nox.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
{!noDust && <Grid2 container direction="column"
|
||||
className={`${classes.carouselItem} ${
|
||||
1 === currentIndex ? classes.active : classes.inactive
|
||||
}`}
|
||||
{!noDust && (
|
||||
<Grid2
|
||||
container
|
||||
direction="column"
|
||||
className={`${classes.carouselItem} ${1 === currentIndex ? classes.active : classes.inactive}`}
|
||||
>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[300]}}>
|
||||
<Typography variant="body2" style={{ color: green[300] }}>
|
||||
{"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[400]}}>
|
||||
<Typography variant="body2" style={{ color: green[400] }}>
|
||||
{"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[500]}}>
|
||||
{"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3
|
||||
<Typography variant="body2" style={{ color: green[500] }}>
|
||||
{"<4um: "}
|
||||
{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
<Typography variant="body2" style={{ color: green[600]}}>
|
||||
{"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3
|
||||
<Typography variant="body2" style={{ color: green[600] }}>
|
||||
{"<10um: "}
|
||||
{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3
|
||||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>}
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
{showTimestamp && device.status.sen5x?.timestamp && (
|
||||
<Grid2>
|
||||
<Grid2 sx={{ pt: 0.25 }}>
|
||||
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
</PulseBox>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
/** 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<T> {
|
|||
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<T> {
|
|||
* });
|
||||
*/
|
||||
export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
|
||||
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<T>(options: UseWebSocketOptions<T>) {
|
|||
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<T>(options: UseWebSocketOptions<T>) {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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<Device>(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<Device | null>({
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
16
src/utils/getWsBaseUrl.ts
Normal file
16
src/utils/getWsBaseUrl.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue