added optional timestamp
This commit is contained in:
parent
b96787698b
commit
1e2003a541
7 changed files with 240 additions and 17 deletions
38
src/common/RelativeTimestamp.tsx
Normal file
38
src/common/RelativeTimestamp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
138
src/hooks/useDeviceStatusStreams.ts
Normal file
138
src/hooks/useDeviceStatusStreams.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue