diff --git a/src/common/ResponsiveTable.tsx b/src/common/ResponsiveTable.tsx index 3f637d3..ec1c317 100644 --- a/src/common/ResponsiveTable.tsx +++ b/src/common/ResponsiveTable.tsx @@ -98,6 +98,7 @@ interface Props { setOrderBy?: React.Dispatch>; order?: string; setOrder?: React.Dispatch>; + endTitleElement?: string | JSX.Element | (() => string | JSX.Element); } export default function ResponsiveTable(props: Props) { @@ -137,6 +138,7 @@ export default function ResponsiveTable(props: Props) { const subtitle = typeof(props.subtitle) === "function" ? props.subtitle() : props.subtitle const title = typeof(props.title) === "function" ? props.title() : props.title // const order = props.order ? props.order : "asc" + const endTitleElement = typeof(props.endTitleElement) === "function" ? props.endTitleElement() : props.endTitleElement const isMobile = useMobile() @@ -318,11 +320,13 @@ export default function ResponsiveTable(props: Props) { if (isMobile || mobileView) return ( + {renderTitle()} {renderSubtitle()} {setSearchText && searchBar()} + {rows.map((row, index) => { return ( onRowClick&&onRowClick(row)}> @@ -422,6 +426,9 @@ export default function ResponsiveTable(props: Props) { {actions && actions} + + {endTitleElement} + {setSearchText && {searchBar()} diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx new file mode 100644 index 0000000..2333318 --- /dev/null +++ b/src/common/StatusDust.tsx @@ -0,0 +1,149 @@ +import { Box, Grid2, Theme, Tooltip, Typography } from "@mui/material"; +import { green } from "@mui/material/colors"; +import { makeStyles } from "@mui/styles"; +import { Device } from "models"; +import PulseBox from "./PulseBox"; +import { or } from "utils"; +import { useEffect, useState } from "react"; +interface Props { + device: Device +} + +const useStyles = makeStyles((theme: Theme) => { + const lightMode = theme.palette?.mode === "light"; + return ({ + box: { + display: "inline-block", + borderRadius: "6px", + backgroundColor: lightMode ? "rgb(210, 210, 210)" : "rgb(50, 50, 50)", + padding: theme.spacing(0.75), + marginRight: "auto", + margin: theme.spacing(1), + width: theme.spacing(16), + height: theme.spacing(11), + }, + carouselContainer: { + 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%', + overflow: "hidden", + // height: '100%', + }, + active: { + opacity: 1, + transform: 'translateX(0)', + }, + inactive: { + transform: 'translateX(-100%)', + }, + }) +}) + +export default function StatusDust(props: Props) { + const { device } = props; + 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 [color, setColor] = useState(colors.length > 0 ? colors[0] : ""); + const [, setColorIndex] = useState(0) + + useEffect(() => { + const interval = setInterval(() => { + setColorIndex(prevIndex => { + const newIndex = prevIndex >= colors.length - 1 ? 0 : prevIndex + 1; + setColor(colors[newIndex]); // Use the new index here + return newIndex; + }); + }, 7500); + + return () => clearInterval(interval); + }, []); + + // 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 tooltip = () => { + if (messages.length === 0) return null + if (messages.length < 2) return ( + messages[0] + ) + return ( + + + + Overlay Messages + + + {messages.map((message, index) => { + return ( + + + + {message} + + + ) + })} + + ) + } + + return ( + + + + + + + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 + + + + + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 + + + + + {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + + + + + {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + + + + + + + ) +} \ No newline at end of file diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx new file mode 100644 index 0000000..dd9737f --- /dev/null +++ b/src/common/StatusGas.tsx @@ -0,0 +1,199 @@ +import { Box, Grid2, Theme, Tooltip, Typography } from "@mui/material"; +import { blue, deepOrange, green, grey, orange, teal } from "@mui/material/colors"; +import { makeStyles } from "@mui/styles"; +import { Device } from "models"; +import PulseBox from "./PulseBox"; +import { or } from "utils"; +import { useEffect, useState } from "react"; +import { pond } from "protobuf-ts/pond"; + +interface Props { + device: Device + // noDust?: boolean; + gasType: "co2" | "co" | "no2" | "o2" | "all" +} + +const useStyles = makeStyles((theme: Theme) => { + const lightMode = theme.palette?.mode === "light"; + return ({ + box: { + display: "inline-block", + borderRadius: "6px", + backgroundColor: lightMode ? "rgb(210, 210, 210)" : "rgb(50, 50, 50)", + padding: theme.spacing(0.75), + marginRight: "auto", + margin: theme.spacing(1), + width: theme.spacing(14), + height: theme.spacing(6), + }, + boxTall: { + display: "inline-block", + borderRadius: "6px", + backgroundColor: lightMode ? "rgb(210, 210, 210)" : "rgb(50, 50, 50)", + padding: theme.spacing(0.75), + marginRight: "auto", + margin: theme.spacing(1), + width: theme.spacing(14), + height: theme.spacing(8), + }, + carouselContainer: { + 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%', + overflow: "hidden", + // height: '100%', + }, + active: { + opacity: 1, + transform: 'translateX(0)', + }, + inactive: { + transform: 'translateX(-100%)', + }, + }) +}) + +export default function StatusGas(props: Props) { + const { device, gasType } = props; + const classes = useStyles() + // console.log(noDust) + 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), [])); + } else { + 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"] + + useEffect(() => { + const interval = setInterval(() => { + setColorIndex(prevIndex => { + const newIndex = prevIndex >= colors.length - 1 ? 0 : prevIndex + 1; + setColor(colors[newIndex]); // Use the new index here + return newIndex; + }); + }, 7500); + + return () => clearInterval(interval); + }, []); + + const [currentIndex, setCurrentIndex] = useState(0); + + // Auto-cycle through measurements every 5 seconds + useEffect(() => { + if (gasType !== "all") { + setCurrentIndex(0) + return + } + const interval = setInterval(() => { + if (gasType === "all") setCurrentIndex((prevIndex) => (prevIndex + 1) % 4); + else setCurrentIndex((prevIndex) => (prevIndex + 1) % 4); + }, 6000); + + return () => clearInterval(interval); // Cleanup on unmount + }, [gasType]); + + const tooltip = () => { + if (messages.length === 0) return null + if (messages.length < 2) return ( + messages[0] + ) + return ( + + + + Overlay Messages + + + {messages.map((message, index) => { + return ( + + + + + + + {message} + + + + ) + })} + + ) + } + + const gasDisplay = () => { + const gas = gasType === "all" ? gasTypes[currentIndex] : gasType + // if (gasType !== "all") { + return ( + + + + { gasType === "all" && + + {gas.toUpperCase()} + + } + + + {gas !== "o2" ? + + Gas: {device.status[gas]?.ppm.toFixed(1)}ppm + + : + + Gas: {(device.status[gas]?.ppm!/10000).toFixed(1)}% + + } + + + + Volt: {device.status[gas]?.millivolts.toFixed(1)}mV + + + + + + + ) + + // } + } + + return ( + <> + {gasDisplay()} + + ) +} \ No newline at end of file diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx index 912f128..288eb10 100644 --- a/src/common/StatusSen5x.tsx +++ b/src/common/StatusSen5x.tsx @@ -5,8 +5,10 @@ import { Device } from "models"; import PulseBox from "./PulseBox"; import { or } from "utils"; import { useEffect, useState } from "react"; + interface Props { device: Device + noDust?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -19,8 +21,8 @@ const useStyles = makeStyles((theme: Theme) => { padding: theme.spacing(0.75), marginRight: "auto", margin: theme.spacing(1), - width: "72px", - height: "50px", + width: theme.spacing(16), + height: theme.spacing(11), }, carouselContainer: { position: 'relative', @@ -47,10 +49,10 @@ const useStyles = makeStyles((theme: Theme) => { }) }) -export default function StatusPlenum(props: Props) { - const { device } = props; +export default function StatusSen5x(props: Props) { + const { device, noDust } = 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), []); @@ -71,14 +73,18 @@ export default function StatusPlenum(props: Props) { const [currentIndex, setCurrentIndex] = useState(0); - // Auto-cycle through measurements every 3 seconds + // Auto-cycle through measurements every 5 seconds useEffect(() => { + if (noDust) { + setCurrentIndex(0) + return + } const interval = setInterval(() => { - setCurrentIndex((prevIndex) => (prevIndex + 1) % 4); - }, 3000); // Adjust timing as needed (3000ms = 3s) + if (!noDust) setCurrentIndex((prevIndex) => (prevIndex + 1) % 2); + }, 5000); return () => clearInterval(interval); // Cleanup on unmount - }, []); + }, [noDust]); const tooltip = () => { if (messages.length === 0) return null @@ -125,66 +131,52 @@ export default function StatusPlenum(props: Props) { > - {device.status.sen5x?.temperature.toFixed(1)}°C + Temp: {device.status.sen5x?.temperature.toFixed(1)}°C - {device.status.sen5x?.humidity.toFixed(1)}% + Humidity: {device.status.sen5x?.humidity.toFixed(1)}% + + + + + Voc: {device.status.sen5x?.voc.toFixed(1)}% + + + + + Nox: {device.status.sen5x?.nox.toFixed(1)}% - - {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 + {"<1um:"} {device.status.sen5x?.dust1ug.toFixed(1)}ug/m3 - {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 + {"<2.5um:"} {device.status.sen5x?.dust2ug.toFixed(1)}ug/m3 - - - - {device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 + {"<4um: "}{device.status.sen5x?.dust4ug.toFixed(1)}ug/m3 - {device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 + {"<10um: "}{device.status.sen5x?.dust10ug.toFixed(1)}ug/m3 - - - - - - V: {device.status.sen5x?.voc.toFixed(1)}% - - - - - N: {device.status.sen5x?.nox.toFixed(1)}% - - - + } diff --git a/src/component/ComponentActions.tsx b/src/component/ComponentActions.tsx index 58c131d..cd1f2c6 100644 --- a/src/component/ComponentActions.tsx +++ b/src/component/ComponentActions.tsx @@ -17,7 +17,7 @@ import { isController, isSource } from "pbHelpers/ComponentType"; import { DeviceAvailabilityMap, OffsetAvailabilityMap } from "pbHelpers/DeviceAvailability"; import { pond } from "protobuf-ts/pond"; import { useGlobalState } from "providers"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { or } from "utils/types"; import ComponentSettings from "./ComponentSettings"; import { green, teal } from "@mui/material/colors"; @@ -94,6 +94,7 @@ export default function ComponentActions(props: Props) { }; const openComponentSettingsDialog = (mode: string) => { + console.log("delete") setIsComponentSettingsOpen(true); setComponentSettingsMode(or(mode, "")); }; diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 911e9f0..eabaf80 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -14,7 +14,6 @@ import { pond, quack } from "protobuf-ts/pond"; import { cloneDeep } from "lodash"; import DeviceOverview from "device/DeviceOverview"; import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap } from "pbHelpers/DeviceAvailability"; -import AddComponentManualDialog from "component/AddComponentManualDialog"; import ComponentCard from "component/ComponentCard"; import { sameComponentID, sortComponents } from "pbHelpers/Component"; import { isController } from "pbHelpers/ComponentType"; diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index 09c9ae6..b93f3a6 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -1,5 +1,5 @@ -import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon } from "@mui/icons-material"; -import { Box, Chip, Grid2, IconButton, Skeleton, Tab, Tabs, Theme, Tooltip, Typography, useTheme } from "@mui/material"; +import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon, Tune } from "@mui/icons-material"; +import { Box, Checkbox, Chip, Grid2, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Skeleton, Tab, Tabs, Theme, Tooltip, Typography, useTheme } from "@mui/material"; import { blue, green, orange } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import ResponsiveTable, { Column } from "common/ResponsiveTable"; @@ -24,6 +24,8 @@ import { Tag as TagUI } from "common/Tag"; import moment from "moment"; import StatusPlenum from "common/StatusPlenum"; import StatusSen5x from "common/StatusSen5x"; +import StatusDust from "common/StatusDust"; +import StatusGas from "common/StatusGas"; const useStyles = makeStyles((theme: Theme) => { return ({ @@ -100,6 +102,10 @@ export default function Devices() { const [isProvisionDialogOpen, setIsProvisionDialogOpen] = useState(false); const [hasPlenums, setHasPlenums] = useState(false) const [hasSen5x, setHasSen5x] = useState(false) + const [hasCo, setHasCo] = useState(false) + const [hasCo2, setHasCo2] = useState(false) + const [hasNo2, setHasNo2] = useState(false) + const [hasO2, setHasO2] = useState(false) const [groupsLoading, setGroupsLoading] = useState(false) const [groups, setGroups] = useState([]); @@ -122,6 +128,26 @@ export default function Devices() { const [groupSettingsIsOpen, setGroupSettingsIsOpen] = useState(false); const [fieldContains, setFieldContains] = useState>(new Map()) + + const [separateDust, setSeparateDust] = useState(() => { + const stored = localStorage.getItem('separateDust'); + return stored !== null ? stored === 'true' : false; + }); + + useEffect(() => { + localStorage.setItem('separateDust', separateDust.toString()); + }, [separateDust]); + + const [groupGas, setGroupGas] = useState(() => { + const stored = localStorage.getItem('groupGas'); + return stored !== null ? stored === 'true' : false; + }); + + useEffect(() => { + localStorage.setItem('groupGas', groupGas.toString()); + }, [groupGas]); + + const [preferencesAnchor, setPreferencesAnchor] = useState(null) const [{ as }] = useGlobalState() @@ -250,12 +276,12 @@ export default function Devices() { ).then(resp => { let newDevices: Device[] = [] resp.data.devices.forEach(device => { - if (device.status?.plenum?.temperature) { - setHasPlenums(true) - } - if (device.status?.sen5x?.temperature) { - setHasSen5x(true) - } + if (device.status?.plenum?.temperature) setHasPlenums(true) + if (device.status?.sen5x?.temperature) setHasSen5x(true) + if (device.status?.o2) setHasO2(true) + if (device.status?.co) setHasCo(true) + if (device.status?.co2) setHasCo2(true) + if (device.status?.no2) setHasNo2(true) newDevices.push(Device.create(device)) }) setDevices(newDevices) @@ -277,15 +303,40 @@ export default function Devices() { setLimit(event.target.value); }; - const prependToUrl = (prependage: number | string) => { - const basePath = location.pathname.replace(/\/$/, ""); - return(`/${prependage}/${basePath}`.replace(/\/{2,}/g, "/").replace(/(\/[^/]+)\/\1+/g, "$1")); - }; + // const prependToUrl = (prependage: number | string) => { + // const basePath = location.pathname.replace(/\/$/, ""); + // return(`/${prependage}/${basePath}`.replace(/\/{2,}/g, "/").replace(/(\/[^/]+)\/\1+/g, "$1")); + // }; + + function insertGroupContext(groupID: string, deviceID: string): string { + const path = location.pathname + const segments = path.split('/').filter(Boolean); + const deviceIndex = segments.findIndex(segment => segment === 'devices'); + + if (deviceIndex === -1) { + throw new Error('Path does not contain "devices"'); + } + + // Build the new segments + const newSegments = [ + ...segments.slice(0, deviceIndex), + 'groups', + groupID, + ...segments.slice(deviceIndex), + deviceID + ]; + + return '/' + newSegments.join('/'); + } + + // useEffect(() => { + // console.log(devices) + // }, [devices]) const toDevice = (device: Device) => { - let url = prependToUrl(getGroup() ? "groups/" + getGroup().id() : "") - url = url + "/" + device.id() - navigate(url, { replace: true, state: {device: device} }) + let url = getGroup() ? insertGroupContext(getGroup().id().toString(), device.id().toString()) : "" + if (url.length < 1) url = device.id().toString() + navigate(url, { state: {device: device} }) }; const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => { @@ -396,7 +447,95 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + + + ) + } else { + return ( + <> + ) + } + } + }) + if (separateDust) columns.push({ + title: "Dust", + render: (device: Device) => { + if (device.status.sen5x?.temperature) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } + if (hasCo) { + columns.push({ + title: groupGas ? "Gas" : "CO", + render: (device: Device) => { + if (device.status.co) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } + if (hasCo2 && !groupGas) { + columns.push({ + title: "CO2", + render: (device: Device) => { + if (device.status.co2) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } + if (hasNo2 && !groupGas) { + columns.push({ + title: "NO2", + render: (device: Device) => { + if (device.status.no2) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } + if (hasO2 && !groupGas) { + columns.push({ + title: "O2", + render: (device: Device) => { + if (device.status.o2) { + return ( + + ) } else { @@ -499,6 +638,51 @@ export default function Devices() { ) } + const preferencesButton = () => { + return ( + setPreferencesAnchor(event.currentTarget)}> + + + ) + } + + const preferencesMenu = () => { + return ( + setPreferencesAnchor(null)} + disableAutoFocusItem> + setSeparateDust(!separateDust)} + sx={{ marginLeft: -0.75 }} + dense + > + + + + + + setGroupGas(!groupGas)} + sx={{ marginLeft: -0.75 }} + dense + > + + + + + + + ) + } + return( + {preferencesMenu()} ) } \ No newline at end of file diff --git a/src/providers/http.tsx b/src/providers/http.tsx index 6811355..c4d9f64 100644 --- a/src/providers/http.tsx +++ b/src/providers/http.tsx @@ -1,14 +1,9 @@ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; -// import { useAuth } from "hooks"; import moment from "moment"; import { createContext, PropsWithChildren, useContext } from "react"; -// import BillingProvider from "./billing"; -// import GitlabProvider from "./gitlab"; import PondProvider from "./pond/pond"; import { useAuth0 } from "@auth0/auth0-react"; import SnackbarProvider from "./Snackbar"; -// import SecurityProvider from "./security"; -// import { useAuth0 } from "@auth0/auth0-react"; interface IHTTPContext { get: (url: string, spreadOptions?: AxiosRequestConfig) => Promise>; @@ -78,9 +73,7 @@ export default function HTTPProvider(props: Props) { } function get(url: string, spreadOptions?: AxiosRequestConfig): Promise> { - if (isTokenExpired(token)) { - loginWithRedirect() - } + if (isTokenExpired(token)) loginWithRedirect() return axios.get(url, {...defaultOptions(), ...spreadOptions}); }