added optional timestamp

This commit is contained in:
Carter 2026-03-13 13:20:51 -06:00
parent b96787698b
commit 1e2003a541
7 changed files with 240 additions and 17 deletions

View file

@ -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 (
<Typography
variant="caption"
sx={{
opacity: 0.7,
fontSize: "0.7rem",
fontStyle: "italic",
}}
>
{date.fromNow()}
</Typography>
);
}

View file

@ -3,10 +3,13 @@ import { green } from "@mui/material/colors";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { Device } from "models"; import { Device } from "models";
import PulseBox from "./PulseBox"; import PulseBox from "./PulseBox";
import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils"; import { or } from "utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface Props { interface Props {
device: Device device: Device;
showTimestamp?: boolean;
} }
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
@ -48,7 +51,7 @@ const useStyles = makeStyles((theme: Theme) => {
}) })
export default function StatusDust(props: Props) { export default function StatusDust(props: Props) {
const { device } = props; const { device, showTimestamp } = props;
const classes = useStyles() const classes = useStyles()
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
@ -165,6 +168,11 @@ export default function StatusDust(props: Props) {
</Typography> </Typography>
</Grid2> </Grid2>
</Grid2> </Grid2>
{showTimestamp && device.status.sen5x?.timestamp && (
<Grid2>
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
</Grid2>
)}
</Grid2> </Grid2>
</PulseBox> </PulseBox>
</Tooltip> </Tooltip>

View file

@ -3,13 +3,14 @@ import { blue, deepOrange, teal } from "@mui/material/colors";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { Device } from "models"; import { Device } from "models";
import PulseBox from "./PulseBox"; import PulseBox from "./PulseBox";
import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils"; import { or } from "utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface Props { interface Props {
device: Device device: Device;
// noDust?: boolean; gasType: "co2" | "co" | "no2" | "o2" | "all";
gasType: "co2" | "co" | "no2" | "o2" | "all" showTimestamp?: boolean;
} }
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
@ -61,7 +62,7 @@ const useStyles = makeStyles((theme: Theme) => {
}) })
export default function StatusGas(props: Props) { export default function StatusGas(props: Props) {
const { device, gasType } = props; const { device, gasType, showTimestamp } = props;
const classes = useStyles() const classes = useStyles()
// console.log(noDust) // console.log(noDust)
let colors: string[] = [] let colors: string[] = []
@ -203,6 +204,11 @@ export default function StatusGas(props: Props) {
Volt: {device.status[gas]?.millivolts.toFixed(1)}mV Volt: {device.status[gas]?.millivolts.toFixed(1)}mV
</Typography> </Typography>
</Grid2> </Grid2>
{showTimestamp && device.status[gas]?.timestamp && (
<Grid2>
<RelativeTimestamp timestamp={device.status[gas].timestamp} />
</Grid2>
)}
</Grid2> </Grid2>
</Grid2> </Grid2>
</PulseBox> </PulseBox>

View file

@ -3,11 +3,13 @@ import { blue, orange } from "@mui/material/colors";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { Device } from "models"; import { Device } from "models";
import PulseBox from "./PulseBox"; import PulseBox from "./PulseBox";
import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils"; import { or } from "utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface Props { interface Props {
device: Device device: Device;
showTimestamp?: boolean;
} }
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
@ -25,7 +27,7 @@ const useStyles = makeStyles((theme: Theme) => {
}) })
export default function StatusPlenum(props: Props) { export default function StatusPlenum(props: Props) {
const { device } = props; const { device, showTimestamp } = props;
const classes = useStyles() const classes = useStyles()
const colors = or(device.status.plenum?.overlays.map(overlay => overlay.color), []); 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)}% {device.status.plenum?.humidity.toFixed(1)}%
</Typography> </Typography>
</Grid2> </Grid2>
{showTimestamp && device.status.plenum?.timestamp && (
<Grid2>
<RelativeTimestamp timestamp={device.status.plenum.timestamp} />
</Grid2>
)}
</Grid2> </Grid2>
</PulseBox> </PulseBox>
</Tooltip> </Tooltip>

View file

@ -3,12 +3,14 @@ import { blue, green, orange } from "@mui/material/colors";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { Device } from "models"; import { Device } from "models";
import PulseBox from "./PulseBox"; import PulseBox from "./PulseBox";
import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils"; import { or } from "utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface Props { interface Props {
device: Device device: Device;
noDust?: boolean; noDust?: boolean;
showTimestamp?: boolean;
} }
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
@ -50,7 +52,7 @@ const useStyles = makeStyles((theme: Theme) => {
}) })
export default function StatusSen5x(props: Props) { export default function StatusSen5x(props: Props) {
const { device, noDust } = props; const { device, noDust, showTimestamp } = props;
const classes = useStyles() const classes = useStyles()
// console.log(noDust) // console.log(noDust)
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
@ -200,6 +202,11 @@ export default function StatusSen5x(props: Props) {
</Typography> </Typography>
</Grid2> </Grid2>
</Grid2>} </Grid2>}
{showTimestamp && device.status.sen5x?.timestamp && (
<Grid2>
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
</Grid2>
)}
</Grid2> </Grid2>
</PulseBox> </PulseBox>
</Tooltip> </Tooltip>

View file

@ -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<Map<string, WebSocket>>(new Map());
const retriesRef = useRef<Map<string, number>>(new Map());
const reconnectTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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]);
}

View file

@ -190,6 +190,15 @@ export default function Devices() {
localStorage.setItem('groupGas', groupGas.toString()); localStorage.setItem('groupGas', groupGas.toString());
}, [groupGas]); }, [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<any>(null) const [preferencesAnchor, setPreferencesAnchor] = useState<any>(null)
const [{ as, team }] = useGlobalState() const [{ as, team }] = useGlobalState()
@ -646,7 +655,7 @@ export default function Devices() {
if (device.status.plenum?.temperature) { if (device.status.plenum?.temperature) {
return ( return (
<Box sx={{ marginLeft: 1 }}> <Box sx={{ marginLeft: 1 }}>
<StatusPlenum device={device} /> <StatusPlenum device={device} showTimestamp={showTimestamp} />
</Box> </Box>
) )
} else { } else {
@ -666,7 +675,7 @@ export default function Devices() {
if (device.status.sen5x?.temperature) { if (device.status.sen5x?.temperature) {
return ( return (
<Box sx={{ marginLeft: 1 }}> <Box sx={{ marginLeft: 1 }}>
<StatusSen5x device={device} noDust={separateDust} /> <StatusSen5x device={device} noDust={separateDust} showTimestamp={showTimestamp} />
</Box> </Box>
) )
} else { } else {
@ -682,7 +691,7 @@ export default function Devices() {
if (device.status.sen5x?.temperature) { if (device.status.sen5x?.temperature) {
return ( return (
<Box sx={{ marginLeft: 1 }}> <Box sx={{ marginLeft: 1 }}>
<StatusDust device={device} /> <StatusDust device={device} showTimestamp={showTimestamp} />
</Box> </Box>
) )
} else { } else {
@ -700,7 +709,7 @@ export default function Devices() {
if (device.status.co) { if (device.status.co) {
return ( return (
<Box sx={{ marginLeft: 1 }}> <Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={groupGas ? "all" : "co"}/> <StatusGas device={device} gasType={groupGas ? "all" : "co"} showTimestamp={showTimestamp} />
</Box> </Box>
) )
} else { } else {
@ -718,7 +727,7 @@ export default function Devices() {
if (device.status.co2) { if (device.status.co2) {
return ( return (
<Box sx={{ marginLeft: 1 }}> <Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"co2"}/> <StatusGas device={device} gasType={"co2"} showTimestamp={showTimestamp} />
</Box> </Box>
) )
} else { } else {
@ -736,7 +745,7 @@ export default function Devices() {
if (device.status.no2) { if (device.status.no2) {
return ( return (
<Box sx={{ marginLeft: 1 }}> <Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"no2"}/> <StatusGas device={device} gasType={"no2"} showTimestamp={showTimestamp} />
</Box> </Box>
) )
} else { } else {
@ -754,7 +763,7 @@ export default function Devices() {
if (device.status.o2) { if (device.status.o2) {
return ( return (
<Box sx={{ marginLeft: 1 }}> <Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"o2"}/> <StatusGas device={device} gasType={"o2"} showTimestamp={showTimestamp} />
</Box> </Box>
) )
} else { } else {
@ -894,6 +903,16 @@ export default function Devices() {
</ListItemIcon> </ListItemIcon>
<ListItemText primary={"Group Gas"} /> <ListItemText primary={"Group Gas"} />
</MenuItem> </MenuItem>
<MenuItem
onClick={() => setShowTimestamp(!showTimestamp)}
sx={{ marginLeft: -0.75 }}
dense
>
<ListItemIcon>
<Checkbox checked={showTimestamp} />
</ListItemIcon>
<ListItemText primary={"Show Timestamp"} />
</MenuItem>
</Menu> </Menu>
) )
} }