restore repo structure

This commit is contained in:
Alex Holliday
2025-04-20 11:29:53 -07:00
parent 6df099a5b6
commit 8b7e3c650b
592 changed files with 37663 additions and 127 deletions

72
client/src/App.jsx Normal file
View File

@@ -0,0 +1,72 @@
import { useEffect } from "react";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react";
import lightTheme from "./Utils/Theme/lightTheme";
import darkTheme from "./Utils/Theme/darkTheme";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { getAppSettings } from "./Features/Settings/settingsSlice";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Routes } from "./Routes";
import WalletProvider from "./Components/WalletProvider";
import { useTranslation } from "react-i18next";
import { setLanguage } from "./Features/UI/uiSlice";
function App() {
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const { i18n } = useTranslation();
useEffect(() => {
if (authToken) {
dispatch(getAppSettings({ authToken })).then((action) => {
if (action.payload && action.payload.success) {
const { language } = action.payload.data;
const availableLanguages = Object.keys(i18n.options.resources || {});
if (language && availableLanguages.includes(language)) {
dispatch(setLanguage(language));
i18n.changeLanguage(language);
} else {
dispatch(setLanguage(availableLanguages[0]));
i18n.changeLanguage(availableLanguages[0]);
}
}
});
}
}, [dispatch, authToken, i18n]);
// Cleanup
useEffect(() => {
return () => {
logger.cleanup();
networkService.cleanup();
};
}, []);
return (
/* Extract Themeprovider, baseline and global styles to Styles */
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<WalletProvider>
<CssBaseline />
<GlobalStyles
styles={({ palette }) => {
return {
body: {
backgroundImage: `radial-gradient(circle, ${palette.gradient.color1}, ${palette.gradient.color2}, ${palette.gradient.color3}, ${palette.gradient.color4}, ${palette.gradient.color5})`,
color: palette.primary.contrastText,
},
};
}}
/>
<Routes />
<ToastContainer />
</WalletProvider>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,234 @@
import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { createToast } from "../../Utils/toastUtils";
import { logger } from "../../Utils/Logger";
import { IconButton, Menu, MenuItem } from "@mui/material";
import {
deleteUptimeMonitor,
pauseUptimeMonitor,
} from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Settings from "../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
import Dialog from "../../Components/Dialog";
const ActionsMenu = ({
monitor,
isAdmin,
updateRowCallback,
pauseCallback,
setIsLoading = () => {},
}) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
const theme = useTheme();
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { _id: actions.id };
const action = await dispatch(deleteUptimeMonitor({ monitor }));
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
updateRowCallback();
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
}
};
const handlePause = async () => {
try {
setIsLoading(true);
const action = await dispatch(pauseUptimeMonitor({ monitorId: monitor._id }));
if (pauseUptimeMonitor.fulfilled.match(action)) {
const state = action?.payload?.data.isActive === false ? "resumed" : "paused";
createToast({ body: `Monitor ${state} successfully.` });
pauseCallback();
} else {
throw new Error(action?.error?.message ?? "Failed to pause monitor.");
}
} catch (error) {
logger.error("Error pausing monitor:", monitor._id, error);
createToast({ body: "Failed to pause monitor." });
}
};
const openMenu = (event, id, url) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event, monitor._id, monitor.type === "ping" ? null : monitor.url);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Settings />
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": {
p: theme.spacing(2.5),
backgroundColor: theme.palette.primary.main,
},
"& li": { m: 0, color: theme.palette.primary.contrastTextSecondary },
/*
This should not be set automatically on the last of type
"& li:last-of-type": {
color: theme.palette.error.main,
}, */
},
},
}}
>
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/uptime/${actions.id}`);
}}
>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/incidents/${actions.id}`);
}}
>
Incidents
</MenuItem>
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/uptime/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/uptime/create/${actions.id}`);
}}
>
Clone
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
handlePause(e);
}}
>
{monitor?.isActive === true ? "Pause" : "Resume"}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
sx={{ "&.MuiButtonBase-root": { color: theme.palette.error.main } }}
>
Remove
</MenuItem>
)}
</Menu>
<Dialog
open={isOpen}
theme={theme}
title="Do you really want to delete this monitor?"
description="Once deleted, this monitor cannot be retrieved."
/* Do we need stop propagation? */
onCancel={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
confirmationButtonLabel="Delete"
/* Do we need stop propagation? */
onConfirm={(e) => {
e.stopPropagation();
handleRemove(e);
}}
isLoading={isLoading}
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
/>
</>
);
};
ActionsMenu.propTypes = {
monitor: PropTypes.shape({
_id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
isActive: PropTypes.bool,
}).isRequired,
isAdmin: PropTypes.bool,
updateRowCallback: PropTypes.func,
pauseCallback: PropTypes.func,
setIsLoading: PropTypes.func,
};
export default ActionsMenu;

View File

@@ -0,0 +1,9 @@
.alert {
margin: 0;
width: fit-content;
}
.alert,
.alert button,
.alert .MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}

View File

@@ -0,0 +1,127 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Button, IconButton, Stack, Typography } from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined";
import CloseIcon from "@mui/icons-material/Close";
import "./index.css";
/**
* Icons mapping for different alert variants.
* @type {Object<string, JSX.Element>}
*/
const icons = {
info: <InfoOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
warning: <WarningAmberOutlinedIcon />,
};
/**
* @param {Object} props
* @param {'info' | 'error' | 'warning'} props.variant - The type of alert.
* @param {string} [props.title] - The title of the alert.
* @param {string} [props.body] - The body text of the alert.
* @param {boolean} [props.isToast] - Indicates if the alert is used as a toast notification.
* @param {boolean} [props.hasIcon] - Whether to display an icon in the alert.
* @param {function} props.onClick - Toast dismiss function.
* @returns {JSX.Element}
*/
const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
const theme = useTheme();
/* TODO
Do we need other variants for alert?
*/
const text = theme.palette.secondary.contrastText;
const border = theme.palette.alert.contrastText;
const bg = theme.palette.alert.main;
const icon = icons[variant];
return (
<Stack
direction="row"
justifyContent="flex-start"
alignItems={hasIcon ? "" : "center"}
className="alert row-stack"
gap={theme.spacing(8)}
sx={{
padding: hasIcon ? theme.spacing(8) : `${theme.spacing(4)} ${theme.spacing(8)}`,
backgroundColor: bg,
border: `solid 1px ${border}`,
borderRadius: theme.shape.borderRadius,
}}
>
{hasIcon && <Box sx={{ color: text }}>{icon}</Box>}
<Stack
direction="column"
gap="2px"
sx={{ flex: 1 }}
>
{title && (
<Typography sx={{ fontWeight: "700", color: `${text}` }}>{title}</Typography>
)}
{body && (
<Typography sx={{ fontWeight: "400", color: `${text}` }}>{body}</Typography>
)}
{hasIcon && isToast && (
<Button
variant="text"
color="info"
onClick={onClick}
sx={{
fontWeight: "600",
width: "fit-content",
mt: theme.spacing(4),
padding: 0,
minWidth: 0,
}}
>
Dismiss
</Button>
)}
</Stack>
{isToast && (
<IconButton
onClick={onClick}
sx={{
alignSelf: "flex-start",
ml: "auto",
mr: "-5px",
mt: hasIcon ? "-5px" : 0,
padding: "5px",
"&:focus": {
outline: "none",
},
}}
>
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
)}
</Stack>
);
};
Alert.propTypes = {
variant: PropTypes.oneOf(["info", "error", "warning"]).isRequired,
title: PropTypes.string,
body: PropTypes.string,
isToast: PropTypes.bool,
hasIcon: PropTypes.bool,
onClick: function (props, propName, componentName) {
if (props.isToast && !props[propName]) {
return new Error(
`Prop '${propName}' is required when 'isToast' is true in '${componentName}'.`
);
}
return null;
},
};
export default Alert;

View File

@@ -0,0 +1,68 @@
import PropTypes from "prop-types";
import { Box, Stack, useTheme } from "@mui/material";
// import useUtils from "../../Pages/Uptime/utils";
/**
* A component that renders a pulsating dot with a specified color.
*
* @component
* @example
* // Example usage:
* <PulseDot color="#f00" />
*
* @param {Object} props
* @param {string} props.color - The color of the dot.
* @returns {JSX.Element} The PulseDot component.
*/
const PulseDot = ({ color }) => {
const theme = useTheme();
// const { statusToTheme } = useUtils();
/* TODO refactor so it gets status and gets theme color. Then uses theme.palette.[themeColor].lowContrast */
/* const themeColor = statusToTheme[status]; */
return (
<Stack
width="26px"
height="24px"
alignItems="center"
justifyContent="center"
>
<Box
minWidth="18px"
minHeight="18px"
sx={{
position: "relative",
backgroundColor: color,
borderRadius: "50%",
"&::before": {
content: `""`,
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "inherit",
borderRadius: "50%",
animation: "ripple 1.8s ease-out infinite",
},
"&::after": {
content: `""`,
position: "absolute",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: theme.palette.accent.contrastText,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
}}
/>
</Stack>
);
};
PulseDot.propTypes = {
color: PropTypes.string.isRequired,
};
export default PulseDot;

View File

View File

@@ -0,0 +1,71 @@
import { Avatar as MuiAvatar } from "@mui/material";
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
/**
* @component
* @param {Object} props
* @param {string} props.src - Path to image for avatar
* @param {boolean} props.small - Specifies if avatar should be large
* @param {Object} [props.sx] - Additional styles to apply to the button.
* @returns {JSX.Element}
* @example
* // Render a red label
* <Avatar src="assets/img" first="Alex" last="Holliday" small />
*/
const Avatar = ({ src, small, sx }) => {
const { user } = useSelector((state) => state.auth);
const theme = useTheme();
const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 };
const border = small ? 1 : 3;
const [image, setImage] = useState();
useEffect(() => {
if (user.avatarImage) {
setImage(`data:image/png;base64,${user.avatarImage}`);
}
}, [user?.avatarImage]);
return (
<MuiAvatar
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"}
sx={{
fontSize: small ? "16px" : "22px",
fontWeight: 400,
color: theme.palette.accent.contrastText,
backgroundColor: theme.palette.accent.main, // Same BG color as checkmate BG in sidebar
display: "inline-flex",
/*
TODO not sure what this is for*/
"&::before": {
content: `""`,
position: "absolute",
top: 0,
left: 0,
width: `100%`,
height: `100%`,
border: `${border}px solid rgba(255,255,255,0.2)`,
borderRadius: "50%",
},
...style,
...sx,
}}
>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0) || ""}
</MuiAvatar>
);
};
Avatar.propTypes = {
src: PropTypes.string,
small: PropTypes.bool,
sx: PropTypes.object,
};
export default Avatar;

View File

@@ -0,0 +1,22 @@
.MuiBreadcrumbs-root {
min-height: 34px;
}
.MuiBreadcrumbs-root svg {
width: 16px;
min-height: 16px;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li a {
font-size: var(--env-var-font-size-medium);
font-weight: 400;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:not(:last-child) {
cursor: pointer;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:last-child a {
font-weight: 500;
opacity: 1;
cursor: default;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-separator {
margin: 0;
}

View File

@@ -0,0 +1,79 @@
import PropTypes from "prop-types";
import { Box, Breadcrumbs as MUIBreadcrumbs } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router";
import ArrowRight from "../../assets/icons/right-arrow.svg?react";
import "./index.css";
/**
* Breadcrumbs component that displays a list of breadcrumb items.
*
* @param {Object} props
* @param {Array} props.list - Array of breadcrumb items. Each item should have `name` and `path` properties.
* @param {string} props.list.name - The name to display for the breadcrumb.
* @param {string} props.list.path - The path to navigate to when the breadcrumb is clicked.
*
* @returns {JSX.Element} The rendered Breadcrumbs component.
*/
const Breadcrumbs = ({ list }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<MUIBreadcrumbs
separator={<ArrowRight />}
aria-label="breadcrumb"
px={theme.spacing(2)}
py={theme.spacing(3.5)}
width="fit-content"
backgroundColor={theme.palette.secondary.main}
borderRadius={theme.shape.borderRadius}
lineHeight="18px"
sx={{
"& .MuiBreadcrumbs-li a": {
transition: "background-color 0.2s ease-in-out, color 0.2s ease-in-out",
},
"& .MuiBreadcrumbs-li:not(:last-of-type):hover a": {
backgroundColor: theme.palette.secondary.contrastText,
color: theme.palette.secondary.main,
},
}}
>
{list.map((item, index) => {
return (
<Box
component="a"
key={`${item.name}-${index}`}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.shape.borderRadius}
onClick={() => navigate(item.path)}
sx={{
opacity: 0.8,
textTransform: "capitalize",
"&, &:hover": {
color: theme.palette.secondary.contrastText,
},
}}
>
{item.name}
</Box>
);
})}
</MUIBreadcrumbs>
);
};
Breadcrumbs.propTypes = {
list: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}).isRequired
).isRequired,
};
export default Breadcrumbs;

View File

@@ -0,0 +1,30 @@
import React from "react";
import Button from "@mui/material/Button";
import { styled } from "@mui/material/styles";
const RoundGradientButton = styled(Button)(({ theme }) => ({
position: "relative",
border: "5px solid transparent",
backgroundClip: "padding-box",
borderRadius: 30,
fontSize: "1.2rem",
color: theme.palette.primary.contrastText,
backgroundColor: theme.palette.background.main,
"&:after": {
position: "absolute",
top: -3,
left: -3,
right: -3,
bottom: -3,
background:
theme.palette.mode === "dark"
? "linear-gradient(90deg, #842bd2, #ff5451, #8c52ff)"
: "linear-gradient(90deg, #842bd2, #ff5451, #8c52ff)",
content: '""',
zIndex: -1,
borderRadius: 30,
},
}));
export default RoundGradientButton;

View File

@@ -0,0 +1,214 @@
/**
* CustomAreaChart component for rendering an area chart with optional gradient and custom ticks.
*
* @param {Object} props - The properties object.
* @param {Array} props.data - The data array for the chart.
* @param {Array} props.dataKeys - An array of data keys to be plotted as separate areas.
* @param {string} props.xKey - The key for the x-axis data.
* @param {string} [props.yKey] - The key for the y-axis data (optional).
* @param {Object} [props.xTick] - Custom tick component for the x-axis.
* @param {Object} [props.yTick] - Custom tick component for the y-axis.
* @param {string} [props.strokeColor] - The base stroke color for the areas.
* If not provided, uses a predefined color palette.
* @param {string} [props.fillColor] - The base fill color for the areas.
* @param {boolean} [props.gradient=false] - Whether to apply a gradient fill to the areas.
* @param {string} [props.gradientDirection="vertical"] - The direction of the gradient.
* @param {string} [props.gradientStartColor] - The start color of the gradient.
* Defaults to the area's stroke color if not provided.
* @param {string} [props.gradientEndColor] - The end color of the gradient.
* @param {Object} [props.customTooltip] - Custom tooltip component for the chart.
* @param {string|number} [props.height="100%"] - Height of the chart container.
*
* @returns {JSX.Element} The rendered area chart component.
*
* @example
* // Single series chart
* <CustomAreaChart
* data={temperatureData}
* dataKeys={["temperature"]}
* xKey="date"
* yKey="temperature"
* gradient={true}
* gradientStartColor="#ff6b6b"
* gradientEndColor="#4ecdc4"
* />
*
* @example
* // Multi-series chart with custom tooltip
* <CustomAreaChart
* data={performanceData}
* dataKeys={["cpu.usage", "memory.usage"]}
* xKey="timestamp"
* xTick={<CustomTimeTick />}
* yTick={<PercentageTick />}
* gradient={true}
* customTooltip={({ active, payload, label }) => (
* <CustomTooltip
* label={label}
* payload={payload}
* active={active}
* />
* )}
* />
*/
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { createGradient } from "../Utils/gradientUtils";
import PropTypes from "prop-types";
import { useTheme } from "@mui/material";
import { useId } from "react";
import { Fragment } from "react";
const CustomAreaChart = ({
data,
dataKeys,
xKey,
xDomain,
yKey,
yDomain,
xTick,
yTick,
strokeColor,
fillColor,
gradient = false,
gradientDirection = "vertical",
gradientStartColor,
gradientEndColor,
customTooltip,
height = "100%",
}) => {
const theme = useTheme();
const uniqueId = useId();
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
return (
<ResponsiveContainer
width="100%"
height={height}
// FE team HELP! Why does this overflow if set to 100%?
>
<AreaChart data={data}>
<XAxis
dataKey={xKey}
{...(xDomain && { domain: xDomain })}
{...(xTick && { tick: xTick })}
/>
<YAxis
dataKey={yKey}
{...(yDomain && { domain: yDomain })}
{...(yTick && { tick: yTick })}
/>
<CartesianGrid
stroke={theme.palette.primary.lowContrast}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
{dataKeys.map((dataKey, index) => {
const gradientId = `gradient-${uniqueId}-${index}`;
return (
<Fragment key={dataKey}>
{gradient === true &&
createGradient({
id: gradientId,
startColor: gradientStartColor || AREA_COLORS[index],
endColor: gradientEndColor,
direction: gradientDirection,
})}
<Area
yKey={dataKey}
key={dataKey}
type="monotone"
dataKey={dataKey}
stroke={strokeColor || AREA_COLORS[index]}
fill={gradient === true ? `url(#${gradientId})` : fillColor}
/>
</Fragment>
);
})}
{customTooltip ? (
<Tooltip
cursor={{ stroke: theme.palette.primary.lowContrast }}
content={customTooltip}
wrapperStyle={{ pointerEvents: "none" }}
/>
) : (
<Tooltip />
)}
</AreaChart>
</ResponsiveContainer>
);
};
CustomAreaChart.propTypes = {
data: PropTypes.array.isRequired,
dataKeys: PropTypes.array.isRequired,
xTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
yTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
xKey: PropTypes.string.isRequired,
xDomain: PropTypes.array,
yKey: PropTypes.string,
yDomain: PropTypes.array,
fillColor: PropTypes.string,
strokeColor: PropTypes.string,
gradient: PropTypes.bool,
gradientDirection: PropTypes.string,
gradientStartColor: PropTypes.string,
gradientEndColor: PropTypes.string,
customTooltip: PropTypes.object,
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default CustomAreaChart;

View File

@@ -0,0 +1,181 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import { useEffect, useState } from "react";
import "./index.css";
import { useSelector } from "react-redux";
/* TODO add prop validation and jsdocs */
const BarChart = ({ checks = [] }) => {
const theme = useTheme();
const [animate, setAnimate] = useState(false);
const uiTimezone = useSelector((state) => state.ui.timezone);
useEffect(() => {
setAnimate(true);
});
// set responseTime to average if there's only one check
if (checks.length === 1) {
checks[0] = { ...checks[0], responseTime: 50 };
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill("placeholder");
checks = [...checks, ...placeholders];
}
return (
<Stack
direction="row"
flexWrap="nowrap"
gap={theme.spacing(1.5)}
height="50px"
width="fit-content"
onClick={(event) => event.stopPropagation()}
sx={{
cursor: "default",
}}
>
{checks.map((check, index) =>
check === "placeholder" ? (
/* TODO what is the purpose of this box? */
// CAIO_REVIEW the purpose of this box is to make sure there are always at least 25 bars
// even if there are less than 25 checks
<Box
key={`${check}-${index}`}
position="relative"
width={theme.spacing(4.5)}
height="100%"
backgroundColor={theme.palette.primary.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
}}
/>
) : (
<Tooltip
title={
<>
<Typography>
{formatDateWithTz(
check.updatedAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
<Box mt={theme.spacing(2)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={
check.status
? theme.palette.success.lowContrast
: theme.palette.error.lowContrast
}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(2)}
gap={theme.spacing(12)}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
Response Time
</Typography>
<Typography component="span">
{check.originalResponseTime}
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
</>
}
placement="top"
key={`check-${check?._id}`}
slotProps={{
popper: {
className: "bar-tooltip",
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
sx: {
"& .MuiTooltip-tooltip": {
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
px: theme.spacing(4),
py: theme.spacing(3),
},
"& .MuiTooltip-tooltip p": {
/* TODO Font size should point to theme */
fontSize: 12,
color: theme.palette.secondary.contrastText,
fontWeight: 500,
},
"& .MuiTooltip-tooltip span": {
/* TODO Font size should point to theme */
fontSize: 11,
color: theme.palette.secondary.contrastText,
fontWeight: 600,
},
},
},
}}
>
<Box
position="relative"
width="9px"
height="100%"
backgroundColor={theme.palette.primary.lowContrast} // CAIO_REVIEW
sx={{
borderRadius: theme.spacing(1.5),
/*
TODO this doesn't seem to be used
"&:hover > .MuiBox-root": {
filter: "brightness(0.8)",
}, */
}}
>
<Box
position="absolute"
bottom={0}
width="100%"
height={`${animate ? check.responseTime : 0}%`}
backgroundColor={
check.status
? theme.palette.success.lowContrast
: theme.palette.error.lowContrast
}
sx={{
borderRadius: theme.spacing(1.5),
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
</Tooltip>
)
)}
</Stack>
);
};
export default BarChart;

View File

@@ -0,0 +1,108 @@
// Components
import { Typography, Stack } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import IconBox from "../../IconBox";
/**
* `EmptyView` is a functional React component that displays an empty state view with an optional icon, header, and message.
*
* @component
* @param {Object} props - The properties that define the `EmptyView` component.
* @param {React.ReactNode} [props.icon] - An optional icon to display at the top of the empty view.
* @param {string} [props.header] - An optional header text displayed next to the icon.
* @param {string} [props.message="No Data"] - The message to be displayed in the empty view.
* @param {'h1' | 'h2' | 'h3'} [props.headingLevel="h2"] - The heading level for the message text.
* @param {string} [props.justifyContent="flex-start"] - The CSS `justify-content` value to align elements vertically.
* @param {string} [props.height="100%"] - The height of the empty view container.
*
* @example
* // Example usage of EmptyView component:
* <EmptyView
* icon={<SomeIcon />}
* header="Average Response Time"
* message="No Response Time Available"
* headingLevel="h2"
* justifyContent="center"
* height="50%"
* />
*
* @returns {React.Element} The `EmptyView` component with customizable icon, header, and message.
*/
const EmptyView = ({
icon,
header,
message = "No Data",
headingLevel = "h2",
justifyContent = "flex-start",
height = "100%"
}) => {
const theme = useTheme();
return (
<Stack
flex={1}
direction="row"
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: 2,
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
}}
>
<Stack
flex={1}
alignItems="center"
sx={{
padding: theme.spacing(8),
justifyContent,
gap: theme.spacing(8),
height,
"& h2": {
color: theme.palette.primary.contrastTextSecondary,
fontSize: 15,
fontWeight: 500,
},
"& tspan, & text": {
fill: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Stack
alignSelf="flex-start"
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
{icon && <IconBox>{icon}</IconBox>}
{header && <Typography component="h2">{header}</Typography>}
</Stack>
<Stack
flex={1}
justifyContent="center"
alignItems="center"
>
<Typography component={headingLevel}>
{message}
</Typography>
</Stack>
</Stack>
</Stack>
);
};
EmptyView.propTypes = {
message: PropTypes.string,
icon: PropTypes.node,
header: PropTypes.string,
headingLevel: PropTypes.oneOf(['h1', 'h2', 'h3']),
justifyContent: PropTypes.string,
height: PropTypes.string
};
export default EmptyView;

View File

@@ -0,0 +1,99 @@
import { Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import IconBox from "../../IconBox";
import EmptyView from "./EmptyView";
import PropTypes from "prop-types";
const ChartBox = ({
children,
icon,
header,
height = "300px",
justifyContent = "space-between",
Legend,
borderRadiusRight = 4,
sx,
noDataMessage,
isEmpty = false,
}) => {
const theme = useTheme();
if (isEmpty) {
return <EmptyView icon={icon} header={header} message={noDataMessage} />;
}
return (
<Stack
flex={1}
direction="row"
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: 2,
borderTopRightRadius: borderRadiusRight,
borderBottomRightRadius: borderRadiusRight,
}}
>
<Stack
flex={1}
alignItems="center"
sx={{
padding: theme.spacing(8),
justifyContent,
gap: theme.spacing(8),
height,
minWidth: 250,
"& h2": {
color: theme.palette.primary.contrastTextSecondary,
fontSize: 15,
fontWeight: 500,
},
"& .MuiBox-root:not(.area-tooltip) p": {
color: theme.palette.primary.contrastTextTertiary,
fontSize: 13,
},
"& .MuiBox-root > span": {
color: theme.palette.primary.contrastText,
fontSize: 20,
"& span": {
opacity: 0.8,
marginLeft: 2,
fontSize: 15,
},
},
"& tspan, & text": {
fill: theme.palette.primary.contrastTextTertiary,
},
"& path": {
transition: "fill 300ms ease, stroke-width 400ms ease",
},
}}
>
<Stack
alignSelf="flex-start"
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
{icon && <IconBox>{icon}</IconBox>}
{header && <Typography component="h2">{header}</Typography>}
</Stack>
{children}
</Stack>
{Legend && Legend}
</Stack>
);
};
export default ChartBox;
ChartBox.propTypes = {
children: PropTypes.node,
icon: PropTypes.node,
header: PropTypes.string,
height: PropTypes.string,
noDataMessage: PropTypes.string,
isEmpty: PropTypes.bool
};

View File

@@ -0,0 +1,14 @@
.radial-chart {
position: relative;
display: inline-block;
}
.radial-chart-base {
opacity: 0.3;
}
.radial-chart-progress {
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 1.5s ease-in-out;
}

View File

@@ -0,0 +1,119 @@
import { useTheme } from "@emotion/react";
import { useEffect, useState, useMemo } from "react";
import { Box, Typography } from "@mui/material";
import PropTypes from "prop-types";
import "./index.css";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
/**
* A Performant SVG based circular gauge
*
* @component
* @param {Object} props - Component properties
* @param {number} [props.progress=0] - Progress percentage (0-100)
* @param {number} [props.radius=60] - Radius of the gauge circle
* @param {number} [props.strokeWidth=15] - Width of the gauge stroke
* @param {number} [props.threshold=50] - Threshold for color change
*
* @example
* <CustomGauge
* progress={75}
* radius={50}
* strokeWidth={10}
* threshold={50}
* />
*
* @returns {React.ReactElement} Rendered CustomGauge component
*/
const CustomGauge = ({ progress = 0, radius = 70, strokeWidth = 15, threshold = 50 }) => {
const theme = useTheme();
// Calculate the length of the stroke for the circle
const { circumference, totalSize, strokeLength } = useMemo(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progress / 100) * (2 * Math.PI * radius),
}),
[radius, strokeWidth, progress]
);
// Handle initial animation
const [offset, setOffset] = useState(circumference);
useEffect(() => {
setOffset(circumference);
const timer = setTimeout(() => {
setOffset(circumference - strokeLength);
}, 100);
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
const fillColor =
progressWithinRange > threshold
? theme.palette.error.lowContrast // CAIO_REVIEW
: theme.palette.accent.main; // CAIO_REVIEW
return (
<Box
className="radial-chart"
width={radius}
height={radius}
sx={{ backgroundColor: theme.palette.primary.main, borderRadius: "50%" }}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
width={radius}
height={radius}
>
<circle
className="radial-chart-base"
stroke={theme.palette.secondary.light} // CAIO_REVIEW
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2} // Center the circle
cy={totalSize / 2} // Center the circle
r={radius}
/>
<circle
className="radial-chart-progress"
stroke={fillColor}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
strokeLinecap="round"
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
/>
</svg>
<Typography
className="radial-chart-text"
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
...theme.typography.h2,
fill: theme.typography.h2.color,
}}
>
{`${progressWithinRange.toFixed(1)}%`}
</Typography>
</Box>
);
};
export default CustomGauge;
CustomGauge.propTypes = {
progress: PropTypes.number,
radius: PropTypes.number,
strokeWidth: PropTypes.number,
threshold: PropTypes.number,
};

View File

@@ -0,0 +1,181 @@
// Components
import { Stack, Box, Tooltip, Typography } from "@mui/material";
// Utils
import { useTheme } from "@emotion/react";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import { useSelector } from "react-redux";
const PlaceholderCheck = ({ daysToShow }) => {
const theme = useTheme();
return (
<Box
width={`calc(30vw / ${daysToShow})`}
height="100%"
backgroundColor={theme.palette.primary.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
}}
/>
);
};
PlaceholderCheck.propTypes = {
daysToShow: PropTypes.number,
};
const Check = ({ check, daysToShow }) => {
const [animate, setAnimate] = useState(false);
const theme = useTheme();
useEffect(() => {
setAnimate(true);
}, []);
const uiTimezone = useSelector((state) => state.ui.timezone);
return (
<Tooltip
title={
<>
<Typography>
{formatDateWithTz(check._id, "ddd, MMMM D, YYYY", uiTimezone)}
</Typography>
<Box mt={theme.spacing(2)}>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
gap={theme.spacing(4)}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
Uptime percentage
</Typography>
<Typography component="span">
{check.upPercentage.toFixed(2)}
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{" "}
%
</Typography>
</Typography>
</Stack>
</Box>
</>
}
placement="top"
key={`check-${check?._id}`}
slotProps={{
popper: {
className: "bar-tooltip",
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
sx: {
"& .MuiTooltip-tooltip": {
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
px: theme.spacing(4),
py: theme.spacing(3),
},
"& .MuiTooltip-tooltip p": {
/* TODO Font size should point to theme */
fontSize: 12,
color: theme.palette.secondary.contrastText,
fontWeight: 500,
},
"& .MuiTooltip-tooltip span": {
/* TODO Font size should point to theme */
fontSize: 11,
color: theme.palette.secondary.contrastText,
fontWeight: 600,
},
},
},
}}
>
<Box
position="relative"
width={`calc(30vw / ${daysToShow})`}
height="100%"
backgroundColor={theme.palette.error.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
}}
>
<Box
position="absolute"
bottom={0}
width="100%"
height={`${animate ? check.upPercentage : 0}%`}
backgroundColor={theme.palette.success.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
</Tooltip>
);
};
Check.propTypes = {
check: PropTypes.object,
daysToShow: PropTypes.number,
};
const DePINStatusPageBarChart = ({ checks = [], daysToShow = 30 }) => {
if (checks.length !== daysToShow) {
const placeholders = Array(daysToShow - checks.length).fill("placeholder");
checks = [...checks, ...placeholders];
}
return (
<Stack
direction="row"
justifyContent="space-between"
width="100%"
flexWrap="nowrap"
height="50px"
>
{checks.map((check) => {
if (check === "placeholder") {
return (
<PlaceholderCheck
key={Math.random()}
daysToShow={daysToShow}
/>
);
}
return (
<Check
key={Math.random()}
check={check}
daysToShow={daysToShow}
/>
);
})}
</Stack>
);
};
DePINStatusPageBarChart.propTypes = {
checks: PropTypes.array,
daysToShow: PropTypes.number,
};
export default DePINStatusPageBarChart;

View File

@@ -0,0 +1,42 @@
import { Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import IconBox from "../../IconBox";
import PropTypes from "prop-types";
const LegendBox = ({ children, icon, header, sx }) => {
const theme = useTheme();
return (
<Stack
direction="column"
gap={theme.spacing(4)}
borderRadius={theme.spacing(8)}
sx={{
...sx,
"& label": { pl: theme.spacing(6) },
borderLeftStyle: "solid",
borderLeftWidth: 1,
borderLeftColor: theme.palette.primary.lowContrast,
backgroundColor: theme.palette.primary.main,
padding: theme.spacing(8),
background: `linear-gradient(325deg, ${theme.palette.tertiary.main} 20%, ${theme.palette.primary.main} 45%)`,
}}
>
<Stack
direction="row"
gap={theme.spacing(6)}
>
<IconBox>{icon}</IconBox>
<Typography component="h2">{header}</Typography>
</Stack>
{children}
</Stack>
);
};
LegendBox.propTypes = {
children: PropTypes.node,
icon: PropTypes.node,
header: PropTypes.string,
};
export default LegendBox;

View File

@@ -0,0 +1,222 @@
import PropTypes from "prop-types";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import {
tooltipDateFormatLookup,
tickDateFormatLookup,
} from "../Utils/chartUtilFunctions";
import "./index.css";
const CustomToolTip = ({ active, payload, label, dateRange }) => {
const format = tooltipDateFormatLookup(dateRange);
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
if (active && payload && payload.length) {
const responseTime = payload[0]?.payload?.originalAvgResponseTime
? payload[0]?.payload?.originalAvgResponseTime
: (payload[0]?.payload?.avgResponseTime ?? 0);
return (
<Box
className="area-tooltip"
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.primary.contrastTextTertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, format, uiTimezone)}
</Typography>
<Box mt={theme.spacing(1)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={theme.palette.primary.main}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.primary.contrastTextTertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
Response time:
</Typography>
<Typography
ml={theme.spacing(4)}
component="span"
>
{Math.floor(responseTime)}
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
}
return null;
};
CustomToolTip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.number,
payload: PropTypes.shape({
_id: PropTypes.string,
avgResponseTime: PropTypes.number,
originalAvgResponseTime: PropTypes.number,
}),
})
),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
dateRange: PropTypes.string,
};
const CustomTick = ({ x, y, payload, dateRange }) => {
const format = tickDateFormatLookup(dateRange);
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.primary.contrastTextTertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
CustomTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
dateRange: PropTypes.string,
};
const MonitorDetailsAreaChart = ({ checks, dateRange }) => {
const theme = useTheme();
const memoizedChecks = useMemo(() => checks, [checks[0]]);
const [isHovered, setIsHovered] = useState(false);
return (
<ResponsiveContainer
width="100%"
minWidth={25}
height={220}
>
<AreaChart
width="100%"
height="100%"
data={memoizedChecks}
margin={{
top: 10,
right: 0,
left: 0,
bottom: 0,
}}
onMouseMove={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<CartesianGrid
stroke={theme.palette.primary.lowContrast}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient
id="colorUv"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.accent.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.accent.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
stroke={theme.palette.primary.lowContrast}
dataKey="_id"
tick={<CustomTick dateRange={dateRange} />}
axisLine={false}
tickLine={false}
height={20}
/>
<Tooltip
cursor={{ stroke: theme.palette.primary.lowContrast }}
content={<CustomToolTip dateRange={dateRange} />}
wrapperStyle={{ pointerEvents: "none" }}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.accent.main} // CAIO_REVIEW
fill="url(#colorUv)"
strokeWidth={isHovered ? 2.5 : 1.5}
activeDot={{ stroke: theme.palette.accent.main, r: 5 }} // CAIO_REVIEW
/>
</AreaChart>
</ResponsiveContainer>
);
};
MonitorDetailsAreaChart.propTypes = {
checks: PropTypes.array,
dateRange: PropTypes.string,
};
export default MonitorDetailsAreaChart;

View File

@@ -0,0 +1,211 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
/**
* A customizable Bar component that renders a colored bar with optional children.
* @component
*
* @param {string} width The width of the bar (e.g., "100px").
* @param {string} height The height of the bar (e.g., "50px").
* @param {string} backgroundColor The background color of the bar (e.g., "#FF5733").
* @param {string} [borderRadius] Optional border radius for the bar (e.g., "8px").
* @param {node} children The content to be rendered inside the bar.
* @returns {JSX.Element} The Bar component.
*/
const Bar = ({ width, height, backgroundColor, borderRadius, children }) => {
const theme = useTheme();
return (
<Box
position="relative"
width={width}
height={height}
backgroundColor={backgroundColor}
sx={{
borderRadius: borderRadius || theme.spacing(1.5),
}}
>
{children}
</Box>
);
};
Bar.propTypes = {
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
backgroundColor: PropTypes.string.isRequired,
borderRadius: PropTypes.string,
children: PropTypes.node,
};
/* TODO add prop validation and jsdocs */
const StatusPageBarChart = ({ checks = [] }) => {
const theme = useTheme();
const [animate, setAnimate] = useState(false);
const uiTimezone = useSelector((state) => state.ui.timezone);
const barWidth = {
xs: "calc(60% / 25)",
xl: "calc(40% / 25)",
};
useEffect(() => {
setAnimate(true);
}, []);
// set responseTime to average if there's only one check
if (checks.length === 1) {
checks[0] = { ...checks[0], responseTime: 50 };
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill("placeholder");
checks = [...checks, ...placeholders];
}
return (
<Stack
direction="row"
justifyContent="space-between"
width="100%"
flexWrap="nowrap"
height="50px"
onClick={(event) => event.stopPropagation()}
sx={{
cursor: "default",
}}
>
{checks.map((check, index) =>
check === "placeholder" ? (
/* TODO what is the purpose of this box? */
// CAIO_REVIEW the purpose of this box is to make sure there are always at least 25 bars
// even if there are less than 25 checks
<Bar
key={`${check}-${index}`}
width={barWidth}
height="100%"
backgroundColor={theme.palette.primary.lowContrast}
/>
) : (
<Tooltip
title={
<>
<Typography>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
<Box mt={theme.spacing(2)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={
check.status
? theme.palette.success.lowContrast
: theme.palette.error.lowContrast
}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(2)}
gap={theme.spacing(12)}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
Response Time
</Typography>
<Typography component="span">
{check.originalResponseTime}
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
</>
}
placement="top"
key={`check-${check?._id}`}
slotProps={{
popper: {
className: "bar-tooltip",
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
sx: {
"& .MuiTooltip-tooltip": {
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
px: theme.spacing(4),
py: theme.spacing(3),
},
"& .MuiTooltip-tooltip p": {
/* TODO Font size should point to theme */
fontSize: 12,
color: theme.palette.secondary.contrastText,
fontWeight: 500,
},
"& .MuiTooltip-tooltip span": {
/* TODO Font size should point to theme */
fontSize: 11,
color: theme.palette.secondary.contrastText,
fontWeight: 600,
},
},
},
}}
>
<Bar
width={barWidth}
height="100%"
backgroundColor={theme.palette.primary.lowContrast}
>
<Box
position="absolute"
bottom={0}
width="100%"
height={`${animate ? check.responseTime : 0}%`}
backgroundColor={
check.status
? theme.palette.success.lowContrast
: theme.palette.error.lowContrast
}
sx={{
borderRadius: theme.spacing(1.5),
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Bar>
</Tooltip>
)
)}
</Stack>
);
};
export default StatusPageBarChart;

View File

@@ -0,0 +1,26 @@
export const tooltipDateFormatLookup = (dateRange) => {
const dateFormatLookup = {
day: "ddd. MMMM D, YYYY, hh:mm A",
week: "ddd. MMMM D, YYYY, hh:mm A",
month: "ddd. MMMM D, YYYY",
};
const format = dateFormatLookup[dateRange];
if (format === undefined) {
return "";
}
return format;
};
export const tickDateFormatLookup = (dateRange) => {
const tickFormatLookup = {
recent: "h:mm A",
day: "h:mm A",
week: "MM/D, h:mm A",
month: "ddd. M/D",
};
const format = tickFormatLookup[dateRange];
if (format === undefined) {
return "";
}
return format;
};

View File

@@ -0,0 +1,292 @@
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useTheme } from "@mui/material";
import { Text } from "recharts";
import { formatDateWithTz } from "../../../Utils/timeUtils";
import { Box, Stack, Typography } from "@mui/material";
import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions";
/**
* Custom tick component for rendering time with timezone.
*
* @param {Object} props - The properties object.
* @param {number} props.x - The x-coordinate for the tick.
* @param {number} props.y - The y-coordinate for the tick.
* @param {Object} props.payload - The payload object containing tick data.
* @param {number} props.index - The index of the tick.
* @returns {JSX.Element} The rendered tick component.
*/
export const TzTick = ({ x, y, payload, index, dateRange }) => {
const theme = useTheme();
const uiTimezone = useSelector((state) => state.ui.timezone);
const format = tickDateFormatLookup(dateRange);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.primary.contrastTextTertiary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
TzTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
dateRange: PropTypes.string,
};
/**
* Custom tick component for rendering percentage values.
*
* @param {Object} props - The properties object.
* @param {number} props.x - The x-coordinate for the tick.
* @param {number} props.y - The y-coordinate for the tick.
* @param {Object} props.payload - The payload object containing tick data.
* @param {number} props.index - The index of the tick.
* @returns {JSX.Element|null} The rendered tick component or null for the first tick.
*/
export const PercentTick = ({ x, y, payload, index }) => {
const theme = useTheme();
if (index === 0) return null;
return (
<Text
x={x - 20}
y={y}
textAnchor="middle"
fill={theme.palette.primary.contrastTextTertiary}
fontSize={11}
fontWeight={400}
>
{`${(payload?.value * 100).toFixed()}%`}
</Text>
);
};
PercentTick.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
payload: PropTypes.object,
index: PropTypes.number,
};
/**
* Converts a decimal value to a formatted percentage string.
*
* @param {number} value - The decimal value to convert (e.g., 0.75)
* @returns {string} Formatted percentage string (e.g., "75.00%") or original input if not a number
*
* @example
* getFormattedPercentage(0.7543) // Returns "75.43%"
* getFormattedPercentage(1) // Returns "100.00%"
* getFormattedPercentage("test") // Returns "test"
*/
const getFormattedPercentage = (value) => {
if (typeof value !== "number") return value;
return `${(value * 100).toFixed(2)}.%`;
};
/**
* Custom tooltip component for displaying infrastructure data.
*
* @param {Object} props - The properties object.
* @param {boolean} props.active - Indicates if the tooltip is active.
* @param {Array} props.payload - The payload array containing tooltip data.
* @param {string} props.label - The label for the tooltip.
* @param {string} props.yKey - The key for the y-axis data.
* @param {string} props.yLabel - The label for the y-axis data.
* @param {string} props.dotColor - The color of the dot in the tooltip.
* @returns {JSX.Element|null} The rendered tooltip component or null if inactive.
*/
export const InfrastructureTooltip = ({
active,
payload,
label,
yKey,
yIdx = -1,
yLabel,
dotColor,
dateRange,
}) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const format = tooltipDateFormatLookup(dateRange);
if (active && payload && payload.length) {
const [hardwareType, metric] = yKey.split(".");
return (
<Box
className="area-tooltip"
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.primary.contrastTextTertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, format, uiTimezone)}
</Typography>
<Box mt={theme.spacing(1)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.primary.contrastTextTertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{yIdx >= 0
? `${yLabel} ${getFormattedPercentage(payload[0].payload[hardwareType][yIdx][metric])}`
: `${yLabel} ${getFormattedPercentage(payload[0].payload[yKey])}`}
</Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
}
return null;
};
InfrastructureTooltip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.array,
label: PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string,
PropTypes.number,
]),
yKey: PropTypes.string,
yIdx: PropTypes.number,
yLabel: PropTypes.string,
dotColor: PropTypes.string,
dateRange: PropTypes.string,
};
export const TemperatureTooltip = ({
active,
payload,
label,
keys,
dotColor,
dateRange,
}) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const theme = useTheme();
const format = tooltipDateFormatLookup(dateRange);
const formatCoreKey = (key) => {
return key.replace(/^core(\d+)$/, "Core $1");
};
if (active && payload && payload.length) {
return (
<Box
className="area-tooltip"
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.primary.contrastTextTertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, format, uiTimezone)}
</Typography>
<Stack direction="column">
{keys.map((key) => {
return (
<Stack
key={key}
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(3)}
sx={{
"& span": {
color: theme.palette.primary.contrastTextTertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={dotColor}
sx={{ borderRadius: "50%" }}
/>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{`${formatCoreKey(key)}: ${payload[0].payload[key]} °C`}
</Typography>
</Stack>
<Typography component="span"></Typography>
</Stack>
);
})}
</Stack>
</Box>
);
}
return null;
};
TemperatureTooltip.propTypes = {
active: PropTypes.bool,
keys: PropTypes.array,
payload: PropTypes.array,
label: PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string,
PropTypes.number,
]),
dotColor: PropTypes.string,
dateRange: PropTypes.string,
};

View File

@@ -0,0 +1,47 @@
/**
* Creates an SVG gradient definition for use in charts
* @param {Object} params - The gradient parameters
* @param {string} [params.id="colorUv"] - Unique identifier for the gradient
* @param {string} params.startColor - Starting color of the gradient (hex, rgb, or color name)
* @param {string} params.endColor - Ending color of the gradient (hex, rgb, or color name)
* @param {number} [params.startOpacity=0.8] - Starting opacity (0-1)
* @param {number} [params.endOpacity=0] - Ending opacity (0-1)
* @param {('vertical'|'horizontal')} [params.direction="vertical"] - Direction of the gradient
* @returns {JSX.Element} SVG gradient definition element
* @example
* createCustomGradient({
* startColor: "#1976D2",
* endColor: "#42A5F5",
* direction: "horizontal"
* })
*/
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical", // or "horizontal"
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);

View File

@@ -0,0 +1,73 @@
import "./check.css";
import PropTypes from "prop-types";
import CheckGrey from "../../assets/icons/check.svg?react";
import CheckOutlined from "../../assets/icons/check-outlined.svg?react";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* `Check` is a functional React component that displays a check icon and a label.
*
* @component
* @param {Object} props - The properties that define the `Check` component.
* @param {string} props.text - The text to be displayed as the label next to the check icon.
* @param {'info' | 'error' | 'success'} [props.variant='info'] - The variant of the check component, affecting its styling.
* @param {boolean} [props.outlined] - Whether the check icon should be outlined or not.
*
* @example
* // To use this component, import it and use it in your JSX like this:
* <Check text="Your Text Here" />
*
* @returns {React.Element} The `Check` component with a check icon and a label, defined by the `text` prop.
*/
const Check = ({ text, noHighlightText, variant = "info", outlined = false }) => {
const theme = useTheme();
const colors = {
success: theme.palette.success.main,
error: theme.palette.error.main,
info: theme.palette.info.border,
};
return (
<Stack
direction="row"
className="check"
gap={outlined ? theme.spacing(6) : theme.spacing(4)}
alignItems="center"
>
{outlined ? (
<CheckOutlined alt="check" />
) : (
<Box
lineHeight={0}
sx={{
"& svg > path": { fill: colors[variant] },
}}
>
<CheckGrey alt="form checks" />
</Box>
)}
<Typography
component="span"
sx={{
color:
variant === "info"
? theme.palette.primary.contrastTextTertiary
: colors[variant],
opacity: 0.8,
}}
>
{noHighlightText && <Typography component="span">{noHighlightText}</Typography>}{" "}
{text}
</Typography>
</Stack>
);
};
Check.propTypes = {
text: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
noHighlightText: PropTypes.string,
variant: PropTypes.oneOf(["info", "error", "success"]),
outlined: PropTypes.bool,
};
export default Check;

View File

View File

@@ -0,0 +1,29 @@
import { Box } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
const CircularCount = ({ count }) => {
const theme = useTheme();
return (
<Box
component="span"
color={theme.palette.tertiary.contrastText}
border={2}
borderColor={theme.palette.accent.main}
backgroundColor={theme.palette.tertiary.main}
sx={{
padding: ".25em .75em",
borderRadius: "50rem",
fontSize: "12px",
fontWeight: 500,
}}
>
{count}
</Box>
);
};
CircularCount.propTypes = {
count: PropTypes.number,
};
export default CircularCount;

View File

@@ -0,0 +1,216 @@
import { useState } from "react";
import { styled, alpha } from "@mui/material/styles";
import Box from "@mui/material/Box";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Container from "@mui/material/Container";
import MenuItem from "@mui/material/MenuItem";
import Drawer from "@mui/material/Drawer";
import MenuIcon from "@mui/icons-material/Menu";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
import ThemeSwitch from "../ThemeSwitch";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
const StyledToolbar = styled(Toolbar)(({ theme, mode }) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
borderRadius: `calc(${theme.shape.borderRadius}px + 4px)`,
backdropFilter: "blur(24px)",
border: "1px solid",
borderColor:
mode === "light"
? alpha(theme.palette.common.black, 0.1)
: alpha(theme.palette.common.white, 0.1),
backgroundColor:
mode === "light"
? alpha(theme.palette.common.white, 0.4)
: alpha(theme.palette.common.black, 0.4),
boxShadow: theme.shadows[3],
padding: "8px 12px",
}));
const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
fontSize: "1.1rem",
margin: theme.spacing(4, 2),
}));
const AppAppBar = () => {
const [open, setOpen] = useState(false);
const theme = useTheme();
const mode = useSelector((state) => state.ui.mode);
const location = useLocation();
const navigate = useNavigate();
// Debugging: Log the current theme mode
console.log("Current theme mode:", mode);
const logoSrc =
mode === "light" ? "/images/prism-black.png" : "/images/prism-white.png";
const toggleDrawer = (newOpen) => () => {
setOpen(newOpen);
};
const handleScroll = (id) => {
if (location.pathname === "/") {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
} else {
navigate(`/#${id}`);
}
};
return (
<AppBar
position="fixed"
sx={{
boxShadow: 0,
bgcolor: "transparent",
backgroundImage: "none",
border: "none",
mt: "calc(var(--template-frame-height, 0px) + 28px)",
}}
>
<Container maxWidth="lg">
<StyledToolbar
variant="dense"
disableGutters
mode={mode}
>
<Box sx={{ flexGrow: 1, display: "flex", alignItems: "center", px: 0 }}>
<img
src={logoSrc}
alt="Prism Logo"
style={{
height: "auto",
width: "auto",
marginRight: "10px",
maxHeight: "32px",
}}
/>
<Box sx={{ display: { xs: "none", md: "flex" } }}>
<Button
variant="text"
color="info"
size="large"
onClick={() => handleScroll("features")}
>
Features
</Button>
<Button
variant="text"
color="info"
size="large"
onClick={() => handleScroll("highlights")}
>
Highlights
</Button>
<Button
variant="text"
color="info"
size="large"
onClick={() => handleScroll("faq")}
>
FAQ
</Button>
<Button
variant="text"
color="info"
size="large"
href="https://uprock.com/blog"
>
Blog
</Button>
</Box>
</Box>
<Box
sx={{
display: { xs: "none", md: "flex" },
gap: 1,
alignItems: "center",
}}
>
{/* <Button color="primary" variant="text" size="small">
Sign in
</Button>
<Button color="primary" variant="contained" size="small">
Sign up
</Button> */}
</Box>
<Box
sx={{
display: { xs: "flex", md: "none" },
gap: 1,
}}
>
<IconButton
aria-label="Menu button"
onClick={toggleDrawer(true)}
>
<MenuIcon sx={{ color: theme.palette.text.primary }} />
</IconButton>
<Drawer
anchor="top"
open={open}
onClose={toggleDrawer(false)}
PaperProps={{
sx: {
top: 0,
marginTop: 0,
borderRadius: 0,
backgroundColor: theme.palette.background.paper,
},
}}
>
<Box sx={{ p: 4, backgroundColor: theme.palette.background.main }}>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
}}
>
<IconButton onClick={toggleDrawer(false)}>
<CloseRoundedIcon sx={{ color: theme.palette.text.primary }} />
</IconButton>
</Box>
<StyledMenuItem>Features</StyledMenuItem>
<StyledMenuItem>Testimonials</StyledMenuItem>
<StyledMenuItem>Highlights</StyledMenuItem>
<StyledMenuItem>FAQ</StyledMenuItem>
<StyledMenuItem
component="a"
href="https://uprock.com/blog"
>
Blog
</StyledMenuItem>
{/* <MenuItem>
<Button color="primary" variant="contained" fullWidth>
Sign up
</Button>
</MenuItem>
<MenuItem>
<Button color="primary" variant="outlined" fullWidth>
Sign in
</Button>
</MenuItem> */}
</Box>
</Drawer>
</Box>
<ThemeSwitch />
</StyledToolbar>
</Container>
</AppBar>
);
};
export default AppAppBar;

View File

@@ -0,0 +1,214 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { FaFacebook, FaLinkedin, FaGithub, FaTwitter, FaEnvelope } from 'react-icons/fa';
function Copyright() {
return (
<Typography sx={{ color: 'text.secondary', mt: 1 }}>
{'Copyright © '}
<Link color="text.secondary" href="https://prism.uprock.com/">
UpRock
</Link>
&nbsp;
{new Date().getFullYear()}
</Typography>
);
}
export default function Footer() {
return (
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { xs: 4, sm: 8 },
py: { xs: 24, sm: 24 },
px: { xs: 12, sm: 12 },
textAlign: { sm: 'center', md: 'left' },
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
pt: { xs: 4, sm: 8 },
width: '100%',
borderColor: 'divider',
}}
>
<div>
<Link color="text.secondary" href="https://uprock.com/privacy-policy">
Privacy Policy
</Link>
<Typography sx={{ display: 'inline', mx: 0.5, opacity: 0.5 }}>
&nbsp;&nbsp;
</Typography>
<Link color="text.secondary" href="https://uprock.com/terms-of-use">
Terms of Service
</Link>
<Copyright />
</div>
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{ justifyContent: 'left', color: 'text.secondary' }}
>
<IconButton
color="inherit"
size="small"
href="mailto:prism@uprock.com?subject=Interested%20in%20UpRock%20Prism"
aria-label="Contact Us"
sx={{ alignSelf: 'center' }}
>
<FaEnvelope />
</IconButton>
<IconButton
color="inherit"
size="small"
href="https://facebook.com/uprockcom"
aria-label="Facebook"
sx={{ alignSelf: 'center' }}
>
<FaFacebook />
</IconButton>
<IconButton
color="inherit"
size="small"
href="https://x.com/uprockcom"
aria-label="X"
sx={{ alignSelf: 'center' }}
>
<FaTwitter />
</IconButton>
<IconButton
color="inherit"
size="small"
href="https://www.linkedin.com/company/uprock/"
aria-label="LinkedIn"
sx={{ alignSelf: 'center' }}
>
<FaLinkedin />
</IconButton>
<IconButton
color="inherit"
size="small"
href="https://github.com/uprockcom"
aria-label="GitHub"
sx={{ alignSelf: 'center' }}
>
<FaGithub />
</IconButton>
</Stack>
</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mt: 4,
}}
>
<Typography variant="h2" sx={{ color: 'text.secondary' }}>
Made with by&nbsp;
<Link href="https://uprock.com" color="inherit" sx={{ mx: 0.5 }}>
UpRock&nbsp;
</Link>
&&nbsp;
<Link href="https://bluewavelabs.ca" color="inherit" sx={{ mx: 0.5 }}>
Bluewave Labs
</Link>
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
mt: 2,
}}
>
<Typography variant="h2" sx={{ color: 'text.secondary', mr: 1 }}>
Built on&nbsp;
</Typography>
<svg
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 397.7 311.7"
xmlSpace="preserve"
width="15"
height="15"
>
<style type="text/css">
{`.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{fill:url(#SVGID_3_);}`}
</style>
<linearGradient
id="SVGID_1_"
gradientUnits="userSpaceOnUse"
x1="360.8791"
y1="351.4553"
x2="141.213"
y2="-69.2936"
gradientTransform="matrix(1 0 0 -1 0 314)"
>
<stop offset="0" style={{ stopColor: 'rgb(0, 255, 163)' }} />
<stop offset="1" style={{ stopColor: 'rgb(220, 31, 255)' }} />
</linearGradient>
<path
className="st0"
d="M64.6,237.9c2.4-2.4,5.7-3.8,9.2-3.8h317.4c5.8,0,8.7,7,4.6,11.1l-62.7,62.7c-2.4,2.4-5.7,3.8-9.2,3.8H6.5 c-5.8,0-8.7-7-4.6-11.1L64.6,237.9z"
/>
<linearGradient
id="SVGID_2_"
gradientUnits="userSpaceOnUse"
x1="264.8291"
y1="401.6014"
x2="45.163"
y2="-19.1475"
gradientTransform="matrix(1 0 0 -1 0 314)"
>
<stop offset="0" style={{ stopColor: 'rgb(0, 255, 163)' }} />
<stop offset="1" style={{ stopColor: 'rgb(220, 31, 255)' }} />
</linearGradient>
<path
className="st1"
d="M64.6,3.8C67.1,1.4,70.4,0,73.8,0h317.4c5.8,0,8.7,7,4.6,11.1l-62.7,62.7c-2.4,2.4-5.7,3.8-9.2,3.8H6.5 c-5.8,0-8.7-7-4.6-11.1L64.6,3.8z"
/>
<linearGradient
id="SVGID_3_"
gradientUnits="userSpaceOnUse"
x1="312.5484"
y1="376.688"
x2="92.8822"
y2="-44.061"
gradientTransform="matrix(1 0 0 -1 0 314)"
>
<stop offset="0" style={{ stopColor: 'rgb(0, 255, 163)' }} />
<stop offset="1" style={{ stopColor: 'rgb(220, 31, 255)' }} />
</linearGradient>
<path
className="st2"
d="M333.1,120.1c-2.4-2.4-5.7-3.8-9.2-3.8H6.5c-5.8,0-8.7,7-4.6,11.1l62.7,62.7c2.4,2.4,5.7,3.8,9.2,3.8h317.4 c5.8,0,8.7-7,4.6-11.1L333.1,120.1z"
/>
</svg>
<Typography variant="h2" sx={{ color: 'text.secondary', ml: 1 }}>
&nbsp;Solana
</Typography>
</Box>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,35 @@
import { Stack, styled } from "@mui/material";
const ConfigBox = styled(Stack)(({ theme }) => ({
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing(20),
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.spacing(2),
"& > *": {
paddingTop: theme.spacing(12),
paddingBottom: theme.spacing(18),
},
"& > div:first-of-type": {
flex: 0.7,
borderRight: 1,
borderRightStyle: "solid",
borderRightColor: theme.palette.primary.lowContrast,
paddingRight: theme.spacing(15),
paddingLeft: theme.spacing(15),
},
"& > div:last-of-type": {
flex: 1,
paddingRight: theme.spacing(20),
paddingLeft: theme.spacing(18),
},
"& h1, & h2": {
color: theme.palette.primary.contrastTextSecondary,
}
}));
export default ConfigBox;

View File

@@ -0,0 +1,49 @@
import { Box, Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import ConfigBox from "../ConfigBox";
/**
* ConfigRow is a styled container used to layout content in a row format with specific padding, border, and spacing.
* It serves as the wrapper for ConfigBox, with the left section displaying the title and description,
* and the right section displaying the children.
*
* @component
* @example
* return (
* <ConfigBox>
* <div>Left content (Title + Description)</div>
* <div>Right content (Children)</div>
* </ConfigBox>
* );
*/
const ConfigRow = ({ title, description, children }) => {
const theme = useTheme();
return (
<ConfigBox>
<Box>
<Typography component="h2" variant="h2">
{title}
</Typography>
{description && (
<Typography variant="body2" mt={theme.spacing(2)}>
{description}
</Typography>
)}
</Box>
<Stack gap={theme.spacing(15)} mt={theme.spacing(4)}>
{children}
</Stack>
</ConfigBox>
);
};
ConfigRow.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
children: PropTypes.node,
};
export default ConfigRow;

View File

@@ -0,0 +1,69 @@
import { useId } from "react";
import PropTypes from "prop-types";
import { Modal, Stack, Typography } from "@mui/material";
const GenericDialog = ({ title, description, open, onClose, theme, children }) => {
const titleId = useId();
const descriptionId = useId();
const ariaDescribedBy = description?.length > 0 ? descriptionId : "";
return (
<Modal
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
open={open}
onClose={onClose}
onClick={(e) => e.stopPropagation()}
>
<Stack
gap={theme.spacing(2)}
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
minWidth: 400,
bgcolor: theme.palette.primary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
boxShadow: 24,
p: theme.spacing(15),
"&:focus": {
outline: "none",
},
}}
>
<Typography
id={titleId}
component="h2"
fontSize={16}
color={theme.palette.primary.contrastText}
fontWeight={600}
>
{title}
</Typography>
{description && (
<Typography
id={descriptionId}
color={theme.palette.primary.contrastTextTertiary}
>
{description}
</Typography>
)}
{children}
</Stack>
</Modal>
);
};
GenericDialog.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node])
.isRequired,
};
export { GenericDialog };

View File

@@ -0,0 +1,61 @@
import PropTypes from "prop-types";
import { Button, Stack } from "@mui/material";
import { GenericDialog } from "./genericDialog";
import { useTheme } from "@emotion/react";
const Dialog = ({
title,
description,
open,
onCancel,
confirmationButtonLabel,
onConfirm,
isLoading,
}) => {
const theme = useTheme();
return (
<GenericDialog
title={title}
description={description}
open={open}
onClose={onCancel}
theme={theme}
>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(12)}
justifyContent="flex-end"
>
<Button
variant="contained"
color="secondary"
onClick={onCancel}
>
Cancel
</Button>
<Button
variant="contained"
color="error"
loading={isLoading}
onClick={onConfirm}
>
{confirmationButtonLabel}
</Button>
</Stack>
</GenericDialog>
);
};
Dialog.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
open: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
confirmationButtonLabel: PropTypes.string.isRequired,
onConfirm: PropTypes.func.isRequired,
isLoading: PropTypes.bool.isRequired,
};
export default Dialog;

View File

@@ -0,0 +1,24 @@
import PropTypes from "prop-types";
const Dot = ({ color = "gray", size = "4px", style }) => {
return (
<span
style={{
content: '""',
width: size,
height: size,
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
...style,
}}
/>
);
};
Dot.propTypes = {
color: PropTypes.string,
size: PropTypes.string,
};
export default Dot;

View File

@@ -0,0 +1,40 @@
[class*="fallback__"] {
width: fit-content;
margin: auto;
margin-top: 100px;
}
[class*="fallback__"] h1.MuiTypography-root {
font-size: var(--env-var-font-size-large);
font-weight: 600;
}
[class*="fallback__"] button.MuiButtonBase-root,
[class*="fallback__"] .check {
width: max-content;
}
[class*="fallback__"] button.MuiButtonBase-root {
min-height: 34px;
}
[class*="fallback__"] .check span.MuiTypography-root,
[class*="fallback__"] button.MuiButtonBase-root {
font-size: var(--env-var-font-size-medium);
}
[class*="fallback__"] .background-pattern-svg {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
z-index: 0;
width: 100%;
max-width: 800px;
height: 100%;
max-height: 800px;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
.fallback__status > .MuiStack-root {
margin-left: var(--env-var-spacing-2);
}

View File

@@ -0,0 +1,161 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Button, Stack, Typography, Link } from "@mui/material";
import Skeleton from "../../assets/Images/create-placeholder.svg?react";
import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react";
import Background from "../../assets/Images/background-grid.svg?react";
import Check from "../Check/Check";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import Alert from "../Alert";
import { useTranslation } from "react-i18next";
import "./index.css";
/**
* Fallback component to display a fallback UI with a title, a list of checks, and a navigation button.
*
* @param {Object} props - The component props.
* @param {string} props.title - The title to be displayed in the fallback UI.
* @param {Array<string>} props.checks - An array of strings representing the checks to display.
* @param {string} [props.link="/"] - The link to navigate to.
* @param {boolean} [props.vowelStart=false] - Whether the title starts with a vowel.
* @param {boolean} [props.showPageSpeedWarning=false] - Whether to show the PageSpeed API warning.
* @returns {JSX.Element} The rendered fallback UI.
*/
const Fallback = ({ title, checks, link = "/", isAdmin, vowelStart = false, showPageSpeedWarning = false }) => {
const theme = useTheme();
const navigate = useNavigate();
const mode = useSelector((state) => state.ui.mode);
const { t } = useTranslation();
// Custom warning message with clickable link
const renderWarningMessage = () => {
return (
<>
{t("pageSpeedWarning")} {" "}
<Link
href="https://docs.checkmate.so/users-guide/quickstart#env-vars-server"
target="_blank"
rel="noopener noreferrer"
sx={{
textDecoration: "underline",
color: "inherit",
fontWeight: "inherit",
"&:hover": {
textDecoration: "underline",
}
}}
>
{t("pageSpeedLearnMoreLink")}
</Link>
{" "}{t("pageSpeedAddApiKey")}
</>
);
};
return (
<Box
position="relative"
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.primary.main}
overflow="hidden"
sx={{
borderStyle: "dashed",
minHeight: "calc(100vh - var(--env-var-spacing-2) * 2)",
}}
>
<Stack
className={`fallback__${title?.trim().split(" ")[0]}`}
alignItems="center"
gap={theme.spacing(20)}
>
{mode === "light" ? (
<Skeleton style={{ zIndex: 1 }} />
) : (
<SkeletonDark style={{ zIndex: 1 }} />
)}
<Box
className="background-pattern-svg"
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
gap={theme.spacing(4)}
maxWidth={"300px"}
zIndex={1}
>
<Typography
component="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{vowelStart ? "An" : "A"} {title} is used to:
</Typography>
{checks?.map((check, index) => (
<Check
text={check}
key={`${title.trim().split(" ")[0]}-${index}`}
outlined={true}
/>
))}
</Stack>
{/* TODO - display a different fallback if user is not an admin*/}
{isAdmin && (
<>
<Button
variant="contained"
color="accent"
sx={{ alignSelf: "center" }}
onClick={() => navigate(link)}
>
Let's create your first {title}
</Button>
{/* Warning box for PageSpeed monitor */}
{(title === "pagespeed monitor" && showPageSpeedWarning) && (
<Box sx={{ width: "80%", maxWidth: "600px", zIndex: 1 }}>
<Box sx={{
'& .alert.row-stack': {
backgroundColor: theme.palette.warningSecondary.main,
borderColor: theme.palette.warningSecondary.lowContrast,
'& .MuiTypography-root': {
color: theme.palette.warningSecondary.contrastText
},
'& .MuiBox-root > svg': {
color: theme.palette.warningSecondary.contrastText
}
}
}}>
<Alert
variant="warning"
hasIcon={true}
body={renderWarningMessage()}
/>
</Box>
</Box>
)}
</>
)}
</Stack>
</Box>
);
};
Fallback.propTypes = {
title: PropTypes.string.isRequired,
checks: PropTypes.arrayOf(PropTypes.string).isRequired,
link: PropTypes.string,
isAdmin: PropTypes.bool,
vowelStart: PropTypes.bool,
showPageSpeedWarning: PropTypes.bool,
};
export default Fallback;

View File

@@ -0,0 +1,95 @@
import { Checkbox, FormControl, ListItemText, MenuItem, Select } from "@mui/material";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
/**
* A reusable filter header component that displays a dropdown menu with selectable options.
*
* @component
* @param {Object} props - The component props.
* @param {string} props.header - The header text to display when no options are selected.
* @param {Array} props.options - An array of options to display in the dropdown menu. Each option should have a `value` and `label`.
* @param {Array} [props.value] - The currently selected values.
* @param {Function} props.onChange - The callback function to handle changes in the selected values.
* @param {boolean} [props.multiple=true] - Whether multiple options can be selected.
* @returns {JSX.Element} The rendered FilterHeader component.
*/
const FilterHeader = ({ header, options, value, onChange, multiple = true }) => {
const theme = useTheme();
const controlledValue = value === undefined ? [] : value; // Ensure value is always treated as an array for controlled component purposes
return (
<FormControl
sx={{ minWidth: "10%" }}
size="small"
>
<Select
multiple={multiple}
IconComponent={(props) => (
<AddCircleOutlineIcon
{...props}
sx={{ fontSize: "medium" }}
/>
)}
displayEmpty
value={controlledValue}
onChange={onChange}
renderValue={(selected) => {
if (!selected?.length) {
return header;
}
return header + " | " + selected
.map((value) => options.find((option) => option.value === value)?.label)
.filter(Boolean)
.join(", ");
}}
MenuProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
}}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
sx={{
height: theme.spacing(17),
padding: 0,
}}
>
<Checkbox
checked={controlledValue.includes(option.value)}
size="small"
/>
<ListItemText primary={option.label} />
</MenuItem>
))}
</Select>
</FormControl>
);
};
FilterHeader.propTypes = {
header: PropTypes.string.isRequired,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})
).isRequired,
value: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func.isRequired,
multiple: PropTypes.bool,
};
export default FilterHeader;

View File

@@ -0,0 +1,20 @@
import { Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
const NetworkError = () => {
const theme = useTheme();
return (
<>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
Network error
</Typography>
<Typography>Please check your connection</Typography>
</>
);
};
export default NetworkError;

View File

@@ -0,0 +1,78 @@
import { useTheme } from "@emotion/react";
import { Box, Stack } from "@mui/material";
import Skeleton from "../../assets/Images/create-placeholder.svg?react";
import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react";
import Background from "../../assets/Images/background-grid.svg?react";
import { useSelector } from "react-redux";
/**
* Fallback component to display a fallback UI for network errors
*
* @returns {JSX.Element} The rendered fallback UI.
*/
const GenericFallback = ({ children }) => {
const theme = useTheme();
const mode = useSelector((state) => state.ui.mode);
return (
<Box
padding={theme.spacing(16)}
position="relative"
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.primary.main}
overflow="hidden"
sx={{
borderStyle: "dashed",
}}
>
<Stack
alignItems="center"
gap={theme.spacing(20)}
sx={{
width: "fit-content",
margin: "auto",
marginTop: "100px",
}}
>
{mode === "light" ? (
<Skeleton style={{ zIndex: 1 }} />
) : (
<SkeletonDark style={{ zIndex: 1 }} />
)}
<Box
sx={{
"& svg g g:last-of-type path": {
stroke: theme.palette.primary.lowContrast,
},
position: "absolute",
top: "0",
left: "50%",
transform: "translate(-50%, -50%)",
width: "100%",
maxWidth: "800px",
height: "100%",
maxHeight: "800px",
backgroundPosition: "center",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
}}
>
<Background style={{ width: "100%" }} />
</Box>
<Stack
gap={theme.spacing(4)}
alignItems="center"
maxWidth={"300px"}
zIndex={1}
>
{children}
</Stack>
</Stack>
</Box>
);
};
export default GenericFallback;

View File

@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { logger } from "../../Utils/Logger";
import { networkService } from "../../main";
const withAdminCheck = (WrappedComponent) => {
const WithAdminCheck = (props) => {
const navigate = useNavigate();
useEffect(() => {
networkService
.doesSuperAdminExist()
.then((response) => {
if (response.data.data === true) {
navigate("/login");
}
})
.catch((error) => {
logger.error(error);
});
}, [navigate]);
return (
<WrappedComponent
{...props}
isSuperAdmin={true}
/>
);
};
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
WithAdminCheck.displayName = `WithAdminCheck(${wrappedComponentName})`;
return WithAdminCheck;
};
export default withAdminCheck;

View File

@@ -0,0 +1,32 @@
import { Typography } from "@mui/material";
import PropTypes from "prop-types";
/**
* A heading component that renders text with a specific heading level.
*
* @param {Object} props - The properties passed to the Heading component.
* @param {('h1'|'h2'|'h3')} props.component - The heading level for the component.
* @param {Object} props.style - Custom styles to apply to the heading.
* @param {string} props.children - The content to display inside the heading.
* @returns {JSX.Element} The Typography component with specified heading properties.
*/
function Heading({ component, style, children }) {
return (
<Typography
component={component}
variant="h2"
fontWeight={600}
style={style}
>
{children}
</Typography>
);
}
Heading.propTypes = {
component: PropTypes.oneOf(["h1", "h2", "h3"]).isRequired,
style: PropTypes.object,
children: PropTypes.string.isRequired,
};
export { Heading };

View File

@@ -0,0 +1,55 @@
import { Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import Dot from "../Dot";
/**
* Host component.
* This subcomponent receives a params object and displays the host details.
*
* @component
* @param {Object} params - An object containing the following properties:
* @param {string} params.url - The URL of the host.
* @param {string} params.title - The name of the host.
* @param {string} params.percentageColor - The color of the percentage text.
* @param {number} params.percentage - The percentage to display.
* @returns {React.ElementType} Returns a div element with the host details.
*/
const Host = ({ url, title, percentageColor, percentage }) => {
const theme = useTheme();
return (
<Stack>
<Stack
direction="row"
position="relative"
alignItems="center"
gap={theme.spacing(4)}
>
{title}
{percentageColor && percentage && (
<>
<Dot />
<Typography
component="span"
sx={{
color: percentageColor,
fontWeight: 500,
}}
>
{percentage}%
</Typography>
</>
)}
</Stack>
<span style={{ opacity: 0.6 }}>{url}</span>
</Stack>
);
};
Host.propTypes = {
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.string,
url: PropTypes.string,
};
export default Host;

View File

@@ -0,0 +1,67 @@
import PropTypes from "prop-types";
import { useTheme } from "@mui/material";
import { BaseLabel } from "../Label";
/**
* @component
* @param {Object} props
* @param {number} props.status - The http status for the label
* @param {Styles} props.customStyles - CSS Styles passed from parent component
* @returns {JSX.Element}
* @example
* // Render a http status label
* <HttpStatusLabel status={404} />
*/
const DEFAULT_CODE = 9999; // Default code for unknown status
const handleStatusCode = (status) => {
if (status) {
return status;
}
return DEFAULT_CODE;
};
const getRoundedStatusCode = (status) => {
return Math.floor(status / 100) * 100;
};
const HttpStatusLabel = ({ status, customStyles }) => {
const theme = useTheme();
const colors = {
400: {
color: theme.palette.warning.main,
borderColor: theme.palette.warning.lowContrast,
},
500: {
color: theme.palette.error.main,
borderColor: theme.palette.error.lowContrast,
},
default: {
color: theme.palette.primary.contrastText,
borderColor: theme.palette.primary.contrastText,
},
};
const statusCode = handleStatusCode(status);
const { borderColor, color } =
colors[getRoundedStatusCode(statusCode)] || colors.default;
return (
<BaseLabel
label={String(statusCode)}
styles={{
color: color,
borderColor: borderColor,
...customStyles,
}}
/>
);
};
HttpStatusLabel.propTypes = {
status: PropTypes.number,
customStyles: PropTypes.object,
};
export { HttpStatusLabel };

View File

@@ -0,0 +1,77 @@
import { Box, styled } from "@mui/material";
import PropTypes from "prop-types";
/**
* IconBox - A styled box component for rendering icons with consistent sizing and styling
*
* @component
* @param {Object} [props] - Configuration options for the IconBox
* @param {number} [props.height=34] - Height of the icon box
* @param {number} [props.width=34] - Width of the icon box
* @param {number} [props.minWidth=34] - Minimum width of the icon box
* @param {number} [props.borderRadius=4] - Border radius of the icon box
* @param {number} [props.svgWidth=20] - Width of the SVG icon
* @param {number} [props.svgHeight=20] - Height of the SVG icon
*
* @example
* // Basic usage
* <IconBox>
* <SomeIcon />
* </IconBox>
*
* @example
* // Customized usage
* <IconBox
* height={40}
* width={40}
* svgWidth={24}
* svgHeight={24}
* >
* <CustomIcon />
* </IconBox>
*
* @returns {React.ReactElement} A styled box containing an icon
*/
const IconBox = styled(Box)(
({
theme,
height = 34,
width = 34,
minWidth = 34,
borderRadius = 4,
svgWidth = 20,
svgHeight = 20,
}) => ({
height: height,
minWidth: minWidth,
width: width,
position: "relative",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: borderRadius,
backgroundColor: theme.palette.tertiary.main,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: svgWidth,
height: svgHeight,
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
},
})
);
IconBox.propTypes = {
height: PropTypes.number,
width: PropTypes.number,
minWidth: PropTypes.number,
borderRadius: PropTypes.number,
svgWidth: PropTypes.number,
svgHeight: PropTypes.number,
};
export default IconBox;

View File

@@ -0,0 +1,72 @@
import { Box } from "@mui/material";
import PropTypes from "prop-types";
const isValidBase64Image = (data) => {
return /^[A-Za-z0-9+/=]+$/.test(data);
};
const Image = ({
shouldRender = true,
src,
alt,
width = "auto",
height = "auto",
minWidth = "auto",
minHeight = "auto",
maxWidth = "auto",
maxHeight = "auto",
base64,
placeholder,
sx,
}) => {
if (shouldRender === false) {
return null;
}
if (typeof src !== "undefined" && typeof base64 !== "undefined") {
console.warn("base64 takes precedence over src and overwrites it");
}
if (typeof base64 !== "undefined" && isValidBase64Image(base64)) {
src = `data:image/png;base64,${base64}`;
}
if (
typeof src === "undefined" &&
typeof base64 === "undefined" &&
typeof placeholder !== "undefined"
) {
src = placeholder;
}
return (
<Box
component="img"
src={src}
alt={alt}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
maxHeight={maxHeight}
width={width}
height={height}
sx={{ ...sx }}
/>
);
};
Image.propTypes = {
shouldRender: PropTypes.bool,
src: PropTypes.string,
alt: PropTypes.string.isRequired,
width: PropTypes.string,
height: PropTypes.string,
minWidth: PropTypes.string,
minHeight: PropTypes.string,
maxWidth: PropTypes.string,
maxHeight: PropTypes.string,
base64: PropTypes.string,
sx: PropTypes.object,
};
export default Image;

View File

@@ -0,0 +1,42 @@
import { RowContainer } from "../StandardContainer";
import { Stack, Typography } from "@mui/material";
import Image from "../Image";
import { useTheme } from "@mui/material/styles";
const InfoBox = ({
img,
icon: Icon,
alt,
heading,
headingLevel = 2,
subHeading,
subHeadingLevel = "",
sx,
}) => {
const theme = useTheme();
return (
<RowContainer sx={{ ...sx }}>
{img && (
<Image
src={img}
height={"30px"}
width={"30px"}
alt={alt}
sx={{ marginRight: theme.spacing(8) }}
/>
)}
{Icon && (
<Icon sx={{ width: "30px", height: "30px", marginRight: theme.spacing(8) }} />
)}
<Stack>
<Typography variant={`h${headingLevel}`}>{heading}</Typography>
<Typography variant={subHeadingLevel ? `h${subHeadingLevel}` : "body1"}>
{subHeading}
</Typography>
</Stack>
</RowContainer>
);
};
export default InfoBox;

View File

@@ -0,0 +1,118 @@
import PropTypes from "prop-types";
import { FormControlLabel, Checkbox as MuiCheckbox } from "@mui/material";
import { useTheme } from "@emotion/react";
import CheckboxOutline from "../../../assets/icons/checkbox-outline.svg?react";
import CheckboxFilled from "../../../assets/icons/checkbox-filled.svg?react";
import "./index.css";
/**
* Checkbox Component
*
* A customized checkbox component using Material-UI that supports custom sizing,
* disabled states, and custom icons.
*
* @component
* @param {Object} props - Component properties
* @param {string} props.id - Unique identifier for the checkbox input
* @param {string} [props.name] - Optional name attribute for the checkbox
* @param {(string|React.ReactNode)} props.label - Label text or node for the checkbox
* @param {('small'|'medium'|'large')} [props.size='medium'] - Size of the checkbox icon
* @param {boolean} props.isChecked - Current checked state of the checkbox
* @param {string} [props.value] - Optional value associated with the checkbox
* @param {Function} [props.onChange] - Callback function triggered when checkbox state changes
* @param {boolean} [props.isDisabled] - Determines if the checkbox is disabled
*
* @returns {React.ReactElement} Rendered Checkbox component
*
* @example
* // Basic usage
* <Checkbox
* id="terms-checkbox"
* label="I agree to terms"
* isChecked={agreed}
* onChange={handleAgree}
* />
*
* @example
* // With custom size and disabled state
* <Checkbox
* id="advanced-checkbox"
* label="Advanced Option"
* size="large"
* isChecked={isAdvanced}
* isDisabled={!canModify}
* onChange={handleAdvancedToggle}
* />
*/
const Checkbox = ({
id,
name,
label,
size = "medium",
isChecked,
value,
onChange,
isDisabled,
}) => {
/* TODO move sizes to theme */
const sizes = { small: "14px", medium: "16px", large: "18px" };
const theme = useTheme();
return (
<FormControlLabel
className="checkbox-wrapper"
control={
<MuiCheckbox
checked={isDisabled ? false : isChecked}
name={name}
value={value}
onChange={onChange}
icon={<CheckboxOutline />}
checkedIcon={<CheckboxFilled />}
inputProps={{
"aria-label": "controlled checkbox",
id: id,
}}
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: sizes[size], height: sizes[size] },
}}
/>
}
label={label}
disabled={isDisabled}
sx={{
borderRadius: theme.shape.borderRadius,
p: theme.spacing(2.5),
"& .MuiButtonBase-root": {
width: theme.spacing(10),
p: 0,
mr: theme.spacing(6),
},
"&:not(:has(.Mui-disabled)):hover": {
backgroundColor: theme.palette.tertiary.main,
},
"& span.MuiTypography-root": {
fontSize: 13,
color: theme.palette.primary.contrastTextTertiary,
},
".MuiFormControlLabel-label.Mui-disabled": {
color: theme.palette.primary.contrastTextTertiary,
opacity: 0.25,
},
}}
/>
);
};
Checkbox.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
size: PropTypes.oneOf(["small", "medium", "large"]),
isChecked: PropTypes.bool.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
onChange: PropTypes.func,
isDisabled: PropTypes.bool,
};
export default Checkbox;

View File

@@ -0,0 +1,63 @@
import PropTypes from "prop-types";
import { Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { MuiColorInput } from "mui-color-input";
/**
*
* @param {*} id The ID of the component
* @param {*} value The color value of the component
* @param {*} error The error of the component
* @param {*} onChange The Change handler function
* @param {*} onBlur The Blur handler function
* @returns The ColorPicker component
* Example usage:
* <ColorPicker
* id="color"
* value={form.color}
* error={errors["color"]}
* onChange={handleColorChange}
* onBlur={handleBlur}
* >
* </ColorPicker>
*/
const ColorPicker = ({ id, name, value, error, onChange, onBlur }) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(4)}>
<MuiColorInput
format="hex"
name={name}
type="color-picker"
value={value}
id={id}
onChange={(color) => onChange({ target: { name, value: color } })}
onBlur={onBlur}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
);
};
ColorPicker.propTypes = {
id: PropTypes.string.isRequired,
value: PropTypes.string,
error: PropTypes.string,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func,
name: PropTypes.string,
};
export default ColorPicker;

View File

@@ -0,0 +1,18 @@
.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
font-weight: 600;
}
.image-field-wrapper h2.MuiTypography-root,
.MuiStack-root:has(#modal-update-picture) button,
.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.image-field-wrapper h2.MuiTypography-root {
margin-top: 10px;
}
.image-field-wrapper + p.MuiTypography-root {
margin-top: 8px;
}
.image-field-wrapper + p.MuiTypography-root,
.image-field-wrapper p.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}

View File

@@ -0,0 +1,175 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import { Box, IconButton, Stack, TextField, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import "./index.css";
import { checkImage } from "../../../Utils/fileUtils";
/**
* @param {Object} props - The component props.
* @param {string} props.id - The unique identifier for the input field.
* @param {string} props.src - The URL of the image to display.
* @param {function} props.onChange - The function to handle file input change.
* @param {boolean} props.isRound - Whether the shape of the image to display is round.
* @param {string} props.maxSize - Custom message for the max uploaded file size
* @returns {JSX.Element} The rendered component.
*/
const ImageField = ({ id, src, loading, onChange, error, isRound = true, maxSize }) => {
const theme = useTheme();
const error_border_style = error ? { borderColor: theme.palette.error.main } : {};
const roundShape = isRound ? { borderRadius: "50%" } : {};
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = () => {
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
return (
<>
{!checkImage(src) || loading ? (
<>
<Box
className="image-field-wrapper"
mt={theme.spacing(8)}
sx={{
position: "relative",
height: "fit-content",
border: "dashed",
borderRadius: theme.shape.borderRadius,
borderColor: isDragging
? theme.palette.primary.main
: theme.palette.primary.lowContrast,
borderWidth: "2px",
transition: "0.2s",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
...error_border_style,
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDragLeave}
>
<TextField
id={id}
type="file"
onChange={onChange}
sx={{
width: "100%",
"& .MuiInputBase-input[type='file']": {
opacity: 0,
cursor: "pointer",
maxWidth: "500px",
minHeight: "175px",
zIndex: 1,
},
"& fieldset": {
padding: 0,
border: "none",
},
}}
/>
<Stack
className="custom-file-text"
alignItems="center"
gap="4px"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 0,
width: "100%",
}}
>
<IconButton
sx={{
pointerEvents: "none",
borderRadius: theme.shape.borderRadius,
border: `solid ${theme.shape.borderThick}px ${theme.palette.primary.lowContrast}`,
boxShadow: theme.shape.boxShadow,
}}
>
<CloudUploadIcon />
</IconButton>
<Typography
component="h2"
color={theme.palette.primary.contrastTextTertiary}
>
<Typography
component="span"
fontSize="inherit"
color="info"
fontWeight={500}
>
Click to upload
</Typography>{" "}
or drag and drop
</Typography>
<Typography
component="p"
color={theme.palette.primary.contrastTextTertiary}
sx={{ opacity: 0.6 }}
>
(maximum size: {maxSize ?? "3MB"})
</Typography>
</Stack>
</Box>
<Typography
component="p"
color={theme.palette.primary.contrastTextTertiary}
sx={{ opacity: 0.6 }}
>
Supported formats: JPG, PNG
</Typography>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</>
) : (
<Stack
direction="row"
justifyContent="center"
>
<Box
sx={{
width: "250px",
height: "250px",
overflow: "hidden",
backgroundImage: `url(${src})`,
backgroundSize: "cover",
...roundShape,
}}
></Box>
</Stack>
)}
</>
);
};
ImageField.propTypes = {
id: PropTypes.string.isRequired,
src: PropTypes.string,
onChange: PropTypes.func.isRequired,
isRound: PropTypes.bool,
maxSize: PropTypes.string,
};
export default ImageField;

View File

@@ -0,0 +1,89 @@
import PropTypes from "prop-types";
import { FormControlLabel, Radio as MUIRadio, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import RadioChecked from "../../../assets/icons/radio-checked.svg?react";
import "./index.css";
/**
* Radio component.
*
* @component
* @example
* // Usage:
* <Radio
* title="Radio Button Title"
* desc="Radio Button Description"
* size="small"
* />
*
* @param {Object} props - The component props.
* @param {string} props.id - The id of the radio button.
* @param {string} props.title - The title of the radio button.
* @param {string} [props.desc] - The description of the radio button.
* @param {string} [props.size="small"] - The size of the radio button.
* @returns {JSX.Element} - The rendered Radio component.
*/
const Radio = (props) => {
const theme = useTheme();
return (
<FormControlLabel
className="custom-radio-button"
checked={props.checked}
value={props.value}
control={
<MUIRadio
id={props.id}
size={props.size}
checkedIcon={<RadioChecked />}
sx={{
color: "transparent",
width: 16,
height: 16,
boxShadow: `inset 0 0 0 1px ${theme.palette.secondary.main}`,
"&:not(.Mui-checked)": {
boxShadow: `inset 0 0 0 1px ${theme.palette.primary.contrastText}70`, // Use theme text color for the outline
},
mt: theme.spacing(0.5),
}}
/>
}
onChange={props.onChange}
label={
<>
<Typography component="p">{props.title}</Typography>
<Typography
component="h6"
mt={theme.spacing(1)}
color={theme.palette.primary.contrastTextSecondary}
>
{props.desc}
</Typography>
</>
}
labelPlacement="end"
sx={{
alignItems: "flex-start",
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.tertiary.main,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
/>
);
};
Radio.propTypes = {
title: PropTypes.string.isRequired,
desc: PropTypes.string,
size: PropTypes.string,
};
export default Radio;

View File

@@ -0,0 +1,232 @@
import PropTypes from "prop-types";
import { Box, ListItem, Autocomplete, TextField, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import SearchIcon from "../../../assets/icons/search.svg?react";
/**
* Search component using Material UI's Autocomplete.
*
* @param {Object} props
* @param {string} props.id - Unique identifier for the Autocomplete component
* @param {Array<Object>} props.options - Options to display in the Autocomplete dropdown
* @param {string} props.filteredBy - Key to access the option label from the options
* @param {string} props.value - Current input value for the Autocomplete
* @param {Function} props.handleChange - Function to call when the input changes
* @param {Function} Prop.onBlur - Function to call when the input is blured
* @param {Object} props.sx - Additional styles to apply to the component
* @returns {JSX.Element} The rendered Search component
*/
const SearchAdornment = () => {
const theme = useTheme();
return (
<Box
mr={theme.spacing(4)}
height={16}
sx={{
"& svg": {
width: 16,
height: 16,
"& path": {
stroke: theme.palette.primary.contrastTextTertiary,
strokeWidth: 1.2,
},
},
}}
>
<SearchIcon />
</Box>
);
};
//TODO keep search state inside of component
const Search = ({
label,
id,
options,
filteredBy,
secondaryLabel,
value,
inputValue,
handleInputChange,
handleChange,
sx,
multiple = false,
isAdorned = true,
error,
disabled,
startAdornment,
endAdornment,
onBlur,
}) => {
const theme = useTheme();
return (
<Autocomplete
onBlur={onBlur}
multiple={multiple}
id={id}
value={value}
inputValue={inputValue}
onInputChange={(_, newValue) => {
handleInputChange(newValue);
}}
onChange={(_, newValue) => {
handleChange(newValue);
}}
fullWidth
freeSolo
disabled={disabled}
disableClearable
options={options}
getOptionLabel={(option) => option[filteredBy]}
isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier
renderInput={(params) => (
<Stack>
<Typography
component="h3"
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
>
{label}
</Typography>
<TextField
{...params}
error={Boolean(error)}
placeholder="Type to search"
slotProps={{
input: {
...params.InputProps,
...(isAdorned && { startAdornment: <SearchAdornment /> }),
...(startAdornment && { startAdornment: startAdornment }),
...(endAdornment && { endAdornment: endAdornment }),
},
}}
sx={{
"& fieldset": {
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
},
"& .MuiOutlinedInput-root:hover:not(:has(input:focus)):not(:has(textarea:focus)) fieldset":
{
borderColor: theme.palette.primary.lowContrast,
},
"& .MuiOutlinedInput-root": {
paddingY: 0,
},
"& .MuiAutocomplete-tag": {
// CAIO_REVIEW
color: theme.palette.primary.contrastText,
backgroundColor: theme.palette.primary.lowContrast,
},
"& .MuiChip-deleteIcon": {
color: theme.palette.primary.contrastText, // CAIO_REVIEW
},
}}
/>
{error && (
<Typography
component="span"
className="input-error"
color={theme.palette.error.main}
mt={theme.spacing(2)}
sx={{
opacity: 0.8,
}}
>
{error}
</Typography>
)}
</Stack>
)}
filterOptions={(options, { inputValue }) => {
const filtered = options.filter((option) =>
option[filteredBy].toLowerCase().includes(inputValue.toLowerCase())
);
if (filtered.length === 0) {
return [{ [filteredBy]: "No monitors found", noOptions: true }];
}
return filtered;
}}
getOptionKey={(option) => {
return option._id;
}}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
const hasSecondaryLabel = secondaryLabel && option[secondaryLabel] !== undefined;
return (
<ListItem
key={key}
{...optionProps}
sx={
option.noOptions
? {
pointerEvents: "none",
backgroundColor: theme.palette.primary.main,
}
: {}
}
>
{option[filteredBy] +
(hasSecondaryLabel ? ` (${option[secondaryLabel]})` : "")}
</ListItem>
);
}}
slotProps={{
popper: {
keepMounted: true,
sx: {
"& ul": { p: 2, backgroundColor: theme.palette.primary.main },
"& li.MuiAutocomplete-option": {
color: theme.palette.primary.contrastTextSecondary,
px: 4,
borderRadius: theme.shape.borderRadius,
},
"& .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true'], & .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true'].Mui-focused, & .MuiAutocomplete-listbox .MuiAutocomplete-option[aria-selected='true']:hover":
{
backgroundColor: theme.palette.primary.lowContrast,
color: "red",
},
"& li.MuiAutocomplete-option:hover:not([aria-selected='true'])": {
color: theme.palette.secondary.contrastText,
backgroundColor: theme.palette.secondary.main,
},
"& .MuiAutocomplete-noOptions": {
px: theme.spacing(6),
py: theme.spacing(5),
},
},
},
}}
sx={{
/* height: 34,*/
"&.MuiAutocomplete-root .MuiAutocomplete-input": { p: 0 },
...sx,
}}
/>
);
};
Search.propTypes = {
label: PropTypes.string,
id: PropTypes.string,
multiple: PropTypes.bool,
options: PropTypes.array.isRequired,
filteredBy: PropTypes.string.isRequired,
secondaryLabel: PropTypes.string,
value: PropTypes.array,
inputValue: PropTypes.string.isRequired,
handleInputChange: PropTypes.func.isRequired,
handleChange: PropTypes.func,
isAdorned: PropTypes.bool,
sx: PropTypes.object,
error: PropTypes.string,
disabled: PropTypes.bool,
startAdornment: PropTypes.object,
endAdornment: PropTypes.object,
onBlur: PropTypes.func,
};
export default Search;

View File

@@ -0,0 +1,7 @@
.select-wrapper .select-component > .MuiSelect-select {
padding: 0 10px;
min-height: 34px;
display: flex;
align-items: center;
line-height: 1;
}

View File

@@ -0,0 +1,182 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { MenuItem, Select as MuiSelect, Stack, Typography } from "@mui/material";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import "./index.css";
/**
* @component
* @param {object} props
* @param {string} props.id - The ID attribute for the select element.
* @param {string} props.placeholder - The label of the select element.
* @param {string} props.placeholder - The placeholder text when no option is selected.
* @param {boolean} props.isHidden - Whether the placeholder should be hidden.
* @param {string} props.value - The currently selected value.
* @param {object[]} props.items - The array of items to populate in the select dropdown.
* @param {(string | number)} props.items._id - The unique identifier of each item.
* @param {string} props.items.name - The display name of each item.
* @param {function} props.onChange - The function to handle onChange event.
* @param {object} props.sx - The custom styles object for MUI Select component.
* @param {number} props.maxWidth - Maximum width in pixels for the select component. Enables responsive text truncation.
* @returns {JSX.Element}
*
* @example
* const frequencies = [
* { _id: 1, name: "1 minute" },
* { _id: 2, name: "2 minutes" },
* { _id: 3, name: "3 minutes" },
* ];
*
* <Select
* id="frequency-id"
* name="my-name"
* label="Check frequency"
* placeholder="Select frequency"
* value={value}
* onChange={handleChange}
* items={frequencies}
* />
*/
const Select = ({
id,
label,
placeholder,
isHidden,
value,
items,
onChange,
onBlur,
sx,
name = "",
labelControlSpacing = 2,
maxWidth,
}) => {
const theme = useTheme();
const itemStyles = {
fontSize: "var(--env-var-font-size-medium)",
color: theme.palette.primary.contrastTextTertiary,
borderRadius: theme.shape.borderRadius,
margin: theme.spacing(2),
};
const responsiveMaxWidth = {
xs: `${maxWidth * 0.5}px`,
sm: `${maxWidth * 0.75}px`,
md: `${maxWidth * 0.9}px`,
lg: `${maxWidth}px`,
};
return (
<Stack
gap={theme.spacing(labelControlSpacing)}
className="select-wrapper"
>
{label && (
<Typography
component="h3"
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
fontSize={13}
>
{label}
</Typography>
)}
<MuiSelect
className="select-component"
value={value}
onChange={onChange}
onBlur={onBlur}
displayEmpty
name={name}
inputProps={{ id: id }}
IconComponent={KeyboardArrowDownIcon}
MenuProps={{ disableScrollLock: true }}
sx={{
fontSize: 13,
minWidth: "125px",
...(maxWidth && { maxWidth: responsiveMaxWidth }),
"& fieldset": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.primary.lowContrast,
},
"&:not(.Mui-focused):hover fieldset": {
borderColor: theme.palette.primary.lowContrast,
},
"& svg path": {
fill: theme.palette.primary.contrastTextTertiary,
},
...sx,
}}
renderValue={(selected) => {
const selectedItem = items.find((item) => item._id === selected);
const displayName = selectedItem ? selectedItem.name : placeholder;
return (
<Typography
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={displayName}
>
{displayName}
</Typography>
);
}}
>
{placeholder && (
<MenuItem
className="select-placeholder"
value="0"
sx={{
display: isHidden ? "none" : "flex",
visibility: isHidden ? "none" : "visible",
...itemStyles,
}}
>
{placeholder}
</MenuItem>
)}
{items.map((item) => (
<MenuItem
value={item._id}
key={`${id}-${item._id}`}
sx={{
...itemStyles,
}}
>
{item.name}
</MenuItem>
))}
</MuiSelect>
</Stack>
);
};
Select.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
isHidden: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
name: PropTypes.string.isRequired,
})
).isRequired,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func,
sx: PropTypes.object,
labelControlSpacing: PropTypes.number,
/**
* Maximum width in pixels. Used to control text truncation and element width.
* Responsive breakpoints will be calculated as percentages of this value.
*/
maxWidth: PropTypes.number,
};
export default Select;

View File

@@ -0,0 +1,68 @@
import { Stack, Typography, InputAdornment, IconButton } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import Visibility from "@mui/icons-material/Visibility";
import ReorderRoundedIcon from "@mui/icons-material/ReorderRounded";
import DeleteIcon from "../../../../assets/icons/trash-bin.svg?react";
export const HttpAdornment = ({ https }) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
height="100%"
sx={{
borderRight: `solid 1px ${theme.palette.primary.lowContrast}`,
backgroundColor: theme.palette.tertiary.main,
pl: theme.spacing(6),
}}
>
<Typography
component="h5"
paddingRight={"var(--env-var-spacing-1-minus)"}
color={theme.palette.primary.contrastTextSecondary}
sx={{ lineHeight: 1, opacity: 0.8 }}
>
{https ? "https" : "http"}
</Typography>
</Stack>
);
};
HttpAdornment.propTypes = {
https: PropTypes.bool.isRequired,
prefix: PropTypes.string,
};
export const PasswordEndAdornment = ({ fieldType, setFieldType }) => {
const theme = useTheme();
return (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setFieldType(fieldType === "password" ? "text" : "password")}
sx={{
color: theme.palette.primary.lowContrast,
padding: theme.spacing(1),
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: `2px`,
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
>
{fieldType === "password" ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
);
};
PasswordEndAdornment.propTypes = {
fieldType: PropTypes.string,
setFieldType: PropTypes.func,
};

View File

@@ -0,0 +1,179 @@
import { Stack, TextField, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { forwardRef, useState, cloneElement } from "react";
import PropTypes from "prop-types";
const getSx = (theme, type, maxWidth) => {
const sx = {
maxWidth: maxWidth,
"& .MuiOutlinedInput-root ": {
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.contrastText, // Adjust hover border color
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.accent.main, // Adjust focus border color
},
"&.Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.contrastText, // CAIO_REVIEW
opacity: 0.5,
},
},
"& .MuiFormHelperText-root": {
position: "absolute",
bottom: `-${theme.spacing(24)}`,
minHeight: theme.spacing(24),
},
};
if (type === "url") {
return {
...sx,
"& .MuiInputBase-root": { padding: 0 },
"& .MuiStack-root": {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
};
}
return sx;
};
const Required = () => {
const theme = useTheme();
return (
<Typography
component="span"
ml={theme.spacing(1)}
color={theme.palette.error.main}
>
*
</Typography>
);
};
const Optional = ({ optionalLabel }) => {
const theme = useTheme();
return (
<Typography
component="span"
fontSize="inherit"
fontWeight={400}
ml={theme.spacing(2)}
sx={{ opacity: 0.6 }}
>
{optionalLabel || "(optional)"}
</Typography>
);
};
Optional.propTypes = {
optionalLabel: PropTypes.string,
};
const TextInput = forwardRef(
(
{
id,
name,
type,
value,
placeholder,
isRequired,
isOptional,
optionalLabel,
onChange,
onBlur,
error = false,
helperText = null,
startAdornment = null,
endAdornment = null,
label = null,
maxWidth = "100%",
flex,
marginTop,
marginRight,
marginBottom,
marginLeft,
disabled = false,
hidden = false,
},
ref
) => {
const [fieldType, setFieldType] = useState(type);
const theme = useTheme();
return (
<Stack
flex={flex}
display={hidden ? "none" : ""}
marginTop={marginTop}
marginRight={marginRight}
marginBottom={marginBottom}
marginLeft={marginLeft}
>
<Typography
component="h3"
fontSize={"var(--env-var-font-size-medium)"}
color={theme.palette.primary.contrastTextSecondary}
fontWeight={500}
>
{label}
{isRequired && <Required />}
{isOptional && <Optional optionalLabel={optionalLabel} />}
</Typography>
<TextField
id={id}
name={name}
type={fieldType}
value={value}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
error={error}
helperText={helperText}
inputRef={ref}
sx={getSx(theme, type, maxWidth)}
slotProps={{
input: {
startAdornment: startAdornment,
endAdornment: endAdornment
? cloneElement(endAdornment, { fieldType, setFieldType })
: null,
},
}}
disabled={disabled}
/>
</Stack>
);
}
);
TextInput.displayName = "TextInput";
TextInput.propTypes = {
type: PropTypes.string,
id: PropTypes.string.isRequired,
name: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,
isRequired: PropTypes.bool,
isOptional: PropTypes.bool,
optionalLabel: PropTypes.string,
onChange: PropTypes.func,
onBlur: PropTypes.func,
error: PropTypes.bool,
helperText: PropTypes.string,
startAdornment: PropTypes.node,
endAdornment: PropTypes.node,
label: PropTypes.string,
maxWidth: PropTypes.string,
flex: PropTypes.number,
marginTop: PropTypes.string,
marginRight: PropTypes.string,
marginBottom: PropTypes.string,
marginLeft: PropTypes.string,
disabled: PropTypes.bool,
hidden: PropTypes.bool,
};
export default TextInput;

View File

@@ -0,0 +1,6 @@
.label {
display: inline-flex;
justify-content: center;
align-items: center;
line-height: normal;
}

View File

@@ -0,0 +1,166 @@
import PropTypes from "prop-types";
import { Box } from "@mui/material";
import { useTheme } from "@mui/material";
import "./index.css";
/**
* @typedef {Object} Styles
* @param {string} [color] - The text color
* @param {string} [backgroundColor] - The background color
* @param {string} [borderColor] - The border color
*/
/**
* @component
* @param {Object} props
* @param {string} props.label - The label of the label
* @param {Styles} props.styles - CSS Styles passed from parent component
* @param {React.ReactNode} children - Children passed from parent component
* @returns {JSX.Element}
*/
const BaseLabel = ({ label, styles, children }) => {
const theme = useTheme();
// Grab the default borderRadius from the theme to match button style
const { borderRadius } = theme.shape;
// Calculate padding for the label to mimic button. Appears to scale correctly, not 100% sure though.
const padding = theme.spacing(3, 5);
return (
<Box
className="label"
sx={{
borderRadius: borderRadius,
border: `1px solid ${theme.palette.primary.lowContrast}`,
color: theme.palette.primary.contrastText,
padding: padding,
...styles,
}}
>
{children}
{label}
</Box>
);
};
BaseLabel.propTypes = {
label: PropTypes.string.isRequired,
styles: PropTypes.shape({
color: PropTypes.string,
backgroundColor: PropTypes.string,
}),
children: PropTypes.node,
};
// Produces a lighter color based on a hex color and a percent
// lightenColor("#067647", 20) will produce a color 20% lighter than #067647
const lightenColor = (color, percent) => {
let r = parseInt(color.substring(1, 3), 16);
let g = parseInt(color.substring(3, 5), 16);
let b = parseInt(color.substring(5, 7), 16);
const amt = Math.round((255 * percent) / 100);
r = r + amt <= 255 ? r + amt : 255;
g = g + amt <= 255 ? g + amt : 255;
b = b + amt <= 255 ? b + amt : 255;
r = r.toString(16).padStart(2, "0");
g = g.toString(16).padStart(2, "0");
b = b.toString(16).padStart(2, "0");
return `#${r}${g}${b}`;
};
/**
* @component
* @param {Object} props
* @param {string} props.label - The label of the label
* @param {string} props.color - The color of the label, specified in #RRGGBB format
* @returns {JSX.Element}
* @example
* // Render a red label
* <ColoredLabel label="Label" color="#FF0000" />
*/
const ColoredLabel = ({ label, color }) => {
const theme = useTheme();
// If an invalid color is passed, default to the labelGray color
if (typeof color !== "string" || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) {
color = theme.palette.primary.lowContrast;
}
// Calculate lighter shades for border and bg
const borderColor = lightenColor(color, 20);
const bgColor = lightenColor(color, 75);
return (
<BaseLabel
label={label}
styles={{
color: color,
borderColor: borderColor,
backgroundColor: bgColor,
}}
></BaseLabel>
);
};
ColoredLabel.propTypes = {
label: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
};
/**
* @component
* @param {Object} props
* @param {'up' | 'down' | 'paused' | 'pending' | 'cannot resolve' | 'published' | 'unpublished'} props.status - The status for the label
* @param {string} props.text - The text of the label
* @returns {JSX.Element}
* @example
* // Render an active label
* <StatusLabel status="up" text="Active" />
*/
const statusToTheme = {
up: "success",
down: "error",
paused: "warning",
pending: "warning",
"cannot resolve": "error",
published: "success",
unpublished: "error",
};
const StatusLabel = ({ status, text, customStyles }) => {
const theme = useTheme();
const themeColor = statusToTheme[status];
return (
<BaseLabel
label={text}
styles={{
color: theme.palette[themeColor].main,
borderColor: theme.palette[themeColor].lowContrast,
...customStyles,
}}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[themeColor].lowContrast}
borderRadius="50%"
marginRight="5px"
/>
</BaseLabel>
);
};
StatusLabel.propTypes = {
status: PropTypes.oneOf(["up", "down", "paused", "pending", "cannot resolve", "published", "unpublished"]),
text: PropTypes.string,
customStyles: PropTypes.object,
};
export { BaseLabel, ColoredLabel, StatusLabel };

View File

@@ -0,0 +1,136 @@
import { useTranslation } from "react-i18next";
import { Box, MenuItem, Select, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
import "flag-icons/css/flag-icons.min.css";
import { useSelector } from "react-redux";
const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const { language } = useSelector((state) => state.ui);
const handleChange = (event) => {
const newLang = event.target.value;
i18n.changeLanguage(newLang);
};
const languages = Object.keys(i18n.options.resources || {});
return (
<Select
value={language}
onChange={handleChange}
size="small"
sx={{
height: 28,
width: 64,
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
borderRadius: theme.shape.borderRadius,
fontSize: 10,
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
"& .MuiSvgIcon-root": {
color: theme.palette.primary.contrastText,
width: 16,
height: 16,
right: 4,
top: "calc(50% - 8px)",
},
"& .MuiSelect-select": {
padding: "2px 20px 2px 8px",
display: "flex",
alignItems: "center",
fontSize: 10,
},
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: theme.palette.primary.main,
borderRadius: theme.shape.borderRadius,
marginTop: 1,
width: 64,
"& .MuiMenuItem-root": {
padding: "2px 8px",
minHeight: 28,
fontSize: 10,
},
},
},
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
}}
>
{languages.map((lang) => {
const flag = lang ? `fi fi-${lang}` : null;
return (
<MenuItem
key={lang}
value={lang}
sx={{
color: theme.palette.primary.contrastText,
"&:hover": {
backgroundColor: theme.palette.primary.lowContrast,
},
"&.Mui-selected": {
backgroundColor: theme.palette.primary.lowContrast,
"&:hover": {
backgroundColor: theme.palette.primary.lowContrast,
},
},
}}
>
<Stack
direction="row"
spacing={2}
alignItems="center"
ml={0.5}
>
<Box
component="span"
sx={{
width: 16,
height: 12,
display: "flex",
alignItems: "center",
"& img": {
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: 0.5,
},
}}
>
{flag && <span className={flag} />}
</Box>
<Box
component="span"
sx={{ textTransform: "uppercase", fontSize: 10 }}
>
{lang}
</Box>
</Stack>
</MenuItem>
);
})}
</Select>
);
};
export default LanguageSelector;

View File

@@ -0,0 +1,33 @@
.home-layout {
position: relative;
min-height: 100vh;
margin: 0 auto;
padding: 0;
}
/* TODO go for this approach for responsiveness. The aside needs to be taken care of */
/* @media (max-width: 1000px) {
.home-layout {
flex-direction: column !important;
}
} */
.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);
flex: 1;
}
.home-content-wrapper {
padding: var(--env-var-spacing-2);
max-width: 1400px;
margin: 0 auto;
flex: 1;
}

View File

@@ -0,0 +1,22 @@
import Sidebar from "../../Sidebar";
import { Outlet } from "react-router";
import { Stack } from "@mui/material";
import "./index.css";
const HomeLayout = () => {
return (
<Stack
className="home-layout"
flexDirection="row"
gap={14}
>
<Sidebar />
<Stack className="home-content-wrapper">
<Outlet />
</Stack>
</Stack>
);
};
export default HomeLayout;

View File

@@ -0,0 +1,69 @@
import { Link as MuiLink, useTheme } from "@mui/material";
import PropTypes from "prop-types";
/**
* @component
* @param {Object} props
* @param {'primary' | 'secondary' | 'tertiary' | 'error'} props.level - The level of the link
* @param {string} props.label - The label of the link
* @param {string} props.url - The URL of the link
* @returns {JSX.Element}
*/
const Link = ({ level, label, url }) => {
const theme = useTheme();
const levelConfig = {
primary: {
color: theme.palette.primary.contrastTextTertiary,
sx: {
":hover": {
color: theme.palette.primary.contrastTextSecondary,
},
},
},
secondary: {
color: theme.palette.primary.contrastTextSecondary,
sx: {
":hover": {
color: theme.palette.primary.contrastTextSecondary,
},
},
},
tertiary: {
color: theme.palette.primary.contrastTextTertiary,
sx: {
textDecoration: "underline",
textDecorationStyle: "dashed",
textDecorationColor: theme.palette.primary.main,
textUnderlineOffset: "1px",
":hover": {
color: theme.palette.primary.contrastTextTertiary,
textDecorationColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.lowContrast,
},
},
},
error: {},
};
const { sx, color } = levelConfig[level];
return (
<MuiLink
href={url}
sx={{ width: "fit-content", ...sx }}
color={color}
target="_blank"
rel="noreferrer"
>
{label}
</MuiLink>
);
};
Link.propTypes = {
url: PropTypes.string.isRequired,
level: PropTypes.oneOf(["primary", "secondary", "tertiary", "error"]),
label: PropTypes.string.isRequired,
};
export default Link;

View File

View File

@@ -0,0 +1,52 @@
import { Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import SkeletonLayout from "./skeleton";
const MonitorCountHeader = ({
shouldRender = true,
monitorCount,
heading = "monitors",
sx,
children,
}) => {
const theme = useTheme();
if (!shouldRender) return <SkeletonLayout />;
if (monitorCount === 1) {
heading = "monitor";
}
return (
<Stack
direction="row"
alignItems="center"
display="flex"
width="fit-content"
height={theme.spacing(18)}
gap={theme.spacing(2)}
mt={theme.spacing(2)}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.spacing(1)}
sx={{
...sx,
backgroundColor: theme.palette.secondary.main,
}}
>
{monitorCount} <Typography component="h2">{heading}</Typography>
{children}
</Stack>
);
};
MonitorCountHeader.propTypes = {
shouldRender: PropTypes.bool,
monitorCount: PropTypes.number,
heading: PropTypes.string,
children: PropTypes.node,
sx: PropTypes.object,
};
export default MonitorCountHeader;

View File

@@ -0,0 +1,26 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Skeleton
variant="text"
width={100}
height={32}
/>
<Skeleton
variant="circular"
width={40}
height={40}
/>
</Stack>
);
};
export default SkeletonLayout;

View File

@@ -0,0 +1,39 @@
import { Stack, Button } from "@mui/material";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import SkeletonLayout from "./skeleton";
const CreateMonitorHeader = ({
isAdmin,
label = "Create new",
shouldRender = true,
path,
}) => {
const navigate = useNavigate();
if (!isAdmin) return null;
if (!shouldRender) return <SkeletonLayout />;
return (
<Stack
direction="row"
justifyContent="end"
alignItems="center"
>
<Button
variant="contained"
color="accent"
onClick={() => navigate(path)}
>
{label}
</Button>
</Stack>
);
};
export default CreateMonitorHeader;
CreateMonitorHeader.propTypes = {
isAdmin: PropTypes.bool.isRequired,
shouldRender: PropTypes.bool,
path: PropTypes.string.isRequired,
label: PropTypes.string,
};

View File

@@ -0,0 +1,19 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack
direction="row"
justifyContent="end"
alignItems="center"
>
<Skeleton
variant="rectangular"
width={100}
height={36}
/>
</Stack>
);
};
export default SkeletonLayout;

View File

@@ -0,0 +1,41 @@
import { Button, Box } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import PropTypes from "prop-types";
const ConfigButton = ({ shouldRender = true, monitorId, path }) => {
const theme = useTheme();
const navigate = useNavigate();
if (!shouldRender) return null;
return (
<Box alignSelf="flex-end">
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/${path}/configure/${monitorId}`)}
sx={{
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
/* Should always be contrastText for the button color */
stroke: theme.palette.secondary.contrastText,
},
},
}}
>
<SettingsIcon /> Configure
</Button>
</Box>
);
};
ConfigButton.propTypes = {
shouldRender: PropTypes.bool,
monitorId: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
};
export default ConfigButton;

View File

@@ -0,0 +1,56 @@
import { Stack, Typography } from "@mui/material";
import PulseDot from "../Animated/PulseDot";
import Dot from "../Dot";
import { useTheme } from "@emotion/react";
import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
import { formatDurationRounded } from "../../Utils/timeUtils";
import ConfigButton from "./ConfigButton";
import SkeletonLayout from "./skeleton";
import PropTypes from "prop-types";
const MonitorStatusHeader = ({ path, isLoading = false, isAdmin, monitor }) => {
const theme = useTheme();
const { statusColor, determineState } = useUtils();
if (isLoading) {
return <SkeletonLayout />;
}
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Stack>
<Typography variant="h1">{monitor?.name}</Typography>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(2)}
>
<PulseDot color={statusColor[determineState(monitor)]} />
<Typography variant="h2">
{monitor?.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Dot />
<Typography>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Stack>
<ConfigButton
path={path}
shouldRender={isAdmin}
monitorId={monitor?._id}
/>
</Stack>
);
};
MonitorStatusHeader.propTypes = {
path: PropTypes.string.isRequired,
isLoading: PropTypes.bool,
isAdmin: PropTypes.bool,
monitor: PropTypes.object,
};
export default MonitorStatusHeader;

View File

@@ -0,0 +1,23 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
</Stack>
);
};
export default SkeletonLayout;

View File

@@ -0,0 +1,86 @@
import { Stack, Typography, Button, ButtonGroup } from "@mui/material";
import { useTheme } from "@emotion/react";
import SkeletonLayout from "./skeleton";
import PropTypes from "prop-types";
const MonitorTimeFrameHeader = ({
isLoading = false,
hasDateRange = true,
dateRange,
setDateRange,
}) => {
const theme = useTheme();
if (isLoading) {
return <SkeletonLayout />;
}
let timeFramePicker = null;
if (hasDateRange) {
timeFramePicker = (
<ButtonGroup sx={{ height: 32 }}>
<Button
variant="group"
filled={(dateRange === "recent").toString()}
onClick={() => setDateRange("recent")}
>
Recent
</Button>
<Button
variant="group"
filled={(dateRange === "day").toString()}
onClick={() => setDateRange("day")}
>
Day
</Button>
<Button
variant="group"
filled={(dateRange === "week").toString()}
onClick={() => setDateRange("week")}
>
Week
</Button>
<Button
variant="group"
filled={(dateRange === "month").toString()}
onClick={() => setDateRange("month")}
>
Month
</Button>
</ButtonGroup>
);
}
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
gap={theme.spacing(4)}
mb={theme.spacing(8)}
>
<Typography variant="body2">
Showing statistics for past{" "}
{dateRange === "recent"
? "2 hours"
: dateRange === "day"
? "24 hours"
: dateRange === "week"
? "7 days"
: "30 days"}
.
</Typography>
{timeFramePicker}
</Stack>
);
};
MonitorTimeFrameHeader.propTypes = {
isLoading: PropTypes.bool,
hasDateRange: PropTypes.bool,
dateRange: PropTypes.string,
setDateRange: PropTypes.func,
};
export default MonitorTimeFrameHeader;

View File

@@ -0,0 +1,23 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Skeleton
variant="rounded"
width="20%"
height={34}
/>
<Skeleton
variant="rounded"
width="20%"
height={34}
/>
</Stack>
);
};
export default SkeletonLayout;

View File

@@ -0,0 +1,353 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
import {
Dialog,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Tabs,
Tab,
} from "@mui/material";
import { useTheme } from "@emotion/react";
import TabPanel from "./TabPanel";
import TabComponent from "./TabComponent";
import useNotifications from "../Hooks/useNotification";
// Define constants for notification types to avoid magic values
const NOTIFICATION_TYPES = {
SLACK: 'slack',
DISCORD: 'discord',
TELEGRAM: 'telegram',
WEBHOOK: 'webhook'
};
// Define constants for field IDs
const FIELD_IDS = {
WEBHOOK: 'webhook',
TOKEN: 'token',
CHAT_ID: 'chatId',
URL: 'url'
};
const NotificationIntegrationModal = ({
open,
onClose,
monitor,
setMonitor,
// Optional prop to configure available notification types
notificationTypes = null
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [tabValue, setTabValue] = useState(0);
const [loading, _, sendTestNotification] = useNotifications();
// Helper to get the field state key with error handling
const getFieldKey = (typeId, fieldId) => {
if (typeof typeId !== 'string' || typeId === '') {
throw new Error(t('errorInvalidTypeId'));
}
if (typeof fieldId !== 'string' || fieldId === '') {
throw new Error(t('errorInvalidFieldId'));
}
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
};
// Define notification types
const DEFAULT_NOTIFICATION_TYPES = [
{
id: NOTIFICATION_TYPES.SLACK,
label: t('notifications.slack.label'),
description: t('notifications.slack.description'),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t('notifications.slack.webhookLabel'),
placeholder: t('notifications.slack.webhookPlaceholder'),
type: 'text'
}
]
},
{
id: NOTIFICATION_TYPES.DISCORD,
label: t('notifications.discord.label'),
description: t('notifications.discord.description'),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t('notifications.discord.webhookLabel'),
placeholder: t('notifications.discord.webhookPlaceholder'),
type: 'text'
}
]
},
{
id: NOTIFICATION_TYPES.TELEGRAM,
label: t('notifications.telegram.label'),
description: t('notifications.telegram.description'),
fields: [
{
id: FIELD_IDS.TOKEN,
label: t('notifications.telegram.tokenLabel'),
placeholder: t('notifications.telegram.tokenPlaceholder'),
type: 'text'
},
{
id: FIELD_IDS.CHAT_ID,
label: t('notifications.telegram.chatIdLabel'),
placeholder: t('notifications.telegram.chatIdPlaceholder'),
type: 'text'
}
]
},
{
id: NOTIFICATION_TYPES.WEBHOOK,
label: t('notifications.webhook.label'),
description: t('notifications.webhook.description'),
fields: [
{
id: FIELD_IDS.URL,
label: t('notifications.webhook.urlLabel'),
placeholder: t('notifications.webhook.urlPlaceholder'),
type: 'text'
}
]
}
];
// Use provided notification types or default to our translated ones
const activeNotificationTypes = notificationTypes || DEFAULT_NOTIFICATION_TYPES;
// Memoized function to initialize integrations state
const initialIntegrationsState = useMemo(() => {
const state = {};
activeNotificationTypes.forEach(type => {
// Add enabled flag for each notification type
state[type.id] = monitor?.notifications?.some(n => n.type === type.id) || false;
// Add state for each field in the notification type
type.fields.forEach(field => {
const fieldKey = getFieldKey(type.id, field.id);
state[fieldKey] = monitor?.notifications?.find(n => n.type === type.id)?.[field.id] || "";
});
});
return state;
}, [monitor, activeNotificationTypes]); // Only recompute when these dependencies change
const [integrations, setIntegrations] = useState(initialIntegrationsState);
const handleChangeTab = (event, newValue) => {
setTabValue(newValue);
};
const handleIntegrationChange = (type, checked) => {
setIntegrations(prev => ({
...prev,
[type]: checked
}));
};
const handleInputChange = (type, value) => {
setIntegrations(prev => ({
...prev,
[type]: value
}));
};
const handleTestNotification = async (type) => {
// Get the notification type details
const notificationType = activeNotificationTypes.find(t => t.id === type);
if (typeof notificationType === "undefined") {
return;
}
// Prepare config object based on notification type
const config = {};
// Add each field value to the config object
notificationType.fields.forEach(field => {
const fieldKey = getFieldKey(type, field.id);
config[field.id] = integrations[fieldKey];
});
await sendTestNotification(type, config);
};
// In NotificationIntegrationModal.jsx, update the handleSave function:
const handleSave = () => {
// Get existing notifications
const notifications = [...(monitor?.notifications || [])];
// Get all notification types IDs
const existingTypes = activeNotificationTypes.map(type => type.id);
// Filter out notifications that are configurable in this modal
const filteredNotifications = notifications.filter(
notification => {
if (notification.platform) {
return !existingTypes.includes(notification.platform);
}
return !existingTypes.includes(notification.type);
}
);
// Add each enabled notification with its configured fields
activeNotificationTypes.forEach(type => {
if (integrations[type.id]) {
let notificationObject = {
type: "webhook",
platform: type.id, // Set platform to identify the specific service
config: {}
};
// Configure based on notification type
switch(type.id) {
case "slack":
case "discord":
notificationObject.config.webhookUrl = integrations[getFieldKey(type.id, 'webhook')];
break;
case "telegram":
notificationObject.config.botToken = integrations[getFieldKey(type.id, 'token')];
notificationObject.config.chatId = integrations[getFieldKey(type.id, 'chatId')];
break;
case "webhook":
notificationObject.config.webhookUrl = integrations[getFieldKey(type.id, 'url')];
break;
}
filteredNotifications.push(notificationObject);
}
});
// Update monitor with new notifications
setMonitor(prev => ({
...prev,
notifications: filteredNotifications
}));
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
sx={{
'& .MuiDialog-paper': {
width: `calc(80% - ${theme.spacing(40)})`,
maxWidth: `${theme.breakpoints.values.md - 70}px`
}
}}
>
<DialogContent>
<Box sx={{
display: 'flex',
height: `calc(26vh - ${theme.spacing(20)})`
}}>
{/* Left sidebar with tabs */}
<Box sx={{
borderRight: 1,
borderColor: theme.palette.primary.lowContrast,
width: '30%',
maxWidth: theme.spacing(120),
pr: theme.spacing(10)
}}>
<Typography variant="subtitle1" sx={{
my: theme.spacing(1),
fontWeight: 'bold',
fontSize: theme.typography.fontSize * 0.9,
color: theme.palette.primary.contrastTextSecondary,
pl: theme.spacing(4)
}}>
{t('notifications.addOrEditNotifications')}
</Typography>
<Tabs
orientation="vertical"
variant="scrollable"
value={tabValue}
onChange={handleChangeTab}
aria-label="Notification tabs"
>
{activeNotificationTypes.map((type) => (
<Tab
key={type.id}
label={type.label}
orientation="vertical"
disableRipple
/>
))}
</Tabs>
</Box>
{/* Right side content */}
<Box sx={{
flex: 1,
pl: theme.spacing(7.5),
overflowY: 'auto'
}}>
{activeNotificationTypes.map((type, index) => (
<TabPanel key={type.id} value={tabValue} index={index}>
<TabComponent
type={type}
integrations={integrations}
handleIntegrationChange={handleIntegrationChange}
handleInputChange={handleInputChange}
handleTestNotification={handleTestNotification}
isLoading={loading}
/>
</TabPanel>
))}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{
p: theme.spacing(4),
display: 'flex',
justifyContent: 'flex-end',
mb: theme.spacing(5),
mr: theme.spacing(5)
}}>
<Button
variant="contained"
color="accent"
onClick={handleSave}
loading={loading}
sx={{
width: 'auto',
minWidth: theme.spacing(60),
px: theme.spacing(8)
}}
>
{t('commonSave')}
</Button>
</DialogActions>
</Dialog>
);
};
NotificationIntegrationModal.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
monitor: PropTypes.object.isRequired,
setMonitor: PropTypes.func.isRequired,
notificationTypes: PropTypes.array
};
export default NotificationIntegrationModal;

View File

@@ -0,0 +1,109 @@
import React from "react";
import {
Typography,
Box,
Button,
CircularProgress
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import TextInput from "../../../Components/Inputs/TextInput";
import Checkbox from "../../../Components/Inputs/Checkbox";
const TabComponent = ({
type,
integrations,
handleIntegrationChange,
handleInputChange,
handleTestNotification,
isLoading
}) => {
const theme = useTheme();
const { t } = useTranslation();
// Helper to get the field state key (e.g., slackWebhook, telegramToken)
const getFieldKey = (typeId, fieldId) => {
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
};
// Check if all fields have values to enable test button
const areAllFieldsFilled = () => {
return type.fields.every(field => {
const fieldKey = getFieldKey(type.id, field.id);
return integrations[fieldKey];
});
};
return (
<>
<Typography variant="subtitle1" component="h4" sx={{
fontWeight: 'bold',
color: theme.palette.primary.contrastTextSecondary
}}>
{type.label}
</Typography>
<Typography sx={{
mt: theme.spacing(0.5),
mb: theme.spacing(1.5),
color: theme.palette.primary.contrastTextTertiary
}}>
{type.description}
</Typography>
<Box sx={{ pl: theme.spacing(1.5) }}>
<Checkbox
id={`enable-${type.id}`}
label={t('notifications.enableNotifications', { platform: type.label })}
isChecked={integrations[type.id]}
onChange={(e) => handleIntegrationChange(type.id, e.target.checked)}
disabled={isLoading}
/>
</Box>
{type.fields.map(field => {
const fieldKey = getFieldKey(type.id, field.id);
return (
<Box key={field.id} sx={{ mt: theme.spacing(1) }}>
<Typography sx={{
mb: theme.spacing(2),
fontWeight: 'bold',
color: theme.palette.primary.contrastTextSecondary
}}>
{field.label}
</Typography>
<TextInput
id={`${type.id}-${field.id}`}
type={field.type}
placeholder={field.placeholder}
value={integrations[fieldKey]}
onChange={(e) => handleInputChange(fieldKey, e.target.value)}
disabled={!integrations[type.id] || isLoading}
/>
</Box>
);
})}
<Box sx={{ mt: theme.spacing(1) }}>
<Button
variant="text"
color="info"
onClick={() => handleTestNotification(type.id)}
disabled={!integrations[type.id] || !areAllFieldsFilled() || isLoading}
>
{isLoading ? (
<CircularProgress
size={theme.spacing(8)}
sx={{ mr: theme.spacing(1), color: theme.palette.accent.main}}
/>
) : null}
{t('notifications.testNotification')}
</Button>
</Box>
</>
);
};
export default TabComponent;

View File

@@ -0,0 +1,46 @@
import React from "react";
import PropTypes from "prop-types";
import { Box } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* TabPanel component that displays content for the selected tab.
*
* @component
* @param {Object} props - The component props.
* @param {React.ReactNode} props.children - The content to be displayed when this tab panel is selected.
* @param {number} props.value - The currently selected tab value.
* @param {number} props.index - The index of this specific tab panel.
* @param {Object} props.other - Any additional props to be spread to the root element.
* @returns {React.ReactElement|null} The rendered tab panel or null if not selected.
*/
function TabPanel({ children, value, index, ...other }) {
const theme = useTheme();
return (
<div
role="tabpanel"
hidden={value !== index}
id={`notification-tabpanel-${index}`}
aria-labelledby={`notification-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ pt: theme.spacing(3) }}>
{children}
</Box>
)}
</div>
);
}
TabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.number.isRequired,
value: PropTypes.number.isRequired
};
export default TabPanel;

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useTranslation } from "react-i18next";
import { networkService } from '../../../Utils/NetworkService';
import { createToast } from '../../../Utils/toastUtils';
// Define constants for notification types to avoid magic values
const NOTIFICATION_TYPES = {
SLACK: 'slack',
DISCORD: 'discord',
TELEGRAM: 'telegram',
WEBHOOK: 'webhook'
};
// Define constants for field IDs
const FIELD_IDS = {
WEBHOOK: 'webhook',
TOKEN: 'token',
CHAT_ID: 'chatId',
URL: 'url'
};
/**
* Custom hook for notification-related operations
*/
const useNotifications = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(undefined);
const { t } = useTranslation();
/**
* Send a test notification
* @param {string} type - The notification type (slack, discord, telegram, webhook)
* @param {object} config - Configuration object with necessary params
*/
const sendTestNotification = async (type, config) => {
setLoading(true);
setError(undefined);
// Validation based on notification type
let payload = { platform: type };
let isValid = true;
let errorMessage = '';
switch(type) {
case NOTIFICATION_TYPES.SLACK:
payload.webhookUrl = config.webhook;
if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') {
isValid = false;
errorMessage = t('notifications.slack.webhookRequired');
}
break;
case NOTIFICATION_TYPES.DISCORD:
payload.webhookUrl = config.webhook;
if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') {
isValid = false;
errorMessage = t('notifications.discord.webhookRequired');
}
break;
case NOTIFICATION_TYPES.TELEGRAM:
payload.botToken = config.token;
payload.chatId = config.chatId;
if (typeof payload.botToken === 'undefined' || payload.botToken === '' ||
typeof payload.chatId === 'undefined' || payload.chatId === '') {
isValid = false;
errorMessage = t('notifications.telegram.fieldsRequired');
}
break;
case NOTIFICATION_TYPES.WEBHOOK:
payload.webhookUrl = config.url;
payload.platform = NOTIFICATION_TYPES.SLACK;
if (typeof payload.webhookUrl === 'undefined' || payload.webhookUrl === '') {
isValid = false;
errorMessage = t('notifications.webhook.urlRequired');
}
break;
default:
isValid = false;
errorMessage = t('notifications.unsupportedType');
}
// If validation fails, show error and return
if (isValid === false) {
createToast({
body: errorMessage,
variant: "error"
});
setLoading(false);
return;
}
try {
const response = await networkService.testNotification({
platform: type,
payload: payload
});
if (response.data.success === true) {
createToast({
body: t('notifications.testSuccess'),
variant: "info"
});
} else {
throw new Error(response.data.msg || t('notifications.testFailed'));
}
} catch (error) {
const errorMsg = error.response?.data?.msg || error.message || t('notifications.networkError');
createToast({
body: `${t('notifications.testFailed')}: ${errorMsg}`,
variant: "error"
});
setError(errorMsg);
} finally {
setLoading(false);
}
};
return [
loading,
error,
sendTestNotification
];
};
export default useNotifications;

View File

@@ -0,0 +1,10 @@
.progress-bar-container h2.MuiTypography-root,
.progress-bar-container p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.progress-bar-container p.MuiTypography-root:has(span) {
font-size: 12px;
}
.progress-bar-container p.MuiTypography-root span {
padding-left: 2px;
}

View File

@@ -0,0 +1,179 @@
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import { Box, IconButton, LinearProgress, Stack, Typography } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import "./index.css";
/**
* @param {Object} props - The component props.
* @param {JSX.Element} props.icon - The icon element to display (optional).
* @param {string} props.label - The label text for the progress item.
* @param {string} props.size - The size information for the progress item.
* @param {number} props.progress - The current progress value (0-100).
* @param {function} props.onClick - The function to handle click events on the remove button.
* @param {string} props.error - Error message to display if there's an error (optional).
* @returns {JSX.Element} The rendered component.
*/
const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) => {
const theme = useTheme();
return (
<Box
className="progress-bar-container"
mt={theme.spacing(10)}
p={theme.spacing(8)}
sx={{
minWidth: "200px",
height: "fit-content",
borderRadius: theme.shape.borderRadius,
border: 1,
borderColor: theme.palette.primary.lowContrast,
backgroundColor: theme.palette.primary.lowContrast,
"&:has(.input-error)": {
borderColor: theme.palette.error.main,
backgroundColor: theme.palette.error.lowContrast,
py: theme.spacing(4),
px: theme.spacing(8),
"& > .MuiStack-root > svg": {
fill: theme.palette.error.contrastText,
width: "20px",
height: "20px",
},
},
}}
>
<Stack
direction="row"
mb={error ? 0 : theme.spacing(5)}
gap={theme.spacing(5)}
alignItems={error ? "center" : "flex-start"}
>
{error ? (
<ErrorOutlineOutlinedIcon />
) : icon ? (
<Box
sx={{
position: "relative",
height: 30,
minWidth: 30,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: 2,
backgroundColor: theme.palette.primary.main,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 23,
height: 23,
"& path": {
fill: theme.palette.primary.contrastTextTertiary,
},
},
}}
>
{icon}
</Box>
) : (
""
)}
{error ? (
<Typography
component="p"
className="input-error"
color={theme.palette.error.contrastText}
>
{error}
</Typography>
) : (
<Box color={theme.palette.primary.contrastTextTertiary}>
<Typography
component="h2"
mb={theme.spacing(1.5)}
sx={{ wordBreak: 'break-all' }}
>
{error ? error : label}
</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
{!error && size}
</Typography>
</Box>
)}
<IconButton
onClick={onClick}
sx={
!error
? {
alignSelf: "flex-start",
ml: "auto",
mr: theme.spacing(-2.5),
mt: theme.spacing(-2.5),
padding: theme.spacing(2.5),
"&:focus": {
outline: "none",
},
}
: {
ml: "auto",
"&:focus": {
outline: "none",
},
}
}
>
<CloseIcon
sx={{
fontSize: "20px",
}}
/>
</IconButton>
</Stack>
{!error ? (
<Stack
direction="row"
alignItems="center"
>
<Box sx={{ width: "100%", mr: theme.spacing(5) }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
width: "100%",
height: "10px",
borderRadius: theme.shape.borderRadius,
maxWidth: "500px",
backgroundColor: theme.palette.primary.lowContrast,
}}
/>
</Box>
<Typography
component="p"
sx={{ minWidth: "max-content", opacity: 0.6 }}
>
{progress}
<span>%</span>
</Typography>
</Stack>
) : (
""
)}
</Box>
);
};
ProgressUpload.propTypes = {
icon: PropTypes.element, // JSX element for the icon (optional)
label: PropTypes.string, // Label text for the progress item
size: PropTypes.string.isRequired, // Size information for the progress item
progress: PropTypes.number.isRequired, // Current progress value (0-100)
onClick: PropTypes.func.isRequired, // Function to handle click events on the remove button
error: PropTypes.string, // Error message to display if there's an error (optional)
};
export default ProgressUpload;

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Step, Stepper, StepLabel, Typography } from "@mui/material";
import RadioButtonCheckedIcon from "@mui/icons-material/RadioButtonChecked";
import CheckCircle from "@mui/icons-material/CheckCircle";
import PropTypes from "prop-types";
const CustomStepIcon = (props) => {
const { completed, active } = props;
return completed ? (
<CheckCircle color="accent" />
) : (
<RadioButtonCheckedIcon color={active ? "accent" : "disabled"} />
);
};
CustomStepIcon.propTypes = {
completed: PropTypes.bool.isRequired,
active: PropTypes.bool.isRequired,
};
/**
* @component
* @param {Object} props
* @param {Array} props.steps
*/
const ProgressStepper = ({ steps }) => {
const [activeStep, setActiveStep] = React.useState(1);
return (
<Stepper
activeStep={activeStep}
alternativeLabel
>
{steps.map((step, index) => {
const color = activeStep === index ? "primary" : "inherit";
return (
<Step
key={step.label}
onClick={() => setActiveStep(index)}
>
<StepLabel StepIconComponent={CustomStepIcon}>
<Typography
variant="body1"
color={color}
sx={{ fontWeight: "bold" }}
>
{step.label}
</Typography>
</StepLabel>
<Typography
variant="body1"
color={color}
>
{step.content}
</Typography>
</Step>
);
})}
</Stepper>
);
};
ProgressStepper.propTypes = {
steps: PropTypes.array.isRequired,
};
export default ProgressStepper;

View File

@@ -0,0 +1,30 @@
import { Navigate } from "react-router-dom";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
/**
* @param {Object} props - The props passed to the ProtectedDistributedUptimeRoute component.
* @param {React.ReactNode} props.children - The children to render if the user is authenticated.
* @returns {React.ReactElement} The children wrapped in a protected route or a redirect to the login page.
*/
const ProtectedDistributedUptimeRoute = ({ children }) => {
const distributedUptimeEnabled = useSelector(
(state) => state.ui.distributedUptimeEnabled
);
return distributedUptimeEnabled === true ? (
children
) : (
<Navigate
to="/uptime"
replace
/>
);
};
ProtectedDistributedUptimeRoute.propTypes = {
children: PropTypes.node.isRequired,
};
export default ProtectedDistributedUptimeRoute;

View File

@@ -0,0 +1,32 @@
import { Navigate } from "react-router-dom";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
/**
* ProtectedRoute is a wrapper component that ensures only authenticated users
* can access the wrapped content. It checks authentication status (e.g., from Redux or Context).
* If the user is authenticated, it renders the children; otherwise, it redirects to the login page.
*
* @param {Object} props - The props passed to the ProtectedRoute component.
* @param {React.ReactNode} props.children - The children to render if the user is authenticated.
* @returns {React.ReactElement} The children wrapped in a protected route or a redirect to the login page.
*/
const ProtectedRoute = ({ children }) => {
const authState = useSelector((state) => state.auth);
return authState.authToken ? (
children
) : (
<Navigate
to="/login"
replace
/>
);
};
ProtectedRoute.propTypes = {
children: PropTypes.element.isRequired,
};
export default ProtectedRoute;

View File

@@ -0,0 +1,67 @@
import html2canvas from "html2canvas";
import ShareIcon from "@mui/icons-material/Share";
import { Button } from "@mui/material";
import { useTheme } from "@emotion/react";
const ShareComponent = ({ elementToCapture, fileName = "screenshot" }) => {
const theme = useTheme();
const captureAndShare = async () => {
try {
// Temporarily apply styles directly to the element
const originalBackground = elementToCapture.current.style.background;
const originalPadding = elementToCapture.current.style.padding;
elementToCapture.current.style.background = `radial-gradient(circle, ${theme.palette.gradient.color1}, ${theme.palette.gradient.color2}, ${theme.palette.gradient.color3}, ${theme.palette.gradient.color4}, ${theme.palette.gradient.color5})`;
elementToCapture.current.style.padding = `${theme.spacing(20)}`;
// Capture the element directly
const canvas = await html2canvas(elementToCapture.current, {
useCORS: true,
scale: 2,
allowTaint: true,
backgroundColor: null,
});
// Restore original styles
elementToCapture.current.style.background = originalBackground;
elementToCapture.current.style.padding = originalPadding;
const imageBlob = await new Promise((resolve) =>
canvas.toBlob(resolve, "image/png")
);
const file = new File([imageBlob], `${fileName}.png`, {
type: "image/png",
});
if (navigator.share) {
await navigator.share({
files: [file],
title: "Screenshot",
text: "Check out this screenshot!",
});
} else {
const url = URL.createObjectURL(imageBlob);
const a = document.createElement("a");
a.href = url;
a.download = `${fileName}.png`;
a.click();
URL.revokeObjectURL(url);
}
} catch (error) {
console.error(error);
}
};
return (
<Button
variant="outlined"
startIcon={<ShareIcon sx={{ color: theme.palette.success.main }} />}
color="success"
onClick={captureAndShare}
>
Share
</Button>
);
};
export default ShareComponent;

View File

@@ -0,0 +1,106 @@
/* 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;
}
}

View File

@@ -0,0 +1,862 @@
import React, { useEffect, useState, useRef } from "react";
import {
Box,
Collapse,
Divider,
IconButton,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import ThemeSwitch from "../ThemeSwitch";
import Avatar from "../Avatar";
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 Account from "../../assets/icons/user-edit.svg?react";
import Maintenance from "../../assets/icons/maintenance.svg?react";
import Monitors from "../../assets/icons/monitors.svg?react";
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 Folder from "../../assets/icons/folder.svg?react";
import StatusPages from "../../assets/icons/status-pages.svg?react";
import Discussions from "../../assets/icons/discussions.svg?react";
import DistributedUptimeIcon from "../../assets/icons/distributed-uptime.svg?react";
import "./index.css";
// Utils
import { useLocation, useNavigate } from "react-router";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { clearAuthState } from "../../Features/Auth/authSlice";
import { toggleSidebar } from "../../Features/UI/uiSlice";
import { clearUptimeMonitorState } from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
const menu = [
{ name: "Uptime", path: "uptime", icon: <Monitors /> },
{ name: "Pagespeed", path: "pagespeed", icon: <PageSpeed /> },
{ name: "Infrastructure", path: "infrastructure", icon: <Integrations /> },
{
name: "Distributed uptime",
path: "distributed-uptime",
icon: <DistributedUptimeIcon />,
},
{ name: "Incidents", path: "incidents", icon: <Incidents /> },
{ name: "Status pages", path: "status", icon: <StatusPages /> },
{ name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
// { name: "Integrations", path: "integrations", icon: <Integrations /> },
{
name: "Settings",
icon: <Settings />,
path: "settings",
},
];
const otherMenuItems = [
{ name: "Support", path: "support", icon: <Support /> },
{
name: "Discussions",
path: "discussions",
icon: <Discussions />,
},
{ name: "Docs", path: "docs", icon: <Docs /> },
{ name: "Changelog", path: "changelog", icon: <ChangeLog /> },
];
const accountMenuItems = [
{ name: "Profile", path: "account/profile", icon: <UserSvg /> },
{ name: "Password", path: "account/password", icon: <LockSvg /> },
{ name: "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/bluewave-uptime/releases",
};
const PATH_MAP = {
monitors: "Dashboard",
pagespeed: "Dashboard",
infrastructure: "Dashboard",
["distributed-uptime"]: "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 theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const authState = useSelector((state) => state.auth);
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 distributedUptimeEnabled = useSelector(
(state) => state.ui.distributedUptimeEnabled
);
const sidebarRef = useRef(null);
const [sidebarReady, setSidebarReady] = useState(false);
const TRANSITION_DURATION = 200;
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());
dispatch(clearUptimeMonitorState());
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
component="aside"
ref={sidebarRef}
className={sidebarClassName}
/* TODO general padding should be here */
py={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,
backgroundColor: theme.palette.primary.main,
"& :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,
},
},
},
}}
>
<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: 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,
},
}}
onClick={() => {
setOpen((prev) =>
Object.fromEntries(Object.keys(prev).map((key) => [key, false]))
);
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 }}
>
Checkmate
</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) => {
if (
item.path === "distributed-uptime" &&
distributedUptimeEnabled === false
) {
return null;
}
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>
{!collapsed && <StarPrompt />}
<List
component="nav"
disablePadding
sx={{ px: theme.spacing(6) }}
>
{otherMenuItems.map((item) => {
return item.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={() => {
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}
</Typography>
</Box>
<Stack
className="sidebar-delay-fade"
flexDirection={"row"}
marginLeft={"auto"}
columnGap={theme.spacing(2)}
>
<ThemeSwitch color={iconColor} />
<Tooltip
title="Controls"
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 />
Log out
</MenuItem>
</Menu>
</Stack>
</Stack>
);
}
export default Sidebar;

View File

@@ -0,0 +1,14 @@
import { Stack, Skeleton } from "@mui/material";
export const SkeletonLayout = () => {
return (
<Stack>
<Skeleton
variant="rectangular"
height={"90vh"}
/>
</Stack>
);
};
export default SkeletonLayout;

View File

@@ -0,0 +1,51 @@
// Components
import { Stack } from "@mui/material";
// Utils
import { useTheme } from "@emotion/react";
const Container = ({ children, direction, backgroundColor, sx }) => {
const theme = useTheme();
let bgColor =
typeof backgroundColor !== "undefined"
? backgroundColor
: theme.palette.background.main;
return (
<Stack
direction={direction}
padding={theme.spacing(8)}
gap={theme.spacing(2)}
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.spacing(4)}
backgroundColor={bgColor}
sx={{ ...sx }}
>
{children}
</Stack>
);
};
export const ColContainer = ({ children, backgroundColor, sx }) => {
return (
<Container
direction="column"
backgroundColor={backgroundColor}
sx={{ ...sx }}
>
{children}
</Container>
);
};
export const RowContainer = ({ children, backgroundColor, sx }) => {
return (
<Container
direction="row"
backgroundColor={backgroundColor}
sx={{ ...sx }}
>
{children}
</Container>
);
};

View File

@@ -0,0 +1,101 @@
import React from 'react';
import { Typography, IconButton, Stack, Box } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { useTheme } from '@emotion/react';
import { useSelector, useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { setStarPromptOpen } from '../../Features/UI/uiSlice';
const StarPrompt = ({
repoUrl = 'https://github.com/bluewave-labs/checkmate'
}) => {
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
const isOpen = useSelector((state) => state.ui?.starPromptOpen ?? true);
const mode = useSelector((state) => state.ui.mode);
const handleClose = () => {
dispatch(setStarPromptOpen(false));
};
const handleStarClick = () => {
window.open(repoUrl, '_blank');
};
if (!isOpen) return null;
return (
<Stack
direction="column"
sx={{
width: '100%',
padding: `${theme.spacing(6)} ${theme.spacing(6)}`,
borderTop: `1px solid ${theme.palette.primary.lowContrast}`,
borderBottom: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: 0,
gap: theme.spacing(1.5),
backgroundColor: theme.palette.primary.main,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center" width="100%" pl={theme.spacing(4)}>
<Typography
variant="subtitle2"
sx={{
color: mode === 'dark' ? theme.palette.primary.contrastText : theme.palette.text.primary,
mt: theme.spacing(3)
}}
>
{t('starPromptTitle')}
</Typography>
<IconButton
onClick={handleClose}
size="small"
sx={{
color: theme.palette.text.primary,
padding: 0,
marginTop: theme.spacing(-5),
'&:hover': {
backgroundColor: 'transparent',
opacity: 0.8
},
}}
>
<CloseIcon sx={{ fontSize: '1.25rem' }} />
</IconButton>
</Stack>
<Typography
variant="body1"
sx={{
color: theme.palette.text.secondary,
fontSize: '0.938rem',
lineHeight: 1.5,
mb: 1,
px: theme.spacing(4)
}}
>
{t('starPromptDescription')}
</Typography>
<Box
component="img"
src={`https://img.shields.io/github/stars/bluewave-labs/checkmate?label=checkmate&style=social${mode === 'dark' ? '&color=white' : ''}`}
alt="GitHub stars"
onClick={handleStarClick}
sx={{
cursor: 'pointer',
transform: 'scale(0.65)',
transformOrigin: 'left center',
'&:hover': {
opacity: 0.8
},
pl: theme.spacing(4),
filter: mode === 'dark' ? 'invert(1)' : 'none'
}}
/>
</Stack>
);
};
export default StarPrompt;

View File

@@ -0,0 +1,149 @@
import { Stack, Typography } from "@mui/material";
import Image from "../Image";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
/**
* StatBox Component
*
* A reusable component that displays a statistic with a heading and subheading
* in a styled box with a gradient background.
*
* @component
* @param {Object} props - The component props
* @param {string} props.heading - The primary heading/title of the statistic
* @param {string|React.ReactNode} props.subHeading - The value or description of the statistic
* @param {boolean} [props.gradient=false] - Determines if the box should have a gradient background
* @param {string} [props.status] - The status of the statistic
* @param {Object} [props.sx] - Additional custom styling to be applied to the box
*
* @example
* return (
* <StatBox
* heading="Total Users"
* subHeading="1,234"
* sx={{ width: 300 }}
* />
* )
*
* @returns {React.ReactElement} A styled box containing the statistic
*/
const StatBox = ({
img,
icon: Icon,
alt,
heading,
subHeading,
gradient = false,
status = "",
sx,
}) => {
const theme = useTheme();
const { statusToTheme } = useUtils();
const themeColor = statusToTheme[status];
const statusBoxStyles = gradient
? {
background: `linear-gradient(to bottom right, ${theme.palette[themeColor].main} 30%, ${theme.palette[themeColor].lowContrast} 70%)`,
borderColor: theme.palette[themeColor].lowContrast,
}
: {
background: `linear-gradient(340deg, ${theme.palette.tertiary.main} 10%, ${theme.palette.primary.main} 45%)`,
borderColor: theme.palette.primary.lowContrast,
};
const headingStyles = gradient
? {
color: theme.palette[themeColor].contrastText,
}
: {
color: theme.palette.primary.contrastTextSecondary,
};
const spanFixedStyles = { marginLeft: theme.spacing(2), fontSize: 15, fontWeight: 600 };
const detailTextStyles = gradient
? {
color: theme.palette[themeColor].contrastText,
"& span": {
color: theme.palette[themeColor].contrastText,
...spanFixedStyles,
},
}
: {
color: theme.palette.primary.contrastText,
"& span": {
color: theme.palette.primary.contrastTextTertiary,
...spanFixedStyles,
},
};
const responsiveWidths = {
default: `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
md: `calc(50% - (1 * ${theme.spacing(8)} / 2))`,
};
return (
<Stack
direction="row"
sx={{
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
/* TODO why are we using width and min width here? */
minWidth: 200,
// this is for status box to be reponsive on diff screens
width: responsiveWidths.default, // Default: 4 items per row
[theme.breakpoints.down("md")]: {
width: responsiveWidths.md, // 2 items per row
},
border: 1,
borderStyle: "solid",
borderRadius: 4,
...statusBoxStyles,
"& h2": {
/* TODO font size should come from theme */
fontSize: 13,
fontWeight: 500,
textTransform: "uppercase",
...headingStyles,
},
"& p": {
fontSize: 18,
marginTop: theme.spacing(2),
...detailTextStyles,
},
...sx,
}}
>
{img && (
<Image
src={img}
height={"30px"}
width={"30px"}
alt={alt}
sx={{ marginRight: theme.spacing(8) }}
/>
)}
{Icon && (
<Icon sx={{ width: "30px", height: "30px", marginRight: theme.spacing(8) }} />
)}
<Stack>
<Typography component="h2">{heading}</Typography>
<Typography sx={{ fontWeight: 500 }}>{subHeading}</Typography>
</Stack>
</Stack>
);
};
StatBox.propTypes = {
heading: PropTypes.string.isRequired,
subHeading: PropTypes.node.isRequired,
gradient: PropTypes.bool,
status: PropTypes.string,
sx: PropTypes.object,
icon: PropTypes.elementType,
img: PropTypes.node,
alt: PropTypes.string,
};
export default StatBox;

View File

@@ -0,0 +1,36 @@
// Components
import { Stack } from "@mui/material";
import SkeletonLayout from "./skeleton";
// Utils
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
const StatusBoxes = ({ shouldRender, flexWrap = "nowrap", children }) => {
const theme = useTheme();
if (!shouldRender) {
return (
<SkeletonLayout
numBoxes={children?.length ?? 1}
flexWrap={flexWrap}
/>
);
}
return (
<Stack
direction="row"
flexWrap={flexWrap}
gap={theme.spacing(8)}
justifyContent="space-between"
display="flex"
>
{children}
</Stack>
);
};
StatusBoxes.propTypes = {
shouldRender: PropTypes.bool,
children: PropTypes.node,
};
export default StatusBoxes;

View File

@@ -0,0 +1,33 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const SkeletonLayout = ({ numBoxes, flexWrap }) => {
const theme = useTheme();
return (
<Stack
direction="row"
flexWrap={flexWrap}
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
{Array.from({ length: numBoxes }).map((_, index) => {
const width = `${200 / numBoxes}%`;
return (
<Skeleton
variant="rounded"
width={width}
height={100}
key={index}
/>
);
})}
</Stack>
);
};
SkeletonLayout.propTypes = {
flexWrap: PropTypes.string,
numBoxes: PropTypes.number,
};
export default SkeletonLayout;

View File

@@ -0,0 +1,83 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Stack, Typography } from "@mui/material";
/**
*
* @component
* @example
*
* @param {boolean} props.shouldRender - Whether to render the subheader
* @param {string} props.direction - Direction of the subheader
* @param {string} props.headerText - Header text
* @param {number} props.headerLevel - Font characteristic of the header
* @param {string} props.subHeaderText - Subheader text
* @param {number} props.subHeaderLevel - Font characteristic of the subheader
* @param {string} props.alignItems - Align items
* @param {node} props.children - Children
* @returns {JSX.Element} The rendered component
*/
const SubHeader = ({
shouldRender = true,
direction = "row",
headerText,
headerLevel = 1,
subHeaderText,
subHeaderLevel = 1,
alignItems = "center",
children,
...props
}) => {
const theme = useTheme();
if (!shouldRender) {
return null;
}
return (
<Stack
direction={direction}
alignItems={alignItems}
justifyContent="space-between"
{...props}
>
<Stack direction={"column"}>
<Typography
component={`h${headerLevel}`}
variant={`h${headerLevel}`}
mb={theme.spacing(1)}
>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.primary.contrastTextTertiary}
>
{headerText}
</Typography>
</Typography>
<Typography
variant={`body${subHeaderLevel}`}
color={theme.palette.primary.contrastTextTertiary}
>
{subHeaderText}
</Typography>
</Stack>
{children}
</Stack>
);
};
SubHeader.propTypes = {
shouldRender: PropTypes.bool,
direction: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
headerText: PropTypes.string,
headerLevel: PropTypes.number,
subHeaderText: PropTypes.string,
subHeaderLevel: PropTypes.number,
alignItems: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
children: PropTypes.node,
};
export default SubHeader;

View File

@@ -0,0 +1,36 @@
import { Box, useTheme } from "@mui/material";
import { TabList } from "@mui/lab";
import PropTypes from "prop-types";
/**
* CustomTabList component
* @param {string} value - The currently selected tab's value.
* @param {function} onChange - Callback when a different tab is selected.
* @param {React.ReactNode} children - Tab components to render inside the TabList.
* @param {object} props - Additional props passed to the TabList component.
*/
const CustomTabList = ({ value, onChange, children, ...props }) => {
const theme = useTheme();
return (
<Box
sx={{
borderBottom: `1px solid ${theme.palette.primary.lowContrast}`,
"& .MuiTabs-root": { height: "fit-content", minHeight: "0" },
}}
>
<TabList value={value} onChange={onChange} {...props}>
{children}
</TabList>
</Box>
);
};
CustomTabList.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
children: PropTypes.node,
};
export default CustomTabList;

View File

@@ -0,0 +1,255 @@
import TabPanel from "@mui/lab/TabPanel";
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography, Button } from "@mui/material";
import { PasswordEndAdornment } from "../../Inputs/TextInput/Adornments";
import TextInput from "../../Inputs/TextInput";
import { credentials } from "../../../Validation/validation";
import Alert from "../../Alert";
import { update } from "../../../Features/Auth/authSlice";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../../Utils/toastUtils";
import { getTouchedFieldErrors } from "../../../Validation/error";
const defaultPasswordsState = {
password: "",
newPassword: "",
confirm: "",
};
/**
* PasswordPanel component manages the form for editing password.
*
* @returns {JSX.Element}
*/
const PasswordPanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
const SPACING_GAP = theme.spacing(12);
//redux state
const { isLoading } = useSelector((state) => state.auth);
const idToName = {
"edit-current-password": "password",
"edit-new-password": "newPassword",
"edit-confirm-password": "confirm",
};
const [localData, setLocalData] = useState(defaultPasswordsState);
const [errors, setErrors] = useState(defaultPasswordsState);
const [touchedFields, setTouchedFields] = useState({
password: false,
newPassword: false,
confirm: false,
});
const handleChange = (event) => {
const { value, id } = event.target;
const name = idToName[id];
const updatedData = {
...localData,
[name]: value,
};
const updatedTouchedFields = {
...touchedFields,
[name]: true,
};
const validation = credentials.validate(
{ ...updatedData },
{ abortEarly: false, context: { password: updatedData.newPassword } }
);
const updatedErrors = getTouchedFieldErrors(validation, updatedTouchedFields);
if (!touchedFields[name]) {
setTouchedFields(updatedTouchedFields);
}
setLocalData(updatedData);
setErrors(updatedErrors);
};
const handleSubmit = async (event) => {
event.preventDefault();
const { error } = credentials.validate(localData, {
abortEarly: false,
context: { password: localData.newPassword },
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
} else {
const action = await dispatch(update({ localData }));
if (action.payload.success) {
createToast({
body: "Your password was changed successfully.",
});
setLocalData({
password: "",
newPassword: "",
confirm: "",
});
} else {
// TODO: Check for other errors?
createToast({
body: "Your password input was incorrect.",
});
setErrors({ password: "*" + action.payload.msg + "." });
}
}
};
return (
<TabPanel
value="password"
sx={{
"& h1, & input": {
color: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Stack
component="form"
onSubmit={handleSubmit}
noValidate
spellCheck="false"
gap={theme.spacing(26)}
maxWidth={"80ch"} // Keep maxWidth
>
<TextInput
type="text"
id="hidden-username"
name="username"
autoComplete="username"
hidden={true}
value=""
/>
<Stack
direction="row"
justifyContent={"flex-start"}
alignItems={"center"}
gap={SPACING_GAP}
flexWrap={"wrap"}
>
<Typography
component="h1"
width="20ch"
>
Current password
</Typography>
<TextInput
type="password"
id="edit-current-password"
placeholder="Enter your current password"
autoComplete="current-password"
value={localData.password}
onChange={handleChange}
error={errors[idToName["edit-current-password"]] ? true : false}
helperText={errors[idToName["edit-current-password"]]}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
</Stack>
<Stack
direction="row"
alignItems={"flex-start"}
gap={SPACING_GAP}
flexWrap={"wrap"}
>
<Typography
component="h1"
width="20ch"
>
New password
</Typography>
<TextInput
type="password"
id="edit-new-password"
placeholder="Enter your new password"
autoComplete="new-password"
value={localData.newPassword}
onChange={handleChange}
error={errors[idToName["edit-new-password"]] ? true : false}
helperText={errors[idToName["edit-new-password"]]}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
</Stack>
<Stack
direction="row"
alignItems={"flex-start"}
gap={SPACING_GAP}
flexWrap={"wrap"}
>
<Typography
component="h1"
width="20ch"
>
Confirm new password
</Typography>
<TextInput
type="password"
id="edit-confirm-password"
placeholder="Reenter your new password"
autoComplete="new-password"
value={localData.confirm}
onChange={handleChange}
error={errors[idToName["edit-confirm-password"]] ? true : false}
helperText={errors[idToName["edit-confirm-password"]]}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
</Stack>
{Object.keys(errors).length > 0 && (
<Box sx={{ maxWidth: "70ch" }}>
<Alert
variant="warning"
body="New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character."
/>
</Box>
)}
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="accent"
type="submit"
loading={isLoading}
loadingIndicator="Saving..."
disabled={
Object.keys(errors).length > 0 ||
Object.values(localData).filter((value) => value === "").length > 0
}
sx={{
px: theme.spacing(12),
mt: theme.spacing(20),
}}
>
Save
</Button>
</Stack>
</Stack>
</TabPanel>
);
};
PasswordPanel.propTypes = {
// No props are being passed to this component, hence no specific PropTypes are defined.
};
export default PasswordPanel;

View File

@@ -0,0 +1,475 @@
import { useTheme } from "@emotion/react";
import { useRef, useState } from "react";
import TabPanel from "@mui/lab/TabPanel";
import { Box, Button, Divider, Stack, Typography } from "@mui/material";
import Avatar from "../../Avatar";
import TextInput from "../../Inputs/TextInput";
import ImageField from "../../Inputs/Image";
import { credentials, imageValidation } from "../../../Validation/validation";
import { useDispatch, useSelector } from "react-redux";
import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice";
import ImageIcon from "@mui/icons-material/Image";
import ProgressUpload from "../../ProgressBars";
import { formatBytes } from "../../../Utils/fileUtils";
import { clearUptimeMonitorState } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { GenericDialog } from "../../Dialog/genericDialog";
import Dialog from "../../Dialog";
/**
* ProfilePanel component displays a form for editing user profile information
* and allows for actions like updating profile picture, credentials,
* and deleting account.
*
* @returns {JSX.Element}
*/
const ProfilePanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
const SPACING_GAP = theme.spacing(12);
//redux state
const { user, isLoading } = useSelector((state) => state.auth);
const idToName = {
"edit-first-name": "firstName",
"edit-last-name": "lastName",
// Disabled for now, will revisit in the future
// "edit-email": "email",
};
// Local state for form data, errors, and file handling
const [localData, setLocalData] = useState({
firstName: user.firstName,
lastName: user.lastName,
// email: user.email, // Disabled for now
});
const [errors, setErrors] = useState({});
const [file, setFile] = useState();
const intervalRef = useRef(null);
const [progress, setProgress] = useState({ value: 0, isLoading: false });
// Handles input field changes and performs validation
const handleChange = (event) => {
errors["unchanged"] && clearError("unchanged");
const { value, id } = event.target;
const name = idToName[id];
setLocalData((prev) => ({
...prev,
[name]: value,
}));
validateField({ [name]: value }, credentials, name);
};
// Handles image file
const handlePicture = (event) => {
const pic = event.target.files[0];
let error = validateField({ type: pic.type, size: pic.size }, imageValidation);
if (error) return;
setProgress((prev) => ({ ...prev, isLoading: true }));
setFile({
src: URL.createObjectURL(pic),
name: pic.name,
size: formatBytes(pic.size),
delete: false,
});
//TODO - potentitally remove, will revisit in the future
intervalRef.current = setInterval(() => {
const buffer = 12;
setProgress((prev) => {
if (prev.value + buffer >= 100) {
clearInterval(intervalRef.current);
return { value: 100, isLoading: false };
}
return { ...prev, value: prev.value + buffer };
});
}, 120);
};
// Validates input against provided schema and updates error state
const validateField = (toValidate, schema, name = "picture") => {
const { error } = schema.validate(toValidate, { abortEarly: false });
setErrors((prev) => {
const prevErrors = { ...prev };
if (error) prevErrors[name] = error.details[0].message;
else delete prevErrors[name];
return prevErrors;
});
if (error) return true;
};
// Clears specific error from errors state
const clearError = (err) => {
setErrors((prev) => {
const updatedErrors = { ...prev };
if (updatedErrors[err]) delete updatedErrors[err];
return updatedErrors;
});
};
// Resets picture-related states and clears interval
const removePicture = () => {
errors["picture"] && clearError("picture");
setFile({ delete: true });
clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
setProgress({ value: 0, isLoading: false });
};
// Opens the picture update modal
const openPictureModal = () => {
setIsOpen("picture");
setFile({ delete: localData.deleteProfileImage });
};
// Closes the picture update modal and resets related states
const closePictureModal = () => {
errors["picture"] && clearError("picture");
setFile(); //reset file
clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
setProgress({ value: 0, isLoading: false });
setIsOpen("");
};
// Updates profile image displayed on UI
const handleUpdatePicture = () => {
setProgress({ value: 0, isLoading: false });
setLocalData((prev) => ({
...prev,
file: file.src,
deleteProfileImage: false,
}));
setIsOpen("");
errors["unchanged"] && clearError("unchanged");
};
// Handles form submission to update user profile
const handleSaveProfile = async (event) => {
event.preventDefault();
if (
localData.firstName === user.firstName &&
localData.lastName === user.lastName &&
localData.deleteProfileImage === undefined &&
localData.file === undefined
) {
createToast({
body: "Unable to update profile — no changes detected.",
});
setErrors({ unchanged: "unable to update profile" });
return;
}
const action = await dispatch(update({ localData }));
if (action.payload.success) {
createToast({
body: "Your profile data was changed successfully.",
});
} else {
createToast({
body: "There was an error updating your profile data.",
});
}
};
// Removes current profile image from UI
const handleDeletePicture = () => {
setLocalData((prev) => ({
...prev,
deleteProfileImage: true,
}));
errors["unchanged"] && clearError("unchanged");
};
// Initiates the account deletion process
const handleDeleteAccount = async () => {
const action = await dispatch(deleteUser());
if (action.payload.success) {
dispatch(clearAuthState());
dispatch(clearUptimeMonitorState());
} else {
if (action.payload) {
// dispatch errors
createToast({
body: action.payload.msg,
});
} else {
// unknown errors
createToast({
body: "Unknown error.",
});
}
}
};
// Modal state and control functions
const [isOpen, setIsOpen] = useState("");
const isModalOpen = (name) => isOpen === name;
return (
<TabPanel
value="profile"
sx={{
"& h1, & p, & input": {
color: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Stack
component="form"
className="edit-profile-form"
noValidate
spellCheck="false"
gap={SPACING_GAP}
>
<Stack
direction="row"
gap={SPACING_GAP}
>
{/* This 0.9 is a bit magic numbering, refactor */}
<Box flex={0.9}>
<Typography component="h1">First name</Typography>
</Box>
<TextInput
id="edit-first-name"
value={localData.firstName}
placeholder="Enter your first name"
autoComplete="given-name"
onChange={handleChange}
error={errors[idToName["edit-first-name"]] ? true : false}
helperText={errors[idToName["edit-first-name"]]}
flex={1}
/>
</Stack>
<Stack
direction="row"
gap={SPACING_GAP}
>
<Box flex={0.9}>
<Typography component="h1">Last name</Typography>
</Box>
<TextInput
id="edit-last-name"
placeholder="Enter your last name"
autoComplete="family-name"
value={localData.lastName}
onChange={handleChange}
error={errors[idToName["edit-last-name"]] ? true : false}
helperText={errors[idToName["edit-last-name"]]}
flex={1}
/>
</Stack>
<Stack
direction="row"
gap={SPACING_GAP}
>
<Stack flex={0.9}>
<Typography component="h1">Email</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
This is your current email address it cannot be changed.
</Typography>
</Stack>
<TextInput
id="edit-email"
value={user.email}
placeholder="Enter your email"
autoComplete="email"
onChange={() => logger.warn("disabled")}
disabled={true}
flex={1}
/>
</Stack>
<Stack
direction="row"
gap={SPACING_GAP}
>
<Stack flex={0.9}>
<Typography component="h1">Your photo</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
This photo will be displayed in your profile page.
</Typography>
</Stack>
<Stack
direction="row"
alignItems="center"
flex={1}
gap={"8px"}
>
<Avatar
src={
localData?.deleteProfileImage
? "/static/images/avatar/2.jpg"
: localData?.file
? localData.file
: ""
}
sx={{ marginRight: "8px" }}
/>
<Button
variant="contained" // CAIO_REIVEW
color="error"
onClick={handleDeletePicture}
>
Delete
</Button>
<Button
variant="contained" // CAIO_REVIEW
color="accent"
onClick={openPictureModal}
>
Update
</Button>
</Stack>
</Stack>
<Divider
aria-hidden="true"
width="0"
sx={{
marginY: theme.spacing(1),
}}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Box width="fit-content">
<Button
variant="contained"
color="accent"
onClick={handleSaveProfile}
loading={isLoading}
loadingIndicator="Saving..."
disabled={Object.keys(errors).length !== 0 && !errors?.picture && true}
sx={{ px: theme.spacing(12) }}
>
Save
</Button>
</Box>
</Stack>
</Stack>
<Divider
aria-hidden="true"
sx={{
marginY: theme.spacing(20),
borderColor: theme.palette.primary.lowContrast,
}}
/>
{!user.role.includes("demo") && (
<Box
component="form"
noValidate
spellCheck="false"
>
<Box mb={theme.spacing(6)}>
<Typography component="h1">Delete account</Typography>
<Typography
component="p"
sx={{ opacity: 0.6 }}
>
Note that deleting your account will remove all data from the server. This
is permanent and non-recoverable.
</Typography>
</Box>
<Button
variant="contained"
color="error"
onClick={() => setIsOpen("delete")}
>
Delete account
</Button>
</Box>
)}
<Dialog
open={isModalOpen("delete")}
theme={theme}
title={"Really delete this account?"}
description={
"If you delete your account, you will no longer be able to sign in, and all of your data will be deleted. Deleting your account is permanent and non-recoverable action."
}
onCancel={() => setIsOpen("")}
confirmationButtonLabel={"Delete account"}
onConfirm={handleDeleteAccount}
isLoading={isLoading}
/>
<GenericDialog
title={"Upload Image"}
open={isModalOpen("picture")}
onClose={closePictureModal}
theme={theme}
>
<ImageField
id="update-profile-picture"
src={
file?.delete
? ""
: file?.src
? file.src
: localData?.file
? localData.file
: user?.avatarImage
? `data:image/png;base64,${user.avatarImage}`
: ""
}
loading={progress.isLoading && progress.value !== 100}
onChange={handlePicture}
/>
{progress.isLoading || progress.value !== 0 || errors["picture"] ? (
<ProgressUpload
icon={<ImageIcon />}
label={file?.name}
size={file?.size}
progress={progress.value}
onClick={removePicture}
error={errors["picture"]}
/>
) : (
""
)}
<Stack
direction="row"
mt={theme.spacing(10)}
gap={theme.spacing(5)}
justifyContent="flex-end"
>
<Button
variant="text"
color="info"
onClick={removePicture}
>
Remove
</Button>
<Button
variant="contained"
color="accent"
onClick={handleUpdatePicture}
disabled={
(Object.keys(errors).length !== 0 && errors?.picture) ||
progress.value !== 100
? true
: false
}
>
Update
</Button>
</Stack>
</GenericDialog>
</TabPanel>
);
};
ProfilePanel.propTypes = {
// No props are being passed to this component, hence no specific PropTypes are defined.
};
export default ProfilePanel;

View File

@@ -0,0 +1,335 @@
import { useTheme } from "@emotion/react";
import TabPanel from "@mui/lab/TabPanel";
import { Button, ButtonGroup, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import TextInput from "../../Inputs/TextInput";
import { credentials } from "../../../Validation/validation";
import { networkService } from "../../../main";
import { createToast } from "../../../Utils/toastUtils";
import { useSelector } from "react-redux";
import Select from "../../Inputs/Select";
import { GenericDialog } from "../../Dialog/genericDialog";
import DataTable from "../../Table/";
import { useGetInviteToken } from "../../../Hooks/inviteHooks";
/**
* TeamPanel component manages the organization and team members,
* providing functionalities like renaming the organization, managing team members,
* and inviting new members.
*
* @returns {JSX.Element}
*/
const TeamPanel = () => {
const theme = useTheme();
const SPACING_GAP = theme.spacing(12);
const [toInvite, setToInvite] = useState({
email: "",
role: ["0"],
});
const [data, setData] = useState([]);
const [members, setMembers] = useState([]);
const [filter, setFilter] = useState("all");
const [isDisabled, setIsDisabled] = useState(true);
const [errors, setErrors] = useState({});
const [isSendingInvite, setIsSendingInvite] = useState(false);
const [getInviteToken, clearToken, isLoading, error, token] = useGetInviteToken();
const headers = [
{
id: "name",
content: "Name",
render: (row) => {
return (
<Stack>
<Typography color={theme.palette.primary.contrastTextSecondary}>
{row.firstName + " " + row.lastName}
</Typography>
<Typography>
Created {new Date(row.createdAt).toLocaleDateString()}
</Typography>
</Stack>
);
},
},
{ id: "email", content: "Email", render: (row) => row.email },
{
id: "role",
content: "Role",
render: (row) => row.role,
},
];
useEffect(() => {
const fetchTeam = async () => {
try {
const response = await networkService.getAllUsers();
setMembers(response.data.data);
} catch (error) {
createToast({
body: error.message || "Error fetching team members.",
});
}
};
fetchTeam();
}, []);
useEffect(() => {
const ROLE_MAP = {
superadmin: "Super admin",
admin: "Admin",
user: "Team member",
demo: "Demo User",
};
let team = members;
if (filter !== "all")
team = members.filter((member) => {
if (filter === "admin") {
return member.role.includes("admin") || member.role.includes("superadmin");
}
return member.role.includes(filter);
});
team = team.map((member) => ({
...member,
id: member._id,
role: member.role.map((role) => ROLE_MAP[role]).join(","),
}));
setData(team);
}, [filter, members]);
useEffect(() => {
setIsDisabled(Object.keys(errors).length !== 0 || toInvite.email === "");
}, [errors, toInvite.email]);
const [isOpen, setIsOpen] = useState(false);
const handleChange = (event) => {
const { value } = event.target;
const newEmail = value?.toLowerCase() || value
setToInvite((prev) => ({
...prev,
email: newEmail,
}));
const validation = credentials.validate({ email: newEmail }, { abortEarly: false });
setErrors((prev) => {
const updatedErrors = { ...prev };
if (validation.error) {
updatedErrors.email = validation.error.details[0].message;
} else {
delete updatedErrors.email;
}
return updatedErrors;
});
};
const handleGetToken = async () => {
await getInviteToken({ email: toInvite.email, role: toInvite.role });
};
const handleInviteMember = async () => {
if (!toInvite.email) {
setErrors((prev) => ({ ...prev, email: "Email is required." }));
return;
}
setIsSendingInvite(true);
if (!toInvite.role.includes("user") || !toInvite.role.includes("admin"))
setToInvite((prev) => ({ ...prev, role: ["user"] }));
const { error } = credentials.validate(
{ email: toInvite.email },
{
abortEarly: false,
}
);
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
return;
}
try {
await networkService.sendInvitationToken({
email: toInvite.email,
role: toInvite.role,
});
closeInviteModal();
createToast({
body: "Member invited. They will receive an email with details on how to create their account.",
});
} catch (error) {
createToast({
body: error.message || "Unknown error.",
});
} finally {
setIsSendingInvite(false);
}
};
const closeInviteModal = () => {
setIsOpen(false);
clearToken();
setToInvite({ email: "", role: ["0"] });
setErrors({});
};
return (
<TabPanel
className="team-panel table-container"
value="team"
sx={{
"& h1": {
color: theme.palette.primary.contrastTextTertiary,
},
"& .MuiTable-root .MuiTableBody-root .MuiTableCell-root, & .MuiTable-root p + p":
{
color: theme.palette.primary.contrastTextSecondary,
},
}}
>
<Stack
component="form"
noValidate
spellCheck="false"
gap={SPACING_GAP}
>
<Typography component="h1">Team members</Typography>
<Stack
direction="row"
justifyContent="space-between"
>
<Stack
direction="row"
alignItems="flex-end"
gap={theme.spacing(6)}
sx={{ fontSize: 14 }}
>
<ButtonGroup>
<Button
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
>
All
</Button>
<Button
variant="group"
filled={(filter === "admin").toString()}
onClick={() => setFilter("admin")}
>
Super admin
</Button>
<Button
variant="group"
filled={(filter === "user").toString()}
onClick={() => setFilter("user")}
>
Member
</Button>
</ButtonGroup>
</Stack>
<Button
variant="contained"
color="accent"
onClick={() => setIsOpen(true)}
>
Invite a team member
</Button>
</Stack>
<DataTable
headers={headers}
data={data}
config={{ emptyView: "There are no team members with this role" }}
/>
</Stack>
<GenericDialog
title={"Invite new team member"}
description={
"When you add a new team member, they will get access to all monitors."
}
open={isOpen}
onClose={closeInviteModal}
theme={theme}
>
<TextInput
marginBottom={SPACING_GAP}
type="email"
id="input-team-member"
placeholder="Email"
value={toInvite.email}
onChange={handleChange}
error={errors.email ? true : false}
helperText={errors.email}
/>
<Select
id="team-member-role"
placeholder="Select role"
isHidden={true}
value={toInvite.role[0]}
onChange={(event) =>
setToInvite((prev) => ({
...prev,
role: [event.target.value],
}))
}
items={[
{ _id: "admin", name: "Admin" },
{ _id: "user", name: "User" },
]}
/>
{token && <Typography>Invite link</Typography>}
{token && (
<TextInput
id="invite-token"
value={token}
/>
)}
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(8)}
justifyContent="flex-end"
>
<Button
loading={isSendingInvite}
variant="contained" // CAIO_REVIEW
color="error"
onClick={closeInviteModal}
>
Cancel
</Button>
<Button
variant="contained"
color="accent"
onClick={handleGetToken}
loading={isSendingInvite}
disabled={isDisabled}
>
Get token
</Button>
<Button
variant="contained"
color="accent"
onClick={handleInviteMember}
loading={isSendingInvite}
disabled={isDisabled}
>
E-mail token
</Button>
</Stack>
</GenericDialog>
</TabPanel>
);
};
TeamPanel.propTypes = {
// No props are being passed to this component, hence no specific PropTypes are defined.
};
export default TeamPanel;

View File

@@ -0,0 +1,83 @@
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 { useTheme } from "@emotion/react";
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
/**
* Component for pagination actions (first, previous, next, last).
*
* @component
* @param {Object} props
* @param {number} props.count - Total number of items.
* @param {number} props.page - Current page number.
* @param {number} props.rowsPerPage - Number of rows per page.
* @param {function} props.onPageChange - Callback function to handle page change.
*
* @returns {JSX.Element} Pagination actions component.
*/
function TablePaginationActions({ count, page, rowsPerPage, onPageChange }) {
const handleFirstPageButtonClick = (event) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
const theme = useTheme();
return (
<Box sx={{ flexShrink: 0, ml: "24px", display: "flex", gap: theme.spacing(2) }}>
<Button
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
<LeftArrowDouble />
</Button>
<Button
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
<LeftArrow />
</Button>
<Button
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
<RightArrow />
</Button>
<Button
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
<RightArrowDouble />
</Button>
</Box>
);
}
export { TablePaginationActions };

Some files were not shown because too many files have changed in this diff Show More