Merge branch 'develop' into feat/register-page
35
client/src/Components/ArrowLeft/index.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import LeftArrow from "../../assets/icons/left-arrow.svg?react";
|
||||
import LeftArrowDouble from "../../assets/icons/left-arrow-double.svg?react";
|
||||
import LeftArrowLong from "../../assets/icons/left-arrow-long.svg?react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const ArrowLeft = ({ type, color = "#667085", ...props }) => {
|
||||
if (type === "double") {
|
||||
return (
|
||||
<LeftArrowDouble
|
||||
style={{ color }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else if (type === "long") {
|
||||
return (
|
||||
<LeftArrowLong
|
||||
style={{ color }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<LeftArrow
|
||||
style={{ color }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ArrowLeft.propTypes = {
|
||||
color: PropTypes.string,
|
||||
type: PropTypes.oneOf(["double", "long", "default"]),
|
||||
};
|
||||
export default ArrowLeft;
|
||||
28
client/src/Components/ArrowRight/index.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import RightArrow from "../../assets/icons/right-arrow.svg?react";
|
||||
import RightArrowDouble from "../../assets/icons/right-arrow-double.svg?react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const ArrowRight = ({ type, color = "#667085", ...props }) => {
|
||||
if (type === "double") {
|
||||
return (
|
||||
<RightArrowDouble
|
||||
style={{ color }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RightArrow
|
||||
style={{ color }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ArrowRight.propTypes = {
|
||||
type: PropTypes.oneOf(["double", "default"]),
|
||||
color: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ArrowRight;
|
||||
@@ -15,7 +15,7 @@ import { useTheme } from "@emotion/react";
|
||||
* <Avatar src="assets/img" first="Alex" last="Holliday" small />
|
||||
*/
|
||||
|
||||
const Avatar = ({ src, small, sx }) => {
|
||||
const Avatar = ({ src, small, sx, onClick = () => {} }) => {
|
||||
const { user } = useSelector((state) => state.auth);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -31,6 +31,7 @@ const Avatar = ({ src, small, sx }) => {
|
||||
|
||||
return (
|
||||
<MuiAvatar
|
||||
onClick={onClick}
|
||||
alt={`${user?.firstName} ${user?.lastName}`}
|
||||
/* TODO What is the /static/images/avatar/2.jpg ?*/
|
||||
src={src ? src : user?.avatarImage ? image : "/static/images/avatar/2.jpg"}
|
||||
@@ -66,6 +67,7 @@ Avatar.propTypes = {
|
||||
src: PropTypes.string,
|
||||
small: PropTypes.bool,
|
||||
sx: PropTypes.object,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
|
||||
@@ -2,8 +2,7 @@ import PropTypes from "prop-types";
|
||||
import { Box, Breadcrumbs as MUIBreadcrumbs } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ArrowRight from "../../assets/icons/right-arrow.svg?react";
|
||||
|
||||
import ArrowRight from "../ArrowRight";
|
||||
import "./index.css";
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
}
|
||||
} */
|
||||
|
||||
.home-layout aside {
|
||||
/* .home-layout aside {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
max-width: var(--env-var-side-bar-width);
|
||||
}
|
||||
} */
|
||||
|
||||
.home-layout > div {
|
||||
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
|
||||
|
||||
286
client/src/Components/Sidebar/components/authFooter.jsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Avatar from "../../Avatar";
|
||||
import ThemeSwitch from "../../ThemeSwitch";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import DotsVertical from "../../../assets/icons/dots-vertical.svg?react";
|
||||
import LogoutSvg from "../../../assets/icons/logout.svg?react";
|
||||
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { clearAuthState } from "../../../Features/Auth/authSlice";
|
||||
import { useDispatch } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const getFilteredAccountMenuItems = (user, items) => {
|
||||
if (!user) return [];
|
||||
|
||||
let filtered = [...items];
|
||||
|
||||
if (user.role?.includes("demo")) {
|
||||
filtered = filtered.filter((item) => item.name !== "Password");
|
||||
}
|
||||
|
||||
if (!user.role?.includes("superadmin")) {
|
||||
filtered = filtered.filter((item) => item.name !== "Team");
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const getRoleDisplayText = (user, t) => {
|
||||
if (!user?.role) return "";
|
||||
|
||||
if (user.role.includes("superadmin")) return t("roles.superAdmin");
|
||||
if (user.role.includes("admin")) return t("roles.admin");
|
||||
if (user.role.includes("user")) return t("roles.teamMember");
|
||||
if (user.role.includes("demo")) return t("roles.demoUser");
|
||||
|
||||
return user.role;
|
||||
};
|
||||
|
||||
const AuthFooter = ({ collapsed, accountMenuItems }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const authState = useSelector((state) => state.auth);
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
const openPopup = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
dispatch(clearAuthState());
|
||||
navigate("/login");
|
||||
};
|
||||
const renderAccountMenuItems = (user, items) => {
|
||||
const filteredItems = getFilteredAccountMenuItems(user, items);
|
||||
|
||||
return filteredItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
closePopup();
|
||||
navigate(item.path);
|
||||
}}
|
||||
sx={{
|
||||
gap: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
pl: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
));
|
||||
};
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
height="var(--env-var-side-bar-auth-footer-height)"
|
||||
alignItems="center"
|
||||
py={theme.spacing(4)}
|
||||
px={theme.spacing(8)}
|
||||
gap={theme.spacing(2)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
boxSizing={"border-box"}
|
||||
>
|
||||
<Avatar
|
||||
small={true}
|
||||
onClick={(e) => collapsed && openPopup(e)}
|
||||
sx={{
|
||||
cursor: collapsed ? "pointer" : "default",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(2)}
|
||||
minWidth={0}
|
||||
maxWidth={collapsed ? 0 : "100%"}
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: "opacity 300ms ease, max-width 300ms ease",
|
||||
transitionDelay: collapsed ? "0ms" : "300ms",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
ml={theme.spacing(2)}
|
||||
sx={{
|
||||
maxWidth: "50%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
color={theme.palette.primary.contrastText}
|
||||
fontWeight={500}
|
||||
lineHeight={1}
|
||||
fontSize={"var(--env-var-font-size-medium)"}
|
||||
sx={{
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{authState.user?.firstName} {authState.user?.lastName}
|
||||
</Typography>
|
||||
<Typography
|
||||
color={theme.palette.primary.contrastText}
|
||||
fontSize={"var(--env-var-font-size-small)"}
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
sx={{ textTransform: "capitalize", opacity: 0.8 }}
|
||||
>
|
||||
{getRoleDisplayText(authState.user, t)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
columnGap={theme.spacing(2)}
|
||||
>
|
||||
<ThemeSwitch color={theme.palette.primary.contrastTextTertiary} />
|
||||
<Tooltip
|
||||
title={t("navControls")}
|
||||
disableInteractive
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
ml: "auto",
|
||||
mr: "-8px",
|
||||
"&:focus": { outline: "none" },
|
||||
alignSelf: "center",
|
||||
padding: "10px",
|
||||
|
||||
"& svg": {
|
||||
width: "22px",
|
||||
height: "22px",
|
||||
},
|
||||
"& svg path": {
|
||||
/* Vertical three dots */
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
}}
|
||||
onClick={(event) => openPopup(event)}
|
||||
>
|
||||
<DotsVertical />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Menu
|
||||
className="sidebar-popup"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={closePopup}
|
||||
disableScrollLock
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
marginTop: theme.spacing(-4),
|
||||
marginLeft: collapsed ? theme.spacing(2) : 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
MenuListProps={{
|
||||
sx: {
|
||||
p: 2,
|
||||
"& li": { m: 0 },
|
||||
"& li:has(.MuiBox-root):hover": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
ml: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
{collapsed && (
|
||||
<MenuItem sx={{ cursor: "default", minWidth: "50%" }}>
|
||||
<Box
|
||||
mb={theme.spacing(2)}
|
||||
sx={{
|
||||
minWidth: "50%",
|
||||
maxWidth: "max-content",
|
||||
overflow: "visible",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontWeight={500}
|
||||
fontSize={13}
|
||||
sx={{
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "visible",
|
||||
// wordBreak: "break-word",
|
||||
textOverflow: "clip",
|
||||
}}
|
||||
>
|
||||
{authState.user?.firstName} {authState.user?.lastName}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "visible",
|
||||
// wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{authState.user?.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* TODO Do we need two dividers? */}
|
||||
{collapsed && <Divider />}
|
||||
{/* <Divider /> */}
|
||||
{renderAccountMenuItems(authState.user, accountMenuItems)}
|
||||
<MenuItem
|
||||
onClick={logout}
|
||||
sx={{
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
pl: theme.spacing(4),
|
||||
"& svg path": {
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LogoutSvg />
|
||||
{t("menu.logOut", "Log out")}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
AuthFooter.propTypes = {
|
||||
collapsed: PropTypes.bool,
|
||||
accountMenuItems: PropTypes.array,
|
||||
};
|
||||
|
||||
export default AuthFooter;
|
||||
55
client/src/Components/Sidebar/components/collapseButton.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ArrowRight from "../../ArrowRight";
|
||||
import ArrowLeft from "../../ArrowLeft";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { toggleSidebar } from "../../../Features/UI/uiSlice";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const CollapseButton = ({ collapsed }) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const arrowIcon = collapsed ? (
|
||||
<ArrowRight
|
||||
height={theme.spacing(8)}
|
||||
width={theme.spacing(8)}
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
/>
|
||||
) : (
|
||||
<ArrowLeft
|
||||
height={theme.spacing(8)}
|
||||
width={theme.spacing(8)}
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<IconButton
|
||||
sx={{
|
||||
position: "absolute",
|
||||
/* TODO 60 is a magic number. if logo chnges size this might break */
|
||||
top: 60,
|
||||
right: 0,
|
||||
transform: `translate(50%, 0)`,
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
border: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
p: theme.spacing(2.5),
|
||||
|
||||
"&:focus": { outline: "none" },
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.primary.lowContrast,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
dispatch(toggleSidebar());
|
||||
}}
|
||||
>
|
||||
{arrowIcon}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
CollapseButton.propTypes = {
|
||||
collapsed: PropTypes.bool.isRequired,
|
||||
};
|
||||
export default CollapseButton;
|
||||
67
client/src/Components/Sidebar/components/logo.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Logo = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
pt={theme.spacing(6)}
|
||||
pb={theme.spacing(12)}
|
||||
pl={theme.spacing(8)}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
onClick={() => navigate("/")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Typography
|
||||
pl={theme.spacing("1px")}
|
||||
minWidth={theme.spacing(16)}
|
||||
minHeight={theme.spacing(16)}
|
||||
display={"flex"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
backgroundColor={theme.palette.accent.main}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
color={theme.palette.accent.contrastText}
|
||||
fontSize={18}
|
||||
>
|
||||
C
|
||||
</Typography>
|
||||
<Box
|
||||
overflow={"hidden"}
|
||||
sx={{
|
||||
transition: "opacity 900ms ease, width 900ms ease",
|
||||
opacity: collapsed ? 0 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
width: collapsed ? 0 : "100%",
|
||||
}}
|
||||
>
|
||||
{" "}
|
||||
<Typography
|
||||
lineHeight={1}
|
||||
mt={theme.spacing(2)}
|
||||
color={theme.palette.accent.contrastText}
|
||||
fontSize={"var(--env-var-font-size-medium-plus)"}
|
||||
sx={{ opacity: 0.8, fontWeight: 500 }}
|
||||
>
|
||||
{t("common.appName")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Logo.propTypes = {
|
||||
collapsed: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
97
client/src/Components/Sidebar/components/navItem.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const NavItem = ({ item, collapsed, selected, onClick }) => {
|
||||
const theme = useTheme();
|
||||
const iconStroke = selected
|
||||
? theme.palette.primary.contrastText
|
||||
: theme.palette.primary.contrastTextTertiary;
|
||||
|
||||
const buttonBgColor = selected ? theme.palette.secondary.main : "transparent";
|
||||
const buttonBgHoverColor = selected
|
||||
? theme.palette.secondary.main
|
||||
: theme.palette.tertiary.main;
|
||||
const fontWeight = selected ? 600 : 400;
|
||||
return (
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={collapsed ? item.name : ""}
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -16],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
disableInteractive
|
||||
>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
backgroundColor: buttonBgColor,
|
||||
"&:hover": {
|
||||
backgroundColor: buttonBgHoverColor,
|
||||
},
|
||||
height: 37,
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
px: theme.spacing(4),
|
||||
pl: theme.spacing(5),
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
"& svg": {
|
||||
height: 20,
|
||||
width: 20,
|
||||
opacity: 0.81,
|
||||
},
|
||||
"& svg path": {
|
||||
stroke: iconStroke,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
transition: "opacity 900ms ease",
|
||||
opacity: collapsed ? 0 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color={theme.palette.primary.contrastText}
|
||||
sx={{
|
||||
fontWeight: fontWeight,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
NavItem.propTypes = {
|
||||
item: PropTypes.object,
|
||||
collapsed: PropTypes.bool,
|
||||
selected: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
export default NavItem;
|
||||
@@ -1,106 +0,0 @@
|
||||
/* TODO */
|
||||
aside .MuiList-root svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
aside span.MuiTypography-root {
|
||||
font-size: var(--env-var-font-size-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
aside .MuiStack-root + span.MuiTypography-root {
|
||||
font-size: var(--env-var-font-size-medium-plus);
|
||||
}
|
||||
aside .MuiListSubheader-root {
|
||||
font-size: var(--env-var-font-size-small);
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
aside p.MuiTypography-root {
|
||||
font-size: var(--env-var-font-size-small);
|
||||
opacity: 0.8;
|
||||
}
|
||||
aside .MuiListItemButton-root:not(.selected-path) > * {
|
||||
opacity: 0.9;
|
||||
}
|
||||
aside .selected-path > * {
|
||||
opacity: 1;
|
||||
}
|
||||
aside .selected-path span.MuiTypography-root {
|
||||
font-weight: 600;
|
||||
}
|
||||
aside .MuiCollapse-wrapperInner .MuiList-root > .MuiListItemButton-root {
|
||||
position: relative;
|
||||
}
|
||||
aside .MuiCollapse-wrapperInner .MuiList-root svg,
|
||||
aside .MuiList-root .MuiListItemText-root + svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.sidebar-popup svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* TRANSITIONS */
|
||||
aside {
|
||||
flex: 1;
|
||||
transition: max-width 650ms cubic-bezier(0.36, -0.01, 0, 0.77);
|
||||
}
|
||||
.home-layout aside.collapsed {
|
||||
max-width: 64px;
|
||||
}
|
||||
|
||||
aside.expanded .MuiTypography-root,
|
||||
aside.expanded p.MuiTypography-root,
|
||||
aside.expanded .MuiListItemText-root + svg,
|
||||
aside.expanded .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root {
|
||||
visibility: visible;
|
||||
animation: fadeIn 1s ease;
|
||||
}
|
||||
|
||||
aside.collapsed .MuiTypography-root,
|
||||
aside.collapsed p.MuiTypography-root,
|
||||
aside.collapsed .MuiListItemText-root + svg,
|
||||
aside.collapsed .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
aside .MuiListSubheader-root {
|
||||
transition: padding 200ms ease;
|
||||
}
|
||||
|
||||
.sidebar-delay-fade {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
aside.expanded.sidebar-ready .sidebar-delay-fade {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
30% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import ThemeSwitch from "../ThemeSwitch";
|
||||
import Avatar from "../Avatar";
|
||||
import Stack from "@mui/material/Stack";
|
||||
|
||||
import List from "@mui/material/List";
|
||||
import Logo from "./components/logo";
|
||||
import CollapseButton from "./components/collapseButton";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import NavItem from "./components/navItem";
|
||||
import AuthFooter from "./components/authFooter";
|
||||
|
||||
import StarPrompt from "../StarPrompt";
|
||||
import LockSvg from "../../assets/icons/lock.svg?react";
|
||||
import UserSvg from "../../assets/icons/user.svg?react";
|
||||
import TeamSvg from "../../assets/icons/user-two.svg?react";
|
||||
import LogoutSvg from "../../assets/icons/logout.svg?react";
|
||||
import Support from "../../assets/icons/support.svg?react";
|
||||
import Maintenance from "../../assets/icons/maintenance.svg?react";
|
||||
import Monitors from "../../assets/icons/monitors.svg?react";
|
||||
@@ -28,11 +18,6 @@ import Incidents from "../../assets/icons/incidents.svg?react";
|
||||
import Integrations from "../../assets/icons/integrations.svg?react";
|
||||
import PageSpeed from "../../assets/icons/page-speed.svg?react";
|
||||
import Settings from "../../assets/icons/settings.svg?react";
|
||||
import ArrowDown from "../../assets/icons/down-arrow.svg?react";
|
||||
import ArrowUp from "../../assets/icons/up-arrow.svg?react";
|
||||
import ArrowRight from "../../assets/icons/right-arrow.svg?react";
|
||||
import ArrowLeft from "../../assets/icons/left-arrow.svg?react";
|
||||
import DotsVertical from "../../assets/icons/dots-vertical.svg?react";
|
||||
import ChangeLog from "../../assets/icons/changeLog.svg?react";
|
||||
import Docs from "../../assets/icons/docs.svg?react";
|
||||
import StatusPages from "../../assets/icons/status-pages.svg?react";
|
||||
@@ -40,17 +25,18 @@ import Discussions from "../../assets/icons/discussions.svg?react";
|
||||
import Notifications from "../../assets/icons/notifications.svg?react";
|
||||
import Logs from "../../assets/icons/logs.svg?react";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
// Utils
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clearAuthState } from "../../Features/Auth/authSlice";
|
||||
import { toggleSidebar } from "../../Features/UI/uiSlice";
|
||||
import { TurnedIn } from "@mui/icons-material";
|
||||
import { rules } from "eslint-plugin-react-refresh";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const URL_MAP = {
|
||||
support: "https://discord.com/invite/NAb6H3UTjK",
|
||||
discussions: "https://github.com/bluewave-labs/checkmate/discussions",
|
||||
docs: "https://bluewavelabs.gitbook.io/checkmate",
|
||||
changelog: "https://github.com/bluewave-labs/checkmate/releases",
|
||||
};
|
||||
|
||||
const getMenu = (t) => [
|
||||
{ name: t("menu.uptime"), path: "uptime", icon: <Monitors /> },
|
||||
@@ -92,782 +78,94 @@ const getAccountMenuItems = (t) => [
|
||||
{ name: t("menu.team"), path: "account/team", icon: <TeamSvg /> },
|
||||
];
|
||||
|
||||
/* TODO this could be a key in nested Path would be the link */
|
||||
const URL_MAP = {
|
||||
support: "https://discord.com/invite/NAb6H3UTjK",
|
||||
discussions: "https://github.com/bluewave-labs/checkmate/discussions",
|
||||
docs: "https://bluewavelabs.gitbook.io/checkmate",
|
||||
changelog: "https://github.com/bluewave-labs/checkmate/releases",
|
||||
};
|
||||
|
||||
const PATH_MAP = {
|
||||
monitors: "Dashboard",
|
||||
pagespeed: "Dashboard",
|
||||
infrastructure: "Dashboard",
|
||||
account: "Account",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* @component
|
||||
* Sidebar component serves as a sidebar containing a menu.
|
||||
*
|
||||
* @returns {JSX.Element} The JSX element representing the Sidebar component.
|
||||
*/
|
||||
|
||||
function Sidebar() {
|
||||
const Sidebar = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const authState = useSelector((state) => state.auth);
|
||||
const navigate = useNavigate();
|
||||
// Redux state
|
||||
const collapsed = useSelector((state) => state.ui.sidebar.collapsed);
|
||||
|
||||
const menu = getMenu(t);
|
||||
const otherMenuItems = getOtherMenuItems(t);
|
||||
const accountMenuItems = getAccountMenuItems(t);
|
||||
const collapsed = useSelector((state) => state.ui.sidebar.collapsed);
|
||||
const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false });
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [popup, setPopup] = useState();
|
||||
const { user } = useSelector((state) => state.auth);
|
||||
|
||||
const sidebarRef = useRef(null);
|
||||
const [sidebarReady, setSidebarReady] = useState(false);
|
||||
const TRANSITION_DURATION = 200;
|
||||
let menu = getMenu(t);
|
||||
menu = menu.filter((item) => {
|
||||
if (item.path === "logs") {
|
||||
return user.role?.includes("admin") || user.role?.includes("superadmin");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!collapsed) {
|
||||
setSidebarReady(false);
|
||||
const timeout = setTimeout(() => {
|
||||
setSidebarReady(true);
|
||||
}, TRANSITION_DURATION);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
setSidebarReady(false);
|
||||
}
|
||||
}, [collapsed]);
|
||||
|
||||
const renderAccountMenuItems = () => {
|
||||
let filteredAccountMenuItems = [...accountMenuItems];
|
||||
|
||||
// If the user is in demo mode, remove the "Password" option
|
||||
if (user.role?.includes("demo")) {
|
||||
filteredAccountMenuItems = filteredAccountMenuItems.filter(
|
||||
(item) => item.name !== "Password"
|
||||
);
|
||||
}
|
||||
|
||||
// If the user is NOT a superadmin, remove the "Team" option
|
||||
if (user.role && !user.role.includes("superadmin")) {
|
||||
filteredAccountMenuItems = filteredAccountMenuItems.filter(
|
||||
(item) => item.name !== "Team"
|
||||
);
|
||||
}
|
||||
|
||||
return filteredAccountMenuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
closePopup();
|
||||
navigate(item.path);
|
||||
}}
|
||||
sx={{
|
||||
gap: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
pl: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
));
|
||||
};
|
||||
|
||||
const openPopup = (event, id) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setPopup(id);
|
||||
};
|
||||
const closePopup = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles logging out the user
|
||||
*
|
||||
*/
|
||||
const logout = async () => {
|
||||
// Clear auth state
|
||||
dispatch(clearAuthState());
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const matchedKey = Object.keys(PATH_MAP).find((key) =>
|
||||
location.pathname.includes(key)
|
||||
);
|
||||
|
||||
if (matchedKey) {
|
||||
setOpen((prev) => ({ ...prev, [PATH_MAP[matchedKey]]: true }));
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const iconColor = theme.palette.primary.contrastTextTertiary;
|
||||
const sidebarClassName = `${collapsed ? "collapsed" : "expanded"} ${sidebarReady ? "sidebar-ready" : ""}`;
|
||||
|
||||
/* TODO refactor this, there are a some ternaries and comments in the return */
|
||||
return (
|
||||
<Stack
|
||||
height="100vh"
|
||||
width={
|
||||
collapsed
|
||||
? "var(--env-var-side-bar-collapsed-width)"
|
||||
: "var(--env-var-side-bar-width)"
|
||||
}
|
||||
component="aside"
|
||||
ref={sidebarRef}
|
||||
className={sidebarClassName}
|
||||
/* TODO general padding should be here */
|
||||
py={theme.spacing(6)}
|
||||
position="sticky"
|
||||
top={0}
|
||||
borderRight={`1px solid ${theme.palette.primary.lowContrast}`}
|
||||
paddingTop={theme.spacing(6)}
|
||||
paddingBottom={theme.spacing(6)}
|
||||
gap={theme.spacing(6)}
|
||||
/* TODO set all style in this sx if possible (when general)
|
||||
This is the top lever for styles
|
||||
*/
|
||||
sx={{
|
||||
position: "relative",
|
||||
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: 0,
|
||||
"& :is(p, span, .MuiListSubheader-root)": {
|
||||
/*
|
||||
Text color for unselected menu items and menu headings
|
||||
Secondary contrast text against main background
|
||||
*/
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
},
|
||||
"& .MuiList-root svg path": {
|
||||
/* Menu Icons */
|
||||
stroke: iconColor,
|
||||
},
|
||||
"& .selected-path": {
|
||||
/* Selected menu item */
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
},
|
||||
"& .MuiListItemIcon-root svg path": {
|
||||
/* Selected menu item icon */
|
||||
stroke: theme.palette.secondary.contrastText,
|
||||
},
|
||||
"& .MuiListItemText-root :is(p, span)": {
|
||||
/* Selected menu item text */
|
||||
color: theme.palette.secondary.contrastText,
|
||||
},
|
||||
},
|
||||
"& .MuiListItemButton-root:not(.selected-path)": {
|
||||
transition: "background-color .3s",
|
||||
" &:hover": {
|
||||
/* Hovered menu item bg color */
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
"& :is(p, span)": {
|
||||
/* Hovered menu item text color */
|
||||
color: theme.palette.tertiary.contrastText,
|
||||
},
|
||||
},
|
||||
},
|
||||
transition: "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
<CollapseButton collapsed={collapsed} />
|
||||
<Logo collapsed={collapsed} />
|
||||
<List
|
||||
component="nav"
|
||||
aria-labelledby="nested-menu-subheader"
|
||||
disablePadding
|
||||
sx={{
|
||||
position: "absolute",
|
||||
/* TODO 60 is a magic number. if logo chnges size this might break */
|
||||
top: 60,
|
||||
right: 0,
|
||||
transform: `translate(50%, 0)`,
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
p: theme.spacing(2.5),
|
||||
"& svg": {
|
||||
width: theme.spacing(8),
|
||||
height: theme.spacing(8),
|
||||
"& path": {
|
||||
/* TODO this should be set at the top level if possible */
|
||||
stroke: theme.palette.primary.contrastTextSecondary,
|
||||
},
|
||||
},
|
||||
"&:focus": { outline: "none" },
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.primary.lowContrast,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
px: theme.spacing(6),
|
||||
height: "100%",
|
||||
}}
|
||||
onClick={() => {
|
||||
setOpen((prev) =>
|
||||
Object.fromEntries(Object.keys(prev).map((key) => [key, false]))
|
||||
>
|
||||
{menu.map((item) => {
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
return (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
selected={selected}
|
||||
onClick={() => navigate(`/${item.path}`)}
|
||||
/>
|
||||
);
|
||||
dispatch(toggleSidebar());
|
||||
}}
|
||||
>
|
||||
{collapsed ? <ArrowRight /> : <ArrowLeft />}
|
||||
</IconButton>
|
||||
{/* TODO Alignment done using padding. Use single source of truth to that*/}
|
||||
<Stack
|
||||
pt={theme.spacing(6)}
|
||||
pb={theme.spacing(12)}
|
||||
pl={theme.spacing(8)}
|
||||
>
|
||||
{/* TODO Abstract logo into component */}
|
||||
{/* TODO Turn logo into a link */}
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
onClick={() => navigate("/")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Stack
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minWidth={theme.spacing(16)}
|
||||
minHeight={theme.spacing(16)}
|
||||
pl="1px"
|
||||
fontSize={18}
|
||||
color={theme.palette.accent.contrastText}
|
||||
sx={{
|
||||
position: "relative",
|
||||
backgroundColor: theme.palette.accent.main,
|
||||
color: theme.palette.accent.contrastText,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
C
|
||||
</Stack>
|
||||
<Typography
|
||||
component="span"
|
||||
mt={theme.spacing(2)}
|
||||
sx={{ opacity: 0.8, fontWeight: 500 }}
|
||||
>
|
||||
{t("common.appName")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
overflowX: "hidden",
|
||||
"&::-webkit-scrollbar": {
|
||||
width: theme.spacing(2),
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.spacing(4),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<List
|
||||
component="nav"
|
||||
aria-labelledby="nested-menu-subheader"
|
||||
disablePadding
|
||||
sx={{
|
||||
px: theme.spacing(6),
|
||||
height: "100%",
|
||||
/* overflow: "hidden", */
|
||||
}}
|
||||
>
|
||||
{menu.map((item) => {
|
||||
return item.path ? (
|
||||
/* If item has a path */
|
||||
<Tooltip
|
||||
key={item.path}
|
||||
placement="right"
|
||||
title={collapsed ? item.name : ""}
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -16],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
disableInteractive
|
||||
>
|
||||
<ListItemButton
|
||||
className={
|
||||
location.pathname.startsWith(`/${item.path}`) ? "selected-path" : ""
|
||||
}
|
||||
onClick={() => navigate(`/${item.path}`)}
|
||||
sx={{
|
||||
height: "37px",
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
px: theme.spacing(4),
|
||||
pl: theme.spacing(5),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText>{item.name}</ListItemText>
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
) : collapsed ? (
|
||||
/* TODO Do we ever get here? If item does not have a path and collapsed state is true */
|
||||
<React.Fragment key={item.name}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
title={collapsed ? item.name : ""}
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -16],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
disableInteractive
|
||||
>
|
||||
<ListItemButton
|
||||
className={
|
||||
Boolean(anchorEl) && popup === item.name ? "selected-path" : ""
|
||||
}
|
||||
onClick={(event) => openPopup(event, item.name)}
|
||||
sx={{
|
||||
position: "relative",
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText>{item.name}</ListItemText>
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
className="sidebar-popup"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl) && popup === item.name}
|
||||
onClose={closePopup}
|
||||
disableScrollLock
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
mt: theme.spacing(-2),
|
||||
ml: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}}
|
||||
MenuListProps={{ sx: { px: 1, py: 2 } }}
|
||||
sx={{
|
||||
ml: theme.spacing(8),
|
||||
/* TODO what is this selection? */
|
||||
"& .selected-path": {
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.nested.map((child) => {
|
||||
if (
|
||||
child.name === "Team" &&
|
||||
authState.user?.role &&
|
||||
!authState.user.role.includes("superadmin")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={
|
||||
location.pathname.includes(child.path) ? "selected-path" : ""
|
||||
}
|
||||
key={child.path}
|
||||
onClick={() => {
|
||||
const url = URL_MAP[child.path];
|
||||
if (url) {
|
||||
window.open(url, "_blank", "noreferrer");
|
||||
} else {
|
||||
navigate(`/${child.path}`);
|
||||
}
|
||||
closePopup();
|
||||
}}
|
||||
sx={{
|
||||
gap: theme.spacing(4),
|
||||
opacity: 0.9,
|
||||
/* TODO this has no effect? */
|
||||
"& svg": {
|
||||
"& path": {
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
strokeWidth: 1.1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{child.icon}
|
||||
{child.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
/* TODO Do we ever get here? If item does not have a path and collapsed state is false */
|
||||
<React.Fragment key={item.name}>
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
setOpen((prev) => ({
|
||||
...Object.fromEntries(Object.keys(prev).map((key) => [key, false])),
|
||||
[item.name]: !prev[item.name],
|
||||
}))
|
||||
}
|
||||
sx={{
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText>{item.name}</ListItemText>
|
||||
{open[`${item.name}`] ? <ArrowUp /> : <ArrowDown />}
|
||||
</ListItemButton>
|
||||
<Collapse
|
||||
in={open[`${item.name}`]}
|
||||
timeout="auto"
|
||||
>
|
||||
<List
|
||||
component="div"
|
||||
disablePadding
|
||||
sx={{ pl: theme.spacing(12) }}
|
||||
>
|
||||
{item.nested.map((child) => {
|
||||
if (
|
||||
child.name === "Team" &&
|
||||
authState.user?.role &&
|
||||
!authState.user.role.includes("superadmin")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
className={
|
||||
location.pathname.includes(child.path) ? "selected-path" : ""
|
||||
}
|
||||
key={child.path}
|
||||
onClick={() => {
|
||||
const url = URL_MAP[child.path];
|
||||
if (url) {
|
||||
window.open(url, "_blank", "noreferrer");
|
||||
} else {
|
||||
navigate(`/${child.path}`);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
pl: theme.spacing(4),
|
||||
"&::before": {
|
||||
content: `""`,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: "-7px",
|
||||
height: "100%",
|
||||
borderLeft: 1,
|
||||
borderLeftColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
"&:last-child::before": {
|
||||
height: "50%",
|
||||
},
|
||||
"&::after": {
|
||||
content: `""`,
|
||||
position: "absolute",
|
||||
top: "45%",
|
||||
left: "-8px",
|
||||
height: "3px",
|
||||
width: "3px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
"&.selected-path::after": {
|
||||
/* TODO what is this selector doing? */
|
||||
backgroundColor: theme.palette.primary.contrastTextTertiary,
|
||||
transform: "scale(1.2)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 0 }}>{child.icon}</ListItemIcon>
|
||||
<ListItemText>{child.name}</ListItemText>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Collapse>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
})}
|
||||
</List>
|
||||
{!collapsed && <StarPrompt />}
|
||||
|
||||
<List
|
||||
component="nav"
|
||||
disablePadding
|
||||
sx={{ px: theme.spacing(6) }}
|
||||
>
|
||||
{otherMenuItems.map((item) => {
|
||||
return item.path ? (
|
||||
<Tooltip
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
placement="right"
|
||||
title={collapsed ? item.name : ""}
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -16],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
disableInteractive
|
||||
>
|
||||
<ListItemButton
|
||||
className={
|
||||
location.pathname.startsWith(`/${item.path}`) ? "selected-path" : ""
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
selected={selected}
|
||||
onClick={() => {
|
||||
const url = URL_MAP[item.path];
|
||||
if (url) {
|
||||
window.open(url, "_blank", "noreferrer");
|
||||
} else {
|
||||
navigate(`/${item.path}`);
|
||||
}
|
||||
onClick={() => {
|
||||
const url = URL_MAP[item.path];
|
||||
if (url) {
|
||||
window.open(url, "_blank", "noreferrer");
|
||||
} else {
|
||||
navigate(`/${item.path}`);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
height: "37px",
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
px: theme.spacing(4),
|
||||
pl: theme.spacing(5),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon} </ListItemIcon>
|
||||
<ListItemText>{item.name} </ListItemText>{" "}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<Divider sx={{ mt: "auto", borderColor: theme.palette.primary.lowContrast }} />
|
||||
<Stack
|
||||
direction="row"
|
||||
height="50px"
|
||||
alignItems="center"
|
||||
py={theme.spacing(4)}
|
||||
px={theme.spacing(8)}
|
||||
gap={theme.spacing(2)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
>
|
||||
{collapsed ? (
|
||||
<>
|
||||
<Tooltip
|
||||
title="Options"
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -10],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
disableInteractive
|
||||
>
|
||||
<IconButton
|
||||
onClick={(event) => openPopup(event, "logout")}
|
||||
sx={{ p: 0, "&:focus": { outline: "none" } }}
|
||||
>
|
||||
<Avatar small={true} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar small={true} />
|
||||
<Box
|
||||
ml={theme.spacing(2)}
|
||||
sx={{ maxWidth: "50%", overflow: "hidden" }}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontWeight={500}
|
||||
sx={{
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{authState.user?.firstName} {authState.user?.lastName}
|
||||
</Typography>
|
||||
<Typography sx={{ textTransform: "capitalize" }}>
|
||||
{authState.user?.role?.includes("superadmin")
|
||||
? t("roles.superAdmin")
|
||||
: authState.user?.role?.includes("admin")
|
||||
? t("roles.admin")
|
||||
: authState.user?.role?.includes("user")
|
||||
? t("roles.teamMember")
|
||||
: authState.user?.role?.includes("demo")
|
||||
? t("roles.demoUser")
|
||||
: authState.user?.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
className="sidebar-delay-fade"
|
||||
flexDirection={"row"}
|
||||
marginLeft={"auto"}
|
||||
columnGap={theme.spacing(2)}
|
||||
>
|
||||
<ThemeSwitch color={iconColor} />
|
||||
<Tooltip
|
||||
title={t("navControls")}
|
||||
disableInteractive
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
ml: "auto",
|
||||
mr: "-8px",
|
||||
"&:focus": { outline: "none" },
|
||||
alignSelf: "center",
|
||||
padding: "10px",
|
||||
|
||||
"& svg": {
|
||||
width: "22px",
|
||||
height: "22px",
|
||||
},
|
||||
"& svg path": {
|
||||
/* Vertical three dots */
|
||||
stroke: iconColor,
|
||||
},
|
||||
}}
|
||||
onClick={(event) => openPopup(event, "logout")}
|
||||
>
|
||||
<DotsVertical />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<Menu
|
||||
className="sidebar-popup"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl) && popup === "logout"}
|
||||
onClose={closePopup}
|
||||
disableScrollLock
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
marginTop: theme.spacing(-4),
|
||||
marginLeft: collapsed ? theme.spacing(2) : 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
MenuListProps={{
|
||||
sx: {
|
||||
p: 2,
|
||||
"& li": { m: 0 },
|
||||
"& li:has(.MuiBox-root):hover": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
ml: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
{collapsed && (
|
||||
<MenuItem sx={{ cursor: "default", minWidth: "50%" }}>
|
||||
<Box
|
||||
mb={theme.spacing(2)}
|
||||
sx={{
|
||||
minWidth: "50%",
|
||||
maxWidth: "max-content",
|
||||
overflow: "visible",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontWeight={500}
|
||||
fontSize={13}
|
||||
sx={{
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "visible",
|
||||
// wordBreak: "break-word",
|
||||
textOverflow: "clip",
|
||||
}}
|
||||
>
|
||||
{authState.user?.firstName} {authState.user?.lastName}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "visible",
|
||||
// wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{authState.user?.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* TODO Do we need two dividers? */}
|
||||
{collapsed && <Divider />}
|
||||
{/* <Divider /> */}
|
||||
{renderAccountMenuItems()}
|
||||
<MenuItem
|
||||
onClick={logout}
|
||||
sx={{
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
pl: theme.spacing(4),
|
||||
"& svg path": {
|
||||
stroke: iconColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LogoutSvg />
|
||||
{t("menu.logOut", "Log out")}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Stack>
|
||||
<AuthFooter
|
||||
collapsed={collapsed}
|
||||
accountMenuItems={accountMenuItems}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Box, Button } from "@mui/material";
|
||||
import LeftArrowDouble from "../../../../assets/icons/left-arrow-double.svg?react";
|
||||
import RightArrowDouble from "../../../../assets/icons/right-arrow-double.svg?react";
|
||||
import LeftArrow from "../../../../assets/icons/left-arrow.svg?react";
|
||||
import RightArrow from "../../../../assets/icons/right-arrow.svg?react";
|
||||
import LeftArrow from "../../../ArrowLeft";
|
||||
import RightArrow from "../../../ArrowRight";
|
||||
import { useTheme } from "@emotion/react";
|
||||
|
||||
TablePaginationActions.propTypes = {
|
||||
@@ -50,7 +48,7 @@ function TablePaginationActions({ count, page, rowsPerPage, onPageChange }) {
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
>
|
||||
<LeftArrowDouble />
|
||||
<LeftArrow type="double" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
@@ -74,7 +72,7 @@ function TablePaginationActions({ count, page, rowsPerPage, onPageChange }) {
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="last page"
|
||||
>
|
||||
<RightArrowDouble />
|
||||
<RightArrow type="double" />
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -2,18 +2,22 @@ import PropTypes from "prop-types";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import Background from "../../../../assets/Images/background-grid.svg?react";
|
||||
import MonitorHeartOutlinedIcon from "@mui/icons-material/MonitorHeartOutlined";
|
||||
import TaskAltOutlinedIcon from "@mui/icons-material/TaskAltOutlined";
|
||||
import CancelOutlinedIcon from "@mui/icons-material/CancelOutlined";
|
||||
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
|
||||
import AlertIcon from "../../../../assets/icons/alert-icon.svg?react";
|
||||
import CheckIcon from "../../../../assets/icons/check-icon.svg?react";
|
||||
import CloseIcon from "../../../../assets/icons/close-icon.svg?react";
|
||||
import WarningIcon from "../../../../assets/icons/warning-icon.svg?react";
|
||||
|
||||
const StatusBox = ({ title, value, status }) => {
|
||||
const theme = useTheme();
|
||||
let sharedStyles = {
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
opacity: 0.5,
|
||||
"& svg path": { stroke: theme.palette.primary.contrastTextTertiary },
|
||||
"& svg": {
|
||||
width: 20,
|
||||
height: 20,
|
||||
opacity: 0.9,
|
||||
"& path": { stroke: theme.palette.primary.contrastTextTertiary, strokeWidth: 1.7 },
|
||||
},
|
||||
};
|
||||
|
||||
let color;
|
||||
@@ -22,28 +26,28 @@ const StatusBox = ({ title, value, status }) => {
|
||||
color = theme.palette.success.lowContrast;
|
||||
icon = (
|
||||
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
|
||||
<TaskAltOutlinedIcon fontSize="small" />
|
||||
<CheckIcon />
|
||||
</Box>
|
||||
);
|
||||
} else if (status === "down") {
|
||||
color = theme.palette.error.lowContrast;
|
||||
icon = (
|
||||
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
|
||||
<CancelOutlinedIcon fontSize="small" />
|
||||
<CloseIcon />
|
||||
</Box>
|
||||
);
|
||||
} else if (status === "paused") {
|
||||
color = theme.palette.warning.lowContrast;
|
||||
icon = (
|
||||
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
|
||||
<WarningAmberRoundedIcon fontSize="small" />
|
||||
<WarningIcon />
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
color = theme.palette.accent.main;
|
||||
icon = (
|
||||
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
|
||||
<MonitorHeartOutlinedIcon fontSize="small" />
|
||||
<AlertIcon />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,51 @@
|
||||
// React, Redux, Router
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
// Utility and Network
|
||||
import { infrastructureMonitorValidation } from "../../../Validation/validation";
|
||||
import { useFetchHardwareMonitorById } from "../../../Hooks/monitorHooks";
|
||||
import { capitalizeFirstLetter } from "../../../Utils/stringUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications";
|
||||
import NotificationsConfig from "../../../Components/NotificationConfig";
|
||||
import {
|
||||
useUpdateMonitor,
|
||||
useCreateMonitor,
|
||||
useFetchGlobalSettings,
|
||||
} from "../../../Hooks/monitorHooks";
|
||||
|
||||
// MUI
|
||||
import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material";
|
||||
|
||||
//Components
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import Link from "../../../Components/Link";
|
||||
import ConfigBox from "../../../Components/ConfigBox";
|
||||
import Dialog from "../../../Components/Dialog";
|
||||
import FieldWrapper from "../../../Components/Inputs/FieldWrapper";
|
||||
import Link from "../../../Components/Link";
|
||||
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
|
||||
import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded";
|
||||
import PulseDot from "../../../Components/Animated/PulseDot";
|
||||
import Select from "../../../Components/Inputs/Select";
|
||||
import TextInput from "../../../Components/Inputs/TextInput";
|
||||
import { Box, Stack, Tooltip, Typography, Button, ButtonGroup } from "@mui/material";
|
||||
import { CustomThreshold } from "./Components/CustomThreshold";
|
||||
import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
import Select from "../../../Components/Inputs/Select";
|
||||
import { CustomThreshold } from "./Components/CustomThreshold";
|
||||
import FieldWrapper from "../../../Components/Inputs/FieldWrapper";
|
||||
|
||||
const SELECT_VALUES = [
|
||||
{ _id: 0.25, name: "15 seconds" },
|
||||
{ _id: 0.5, name: "30 seconds" },
|
||||
{ _id: 1, name: "1 minute" },
|
||||
{ _id: 2, name: "2 minutes" },
|
||||
{ _id: 5, name: "5 minutes" },
|
||||
{ _id: 10, name: "10 minutes" },
|
||||
];
|
||||
|
||||
const METRICS = ["cpu", "memory", "disk", "temperature"];
|
||||
const METRIC_PREFIX = "usage_";
|
||||
const MS_PER_MINUTE = 60000;
|
||||
|
||||
const hasAlertError = (errors) => {
|
||||
return Object.keys(errors).filter((k) => k.startsWith(METRIC_PREFIX)).length > 0;
|
||||
};
|
||||
|
||||
const getAlertError = (errors) => {
|
||||
return Object.keys(errors).find((key) => key.startsWith(METRIC_PREFIX))
|
||||
? errors[Object.keys(errors).find((key) => key.startsWith(METRIC_PREFIX))]
|
||||
: null;
|
||||
};
|
||||
// Utils
|
||||
import NotificationsConfig from "../../../Components/NotificationConfig";
|
||||
import { capitalizeFirstLetter } from "../../../Utils/stringUtils";
|
||||
import { infrastructureMonitorValidation } from "../../../Validation/validation";
|
||||
import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications";
|
||||
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useCreateMonitor,
|
||||
useDeleteMonitor,
|
||||
useFetchGlobalSettings,
|
||||
useFetchHardwareMonitorById,
|
||||
usePauseMonitor,
|
||||
useUpdateMonitor,
|
||||
} from "../../../Hooks/monitorHooks";
|
||||
|
||||
const CreateInfrastructureMonitor = () => {
|
||||
const theme = useTheme();
|
||||
const { user } = useSelector((state) => state.auth);
|
||||
const { monitorId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Determine if we are creating or editing
|
||||
const isCreate = typeof monitorId === "undefined";
|
||||
|
||||
// Fetch monitor details if editing
|
||||
const [monitor, isLoading, networkError] = useFetchHardwareMonitorById({ monitorId });
|
||||
const [notifications, notificationsAreLoading, notificationsError] =
|
||||
useGetNotificationsByTeamId();
|
||||
const [updateMonitor, isUpdating] = useUpdateMonitor();
|
||||
const [createMonitor, isCreating] = useCreateMonitor();
|
||||
const [globalSettings, globalSettingsLoading] = useFetchGlobalSettings();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State
|
||||
const [errors, setErrors] = useState({});
|
||||
const [https, setHttps] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [updateTrigger, setUpdateTrigger] = useState(false);
|
||||
const [infrastructureMonitor, setInfrastructureMonitor] = useState({
|
||||
url: "",
|
||||
name: "",
|
||||
@@ -90,8 +63,50 @@ const CreateInfrastructureMonitor = () => {
|
||||
secret: "",
|
||||
});
|
||||
|
||||
// Populate form fields if editing
|
||||
// Fetch monitor details if editing
|
||||
const { statusColor, pagespeedStatusMsg, determineState } = useMonitorUtils();
|
||||
const [monitor, isLoading] = useFetchHardwareMonitorById({
|
||||
monitorId,
|
||||
updateTrigger,
|
||||
});
|
||||
const [createMonitor, isCreating] = useCreateMonitor();
|
||||
const [deleteMonitor, isDeleting] = useDeleteMonitor();
|
||||
const [globalSettings, globalSettingsLoading] = useFetchGlobalSettings();
|
||||
const [notifications, notificationsAreLoading] = useGetNotificationsByTeamId();
|
||||
const [pauseMonitor, isPausing] = usePauseMonitor();
|
||||
const [updateMonitor, isUpdating] = useUpdateMonitor();
|
||||
|
||||
const FREQUENCIES = [
|
||||
{ _id: 0.25, name: t("time.fifteenSeconds") },
|
||||
{ _id: 0.5, name: t("time.thirtySeconds") },
|
||||
{ _id: 1, name: t("time.oneMinute") },
|
||||
{ _id: 2, name: t("time.twoMinutes") },
|
||||
{ _id: 5, name: t("time.fiveMinutes") },
|
||||
{ _id: 10, name: t("time.tenMinutes") },
|
||||
];
|
||||
const CRUMBS = [
|
||||
{ name: "Infrastructure monitors", path: "/infrastructure" },
|
||||
...(isCreate
|
||||
? [{ name: "Create", path: "/infrastructure/create" }]
|
||||
: [
|
||||
{ name: "Details", path: `/infrastructure/${monitorId}` },
|
||||
{ name: "Configure", path: `/infrastructure/configure/${monitorId}` },
|
||||
]),
|
||||
];
|
||||
const METRICS = ["cpu", "memory", "disk", "temperature"];
|
||||
const METRIC_PREFIX = "usage_";
|
||||
const MS_PER_MINUTE = 60000;
|
||||
|
||||
const hasAlertError = (errors) => {
|
||||
return Object.keys(errors).filter((k) => k.startsWith(METRIC_PREFIX)).length > 0;
|
||||
};
|
||||
|
||||
const getAlertError = (errors) => {
|
||||
const errorKey = Object.keys(errors).find((key) => key.startsWith(METRIC_PREFIX));
|
||||
return errorKey ? errors[errorKey] : null;
|
||||
};
|
||||
|
||||
// Populate form fields if editing
|
||||
useEffect(() => {
|
||||
if (isCreate) {
|
||||
if (globalSettingsLoading) return;
|
||||
@@ -231,6 +246,10 @@ const CreateInfrastructureMonitor = () => {
|
||||
: await updateMonitor({ monitor: form, redirect: "/infrastructure" });
|
||||
};
|
||||
|
||||
const triggerUpdate = () => {
|
||||
setUpdateTrigger(!updateTrigger);
|
||||
};
|
||||
|
||||
const onChange = (event) => {
|
||||
const { value, name } = event.target;
|
||||
setInfrastructureMonitor({
|
||||
@@ -257,19 +276,26 @@ const CreateInfrastructureMonitor = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
await pauseMonitor({ monitorId, triggerUpdate });
|
||||
};
|
||||
|
||||
const handleRemove = async (event) => {
|
||||
event.preventDefault();
|
||||
await deleteMonitor({ monitor, redirect: "/infrastructure" });
|
||||
};
|
||||
|
||||
const isBusy =
|
||||
isLoading ||
|
||||
isUpdating ||
|
||||
isCreating ||
|
||||
isDeleting ||
|
||||
isPausing ||
|
||||
notificationsAreLoading;
|
||||
|
||||
return (
|
||||
<Box className="create-infrastructure-monitor">
|
||||
<Breadcrumbs
|
||||
list={[
|
||||
{ name: "Infrastructure monitors", path: "/infrastructure" },
|
||||
...(isCreate
|
||||
? [{ name: "Create", path: "/infrastructure/create" }]
|
||||
: [
|
||||
{ name: "Details", path: `/infrastructure/${monitorId}` },
|
||||
{ name: "Configure", path: `/infrastructure/configure/${monitorId}` },
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
<Breadcrumbs list={CRUMBS} />
|
||||
<Stack
|
||||
component="form"
|
||||
onSubmit={onSubmit}
|
||||
@@ -278,25 +304,139 @@ const CreateInfrastructureMonitor = () => {
|
||||
gap={theme.spacing(12)}
|
||||
mt={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
>
|
||||
{t(isCreate ? "infrastructureCreateYour" : "infrastructureEditYour")}{" "}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="h2"
|
||||
fontSize="inherit"
|
||||
fontWeight="inherit"
|
||||
>
|
||||
{t("monitor")}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
color={
|
||||
!isCreate ? theme.palette.primary.contrastTextSecondary : undefined
|
||||
}
|
||||
>
|
||||
{!isCreate ? infrastructureMonitor.name : t("createYour") + " "}
|
||||
</Typography>
|
||||
{isCreate ? (
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
fontWeight="inherit"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
{t("monitor")}
|
||||
</Typography>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Typography>
|
||||
{!isCreate && (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
height="fit-content"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Tooltip
|
||||
title={pagespeedStatusMsg[determineState(monitor)]}
|
||||
disableInteractive
|
||||
slotProps={{
|
||||
popper: {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: { offset: [0, -8] },
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<PulseDot color={statusColor[determineState(monitor)]} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="monitorUrl"
|
||||
>
|
||||
{infrastructureMonitor.url?.replace(/^https?:\/\//, "") || "..."}
|
||||
</Typography>
|
||||
<Typography
|
||||
position="relative"
|
||||
variant="body2"
|
||||
ml={theme.spacing(6)}
|
||||
mt={theme.spacing(1)}
|
||||
sx={{
|
||||
"&:before": {
|
||||
position: "absolute",
|
||||
content: `""`,
|
||||
width: theme.spacing(2),
|
||||
height: theme.spacing(2),
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.primary.contrastTextTertiary,
|
||||
opacity: 0.8,
|
||||
left: theme.spacing(-5),
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("editing")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
{!isCreate && (
|
||||
<Box
|
||||
alignSelf="flex-end"
|
||||
ml="auto"
|
||||
>
|
||||
<Button
|
||||
onClick={handlePause}
|
||||
loading={isBusy}
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{
|
||||
pl: theme.spacing(4),
|
||||
pr: theme.spacing(6),
|
||||
"& svg": {
|
||||
mr: theme.spacing(2),
|
||||
"& path": {
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
strokeWidth: 0.1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{monitor?.isActive ? (
|
||||
<>
|
||||
<PauseCircleOutlineIcon />
|
||||
{t("pause")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlineRoundedIcon />
|
||||
{t("resume")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
loading={isBusy}
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{ ml: theme.spacing(6) }}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<ConfigBox>
|
||||
<Stack>
|
||||
<Typography
|
||||
@@ -462,7 +602,7 @@ const CreateInfrastructureMonitor = () => {
|
||||
label="Check frequency"
|
||||
value={infrastructureMonitor.interval || 15}
|
||||
onChange={onChange}
|
||||
items={SELECT_VALUES}
|
||||
items={FREQUENCIES}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
@@ -474,12 +614,23 @@ const CreateInfrastructureMonitor = () => {
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="accent"
|
||||
loading={isLoading || isUpdating || isCreating || notificationsAreLoading}
|
||||
loading={isBusy}
|
||||
>
|
||||
{t(isCreate ? "infrastructureCreateMonitor" : "infrastructureEditMonitor")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{!isCreate && (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
theme={theme}
|
||||
title={t("deleteDialogTitle")}
|
||||
description={t("deleteDialogDescription")}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
confirmationButtonLabel={t("delete")}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,80 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Gauge from "../../../../../Components/Charts/CustomGauge";
|
||||
import CustomGauge from "../../../../../Components/Charts/CustomGauge";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PropTypes from "prop-types";
|
||||
import { getPercentage } from "../../utils/utils";
|
||||
import { getPercentage, formatBytes } from "../../utils/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
const GaugeBox = ({ title, subtitle, children }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
p={theme.spacing(2)}
|
||||
maxWidth={150}
|
||||
width={150}
|
||||
>
|
||||
{children}
|
||||
|
||||
<Typography variant="h2">{title}</Typography>
|
||||
<Typography variant="body2">{subtitle}</Typography>
|
||||
</Stack>
|
||||
const BaseContainer = ({children}) => {
|
||||
const theme = useTheme()
|
||||
return(
|
||||
<Box
|
||||
sx={{
|
||||
padding: theme.spacing(3),
|
||||
borderRadius: theme.spacing(2),
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
minWidth: 250,
|
||||
width: "fit-content",
|
||||
}}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
GaugeBox.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
subtitle: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
const InfrastructureStyleGauge = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const MetricRow = ({ label, value }) => (
|
||||
<Stack
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{label}</Typography>
|
||||
<Typography sx={{
|
||||
borderRadius: theme.spacing(2),
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
width: "40%",
|
||||
mb: theme.spacing(2),
|
||||
mt: theme.spacing(2),
|
||||
pr: theme.spacing(2),
|
||||
textAlign: "right",
|
||||
}}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return(
|
||||
<BaseContainer>
|
||||
<Stack direction="column" gap={theme.spacing(2)} alignItems="center">
|
||||
<Box
|
||||
sx = {{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<CustomGauge progress={value} radius={100}/>
|
||||
<Typography component="h2" sx={{fontWeight: 600}}>
|
||||
{heading}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ width:"100%", borderTop:`1px solid ${theme.palette.divider}`}}>
|
||||
<MetricRow label={metricOne} value={valueOne} />
|
||||
{metricTwo && valueTwo && (
|
||||
<MetricRow label={metricTwo} value={valueTwo} />
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</BaseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const Gauges = ({ diagnostics, isLoading }) => {
|
||||
@@ -53,50 +99,41 @@ const Gauges = ({ diagnostics, isLoading }) => {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={theme.spacing(4)}
|
||||
spacing={theme.spacing(8)}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<GaugeBox
|
||||
title={t("diagnosticsPage.gauges.heapAllocationTitle")}
|
||||
subtitle={t("diagnosticsPage.gauges.heapAllocationSubtitle")}
|
||||
>
|
||||
<Gauge
|
||||
isLoading={isLoading}
|
||||
radius={100}
|
||||
progress={heapTotalSize}
|
||||
/>
|
||||
</GaugeBox>
|
||||
<GaugeBox
|
||||
title={t("diagnosticsPage.gauges.heapUsageTitle")}
|
||||
subtitle={t("diagnosticsPage.gauges.heapUsageSubtitle")}
|
||||
>
|
||||
<Gauge
|
||||
isLoading={isLoading}
|
||||
radius={100}
|
||||
progress={heapUsedSize}
|
||||
/>
|
||||
</GaugeBox>
|
||||
<GaugeBox
|
||||
title={t("diagnosticsPage.gauges.heapUtilizationTitle")}
|
||||
subtitle={t("diagnosticsPage.gauges.heapUtilizationSubtitle")}
|
||||
>
|
||||
<Gauge
|
||||
isLoading={isLoading}
|
||||
radius={100}
|
||||
progress={actualHeapUsed}
|
||||
/>
|
||||
</GaugeBox>
|
||||
<GaugeBox
|
||||
title={t("diagnosticsPage.gauges.instantCpuUsageTitle")}
|
||||
subtitle={t("diagnosticsPage.gauges.instantCpuUsageSubtitle")}
|
||||
>
|
||||
<Gauge
|
||||
isLoading={isLoading}
|
||||
radius={100}
|
||||
progress={diagnostics?.cpuUsage?.usagePercentage}
|
||||
precision={2}
|
||||
/>
|
||||
</GaugeBox>
|
||||
<InfrastructureStyleGauge
|
||||
value={heapTotalSize}
|
||||
heading={t("diagnosticsPage.gauges.heapAllocationTitle")}
|
||||
metricOne={t("diagnosticsPage.gauges.heapAllocationSubtitle")}
|
||||
valueOne={`${heapTotalSize?.toFixed(1) || 0}%`}
|
||||
metricTwo={t("total")}
|
||||
valueTwo={formatBytes(diagnostics?.v8HeapStats?.heapSizeLimitBytes)}
|
||||
/>
|
||||
<InfrastructureStyleGauge
|
||||
value={heapUsedSize}
|
||||
heading={t("diagnosticsPage.gauges.heapUsageTitle")}
|
||||
metricOne={t("diagnosticsPage.gauges.heapUsageSubtitle")}
|
||||
valueOne={`${heapUsedSize?.toFixed(1) || 0}%`}
|
||||
metricTwo={t("used")}
|
||||
valueTwo={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
|
||||
/>
|
||||
<InfrastructureStyleGauge
|
||||
value={actualHeapUsed}
|
||||
heading={t("diagnosticsPage.gauges.heapUtilizationTitle")}
|
||||
metricOne={t("diagnosticsPage.gauges.heapUtilizationSubtitle")}
|
||||
valueOne={`${actualHeapUsed?.toFixed(1) || 0}%`}
|
||||
metricTwo={t("total")}
|
||||
valueTwo={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
|
||||
/>
|
||||
<InfrastructureStyleGauge
|
||||
value={diagnostics?.cpuUsage?.usagePercentage}
|
||||
heading={t("diagnosticsPage.gauges.instantCpuUsageTitle")}
|
||||
metricOne={t("diagnosticsPage.gauges.instantCpuUsageSubtitle")}
|
||||
valueOne={`${diagnostics?.cpuUsage?.usagePercentage?.toFixed(1) || 0}%`}
|
||||
metricTwo=""
|
||||
valueTwo=""
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -106,4 +143,17 @@ Gauges.propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
InfrastructureStyleGauge.propTypes = {
|
||||
value: PropTypes.number,
|
||||
heading: PropTypes.string,
|
||||
metricOne: PropTypes.string,
|
||||
valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
metricTwo: PropTypes.string,
|
||||
valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
};
|
||||
|
||||
BaseContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default Gauges;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Gauges from "./components/gauges";
|
||||
import Stats from "./components/stats";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
import StatBox from "../../../Components/StatBox";
|
||||
import StatusBoxes from "../../../Components/StatusBoxes";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetchDiagnostics } from "../../../Hooks/logHooks";
|
||||
import { getHumanReadableDuration } from "../../../Utils/timeUtils";
|
||||
import { formatBytes, getPercentage } from "./utils/utils";
|
||||
|
||||
const Diagnostics = () => {
|
||||
// Local state
|
||||
@@ -19,34 +19,57 @@ const Diagnostics = () => {
|
||||
const [diagnostics, fetchDiagnostics, isLoading, error] = useFetchDiagnostics();
|
||||
// Setup
|
||||
return (
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<StatusBoxes flexWrap="wrap">
|
||||
<StatBox
|
||||
gradient={true}
|
||||
status="up"
|
||||
heading={t("status")}
|
||||
subHeading={
|
||||
error
|
||||
? t("logsPage.logLevelSelect.values.error")
|
||||
: isLoading
|
||||
? t("commonSaving")
|
||||
: diagnostics
|
||||
? t("diagnosticsPage.diagnosticDescription")
|
||||
: t("general.noOptionsFound", { unit: "data" })
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
heading={t("diagnosticsPage.stats.eventLoopDelayTitle")}
|
||||
subHeading={getHumanReadableDuration(diagnostics?.eventLoopDelayMs)}
|
||||
/>
|
||||
<StatBox
|
||||
heading={t("diagnosticsPage.stats.uptimeTitle")}
|
||||
subHeading={getHumanReadableDuration(diagnostics?.uptimeMs)}
|
||||
/>
|
||||
<StatBox
|
||||
heading={t("diagnosticsPage.stats.usedHeapSizeTitle")}
|
||||
subHeading={formatBytes(diagnostics?.v8HeapStats?.usedHeapSizeBytes)}
|
||||
/>
|
||||
<StatBox
|
||||
heading={t("diagnosticsPage.stats.totalHeapSizeTitle")}
|
||||
subHeading={formatBytes(diagnostics?.v8HeapStats?.totalHeapSizeBytes)}
|
||||
/>
|
||||
<StatBox
|
||||
heading={t("diagnosticsPage.stats.osMemoryLimitTitle")}
|
||||
subHeading={formatBytes(diagnostics?.osStats?.totalMemoryBytes)}
|
||||
/>
|
||||
</StatusBoxes>
|
||||
<Gauges
|
||||
diagnostics={diagnostics}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="h2">{t("diagnosticsPage.diagnosticDescription")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={fetchDiagnostics}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("queuePage.refreshButton")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider color={theme.palette.accent.main} />
|
||||
<Stack
|
||||
gap={theme.spacing(20)}
|
||||
mt={theme.spacing(10)}
|
||||
>
|
||||
<Gauges
|
||||
diagnostics={diagnostics}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<Stats
|
||||
diagnostics={diagnostics}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={fetchDiagnostics}
|
||||
loading={isLoading}
|
||||
>
|
||||
Fetch Diagnostics
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -232,16 +232,16 @@ const baseTheme = (palette) => ({
|
||||
},
|
||||
MuiList: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
root: ({ theme }) => ({
|
||||
padding: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
transition: "none",
|
||||
},
|
||||
root: ({ theme }) => ({
|
||||
transition: "background-color .3s",
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListItemText: {
|
||||
@@ -278,6 +278,7 @@ const baseTheme = (palette) => ({
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
MuiTableHead: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
|
||||
3
client/src/assets/icons/alert-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.35395 21C10.0591 21.6224 10.9853 22 11.9998 22C13.0142 22 13.9405 21.6224 14.6456 21M17.9998 8C17.9998 6.4087 17.3676 4.88258 16.2424 3.75736C15.1172 2.63214 13.5911 2 11.9998 2C10.4085 2 8.88235 2.63214 7.75713 3.75736C6.63192 4.88258 5.99977 6.4087 5.99977 8C5.99977 11.0902 5.22024 13.206 4.34944 14.6054C3.6149 15.7859 3.24763 16.3761 3.2611 16.5408C3.27601 16.7231 3.31463 16.7926 3.46155 16.9016C3.59423 17 4.19237 17 5.38863 17H18.6109C19.8072 17 20.4053 17 20.538 16.9016C20.6849 16.7926 20.7235 16.7231 20.7384 16.5408C20.7519 16.3761 20.3846 15.7859 19.6501 14.6054C18.7793 13.206 17.9998 11.0902 17.9998 8Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 B |
3
client/src/assets/icons/check-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 12L10.5 15L16.5 9M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
3
client/src/assets/icons/close-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 9L9 15M9 9L15 15M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -1,3 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 17L13 12L18 7M11 17L6 12L11 7" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<path d="M18 17L13 12L18 7M11 17L6 12L11 7" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 249 B |
@@ -1,3 +1,4 @@
|
||||
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 7H1M1 7L7 13M1 7L7 1" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<path d="M17 7H1M1 7L7 13M1 7L7 1" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 223 B After Width: | Height: | Size: 240 B |
@@ -1,3 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 18L9 12L15 6" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 232 B |
@@ -1,3 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 17L11 12L6 7M13 17L18 12L13 7" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<path d="M6 17L11 12L6 7M13 17L18 12L13 7" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 231 B After Width: | Height: | Size: 248 B |
@@ -1,3 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18L15 12L9 6" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 214 B After Width: | Height: | Size: 231 B |
3
client/src/assets/icons/warning-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 8V12M12 16H12.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -28,6 +28,8 @@ html {
|
||||
|
||||
--env-var-nav-bar-height: 70px;
|
||||
--env-var-side-bar-width: 250px;
|
||||
--env-var-side-bar-collapsed-width: 64px;
|
||||
--env-var-side-bar-auth-footer-height: 50px;
|
||||
|
||||
--env-var-spacing-1: 12px;
|
||||
--env-var-spacing-1-plus: 16px;
|
||||
|
||||
@@ -981,6 +981,7 @@
|
||||
"testLocale": "testLocale",
|
||||
"testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button",
|
||||
"time": {
|
||||
"fifteenSeconds": "15 seconds",
|
||||
"fiveMinutes": "5 minutes",
|
||||
"fourMinutes": "4 minutes",
|
||||
"oneDay": "1 day",
|
||||
@@ -988,6 +989,7 @@
|
||||
"oneMinute": "1 minute",
|
||||
"oneWeek": "1 week",
|
||||
"tenMinutes": "10 minutes",
|
||||
"thirtySeconds": "30 seconds",
|
||||
"threeMinutes": "3 minutes",
|
||||
"twentyMinutes": "20 minutes",
|
||||
"twoMinutes": "2 minutes"
|
||||
|
||||
@@ -44,4 +44,4 @@ services:
|
||||
timeout: 30s
|
||||
start_period: 0s
|
||||
start_interval: 1s
|
||||
retries: 30
|
||||
retries: 30
|
||||
@@ -40,6 +40,20 @@ const captureSchema = mongoose.Schema({
|
||||
mode: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const networkInterfaceSchema = mongoose.Schema({
|
||||
name: { type: String },
|
||||
bytes_sent: { type: Number, default: 0 },
|
||||
bytes_recv: { type: Number, default: 0 },
|
||||
packets_sent: { type: Number, default: 0 },
|
||||
packets_recv: { type: Number, default: 0 },
|
||||
err_in: { type: Number, default: 0 },
|
||||
err_out: { type: Number, default: 0 },
|
||||
drop_in: { type: Number, default: 0 },
|
||||
drop_out: { type: Number, default: 0 },
|
||||
fifo_in: { type: Number, default: 0 },
|
||||
fifo_out: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const HardwareCheckSchema = mongoose.Schema(
|
||||
{
|
||||
...BaseCheckSchema.obj,
|
||||
@@ -69,6 +83,12 @@ const HardwareCheckSchema = mongoose.Schema(
|
||||
type: captureSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
net: {
|
||||
type: [networkInterfaceSchema],
|
||||
default: () => [],
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -74,6 +74,10 @@ const StatusPageSchema = mongoose.Schema(
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customCSS: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -220,6 +220,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => {
|
||||
diskCount: {
|
||||
$size: "$disk",
|
||||
},
|
||||
netCount: { $size: "$net" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -227,6 +228,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => {
|
||||
from: "hardwarechecks",
|
||||
let: {
|
||||
diskCount: "$diskCount",
|
||||
netCount: "$netCount",
|
||||
},
|
||||
pipeline: [
|
||||
{
|
||||
@@ -258,6 +260,9 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => {
|
||||
disks: {
|
||||
$push: "$disk",
|
||||
},
|
||||
net: {
|
||||
$push: "$net",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -371,6 +376,92 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
net: {
|
||||
$map: {
|
||||
input: { $range: [0, "$$netCount"] },
|
||||
as: "netIndex",
|
||||
in: {
|
||||
name: {
|
||||
$arrayElemAt: [
|
||||
{
|
||||
$map: {
|
||||
input: "$net",
|
||||
as: "netArray",
|
||||
in: { $arrayElemAt: ["$$netArray.name", "$$netIndex"] },
|
||||
},
|
||||
},
|
||||
0,
|
||||
],
|
||||
},
|
||||
avgBytesSent: {
|
||||
$avg: {
|
||||
$map: {
|
||||
input: "$net",
|
||||
as: "netArray",
|
||||
in: {
|
||||
$arrayElemAt: ["$$netArray.bytes_sent", "$$netIndex"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
avgBytesRecv: {
|
||||
$avg: {
|
||||
$map: {
|
||||
input: "$net",
|
||||
as: "netArray",
|
||||
in: {
|
||||
$arrayElemAt: ["$$netArray.bytes_recv", "$$netIndex"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
avgPacketsSent: {
|
||||
$avg: {
|
||||
$map: {
|
||||
input: "$net",
|
||||
as: "netArray",
|
||||
in: {
|
||||
$arrayElemAt: ["$$netArray.packets_sent", "$$netIndex"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
avgPacketsRecv: {
|
||||
$avg: {
|
||||
$map: {
|
||||
input: "$net",
|
||||
as: "netArray",
|
||||
in: {
|
||||
$arrayElemAt: ["$$netArray.packets_recv", "$$netIndex"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
avgErrIn: {
|
||||
$avg: {
|
||||
$map: {
|
||||
input: "$net",
|
||||
as: "netArray",
|
||||
in: {
|
||||
$arrayElemAt: ["$$netArray.err_in", "$$netIndex"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
avgErrOut: {
|
||||
$avg: {
|
||||
$map: {
|
||||
input: "$net",
|
||||
as: "netArray",
|
||||
in: {
|
||||
$arrayElemAt: ["$$netArray.err_out", "$$netIndex"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -236,7 +236,7 @@ class StatusService {
|
||||
}
|
||||
|
||||
if (type === "hardware") {
|
||||
const { cpu, memory, disk, host } = payload?.data ?? {};
|
||||
const { cpu, memory, disk, host, net } = payload?.data ?? {};
|
||||
const { errors } = payload?.errors ?? [];
|
||||
check.cpu = cpu ?? {};
|
||||
check.memory = memory ?? {};
|
||||
@@ -244,6 +244,7 @@ class StatusService {
|
||||
check.host = host ?? {};
|
||||
check.errors = errors ?? [];
|
||||
check.capture = payload?.capture ?? {};
|
||||
check.net = net ?? {};
|
||||
}
|
||||
return check;
|
||||
};
|
||||
|
||||