frontend/src/pages/Devices.tsx
2025-06-03 12:21:02 -06:00

836 lines
No EOL
25 KiB
TypeScript

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";
import ProvisionDevice from "device/ProvisionDevice";
import { pond } from "protobuf-ts/pond";
import { useDeviceAPI, useGlobalState, useGroupAPI } from "providers";
import { useCallback, useEffect, useState } from "react";
import PageContainer from "./PageContainer";
import { useMobile } from "hooks";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { getContextKeys, getContextTypes } from "pbHelpers/Context";
import { getDeviceStateHelper } from "pbHelpers/DeviceState";
import { Device, Group, Tag } from "models";
import GroupSettings from "group/GroupSettings";
import SmartBreadcrumb from "common/SmartBreadcrumb";
import GroupActions from "group/GroupActions";
// import CircleGraphIcon from "common/CircleGraphIcon";
import DevicesSummary from "device/DevicesSummary";
import BindaptIcon from "products/Bindapt/BindaptIcon";
import { describePower } from "pbHelpers/Power";
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";
import { cloneDeep } from "lodash";
import LoadingScreen from "app/LoadingScreen";
const useStyles = makeStyles((theme: Theme) => {
return ({
buttonBox: {
position: "absolute",
zIndex: 1,
right: theme.spacing(4),
transform: "translateY(-50%)", // Move it up by half its height
transition: "opacity 0.2s ease-in-out",
opacity: 1,
// top: 0,
},
fadeBox: {
opacity: 0, // Fully transparent by default
transition: "opacity 0.2s ease-in-out", // Smooth transition
"&:hover": {
opacity: 1, // Fully opaque on hover
},
},
provisionIcon: {
color: blue["700"]
},
cellContainer: {
margin: theme.spacing(1),
marginLeft: theme.spacing(2),
display: "flex",
width: theme.spacing(10)
// justifyContent: "center",
},
descriptionCellContainer: {
margin: theme.spacing(1),
marginLeft: theme.spacing(2),
display: "flex",
// width: theme.spacing(26)
// justifyContent: "center",
},
tagsCellContainer: {
margin: theme.spacing(1),
marginLeft: theme.spacing(2),
display: "flex",
width: theme.spacing(18)
// justifyContent: "center",
},
green: {
color: green["600"]
},
tab: {
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: theme.spacing(26),
whiteSpace: "nowrap",
padding: theme.spacing(1.5)
}
});
});
export default function Devices() {
const theme = useTheme();
const isMobile = useMobile();
const classes = useStyles();
const navigate = useNavigate();
const location = useLocation();
const deviceAPI = useDeviceAPI();
const groupAPI = useGroupAPI();
const [devicesLoading, setDevicesLoading] = useState(false)
const [limit, setLimit] = useState(10);
const [page, setPage] = useState(0);
const [order, setOrder] = useState<"asc" | "desc">(() => {
// Load from sessionStorage on initial render
const savedOrder = sessionStorage.getItem('order');
return (savedOrder === "asc" || savedOrder === "desc") ? savedOrder : "asc";
});
// Save to sessionStorage whenever order changes
useEffect(() => {
sessionStorage.setItem('order', order);
}, [order]);
const [orderBy, setOrderBy] = useState(() => {
// Load from sessionStorage on initial render
const savedOrder = sessionStorage.getItem('orderBy');
return (savedOrder && savedOrder?.length > 0) ? savedOrder : "name";
});
// Save to sessionStorage whenever order changes
useEffect(() => {
sessionStorage.setItem('orderBy', orderBy);
}, [orderBy]);
// const [orderBy, setOrderBy] = useState("name");
const [search, setSearch] = useState("");
const [total, setTotal] = useState(0);
const [devices, setDevices] = useState<Device[]>([])
const [isProvisionDialogOpen, setIsProvisionDialogOpen] = useState<boolean>(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<Group[]>([]);
const [groupLimit, ] = useState(10);
const [groupPage, ] = useState(0);
const [orderGroup, ] = useState<"asc" | "desc">("asc");
const [orderGroupBy, ] = useState("name");
const [searchGroup, ] = useState("");
// const [totalGroups, setTotalGroups] = useState(0);
const [groupPermissions, setGroupPermissions] = useState<pond.Permission[]>([])
const [groupsLoaded, setGroupsLoaded] = useState(false)
const groupID = useParams<{ groupID: string }>()?.groupID ?? "all";
// const [tab, setTab] = useState(groupID==="all" ? groupID : groupID);
const [tab, setTab] = useState(() => {
console.log("groupID: "+groupID)
if (groupID!=="all") return groupID
const stored = sessionStorage.getItem(location.pathname+"-groups-tab");
console.log("stored: "+stored)
return stored !== null ? stored : "all";
});
useEffect(() => {
console.log(tab)
sessionStorage.setItem(location.pathname+"-groups-tab", tab);
}, [tab]);
const [selectedGroup, setSelectedGroup] = useState<Group | undefined>(undefined);
const [groupSettingsMode, setGroupSettingsMode] = useState<
"add" | "update" | "remove" | undefined
>(undefined);
const [groupSettingsIsOpen, setGroupSettingsIsOpen] = useState<boolean>(false);
const [fieldContains, setFieldContains] = useState<Map<string, string>>(new Map<string, string>())
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<any>(null)
const [{ as }] = useGlobalState()
const updateGroups = (newGroups: Group[]) => {
setGroups(newGroups);
};
const getGroup = () => {
return groups[parseInt(tab)]
}
useEffect(() => {
setGroupPermissions([])
if (tab === "all" || !getGroup()) return
groupAPI.getGroupPermissions(getGroup().id()).then(resp => {
setGroupPermissions(resp.data.permissions)
})
}, [tab])
const openProvisionDialog = () => {
setIsProvisionDialogOpen(true);
};
const closeProvisionDialog = () => {
setIsProvisionDialogOpen(false);
};
const removeGroupCallback = useCallback(() => {
let newGroups = [...groups];
newGroups.splice(parseInt(tab), 1);
setGroups(newGroups);
setTab("all");
setGroupSettingsIsOpen(false);
}, [groups, tab]); // Include dependencies
const addGroupCallback = useCallback((groupSettings: pond.GroupSettings) => {
let newGroups = [...groups];
let group = pond.Group.create({
settings: groupSettings
})
newGroups.push(Group.create(group))
setGroups(newGroups);
// setTab(groups.length.toString())
}, [groups, tab]); // Include dependencies
const openGroupSettings = (
group: Group | undefined,
mode: "add" | "update" | "remove" | undefined
) => {
setGroupSettingsIsOpen(true);
setGroupSettingsMode(mode);
setSelectedGroup(group);
};
const closeGroupSettings = (_event: any) => {
setGroupSettingsIsOpen(false);
setGroupSettingsMode(undefined);
setSelectedGroup(undefined);
};
const getKeys = () => {
if (tab !== "all" && getGroup()) {
let keys = getContextKeys()
//keys.splice(keys.length - 1, 0, getGroup().id().toString());
keys.push(getGroup().id().toString());
return keys
} else {
return getContextKeys()
}
}
const getTypes = () => {
if (tab !== "all") {
let types = getContextTypes()
//types.splice(types.length - 1, 0, "group");
types.push("group");
return types
} else {
return getContextTypes()
}
}
const loadGroups = () => {
if (groupsLoading) return
if (groups.length > 0) return
setGroupsLoading(true)
groupAPI.listGroups(
groupLimit,
groupPage*groupLimit,
orderGroup,
orderGroupBy,
searchGroup,
undefined,
getKeys(),
getTypes(),
).then(resp => {
let newGroups: Group[] = []
resp.data.groups?.forEach((group: pond.Group) => {
newGroups.push(Group.create(group))
})
updateGroups(newGroups)
}).finally(() => {
setGroupsLoading(false)
setGroupsLoaded(true)
})
}
const loadDevices = () => {
setDevicesLoading(true)
if (!groupsLoaded) return
deviceAPI.list(
limit,
page*limit,
order,
orderBy,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
getKeys(),
getTypes(),
fieldContains,
search,
as
).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?.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)
setTotal(resp.data.total)
}).finally(() => {
setDevicesLoading(false)
})
}
useEffect(() => {
loadDevices()
}, [limit, page, order, orderBy, fieldContains, search, tab, as, groupsLoaded])
useEffect(() => {
loadGroups()
}, [groupLimit, groupPage, orderGroup, orderGroupBy, searchGroup, as])
const handleChange = (event: any) => {
setLimit(event.target.value);
};
// 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 = 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) => {
if (newValue !== "never") setTab(newValue);
if (newValue === "never") openGroupSettings(undefined, "add");
};
const columns = (): Column<Device>[] => {
let columns: Column<Device>[] = [
{
title: "State",
// cellStyle: {width: 100000},
// sortKey: "state",
render: (device: Device) => {
const status = device.status ?? pond.DeviceStatus.create()
const deviceStateHelper = getDeviceStateHelper(status.state);
return (
<Box className={classes.cellContainer} style={{ width: theme.spacing(0.5)}}>
{/* <Box > */}
<Tooltip title={deviceStateHelper.description}>{deviceStateHelper.icon}</Tooltip>
</Box>
)
}
},
{
title: "ID",
sortKey: "deviceId",
render: (device: Device) => <span className={classes.cellContainer}>
{device.settings?.deviceId}
</span>
},
{
title: "Device",
cellStyle: { width: 160 },
sortKey: "name",
render: (device: Device) => {
return (
<Box className={classes.cellContainer}>
{/* // <Box > */}
<Chip
variant="outlined"
label={device.settings?.name ? device.settings.name : "Device " + device.settings?.deviceId}
sx={{ maxWidth: 160}}
/>
</Box>
)
}
},
{
title: "Description",
cellStyle: { width: 400 },
render: (device: Device) => {
const description = device.settings?.description ?? ""
let size = (hasCo2 && !groupGas) ? 26 : 36
return (
<Box className={classes.descriptionCellContainer} >
<Tooltip title={description}>
<span>
{description.length > size
? description.substring(0, size) + "..."
: description}
</span>
</Tooltip>
</Box>
)
}
},
{
title: "Tags",
sortKey: "tags",
render: (device: Device) => {
return (
<Grid2 container spacing={1} className={classes.tagsCellContainer}>
{device.status.tags.map((tag: pond.TagSettings) => (
<Grid2 key={"device-"+device.id()+"-tag-"+tag.key}>
<TagUI tag={Tag.any({ settings: tag })} />
</Grid2>
))}
</Grid2>
)
}
},
]
if (hasPlenums) {
columns.push({
title: "Plenum",
// sortKey: "hi",
// disableSort: true,
render: (device: Device) => {
if (device.status.plenum?.temperature) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusPlenum device={device} />
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasSen5x) {
columns.push({
title: "Sen5X",
// sortKey: "hi",
// disableSort: true,
render: (device: Device) => {
if (device.status.sen5x?.temperature) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusSen5x device={device} noDust={separateDust} />
</Box>
)
} else {
return (
<></>
)
}
}
})
if (separateDust) columns.push({
title: "Dust",
render: (device: Device) => {
if (device.status.sen5x?.temperature) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusDust device={device} />
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasCo) {
columns.push({
title: groupGas ? "Gas" : "CO",
render: (device: Device) => {
if (device.status.co) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={groupGas ? "all" : "co"}/>
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasCo2 && !groupGas) {
columns.push({
title: "CO2",
render: (device: Device) => {
if (device.status.co2) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"co2"}/>
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasNo2 && !groupGas) {
columns.push({
title: "NO2",
render: (device: Device) => {
if (device.status.no2) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"no2"}/>
</Box>
)
} else {
return (
<></>
)
}
}
})
}
if (hasO2 && !groupGas) {
columns.push({
title: "O2",
render: (device: Device) => {
if (device.status.o2) {
return (
<Box sx={{ marginLeft: 1 }}>
<StatusGas device={device} gasType={"o2"}/>
</Box>
)
} else {
return (
<></>
)
}
}
})
}
return columns
}
const provisionButton= () => {
return (
<Tooltip title="Provision Device">
<IconButton onClick={openProvisionDialog} aria-label="Provision Device">
<ProvisionIcon className={classes.provisionIcon} />
</IconButton>
</Tooltip>
)
}
const groupButton = () => {
return (
<Tooltip title="Create Group">
<IconButton
onClick={() => openGroupSettings(undefined, "add")}
aria-label="Create Group">
<CreateGroupIcon className={classes.green} />
</IconButton>
</Tooltip>
)
}
const subtitle = () => {
return (
<>
{provisionButton()}
{groupButton()}
</>
)
}
const mobile = (row: Device) => {
return (
<Box sx={{ margin: 2 }}>
<Grid2 spacing={1} container direction={"row"} justifyContent="space-between" alignItems={"center"}>
<Grid2 >
{/* {GetDeviceProductIcon(row)} */}
{/* {icon} */}
<BindaptIcon />
</Grid2>
<Grid2 size={{ xs: 9}}>
<Grid2 container direction="column">
<Grid2>
{row.name()}
</Grid2>
<Grid2>
Last Active: {moment(row.status.lastActive).fromNow()}
</Grid2>
</Grid2>
</Grid2>
<Grid2 >
{describePower(row.status.power).icon}
</Grid2>
</Grid2>
</Box>
)
}
const gutter = (row: Device) => {
return (
<Box sx={{ margin: 1 }}>
<Grid2 container spacing={1} direction="column">
<Grid2>
{row.settings.description}
</Grid2>
{row.status.plenum &&
<Grid2 container direction="row" spacing={0}>
<Grid2>
<Typography color={orange[400]}>
{row.status.plenum.temperature}°C
</Typography>
</Grid2>
<Grid2 >
<Typography style={{ marginRight: 6 }}>
{", "}
</Typography>
</Grid2>
<Grid2>
<Typography color={blue[300]}>
{row.status.plenum.humidity}%
</Typography>
</Grid2>
</Grid2>
}
</Grid2>
</Box>
)
}
const preferencesButton = () => {
return (
<IconButton onClick={(event) => setPreferencesAnchor(event.currentTarget)}>
<Tune />
</IconButton>
)
}
const preferencesMenu = () => {
return (
<Menu
id="userMenu"
anchorEl={preferencesAnchor}
open={preferencesAnchor !== null}
onClose={() => setPreferencesAnchor(null)}
disableAutoFocusItem>
<MenuItem
onClick={() => setSeparateDust(!separateDust)}
sx={{ marginLeft: -0.75 }}
dense
>
<ListItemIcon>
<Checkbox checked={!separateDust} />
</ListItemIcon>
<ListItemText primary={"Group Dust"} />
</MenuItem>
<MenuItem
onClick={() => setGroupGas(!groupGas)}
sx={{ marginLeft: -0.75 }}
dense
>
<ListItemIcon>
<Checkbox checked={groupGas} />
</ListItemIcon>
<ListItemText primary={"Group Gas"} />
</MenuItem>
</Menu>
)
}
useEffect(() => {
console.log('Current tab value:', tab, typeof tab);
}, [tab]);
if (!groupsLoaded) return (
<LoadingScreen message="Loading groups" />
)
return(
<PageContainer padding={isMobile ? 0 : 2}>
<Box
sx={{
borderRadius: 1,
border: "1px solid rgba(150, 150, 150, 0.2)",
marginTop: 0,
marginBottom: 1
}}
>
<Tabs
value={groups.length > 0 ? tab : "all"}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
sx={{ borderRadius: 1 }}
>
<Tab wrapped value={"all"} label="All" />
{groupsLoading ?
<Tab value={"loading"} label={<Skeleton variant="rectangular" height={24} width={128} animation="pulse" />}/>
: groups.map((group, index) => {
if (group.id()) return (
<Tab className={classes.tab} wrapped value={index.toString()} label={<Typography variant="inherit" noWrap>{group.name()}</Typography>} key={"group-tab-"+index}/>
)
})}
<Tab wrapped value={"never"} label={<CreateGroupIcon className={classes.green} />} onClick={() => openGroupSettings(undefined, "add")} />
</Tabs>
</Box>
<DevicesSummary setFieldContains={setFieldContains} group={getGroup()} />
<ResponsiveTable<Device>
title={<SmartBreadcrumb prependPaths={getGroup() && ["groups", getGroup().id().toString()]} groupName={getGroup() && getGroup().name()} paddingBottom={1}/>}
subtitle={subtitle}
rows={devices}
renderGutter={ isMobile ? gutter : undefined }
columns={columns}
renderMobile={mobile}
total={total}
pageSize={limit}
page={page}
setPage={setPage}
handleRowsPerPageChange={handleChange}
onRowClick={toDevice}
setSearchText={setSearch}
isLoading={devicesLoading}
actions={getGroup() && <GroupActions removeCallback={removeGroupCallback} group={getGroup()} permissions={groupPermissions} devices={devices} refreshCallback={loadDevices}/>}
order={order}
setOrder={setOrder}
orderBy={orderBy}
setOrderBy={setOrderBy}
endTitleElement={preferencesButton}
loadMore={()=>{
let currentRows = cloneDeep(devices)
deviceAPI.list(
limit,
currentRows.length,
order,
orderBy,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
getKeys(),
getTypes(),
fieldContains,
search,
as
).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?.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(currentRows.concat(newDevices))
}).catch(_err => {
})
}}
/>
<ProvisionDevice
isOpen={isProvisionDialogOpen}
refreshCallback={loadDevices}
closeDialogCallback={closeProvisionDialog}
/>
{/* This GroupSettings is used for adding */}
<GroupSettings
initialGroup={selectedGroup}
mode={groupSettingsMode}
isDialogOpen={groupSettingsIsOpen}
closeDialogCallback={closeGroupSettings}
refreshCallback={() => {}}
canEdit={true}
groupDevices={devices}
removeGroupCallback={removeGroupCallback}
addGroupCallback={addGroupCallback}
/>
{preferencesMenu()}
</PageContainer>
)
}