frontend/src/pages/Devices.tsx

486 lines
No EOL
14 KiB
TypeScript

import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon } from "@mui/icons-material";
import { Box, Chip, Grid2, IconButton, Skeleton, Tab, Tabs, Theme, Tooltip, Typography } 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, useGroupAPI } from "providers";
import { 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 } 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 { GetDeviceProductIcon } from "products/DeviceProduct";
import BindaptIcon from "products/Bindapt/BindaptIcon";
import { describePower } from "pbHelpers/Power";
import moment from "moment";
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(32)
// 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 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, ] = useState<"asc" | "desc">("asc");
const [orderBy, ] = 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 [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 groupID = useParams<{ groupID: string }>()?.groupID ?? "all";
const [tab, setTab] = useState(groupID==="all" ? groupID : groupID);
const [selectedGroup, setSelectedGroup] = useState<Group | undefined>(undefined);
const [groupSettingsMode, setGroupSettingsMode] = useState<
"add" | "update" | "remove" | undefined
>(undefined);
const [groupSettingsIsOpen, setGroupSettingsIsOpen] = useState<boolean>(false);
const updateGroups = (newGroups: Group[]) => {
setGroups(newGroups);
};
useEffect(() => {
setGroupPermissions([])
if (tab === "all") return
groupAPI.getGroupPermissions(parseInt(tab)).then(resp => {
setGroupPermissions(resp.data.permissions)
})
}, [tab])
const openProvisionDialog = () => {
setIsProvisionDialogOpen(true);
};
const closeProvisionDialog = () => {
setIsProvisionDialogOpen(false);
};
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") {
let keys = getContextKeys()
keys.splice(keys.length - 1, 0, tab);
return keys
} else {
return getContextKeys()
}
}
const getTypes = () => {
if (tab !== "all") {
let types = getContextTypes()
types.splice(types.length - 1, 0, "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
).then(resp => {
let newGroups: Group[] = []
resp.data.groups?.forEach((group: pond.Group) => {
newGroups.push(Group.create(group))
})
updateGroups(newGroups)
}).finally(() => {
setGroupsLoading(false)
})
}
const loadDevices = () => {
setDevicesLoading(true)
deviceAPI.list(
limit,
page*limit,
order,
orderBy,
search,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
getKeys(),
getTypes()
).then(resp => {
let newDevices: Device[] = []
resp.data.devices.forEach(device => {
if (device.status?.plenum?.temperature) {
setHasPlenums(true)
}
newDevices.push(Device.create(device))
})
setDevices(newDevices)
setTotal(resp.data.total)
}).finally(() => {
setDevicesLoading(false)
})
}
useEffect(() => {
loadDevices()
}, [limit, page, order, orderBy, search, tab])
useEffect(() => {
loadGroups()
}, [groupLimit, groupPage, orderGroup, orderGroupBy, searchGroup])
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"));
};
const toDevice = (device: Device) => {
let url = prependToUrl(getGroup() ? "groups/" + getGroup().id() : "")
url = url + "/" + device.id()
navigate(url, { replace: true, 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 = [
{
title: "State",
render: (device: Device) => {
const status = device.status ?? pond.DeviceStatus.create()
const deviceStateHelper = getDeviceStateHelper(status.state);
return (
<Box className={classes.cellContainer}>
<Tooltip title={deviceStateHelper.description}>{deviceStateHelper.icon}</Tooltip>
</Box>
)
}
},
{
title: "ID",
render: (device: Device) => <span className={classes.cellContainer}>
{device.settings?.deviceId}
</span>
},
{
title: "Device",
render: (device: Device) => {
return (
<Box className={classes.cellContainer} >
<Chip
variant="outlined"
label={device.settings?.name ? device.settings.name : "Device " + device.settings?.deviceId}
/>
</Box>
)
}
},
{
title: "Description",
render: (device: Device) => {
const description = device.settings?.description ?? ""
return (
<Box className={classes.descriptionCellContainer}>
<Tooltip title={device.settings?.description}>
<span>
{description.length > 32
? description.substring(0, 32) + "..."
: description}
</span>
</Tooltip>
</Box>
)
}
},
]
if (hasPlenums) {
columns.push({
title: "Plenum",
render: (device: Device) => {
if (device.status.plenum?.temperature) {
return (
<Grid2 container direction="column" marginLeft={2}>
<Grid2>
<Typography variant="body2" style={{ color: orange[300]}}>
{device.status.plenum.temperature.toFixed(1)}°C
</Typography>
</Grid2>
<Grid2>
<Typography variant="body2" color="info">
{device.status.plenum.humidity.toFixed(1)}%
</Typography>
</Grid2>
</Grid2>
)
} 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 getGroup = () => {
let group = groups[parseInt(tab)-1]
return group
}
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>
)
}
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={group.id().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 setSearch={setSearch} />
<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 group={getGroup()} permissions={groupPermissions} devices={devices} refreshCallback={loadDevices}/>}
/>
<ProvisionDevice
isOpen={isProvisionDialogOpen}
refreshCallback={loadDevices}
closeDialogCallback={closeProvisionDialog}
/>
<GroupSettings
initialGroup={selectedGroup}
mode={groupSettingsMode}
isDialogOpen={groupSettingsIsOpen}
closeDialogCallback={closeGroupSettings}
refreshCallback={loadDevices}
canEdit={true}
groupDevices={devices}
/>
</PageContainer>
)
}