Merge branch 'develop' into HEAD

This commit is contained in:
Alex Holliday
2024-09-06 09:58:14 -07:00
91 changed files with 5938 additions and 1995 deletions
+2 -1
View File
@@ -39,6 +39,7 @@ function App() {
const DetailsWithAdminProp = withAdminProp(Details);
const PageSpeedWithAdminProp = withAdminProp(PageSpeed);
const MaintenanceWithAdminProp = withAdminProp(Maintenance);
const SettingsWithAdminProp = withAdminProp(Settings);
const mode = useSelector((state) => state.ui.mode);
@@ -90,7 +91,7 @@ function App() {
/>
<Route
path="settings"
element={<ProtectedRoute Component={Settings} />}
element={<ProtectedRoute Component={SettingsWithAdminProp} />}
/>
<Route
path="account/profile"
+8 -14
View File
@@ -1,12 +1,11 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, IconButton, Stack, Typography } from "@mui/material";
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";
import Button from "../Button";
/**
* Icons mapping for different alert variants.
@@ -32,7 +31,7 @@ const icons = {
const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
const theme = useTheme();
const { text, light, border } = theme.palette[variant];
const { text, bg, border } = theme.palette[variant];
const icon = icons[variant];
return (
@@ -46,7 +45,7 @@ const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
padding: hasIcon
? theme.spacing(8)
: `${theme.spacing(4)} ${theme.spacing(8)}`,
backgroundColor: light,
backgroundColor: bg,
border: `solid 1px ${border}`,
borderRadius: theme.shape.borderRadius,
}}
@@ -65,8 +64,8 @@ const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
)}
{hasIcon && isToast && (
<Button
level="tertiary"
label="Dismiss"
variant="text"
color="info"
onClick={onClick}
sx={{
fontWeight: "600",
@@ -74,15 +73,10 @@ const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
mt: theme.spacing(4),
padding: 0,
minWidth: 0,
"&:hover": {
backgroundColor: "transparent",
},
"& .MuiTouchRipple-root": {
pointerEvents: "none",
display: "none",
},
}}
></Button>
>
Dismiss
</Button>
)}
</Stack>
{isToast && (
+2 -2
View File
@@ -41,8 +41,8 @@ const PulseDot = ({ color }) => {
"&::after": {
content: `""`,
position: "absolute",
width: "6px",
height: "6px",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: "white",
top: "50%",
+25 -58
View File
@@ -13,6 +13,7 @@ import {
Box,
Typography,
Stack,
Button,
} from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { setRowsPerPage } from "../../Features/UI/uiSlice";
@@ -21,7 +22,6 @@ 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 SelectorVertical from "../../assets/icons/selector-vertical.svg?react";
import Button from "../Button";
import "./index.css";
/**
* Component for pagination actions (first, previous, next, last).
@@ -54,37 +54,37 @@ const TablePaginationActions = (props) => {
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<Button
level="secondary"
label=""
img={<LeftArrowDouble />}
variant="group"
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
/>
>
<LeftArrowDouble />
</Button>
<Button
level="secondary"
label=""
img={<LeftArrow />}
variant="group"
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
/>
>
<LeftArrow />
</Button>
<Button
level="secondary"
label=""
img={<RightArrow />}
variant="group"
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
/>
>
<RightArrow />
</Button>
<Button
level="secondary"
label=""
img={<RightArrowDouble />}
variant="group"
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
/>
>
<RightArrowDouble />
</Button>
</Box>
);
};
@@ -200,31 +200,12 @@ const BasicTable = ({ data, paginated, reversed, table }) => {
return (
<>
<TableContainer
component={Paper}
sx={{
backgroundColor: theme.palette.background.main,
border: `solid 1px ${theme.palette.border.light}`,
borderRadius: theme.shape.borderRadius,
}}
>
<TableContainer component={Paper}>
<Table>
<TableHead
sx={{
backgroundColor: theme.palette.background.accent,
}}
>
<TableHead>
<TableRow>
{data.cols.map((col) => (
<TableCell
key={col.id}
sx={{
color: theme.palette.text.secondary,
borderBottomColor: theme.palette.border.light,
}}
>
{col.name}
</TableCell>
<TableCell key={col.id}>{col.name}</TableCell>
))}
</TableRow>
</TableHead>
@@ -242,17 +223,7 @@ const BasicTable = ({ data, paginated, reversed, table }) => {
onClick={row.handleClick ? row.handleClick : null}
>
{row.data.map((cell) => {
return (
<TableCell
key={cell.id}
sx={{
color: theme.palette.text.secondary,
borderBottomColor: theme.palette.border.light,
}}
>
{cell.data}
</TableCell>
);
return <TableCell key={cell.id}>{cell.data}</TableCell>;
})}
</TableRow>
);
@@ -322,18 +293,14 @@ const BasicTable = ({ data, paginated, reversed, table }) => {
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& button.MuiButtonBase-root, & .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& button:not(.Mui-disabled):hover": {
backgroundColor: theme.palette.background.fill,
borderColor: theme.palette.background.fill,
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
-33
View File
@@ -1,33 +0,0 @@
.MuiButtonBase-root.button-rotate90 svg {
transition: transform 500ms ease;
}
.MuiButtonBase-root.button-rotate90:hover svg {
transform: rotate(90deg);
transform-origin: center;
}
.MuiButtonBase-root.button-rotate180 svg {
transition: transform 700ms ease;
}
.MuiButtonBase-root.button-rotate180:hover svg {
transform: rotate(180deg);
transform-origin: center;
}
.MuiButtonBase-root.button-slideLeft {
padding-left: 45px;
}
.MuiButtonBase-root.button-slideLeft svg {
position: absolute;
left: 15px;
transition: left 250ms cubic-bezier(0.65, 0, 0.076, 1);
}
.MuiButtonBase-root.button-slideLeft:hover svg {
left: 11px;
}
.MuiButtonBase-root.button-slideLeft:hover,
.MuiButtonBase-root.button-rotate180:hover,
.MuiButtonBase-root.button-rotate90:hover {
background-color: rgba(71, 84, 103, 0.08);
}
-108
View File
@@ -1,108 +0,0 @@
import { Button as MuiButton } from "@mui/material";
import PropTypes from "prop-types";
import "./index.css";
const levelConfig = {
primary: {
variant: "contained",
color: "primary",
},
secondary: {
variant: "outlined",
color: "secondary",
},
tertiary: {
variant: "text",
color: "tertiary",
},
error: {
variant: "contained",
color: "error",
},
imagePrimary: {
color: "primary",
variant: "text",
},
imageSecondary: {
color: "secondary",
variant: "text",
},
imageTertiary: {
color: "tertiary",
variant: "text",
},
};
/**
* @component
* @param {Object} props
* @param {'primary' | 'secondary' | 'tertiary' | 'error' | 'imagePrimary' | 'imageSecondary' | 'imageTertiary'} props.level - The level of the button
* @param {string} props.type - The type of the button
* @param {string} props.label - The label of the button
* @param {React.ReactNode} props.img - Image for button
* @param {boolean} [props.disabled] - Whether the button is disabled
* @param {function} props.onClick - Function to run when the button is clicked
* @param {Object} props.sx - Styles for the button
* @returns {JSX.Element}
* @example
* // Render an error button
* <Button type="submit" level="error" label="Error" disabled sx={{marginTop: "1rem"}}/>
*/
const Button = ({
id,
animate,
type,
level,
label,
disabled,
img,
onClick,
props,
sx,
}) => {
const { variant, color } = levelConfig[level];
return (
<MuiButton
id={id}
className={`button-${animate}`}
type={type}
variant={variant}
color={color}
disabled={disabled}
onClick={onClick}
disableRipple
sx={{
lineHeight: 1.5,
fontWeight: 400,
boxShadow: "none",
textTransform: "none",
borderRadius: "4px",
"&:focus": {
outline: "none",
},
"&:hover": {
boxShadow: "none",
},
...sx,
}}
{...props}
>
{img && img}
<span>{label}</span>
</MuiButton>
);
};
Button.propTypes = {
id: PropTypes.string,
type: PropTypes.string,
level: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
img: PropTypes.node,
onClick: PropTypes.func,
sx: PropTypes.object,
disabled: PropTypes.bool,
};
export default Button;
@@ -1,105 +0,0 @@
import LoadingButton from "@mui/lab/LoadingButton";
import PropTypes from "prop-types";
const levelConfig = {
primary: {
variant: "contained",
color: "primary",
},
secondary: {
variant: "outlined",
color: "secondary",
},
tertiary: {
variant: "text",
color: "tertiary",
},
error: {
variant: "contained",
color: "error",
},
};
/**
* @component
* @param {Object} props
* @param {'primary' | 'secondary' | 'tertiary' | 'error'} props.level - The style level of the button.
* @param {string} props.label - The label text displayed on the button.
* @param {React.ReactNode} [props.img] - Icon or image element to display within the button.
* @param {boolean} [props.disabled=false] - Determines if the button is disabled.
* @param {string} [props.loadingText] - Text displayed when the button is in a loading state.
* @param {'start' | 'end'} [props.position] - Specifies where the icon or loading loadingText should be positioned relative to the label.
* @param {function} props.onClick - Callback function invoked when the button is clicked.
* @param {boolean} props.isLoading - Indicates if the button is in a loading state.
* @param {Object} [props.sx] - Additional styles to apply to the button.
* @returns {JSX.Element}
* @example
* // Render a primary button with an icon at the end
* <ButtonSpinner
* level="primary"
* label="Save"
* img={<SaveIcon />}
* position="end"
* onClick={handleSaveClick}
* isLoading={loading}
* />
*/
const ButtonSpinner = ({
level,
label,
disabled,
loadingText,
position,
img,
onClick,
isLoading,
sx,
}) => {
const { variant, color } = levelConfig[level];
//if both a loadingPosition and loadingIndicator are provided, the spinner overlaps with the text and it breaks
if (position && loadingText) loadingText = null;
return (
<LoadingButton
variant={variant}
color={color}
disabled={disabled}
loadingIndicator={loadingText}
loadingPosition={position}
startIcon={position === "start" && img}
endIcon={position === "end" && img}
onClick={onClick}
loading={isLoading}
disableRipple
sx={{
boxShadow: "none",
textTransform: "none",
borderRadius: "4px",
"&:focus": {
outline: "none",
},
"&:hover": {
boxShadow: "none",
transition: "none",
},
...sx,
}}
>
<span>{label}</span>
</LoadingButton>
);
};
ButtonSpinner.propTypes = {
level: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
loadingText: PropTypes.string,
position: PropTypes.oneOf(["start", "end"]),
img: PropTypes.node,
onClick: PropTypes.func.isRequired,
isLoading: PropTypes.bool.isRequired,
sx: PropTypes.object,
disabled: PropTypes.bool,
};
export default ButtonSpinner;
@@ -1,53 +0,0 @@
import { BarChart, Bar, Cell, ReferenceLine, Label } from "recharts";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
const MonitorDetails60MinChart = ({ data }) => {
const theme = useTheme();
const labelStyle = {
fontSize: "10px",
fill: theme.palette.text.tertiary,
};
const color = {
true: theme.palette.success.main,
false: theme.palette.error.text,
undefined: theme.palette.unresolved.main,
};
return (
<BarChart
width={data.length * 10 + 30}
height={35}
data={data}
margin={{ top: 14, left: 15, right: 15, bottom: 2 }}
style={{ alignSelf: "baseline" }}
>
<Bar dataKey="value" barSize={10}>
{data.map((check, index) => (
<Cell key={`cell-${index}`} fill={color[check.status]} />
))}
</Bar>
<ReferenceLine x={0} stroke="black" strokeDasharray="3 3">
<Label value="60 mins" position="top" style={labelStyle} />
</ReferenceLine>
<ReferenceLine
x={Math.floor(data.length * (2 / 3))}
stroke="black"
strokeDasharray="3 3"
>
<Label value="20 mins" position="top" style={labelStyle} />
</ReferenceLine>
<ReferenceLine x={data.length - 1} stroke="black" strokeDasharray="3 3">
<Label value="now" position="top" style={labelStyle} />
</ReferenceLine>
</BarChart>
);
};
MonitorDetails60MinChart.propTypes = {
data: PropTypes.array.isRequired,
};
export default MonitorDetails60MinChart;
@@ -1,8 +1,16 @@
import PropTypes from "prop-types";
import { AreaChart, Area, XAxis, Tooltip, ResponsiveContainer } from "recharts";
import { Box, Typography } from "@mui/material";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import "./index.css";
import React, { useMemo } from "react";
const CustomToolTip = ({ active, payload, label }) => {
const theme = useTheme();
@@ -16,14 +24,15 @@ const CustomToolTip = ({ active, payload, label }) => {
border: 1,
borderColor: theme.palette.border.dark,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(6),
px: theme.spacing(8),
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.common.main,
fontSize: 13,
color: theme.palette.text.tertiary,
fontSize: 12,
fontWeight: 500,
}}
>
{new Date(label).toLocaleDateString("en-US", {
@@ -38,15 +47,39 @@ const CustomToolTip = ({ active, payload, label }) => {
hour12: true, // AM/PM format
})}
</Typography>
<Typography
mt={theme.spacing(2.5)}
sx={{
color: theme.palette.text.secondary,
fontSize: 13,
}}
>
Response Time (ms): {payload[0].payload.originalResponseTime}
</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.text.tertiary,
fontSize: 11,
fontWeight: 500,
},
}}
>
<Typography component="span" sx={{ opacity: 0.8 }}>
Response Time
</Typography>{" "}
<Typography component="span">
{payload[0].payload.originalResponseTime}
<Typography component="span" sx={{ opacity: 0.8 }}>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
{/* Display original value */}
</Box>
);
@@ -64,12 +97,16 @@ const MonitorDetailsAreaChart = ({ checks }) => {
});
};
const memoizedChecks = useMemo(() => checks, []);
const theme = useTheme();
return (
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" minWidth={25} height={220}>
<AreaChart
width={500}
height={400}
data={checks}
width="100%"
height="100%"
data={memoizedChecks}
margin={{
top: 10,
right: 0,
@@ -77,18 +114,41 @@ const MonitorDetailsAreaChart = ({ checks }) => {
bottom: 0,
}}
>
<CartesianGrid
stroke={theme.palette.border.light}
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.primary.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.primary.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
stroke={theme.palette.border.dark}
dataKey="createdAt"
tickFormatter={formatDate}
tick={{ fontSize: "13px" }}
tickLine={false}
height={18}
/>
<Tooltip content={<CustomToolTip />} />
<Area
type="monotone"
dataKey="responseTime"
stroke="#29afee"
fill="#eaf2fd"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
@@ -1,43 +0,0 @@
import PropTypes from "prop-types";
import { Stack } from "@mui/material";
import { BarChart, Bar, ResponsiveContainer, Cell } from "recharts";
import "./index.css";
const ResponseTimeChart = ({ checks = [] }) => {
return (
<Stack
flexDirection="row"
justifyContent="space-around"
alignItems="flex-end"
height="50px"
width="300px"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={150}
height={40}
data={checks}
style={{ cursor: "pointer" }}
>
<Bar maxBarSize={10} dataKey="responseTime">
{checks.map((check, index) => (
<Cell
key={`cell-${index}`}
fill={
check.status === true
? "var(--success-color)"
: "var(--error-color)"
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Stack>
);
};
ResponseTimeChart.propTypes = {
checks: PropTypes.array,
};
export default ResponseTimeChart;
+15 -7
View File
@@ -1,11 +1,12 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } 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 Button from "../Button";
import Check from "../Check/Check";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import "./index.css";
/**
@@ -22,6 +23,7 @@ import "./index.css";
const Fallback = ({ title, checks, link = "/", isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const mode = useSelector((state) => state.ui.mode);
return (
<Stack
@@ -29,7 +31,11 @@ const Fallback = ({ title, checks, link = "/", isAdmin }) => {
alignItems="center"
gap={theme.spacing(20)}
>
<Skeleton style={{ zIndex: 1 }} />
{mode === "light" ? (
<Skeleton style={{ zIndex: 1 }} />
) : (
<SkeletonDark style={{ zIndex: 1 }} />
)}
<Box
className="background-pattern-svg"
sx={{
@@ -44,7 +50,7 @@ const Fallback = ({ title, checks, link = "/", isAdmin }) => {
<Typography
component="h1"
marginY={theme.spacing(4)}
color={theme.palette.text.secondary}
color={theme.palette.text.tertiary}
>
A {title} is used to:
</Typography>
@@ -59,11 +65,13 @@ const Fallback = ({ title, checks, link = "/", isAdmin }) => {
{/* TODO - display a different fallback if user is not an admin*/}
{isAdmin && (
<Button
level="primary"
label={`Let's create your ${title}`}
variant="contained"
color="primary"
sx={{ alignSelf: "center" }}
onClick={() => navigate(link)}
/>
>
Let's create your {title}
</Button>
)}
</Stack>
);
@@ -0,0 +1,116 @@
import { Autocomplete, TextField } from "@mui/material";
import React from "react";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
/**
* @example
*
* const options = [
* { _id: "66d6119ef959cbc681e034f0", name: "Googler" },
* { _id: "66d6119ef959cbc681e034f0", name: "CNN" },
* { _id: "66d61a1bf959cbc681e0353f", name: "X Corp." },
* ];
*
* <AutoCompleteField options={options} />
*
*/
/**
* AutoCompleteField component.
*
* @component
* @param {Object} props - The component props.
* @param {string} props.id - The ID of the autocomplete field.
* @param {string} props.type - The type of the input field (text or number).
* @param {Array} props.options - The options for the autocomplete field.
* @param {string} props.placeholder - The placeholder text for the input field.
* @param {boolean} props.disabled - Indicates if the field is disabled.
* @returns {JSX.Element} The AutoCompleteField component.
*/
const AutoCompleteField = ({
id,
type,
options,
placeholder = "Type to search",
disabled,
}) => {
const [value, setValue] = React.useState();
const [inputValue, setInputValue] = React.useState("");
const theme = useTheme();
return (
<Autocomplete
freeSolo
className="auto-complete-field"
id={id}
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
options={options}
getOptionLabel={(option) => option.name}
renderInput={(params) => (
<TextField
{...params}
type={type}
placeholder={placeholder}
disabled={disabled}
InputProps={{
...params.InputProps,
sx: {
width: 360,
height: 34,
fontSize: 13,
p: 0,
borderRadius: theme.shape.borderRadius,
"& input": {
p: 0,
},
},
}}
/>
)}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
return (
<li key={option._id} {...optionProps}>
<div>{<span>{option.name}</span>}</div>
</li>
);
}}
slotProps={{
popper: {
sx: {
"& ul": { p: 0 },
"& li": { borderRadius: theme.shape.borderRadius },
},
},
paper: {
sx: {
p: 2,
fontSize: 13,
},
},
}}
/>
);
};
AutoCompleteField.displayName = "AutoCompleteField";
AutoCompleteField.propTypes = {
id: PropTypes.string,
type: PropTypes.oneOf(["text", "number"]),
options: PropTypes.array,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
setWidth: PropTypes.string,
};
export default AutoCompleteField;
+3 -3
View File
@@ -38,12 +38,12 @@ const ImageField = ({ id, src, loading, onChange }) => {
border: "dashed",
borderRadius: theme.shape.borderRadius,
borderColor: isDragging
? theme.palette.common.main
? theme.palette.primary.main
: theme.palette.border.light,
borderWidth: "2px",
transition: "0.2s",
"&:hover": {
borderColor: theme.palette.common.main,
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
}}
@@ -96,7 +96,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
<Typography
component="span"
fontSize="inherit"
color={theme.palette.common.main}
color={theme.palette.primary.main}
fontWeight={500}
>
Click to upload
@@ -67,6 +67,7 @@ const Select = ({
component="h3"
color={theme.palette.text.secondary}
fontWeight={500}
fontSize={13}
>
{label}
</Typography>
+6 -1
View File
@@ -138,6 +138,11 @@ const StatusLabel = ({ status, text, customStyles }) => {
bgColor: theme.palette.error.bg,
borderColor: theme.palette.error.light,
},
pending: {
dotColor: theme.palette.warning.main,
bgColor: theme.palette.warning.bg,
borderColor: theme.palette.warning.light,
},
"cannot resolve": {
dotColor: theme.palette.unresolved.main,
bgColor: theme.palette.unresolved.bg,
@@ -170,7 +175,7 @@ const StatusLabel = ({ status, text, customStyles }) => {
};
StatusLabel.propTypes = {
status: PropTypes.oneOf(["up", "down", "cannot resolve"]),
status: PropTypes.oneOf(["up", "down", "pending", "cannot resolve"]),
text: PropTypes.string,
customStyles: PropTypes.object,
};
+2 -2
View File
@@ -28,11 +28,11 @@ const Link = ({ level, label, url }) => {
sx: {
textDecoration: "underline",
textDecorationStyle: "dashed",
textDecorationColor: theme.palette.common.main,
textDecorationColor: theme.palette.primary.main,
textUnderlineOffset: "1px",
":hover": {
color: theme.palette.text.tertiary,
textDecorationColor: theme.palette.common.main,
textDecorationColor: theme.palette.primary.main,
backgroundColor: theme.palette.background.fill,
},
},
+1 -1
View File
@@ -75,7 +75,7 @@ aside.collapsed .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root {
}
aside .MuiListSubheader-root {
transition: all 200ms ease;
transition: padding 200ms ease;
}
@keyframes fadeIn {
+19 -4
View File
@@ -154,7 +154,7 @@ function Sidebar() {
color="white"
sx={{
position: "relative",
backgroundColor: theme.palette.common.main,
backgroundColor: theme.palette.primary.main,
borderRadius: theme.shape.borderRadius,
userSelect: "none",
}}
@@ -598,8 +598,9 @@ function Sidebar() {
MenuListProps={{
sx: {
p: 2,
"& li": { m: 0 },
"& li:has(.MuiBox-root):hover": {
backgroundColor: theme.palette.background.main,
backgroundColor: "transparent",
},
},
}}
@@ -620,8 +621,22 @@ function Sidebar() {
</MenuItem>
)}
{collapsed && <Divider />}
<MenuItem onClick={() => dispatch(setMode("light"))}>Light</MenuItem>
<MenuItem onClick={() => dispatch(setMode("dark"))}>Dark</MenuItem>
<MenuItem
onClick={() => {
dispatch(setMode("light"));
closePopup();
}}
>
Light
</MenuItem>
<MenuItem
onClick={() => {
dispatch(setMode("dark"));
closePopup();
}}
>
Dark
</MenuItem>
<Divider />
<MenuItem
onClick={logout}
@@ -2,7 +2,7 @@ import TabPanel from "@mui/lab/TabPanel";
import React, { useState } from "react";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import ButtonSpinner from "../../ButtonSpinner";
import LoadingButton from "@mui/lab/LoadingButton";
import Field from "../../Inputs/Field";
import { credentials } from "../../../Validation/validation";
import Alert from "../../Alert";
@@ -164,19 +164,20 @@ const PasswordPanel = () => {
</Box>
</Stack>
<Stack direction="row" justifyContent="flex-end">
<ButtonSpinner
level="primary"
label="Save"
<LoadingButton
variant="contained"
color="primary"
onClick={handleSubmit}
isLoading={isLoading}
loadingText="Saving..."
loading={isLoading}
loadingIndicator="Saving..."
disabled={Object.keys(errors).length !== 0 && true}
sx={{
paddingX: theme.spacing(12),
width: "fit-content",
px: theme.spacing(12),
mt: theme.spacing(20),
}}
/>
>
Save
</LoadingButton>
</Stack>
</Stack>
</TabPanel>
@@ -1,9 +1,7 @@
import { useTheme } from "@emotion/react";
import { useRef, useState } from "react";
import TabPanel from "@mui/lab/TabPanel";
import { Box, Divider, Modal, Stack, Typography } from "@mui/material";
import ButtonSpinner from "../../ButtonSpinner";
import Button from "../../Button";
import { Box, Button, Divider, Modal, Stack, Typography } from "@mui/material";
import Avatar from "../../Avatar";
import Field from "../../Inputs/Field";
import ImageField from "../../Inputs/Image";
@@ -20,6 +18,7 @@ import { formatBytes } from "../../../Utils/fileUtils";
import { clearUptimeMonitorState } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import LoadingButton from "@mui/lab/LoadingButton";
/**
* ProfilePanel component displays a form for editing user profile information
@@ -292,19 +291,12 @@ const ProfilePanel = () => {
}
sx={{ mr: "8px" }}
/>
<Button
level="tertiary"
label="Delete"
onClick={handleDeletePicture}
/>
<Button
level="tertiary"
label="Update"
onClick={openPictureModal}
sx={{
color: theme.palette.common.main,
}}
/>
<Button variant="text" color="info" onClick={handleDeletePicture}>
Delete
</Button>
<Button variant="text" color="primary" onClick={openPictureModal}>
Update
</Button>
</Stack>
</Stack>
<Divider
@@ -316,19 +308,19 @@ const ProfilePanel = () => {
/>
<Stack direction="row" justifyContent="flex-end">
<Box width="fit-content">
<ButtonSpinner
level="primary"
label="Save"
<LoadingButton
variant="contained"
color="primary"
onClick={handleSaveProfile}
isLoading={isLoading}
loadingText="Saving..."
loading={isLoading}
loadingIndicator="Saving..."
disabled={
Object.keys(errors).length !== 0 && !errors?.picture && true
}
sx={{
paddingX: theme.spacing(12),
}}
/>
sx={{ px: theme.spacing(12) }}
>
Save
</LoadingButton>
</Box>
</Stack>
</Stack>
@@ -339,13 +331,8 @@ const ProfilePanel = () => {
borderColor: theme.palette.border.light,
}}
/>
<Stack
component="form"
noValidate
spellCheck="false"
gap={theme.spacing(4)}
>
<Box>
<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 our
@@ -353,12 +340,13 @@ const ProfilePanel = () => {
</Typography>
</Box>
<Button
level="error"
label="Delete account"
variant="contained"
color="error"
onClick={() => setIsOpen("delete")}
sx={{ width: "fit-content", mt: theme.spacing(4) }}
/>
</Stack>
>
Delete account
</Button>
</Box>
{/* TODO - Update ModalPopup Component with @mui for reusability */}
<Modal
aria-labelledby="modal-delete-account"
@@ -400,17 +388,17 @@ const ProfilePanel = () => {
mt={theme.spacing(5)}
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
onClick={() => setIsOpen("")}
/>
<ButtonSpinner
level="error"
label="Delete account"
<Button variant="text" color="info" onClick={() => setIsOpen("")}>
Cancel
</Button>
<LoadingButton
variant="contained"
color="error"
onClick={handleDeleteAccount}
isLoading={isLoading}
/>
loading={isLoading}
>
Delete account
</LoadingButton>
</Stack>
</Stack>
</Modal>
@@ -480,15 +468,19 @@ const ProfilePanel = () => {
justifyContent="flex-end"
>
<Button
level="secondary"
label="Edit"
variant="outlined"
color="secondary"
disabled
sx={{ mr: "auto" }}
/>
<Button level="tertiary" label="Remove" onClick={removePicture} />
>
Edit
</Button>
<Button variant="text" color="info" onClick={removePicture}>
Remove
</Button>
<Button
level="primary"
label="Update"
variant="contained"
color="primary"
onClick={handleUpdatePicture}
disabled={
(Object.keys(errors).length !== 0 && errors?.picture) ||
@@ -496,7 +488,9 @@ const ProfilePanel = () => {
? true
: false
}
/>
>
Update
</Button>
</Stack>
</Stack>
</Modal>
@@ -1,27 +1,15 @@
import { useTheme } from "@emotion/react";
import TabPanel from "@mui/lab/TabPanel";
import {
Box,
ButtonGroup,
Divider,
IconButton,
Modal,
Stack,
TextField,
Typography,
} from "@mui/material";
import ButtonSpinner from "../../ButtonSpinner";
import Button from "../../Button";
import { Button, ButtonGroup, Modal, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import EditSvg from "../../../assets/icons/edit.svg?react";
import Field from "../../Inputs/Field";
import { credentials } from "../../../Validation/validation";
import { networkService } from "../../../main";
import { createToast } from "../../../Utils/toastUtils";
import { useSelector } from "react-redux";
import BasicTable from "../../BasicTable";
import Remove from "../../../assets/icons/trash-bin.svg?react";
import Select from "../../Inputs/Select";
import LoadingButton from "@mui/lab/LoadingButton";
/**
* TeamPanel component manages the organization and team members,
@@ -54,6 +42,7 @@ const TeamPanel = () => {
const [members, setMembers] = useState([]);
const [filter, setFilter] = useState("all");
const [errors, setErrors] = useState({});
const [isSendingInvite, setIsSendingInvite] = useState(false);
useEffect(() => {
const fetchTeam = async () => {
@@ -173,6 +162,7 @@ const TeamPanel = () => {
};
const handleInviteMember = async () => {
setIsSendingInvite(true);
if (!toInvite.role.includes("user") || !toInvite.role.includes("admin"))
setToInvite((prev) => ({ ...prev, role: ["user"] }));
@@ -185,24 +175,28 @@ const TeamPanel = () => {
if (error) {
setErrors((prev) => ({ ...prev, email: error.details[0].message }));
} else
try {
await networkService.requestInvitationToken(
authToken,
toInvite.email,
toInvite.role
);
return;
}
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.",
});
}
try {
await networkService.requestInvitationToken(
authToken,
toInvite.email,
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);
setToInvite({ email: "", role: ["0"] });
@@ -283,48 +277,38 @@ const TeamPanel = () => {
gap={theme.spacing(6)}
sx={{ fontSize: 14 }}
>
<ButtonGroup
sx={{
"& button, & button:hover": {
borderColor: theme.palette.border.light,
},
}}
>
<ButtonGroup>
<Button
level="secondary"
label="All"
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
sx={{
backgroundColor:
filter === "all" && theme.palette.background.fill,
}}
/>
>
All
</Button>
<Button
level="secondary"
label="Administrator"
variant="group"
filled={(filter === "admin").toString()}
onClick={() => setFilter("admin")}
sx={{
backgroundColor:
filter === "admin" && theme.palette.background.fill,
}}
/>
>
Administrator
</Button>
<Button
level="secondary"
label="Member"
variant="group"
filled={(filter === "user").toString()}
onClick={() => setFilter("user")}
sx={{
backgroundColor:
filter === "user" && theme.palette.background.fill,
}}
/>
>
Member
</Button>
</ButtonGroup>
</Stack>
<Button
level="primary"
label="Invite a team member"
sx={{ paddingX: theme.spacing(15) }}
<LoadingButton
loading={isSendingInvite}
variant="contained"
color="primary"
onClick={() => setIsOpen(true)}
/>
>
Invite a team member
</LoadingButton>
</Stack>
<BasicTable
data={tableData}
@@ -400,18 +384,23 @@ const TeamPanel = () => {
mt={theme.spacing(8)}
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
<LoadingButton
loading={isSendingInvite}
variant="text"
color="info"
onClick={closeInviteModal}
/>
<ButtonSpinner
level="primary"
label="Send invite"
>
Cancel
</LoadingButton>
<LoadingButton
variant="contained"
color="primary"
onClick={handleInviteMember}
isLoading={false}
loading={isSendingInvite}
disabled={Object.keys(errors).length !== 0}
/>
>
Send invite
</LoadingButton>
</Stack>
</Stack>
</Modal>
@@ -3,7 +3,7 @@ import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitors: [],
monitorsSummary: [],
success: null,
msg: null,
};
@@ -28,19 +28,35 @@ export const createPageSpeed = createAsyncThunk(
}
);
export const getPagespeedMonitorById = createAsyncThunk(
"monitors/getMonitorById",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.getMonitorByid(authToken, monitorId);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
export const getPageSpeedByTeamId = createAsyncThunk(
"pageSpeedMonitors/getPageSpeedByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsByTeamId(
const res = await networkService.getMonitorsAndSummaryByTeamId(
token,
user.teamId,
25,
["pagespeed"],
null,
"desc",
false
["pagespeed"]
);
return res.data;
@@ -109,6 +125,25 @@ export const deletePageSpeed = createAsyncThunk(
}
}
);
export const pausePageSpeed = createAsyncThunk(
"pageSpeedMonitors/pausePageSpeed",
async (data, thunkApi) => {
try {
const { authToken, monitorId } = data;
const res = await networkService.pauseMonitorById(authToken, monitorId);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
const pageSpeedMonitorSlice = createSlice({
name: "pageSpeedMonitor",
@@ -116,7 +151,7 @@ const pageSpeedMonitorSlice = createSlice({
reducers: {
clearMonitorState: (state) => {
state.isLoading = false;
state.monitors = [];
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
@@ -124,7 +159,7 @@ const pageSpeedMonitorSlice = createSlice({
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by userId
// Monitors by teamId
// *****************************************************
.addCase(getPageSpeedByTeamId.pending, (state) => {
@@ -133,7 +168,7 @@ const pageSpeedMonitorSlice = createSlice({
.addCase(getPageSpeedByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitors = action.payload.data;
state.monitorsSummary = action.payload.data;
})
.addCase(getPageSpeedByTeamId.rejected, (state, action) => {
state.isLoading = false;
@@ -143,6 +178,23 @@ const pageSpeedMonitorSlice = createSlice({
: "Getting page speed monitors failed";
})
// *****************************************************
.addCase(getPagespeedMonitorById.pending, (state) => {
state.isLoading = true;
})
.addCase(getPagespeedMonitorById.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(getPagespeedMonitorById.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to get pagespeed monitor";
})
// *****************************************************
// Create Monitor
// *****************************************************
@@ -163,7 +215,7 @@ const pageSpeedMonitorSlice = createSlice({
})
// *****************************************************
// Create Monitor
// Update Monitor
// *****************************************************
.addCase(updatePageSpeed.pending, (state) => {
state.isLoading = true;
@@ -198,6 +250,24 @@ const pageSpeedMonitorSlice = createSlice({
state.msg = action.payload
? action.payload.msg
: "Failed to delete page speed monitor";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pausePageSpeed.pending, (state) => {
state.isLoading = true;
})
.addCase(pausePageSpeed.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(pausePageSpeed.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause page speed monitor";
});
},
});
+8 -2
View File
@@ -4,7 +4,7 @@ import { createSlice } from "@reduxjs/toolkit";
// Add more settings as needed (e.g., theme preferences, user settings)
const initialState = {
monitors: {
rowsPerPage: 5,
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
@@ -13,6 +13,7 @@ const initialState = {
collapsed: false,
},
mode: "light",
greeting: { index: 0, lastUpdate: null },
};
const uiSlice = createSlice({
@@ -31,8 +32,13 @@ const uiSlice = createSlice({
setMode: (state, action) => {
state.mode = action.payload;
},
setGreeting(state, action) {
state.greeting.index = action.payload.index;
state.greeting.lastUpdate = action.payload.lastUpdate;
},
},
});
export default uiSlice.reducer;
export const { setRowsPerPage, toggleSidebar, setMode } = uiSlice.actions;
export const { setRowsPerPage, toggleSidebar, setMode, setGreeting } =
uiSlice.actions;
@@ -3,7 +3,7 @@ import { jwtDecode } from "jwt-decode";
import { networkService } from "../../main";
const initialState = {
isLoading: false,
monitors: [],
monitorsSummary: [],
success: null,
msg: null,
};
@@ -49,18 +49,14 @@ export const getUptimeMonitorById = createAsyncThunk(
);
export const getUptimeMonitorsByTeamId = createAsyncThunk(
"montiors/getMonitorsByTeamId",
"monitors/getMonitorsByTeamId",
async (token, thunkApi) => {
const user = jwtDecode(token);
try {
const res = await networkService.getMonitorsByTeamId(
const res = await networkService.getMonitorsAndSummaryByTeamId(
token,
user.teamId,
25,
["http", "ping"],
null,
"desc",
true
["http", "ping"]
);
return res.data;
} catch (error) {
@@ -149,13 +145,33 @@ export const pauseUptimeMonitor = createAsyncThunk(
}
);
export const deleteMonitorChecksByTeamId = createAsyncThunk(
"monitors/deleteChecksByTeamId",
async (data, thunkApi) => {
try {
const { authToken, teamId } = data;
const res = await networkService.deleteChecksByTeamId(authToken, teamId);
return res.data;
} catch (error) {
if (error.response && error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
}
);
const uptimeMonitorsSlice = createSlice({
name: "uptimeMonitors",
initialState,
reducers: {
clearUptimeMonitorState: (state) => {
state.isLoading = false;
state.monitors = [];
state.monitorsSummary = [];
state.success = null;
state.msg = null;
},
@@ -163,7 +179,7 @@ const uptimeMonitorsSlice = createSlice({
extraReducers: (builder) => {
builder
// *****************************************************
// Monitors by userId
// Monitors by teamId
// *****************************************************
.addCase(getUptimeMonitorsByTeamId.pending, (state) => {
@@ -172,7 +188,7 @@ const uptimeMonitorsSlice = createSlice({
.addCase(getUptimeMonitorsByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.msg;
state.monitors = action.payload.data;
state.monitorsSummary = action.payload.data;
})
.addCase(getUptimeMonitorsByTeamId.rejected, (state, action) => {
state.isLoading = false;
@@ -216,7 +232,7 @@ const uptimeMonitorsSlice = createSlice({
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to pause uptime monitor";
: "Failed to get uptime monitor";
})
// *****************************************************
// update Monitor
@@ -256,6 +272,24 @@ const uptimeMonitorsSlice = createSlice({
: "Failed to delete uptime monitor";
})
// *****************************************************
// Delete Monitor checks by Team ID
// *****************************************************
.addCase(deleteMonitorChecksByTeamId.pending, (state) => {
state.isLoading = true;
})
.addCase(deleteMonitorChecksByTeamId.fulfilled, (state, action) => {
state.isLoading = false;
state.success = action.payload.success;
state.msg = action.payload.msg;
})
.addCase(deleteMonitorChecksByTeamId.rejected, (state, action) => {
state.isLoading = false;
state.success = false;
state.msg = action.payload
? action.payload.msg
: "Failed to delete monitor checks";
})
// *****************************************************
// Pause Monitor
// *****************************************************
.addCase(pauseUptimeMonitor.pending, (state) => {
-2
View File
@@ -3,9 +3,7 @@
}
.home-layout {
display: flex;
position: relative;
gap: var(--env-var-spacing-2);
min-height: 100vh;
max-width: 1400px;
margin: 0 auto;
+7 -3
View File
@@ -1,6 +1,6 @@
import Sidebar from "../../Components/Sidebar";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { Box, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
import "./index.css";
@@ -10,10 +10,14 @@ const HomeLayout = () => {
return (
<Box backgroundColor={theme.palette.background.alt}>
<Box className="home-layout">
<Stack
className="home-layout"
flexDirection="row"
gap={theme.spacing(14)}
>
<Sidebar />
<Outlet />
</Box>
</Stack>
</Box>
);
};
+10 -9
View File
@@ -1,11 +1,10 @@
import { Box, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router";
import { createToast } from "../../Utils/toastUtils";
import { forgotPassword } from "../../Features/Auth/authSlice";
import Button from "../../Components/Button";
import background from "../../assets/Images/background_pattern_decorative.png";
import EmailIcon from "../../assets/icons/email.svg?react";
import Logo from "../../assets/icons/bwu-icon.svg?react";
@@ -90,9 +89,9 @@ const CheckEmail = () => {
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 26,
fontSize: 22,
},
"& p": {
fontSize: 14,
@@ -150,21 +149,23 @@ const CheckEmail = () => {
</Typography>
</Box>
<Button
level="primary"
label="Open email app"
variant="contained"
color="primary"
onClick={openMail}
sx={{
width: "100%",
maxWidth: 400,
}}
/>
>
Open email app
</Button>
<Typography sx={{ alignSelf: "center", mb: theme.spacing(6) }}>
Didn&apos;t receive the email?{" "}
<Typography
component="span"
onClick={resendToken}
sx={{
color: theme.palette.common.main,
color: theme.palette.primary.main,
userSelect: "none",
pointerEvents: disabled ? "none" : "auto",
cursor: disabled ? "default" : "pointer",
@@ -181,7 +182,7 @@ const CheckEmail = () => {
<Typography
component="span"
ml={theme.spacing(2)}
color={theme.palette.common.main}
color={theme.palette.primary.main}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
>
+10 -9
View File
@@ -7,10 +7,10 @@ import { useEffect, useState } from "react";
import { credentials } from "../../Validation/validation";
import { useNavigate } from "react-router-dom";
import Field from "../../Components/Inputs/Field";
import ButtonSpinner from "../../Components/ButtonSpinner";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import Key from "../../assets/icons/key.svg?react";
import background from "../../assets/Images/background_pattern_decorative.png";
import LoadingButton from "@mui/lab/LoadingButton";
import "./index.css";
const ForgotPassword = () => {
@@ -92,7 +92,7 @@ const ForgotPassword = () => {
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 24,
},
@@ -166,18 +166,19 @@ const ForgotPassword = () => {
onChange={handleChange}
error={errors.email}
/>
<ButtonSpinner
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
disabled={errors.email !== undefined}
onClick={handleSubmit}
isLoading={isLoading}
level="primary"
label="Send instructions"
sx={{
width: "100%",
fontWeight: 400,
mt: theme.spacing(15),
}}
/>
>
Send instructions
</LoadingButton>
</Box>
</Stack>
</Stack>
@@ -185,7 +186,7 @@ const ForgotPassword = () => {
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.common.main}
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
+50 -33
View File
@@ -1,12 +1,11 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { credentials } from "../../Validation/validation";
import { login } from "../../Features/Auth/authSlice";
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import Button from "../../Components/Button";
import { networkService } from "../../main";
import Field from "../../Components/Inputs/Field";
import background from "../../assets/Images/background_pattern_decorative.png";
@@ -40,17 +39,22 @@ const LandingPage = ({ onContinue }) => {
</Box>
<Box width="100%">
<Button
level="secondary"
label="Continue with Email"
img={<Mail />}
variant="outlined"
color="info"
onClick={onContinue}
sx={{
width: "100%",
"& svg": {
mr: theme.spacing(4),
"& path": {
stroke: theme.palette.other.icon,
},
},
}}
/>
>
<Mail />
Continue with Email
</Button>
</Box>
<Box maxWidth={400}>
<Typography className="tos-p">
@@ -151,27 +155,29 @@ const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
level="secondary"
label="Back"
animate="slideLeft"
img={<ArrowBackRoundedIcon />}
variant="outlined"
color="info"
onClick={onBack}
sx={{
mb: theme.spacing(6),
px: theme.spacing(8),
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(2),
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
/>
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
level="primary"
label="Continue"
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
/>
>
Continue
</Button>
</Stack>
</Stack>
</>
@@ -219,6 +225,7 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
<>
<Stack
gap={{ xs: theme.spacing(8), sm: theme.spacing(12) }}
position="relative"
textAlign="center"
>
<Box>
@@ -243,39 +250,49 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
level="secondary"
label="Back"
animate="slideLeft"
img={<ArrowBackRoundedIcon />}
variant="outlined"
color="info"
onClick={onBack}
sx={{
mb: theme.spacing(6),
px: theme.spacing(8),
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(2),
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
/>
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
level="primary"
label="Continue"
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.password && true}
sx={{ width: "30%" }}
/>
>
Continue
</Button>
</Stack>
<Box textAlign="center">
<Box
textAlign="center"
sx={{
position: "absolute",
top: "103%",
left: "50%",
transform: "translateX(-50%)"
}}
>
<Typography
className="forgot-p"
display="inline-block"
color={theme.palette.common.main}
color={theme.palette.primary.main}
>
Forgot password?
</Typography>
<Typography
component="span"
color={theme.palette.common.main}
color={theme.palette.primary.main}
ml={theme.spacing(2)}
sx={{ userSelect: "none" }}
onClick={handleNavigate}
@@ -419,7 +436,7 @@ const Login = () => {
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 30,
},
@@ -491,7 +508,7 @@ const Login = () => {
</Typography>
<Typography
component="span"
color={theme.palette.common.main}
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => {
navigate("/register");
@@ -1,10 +1,9 @@
import { Box, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router";
import { useDispatch } from "react-redux";
import { clearAuthState } from "../../Features/Auth/authSlice";
import { clearUptimeMonitorState } from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Button from "../../Components/Button";
import background from "../../assets/Images/background_pattern_decorative.png";
import ConfirmIcon from "../../assets/icons/confirm-icon.svg?react";
import Logo from "../../assets/icons/bwu-icon.svg?react";
@@ -27,7 +26,7 @@ const NewPasswordConfirmed = () => {
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 22,
},
@@ -85,21 +84,23 @@ const NewPasswordConfirmed = () => {
</Typography>
</Box>
<Button
level="primary"
label="Continue"
variant="contained"
color="primary"
onClick={() => navigate("/monitors")}
sx={{
width: "100%",
maxWidth: 400,
}}
/>
>
Continue
</Button>
</Stack>
</Stack>
<Box textAlign="center" p={theme.spacing(12)}>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.common.main}
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={handleNavigate}
sx={{ userSelect: "none" }}
+51 -39
View File
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useDispatch } from "react-redux";
import { credentials } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
@@ -13,11 +13,9 @@ import Logo from "../../../assets/icons/bwu-icon.svg?react";
import Mail from "../../../assets/icons/mail.svg?react";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import Check from "../../../Components/Check/Check";
import Button from "../../../Components/Button";
import Field from "../../../Components/Inputs/Field";
import { networkService } from "../../../main";
import "../index.css";
import { logger } from "../../../Utils/Logger";
/**
* Displays the initial landing page.
@@ -46,17 +44,22 @@ const LandingPage = ({ isSuperAdmin, onSignup }) => {
</Box>
<Box width="100%">
<Button
level="secondary"
label="Sign up with Email"
img={<Mail />}
variant="outlined"
color="info"
onClick={onSignup}
sx={{
width: "100%",
"& svg": {
mr: theme.spacing(4),
"& path": {
stroke: theme.palette.other.icon,
},
},
}}
/>
>
<Mail />
Sign up with Email
</Button>
</Box>
<Box maxWidth={400}>
<Typography className="tos-p">
@@ -180,26 +183,29 @@ const StepOne = ({ form, errors, onSubmit, onChange, onBack }) => {
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
level="secondary"
label="Back"
animate="slideLeft"
img={<ArrowBackRoundedIcon />}
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(8),
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(2),
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
/>
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
level="primary"
label="Continue"
variant="contained"
color="primary"
onClick={onSubmit}
disabled={(errors.firstName || errors.lastName) && true}
sx={{ width: "30%" }}
/>
>
Continue
</Button>
</Stack>
</Stack>
</>
@@ -270,26 +276,29 @@ const StepTwo = ({ form, errors, onSubmit, onChange, onBack }) => {
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
level="secondary"
label="Back"
animate="slideLeft"
img={<ArrowBackRoundedIcon />}
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(8),
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(2),
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
/>
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
level="primary"
label="Continue"
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
/>
>
Continue
</Button>
</Stack>
</Stack>
</>
@@ -443,26 +452,29 @@ const StepThree = ({ form, errors, onSubmit, onChange, onBack }) => {
</Box>
<Stack direction="row" justifyContent="space-between">
<Button
level="secondary"
label="Back"
animate="slideLeft"
img={<ArrowBackRoundedIcon />}
variant="outlined"
color="info"
onClick={onBack}
sx={{
px: theme.spacing(8),
px: theme.spacing(5),
"& svg.MuiSvgIcon-root": {
mr: theme.spacing(2),
mr: theme.spacing(3),
},
}}
props={{ tabIndex: -1 }}
/>
>
<ArrowBackRoundedIcon />
Back
</Button>
<Button
level="primary"
label="Continue"
variant="contained"
color="primary"
onClick={onSubmit}
disabled={errors.email && true}
sx={{ width: "30%" }}
/>
>
Continue
</Button>
</Stack>
</Stack>
</>
@@ -644,7 +656,7 @@ const Register = ({ isSuperAdmin }) => {
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 30,
},
@@ -731,7 +743,7 @@ const Register = ({ isSuperAdmin }) => {
onClick={() => {
navigate("/login");
}}
sx={{ userSelect: "none", color: theme.palette.common.main }}
sx={{ userSelect: "none", color: theme.palette.primary.main }}
>
Log In
</Typography>
+11 -9
View File
@@ -8,11 +8,11 @@ import { useState } from "react";
import { credentials } from "../../Validation/validation";
import { useNavigate } from "react-router-dom";
import Check from "../../Components/Check/Check";
import ButtonSpinner from "../../Components/ButtonSpinner";
import Field from "../../Components/Inputs/Field";
import LockIcon from "../../assets/icons/lock-button-icon.svg?react";
import background from "../../assets/Images/background_pattern_decorative.png";
import Logo from "../../assets/icons/bwu-icon.svg?react";
import LoadingButton from "@mui/lab/LoadingButton";
import "./index.css";
const SetNewPassword = () => {
@@ -109,7 +109,7 @@ const SetNewPassword = () => {
overflow="hidden"
sx={{
"& h1": {
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 600,
fontSize: 24,
},
@@ -266,21 +266,23 @@ const SetNewPassword = () => {
/>
</Stack>
</Box>
<ButtonSpinner
disabled={Object.keys(errors).length !== 0}
isLoading={isLoading}
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
onClick={handleSubmit}
level="primary"
label="Reset password"
disabled={Object.keys(errors).length !== 0}
sx={{ width: "100%", maxWidth: 400 }}
/>
>
Reset password
</LoadingButton>
</Stack>
</Stack>
<Box textAlign="center" p={theme.spacing(12)}>
<Typography display="inline-block">Go back to </Typography>
<Typography
component="span"
color={theme.palette.common.main}
color={theme.palette.primary.main}
ml={theme.spacing(2)}
onClick={() => navigate("/login")}
sx={{ userSelect: "none" }}
@@ -15,14 +15,12 @@ import {
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { networkService } from "../../../main";
import { StatusLabel } from "../../../Components/Label";
import { logger } from "../../../Utils/Logger";
import { useTheme } from "@emotion/react";
const IncidentTable = ({ monitors, selectedMonitor, filter }) => {
const theme = useTheme();
const { authToken, user } = useSelector((state) => state.auth);
+22 -62
View File
@@ -1,38 +1,12 @@
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { ButtonGroup, Stack, Skeleton, Typography } from "@mui/material";
import Button from "../../Components/Button";
import { ButtonGroup, Stack, Typography, Button } from "@mui/material";
import { networkService } from "../../main";
import { useTheme } from "@emotion/react";
import Select from "../../Components/Inputs/Select";
import IncidentTable from "./IncidentTable";
import "./index.css";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<Skeleton variant="rounded" width={150} height={34} />
<Skeleton variant="rounded" width="15%" height={34} />
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ ml: "auto" }}
/>
</Stack>
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</>
);
};
import SkeletonLayout from "./skeleton";
const Incidents = () => {
const theme = useTheme();
@@ -55,11 +29,13 @@ const Incidents = () => {
null,
null,
null,
null,
null,
null
);
// Reduce to a lookup object for 0(1) lookup
if (res.data && res.data.data.length > 0) {
const monitorLookup = res.data.data.reduce((acc, monitor) => {
if (res?.data?.data?.monitors?.length > 0) {
const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => {
acc[monitor._id] = monitor;
return acc;
}, {});
@@ -78,11 +54,7 @@ const Incidents = () => {
};
return (
<Stack
className="incidents"
pt={theme.spacing(21)}
gap={theme.spacing(12)}
>
<Stack className="incidents" pt={theme.spacing(20)} gap={theme.spacing(12)}>
{loading ? (
<SkeletonLayout />
) : (
@@ -114,38 +86,26 @@ const Incidents = () => {
}}
>
<Button
level="secondary"
label="All"
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
sx={{
backgroundColor:
filter === "all"
? theme.palette.background.fill
: theme.palette.background.main,
}}
/>
>
All
</Button>
<Button
level="secondary"
label="Down"
variant="group"
filled={(filter === "down").toString()}
onClick={() => setFilter("down")}
sx={{
backgroundColor:
filter === "down"
? theme.palette.background.fill
: theme.palette.background.main,
}}
/>
>
Down
</Button>
<Button
level="secondary"
label="Cannot Resolve"
variant="group"
filled={(filter === "resolve").toString()}
onClick={() => setFilter("resolve")}
sx={{
backgroundColor:
filter === "resolve"
? theme.palette.background.fill
: theme.palette.background.main,
}}
/>
>
Cannot Resolve
</Button>
</ButtonGroup>
</Stack>
<IncidentTable
+30
View File
@@ -0,0 +1,30 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Stack direction="row" alignItems="center" gap={theme.spacing(6)}>
<Skeleton variant="rounded" width={150} height={34} />
<Skeleton variant="rounded" width="15%" height={34} />
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ ml: "auto" }}
/>
</Stack>
<Skeleton variant="rounded" width="100%" height={300} />
<Skeleton variant="rounded" width="100%" height={100} />
</>
);
};
export default SkeletonLayout;
+6 -5
View File
@@ -1,6 +1,5 @@
import PropTypes from "prop-types";
import { Stack, Typography, Grid } from "@mui/material";
import Button from "../../Components/Button";
import { Stack, Typography, Grid, Button } from "@mui/material";
import { useTheme } from "@emotion/react";
import Discord from "../../assets/icons/discord-icon.svg?react";
import Slack from "../../assets/icons/slack-icon.svg?react";
@@ -47,12 +46,14 @@ const IntegrationsComponent = ({ icon, header, info, onClick }) => {
</Typography>
</Stack>
<Button
label="Add"
level="primary"
variant="contained"
color="primary"
onClick={onClick}
sx={{ alignSelf: "center" }}
disabled={true}
/>
>
Add
</Button>
</Stack>
</Grid>
);
@@ -1,7 +1,6 @@
import { Box, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } from "@mui/material";
import "./index.css";
import { useState } from "react";
import Button from "../../../Components/Button";
import Back from "../../../assets/icons/left-arrow-long.svg?react";
import Select from "../../../Components/Inputs/Select";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
@@ -215,7 +214,7 @@ const CreateNewMaintenanceWindow = () => {
sx={{
width: "fit-content",
fontSize: "var(--env-var-font-size-small)",
borderBottom: `1px dashed ${theme.palette.common.main}`,
borderBottom: `1px dashed ${theme.palette.primary.main}`,
paddingBottom: "4px",
}}
>
@@ -230,18 +229,17 @@ const CreateNewMaintenanceWindow = () => {
<div className="create-maintenance-window">
<Stack gap={theme.spacing(10)}>
<Button
id="btn-back"
variant="contained"
color="secondary"
sx={{
width: "100px",
height: "30px",
gap: "10px",
backgroundColor: theme.palette.background.fill,
color: theme.palette.text.secondary,
}}
label="Back"
level="tertiary"
img={<Back />}
/>
>
<Back />
Back
</Button>
<Box>
<Typography
sx={{
@@ -278,26 +276,19 @@ const CreateNewMaintenanceWindow = () => {
</Stack>
<Stack justifyContent="end" direction="row" marginTop={3}>
<Button
variant="text"
color="info"
sx={{
"&:hover": {
backgroundColor: "transparent",
boxShadow: "none",
},
}}
level="tertiary"
label="Cancel"
/>
<Button
sx={{
"&:hover": {
backgroundColor: "#1570EF",
boxShadow: "none",
},
}}
level="primary"
label="Create"
onClick={handleSubmit}
/>
>
Cancel
</Button>
<Button variant="contained" color="primary" onClick={handleSubmit}>
Create
</Button>
</Stack>
</Stack>
</div>
+67 -45
View File
@@ -2,7 +2,7 @@ import { useNavigate, useParams } from "react-router";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { useEffect, useState } from "react";
import { Box, Modal, Stack, Typography } from "@mui/material";
import { Box, Button, Modal, Stack, Typography } from "@mui/material";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
@@ -14,16 +14,17 @@ import {
getUptimeMonitorsByTeamId,
deleteUptimeMonitor,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Button from "../../../Components/Button";
import Field from "../../../Components/Inputs/Field";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import PulseDot from "../../../Components/Animated/PulseDot";
import "./index.css";
import SkeletonLayout from "./skeleton";
import ButtonSpinner from "../../../Components/ButtonSpinner";
import LoadingButton from "@mui/lab/LoadingButton";
import "./index.css";
/**
* Parses a URL string and returns a URL object.
*
@@ -52,7 +53,6 @@ const Configure = () => {
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
const { monitorId } = useParams();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
@@ -189,6 +189,18 @@ const Configure = () => {
const parsedUrl = parseUrl(monitor?.url);
const protocol = parsedUrl?.protocol?.replace(":", "") || "";
const statusColor = {
true: theme.palette.success.main,
false: theme.palette.error.main,
undefined: theme.palette.warning.main,
};
const statusMsg = {
true: "Your site is up.",
false: "Your site is down.",
undefined: "Pending...",
};
return (
<Stack className="configure-monitor" gap={theme.spacing(12)}>
{Object.keys(monitor).length === 0 ? (
@@ -210,13 +222,7 @@ const Configure = () => {
flex={1}
>
<Stack direction="row" gap={theme.spacing(2)}>
<PulseDot
color={
monitor?.status
? theme.palette.success.main
: theme.palette.error.main
}
/>
<PulseDot color={statusColor[monitor?.status ?? undefined]} />
<Box>
{parsedUrl?.host ? (
<Typography
@@ -233,13 +239,9 @@ const Configure = () => {
<Typography
component="span"
lineHeight={theme.spacing(12)}
sx={{
color: monitor?.status
? theme.palette.success.main
: theme.palette.error.text,
}}
sx={{ color: statusColor[monitor?.status ?? undefined] }}
>
Your site is {monitor?.status ? "up" : "down"}.
{statusMsg[monitor?.status ?? undefined]}
</Typography>
</Box>
<Box
@@ -248,33 +250,42 @@ const Configure = () => {
ml: "auto",
}}
>
<ButtonSpinner
isLoading={isLoading}
level="tertiary"
label={monitor?.isActive ? "Pause" : "Resume"}
animate="rotate180"
img={<PauseCircleOutlineIcon />}
<LoadingButton
variant="contained"
color="secondary"
loading={isLoading}
sx={{
border: "none",
backgroundColor: theme.palette.background.main,
pl: theme.spacing(4),
pr: theme.spacing(6),
px: theme.spacing(5),
mr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
},
}}
onClick={handlePause}
/>
<ButtonSpinner
isLoading={isLoading}
level="error"
label="Remove"
sx={{
boxShadow: "none",
px: theme.spacing(8),
}}
>
{monitor?.isActive ? (
<>
<PauseCircleOutlineIcon />
Pause
</>
) : (
<>
<PlayCircleOutlineRoundedIcon />
Resume
</>
)}
</LoadingButton>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
sx={{ px: theme.spacing(8) }}
onClick={() => setIsOpen(true)}
/>
>
Remove
</LoadingButton>
</Box>
</Stack>
<ConfigBox>
@@ -381,13 +392,15 @@ const Configure = () => {
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end" mt="auto">
<ButtonSpinner
isLoading={isLoading}
level="primary"
label="Save"
<LoadingButton
variant="contained"
color="primary"
loading={isLoading}
sx={{ px: theme.spacing(12) }}
onClick={handleSubmit}
/>
>
Save
</LoadingButton>
</Stack>
</Stack>
</>
@@ -440,11 +453,20 @@ const Configure = () => {
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
variant="text"
color="info"
onClick={() => setIsOpen(false)}
/>
<Button level="error" label="Delete" onClick={handleRemove} />
>
Cancel
</Button>
<LoadingButton
variant="contained"
color="error"
loading={isLoading}
onClick={handleRemove}
>
Delete
</LoadingButton>
</Stack>
</Stack>
</Modal>
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Box, ButtonGroup, Stack, Typography } from "@mui/material";
import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material";
import { useSelector, useDispatch } from "react-redux";
import { monitorValidation } from "../../../Validation/validation";
import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
@@ -9,7 +9,6 @@ import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../styled";
import Radio from "../../../Components/Inputs/Radio";
import Button from "../../../Components/Button";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
@@ -44,16 +43,16 @@ const CreateMonitor = () => {
const [errors, setErrors] = useState({});
useEffect(() => {
if(monitorId){
if (monitorId) {
const data = monitors.find((monitor) => monitor._id === monitorId);
if (!data) {
console.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found", {replace: true});
navigate("/not-found", { replace: true });
}
const {name, ...rest} = data; //data.name is read-only
if(rest.type === 'http'){
const url = new URL(rest.url)
rest.url = url.host
const { name, ...rest } = data; //data.name is read-only
if (rest.type === "http") {
const url = new URL(rest.url);
rest.url = url.host;
}
rest.name = `${name} (Clone)`;
rest.interval /= MS_PER_MINUTE;
@@ -254,29 +253,19 @@ const CreateMonitor = () => {
{monitor.type === "http" ? (
<ButtonGroup sx={{ ml: "32px" }}>
<Button
level="secondary"
label="HTTPS"
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
sx={{
backgroundColor: https && theme.palette.background.fill,
borderColor: theme.palette.border.dark,
"&:hover": {
borderColor: theme.palette.border.dark,
},
}}
/>
>
HTTPS
</Button>
<Button
level="secondary"
label="HTTP"
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
sx={{
backgroundColor: !https && theme.palette.background.fill,
borderColor: theme.palette.border.dark,
"&:hover": {
borderColor: theme.palette.border.dark,
},
}}
/>
>
HTTP
</Button>
</ButtonGroup>
) : (
""
@@ -376,12 +365,13 @@ const CreateMonitor = () => {
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<Button
id="create-monitor-btn"
level="primary"
label="Create monitor"
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
/>
>
Create monitor
</Button>
</Stack>
</Stack>
</Box>
@@ -0,0 +1,283 @@
import { useTheme } from "@emotion/react";
import {
BarChart,
Bar,
XAxis,
ResponsiveContainer,
Cell,
RadialBarChart,
RadialBar,
} from "recharts";
import { formatDate } from "../../../../Utils/timeUtils";
import { memo, useMemo, useState } from "react";
const CustomLabels = ({
x,
width,
height,
firstDataPoint,
lastDataPoint,
type,
}) => {
let options = {
month: "short",
year: undefined,
hour: undefined,
minute: undefined,
};
if (type === "day") delete options.hour;
return (
<>
<text x={x} y={height} dy={-3} textAnchor="start" fontSize={11}>
{formatDate(new Date(firstDataPoint.time), options)}
</text>
<text x={width} y={height} dy={-3} textAnchor="end" fontSize={11}>
{formatDate(new Date(lastDataPoint.time), options)}
</text>
</>
);
};
export const UpBarChart = memo(({ data, type, onBarHover }) => {
const theme = useTheme();
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
const getColorRange = (uptime) => {
return uptime > 80
? { main: theme.palette.success.main, light: theme.palette.success.light }
: uptime > 50
? { main: theme.palette.warning.main, light: theme.palette.warning.light }
: { main: theme.palette.error.text, light: theme.palette.error.light };
};
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
return (
<ResponsiveContainer width="100%" minWidth={210} height={155}>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalChecks: 0, uptimePercentage: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalChecks"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => {
let { main, light } = getColorRange(entry.uptimePercentage);
return (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index ? main : chartHovered ? light : main
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({
time: null,
totalChecks: 0,
uptimePercentage: 0,
});
}}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
export const DownBarChart = memo(({ data, type, onBarHover }) => {
const theme = useTheme();
const [chartHovered, setChartHovered] = useState(false);
const [hoveredBarIndex, setHoveredBarIndex] = useState(null);
// TODO - REMOVE THIS LATER
const reversedData = useMemo(() => [...data].reverse(), [data]);
return (
<ResponsiveContainer width="100%" minWidth={250} height={155}>
<BarChart
width="100%"
height="100%"
data={reversedData}
onMouseEnter={() => {
setChartHovered(true);
onBarHover({ time: null, totalIncidents: 0 });
}}
onMouseLeave={() => {
setChartHovered(false);
setHoveredBarIndex(null);
onBarHover(null);
}}
>
<XAxis
stroke={theme.palette.border.dark}
height={15}
tick={false}
label={
<CustomLabels
x={0}
y={0}
width="100%"
height="100%"
firstDataPoint={reversedData[0]}
lastDataPoint={reversedData[reversedData.length - 1]}
type={type}
/>
}
/>
<Bar
dataKey="totalIncidents"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{reversedData.map((entry, index) => (
<Cell
key={`cell-${entry.time}`}
fill={
hoveredBarIndex === index
? theme.palette.error.text
: chartHovered
? theme.palette.error.light
: theme.palette.error.text
}
onMouseEnter={() => {
setHoveredBarIndex(index);
onBarHover(entry);
}}
onMouseLeave={() => {
setHoveredBarIndex(null);
onBarHover({ time: null, totalIncidents: 0 });
}}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
});
export const ResponseGaugeChart = ({ data }) => {
const theme = useTheme();
let max = 1000; // max ms
const memoizedData = useMemo(
() => [{ response: max, fill: "transparent", background: false }, ...data],
[data[0].response]
);
let responseTime = Math.floor(memoizedData[1].response);
let responseProps =
responseTime <= 200
? {
category: "Excellent",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 500
? {
category: "Fair",
main: theme.palette.success.main,
bg: theme.palette.success.bg,
}
: responseTime <= 600
? {
category: "Acceptable",
main: theme.palette.warning.main,
bg: theme.palette.warning.bg,
}
: {
category: "Poor",
main: theme.palette.error.text,
bg: theme.palette.error.bg,
};
return (
<ResponsiveContainer width="100%" minWidth={210} height={155}>
<RadialBarChart
width="100%"
height="100%"
cy="89%"
data={memoizedData}
startAngle={180}
endAngle={0}
innerRadius={100}
outerRadius={150}
>
<text x={0} y="100%" dx="5%" dy={-2} textAnchor="start" fontSize={11}>
low
</text>
<text x="100%" y="100%" dx="-3%" dy={-2} textAnchor="end" fontSize={11}>
high
</text>
<text
x="50%"
y="45%"
textAnchor="middle"
dominantBaseline="middle"
fontSize={18}
fontWeight={400}
>
{responseProps.category}
</text>
<text
x="50%"
y="55%"
textAnchor="middle"
dominantBaseline="hanging"
fontSize={25}
>
<tspan fontWeight={600}>{responseTime}</tspan>{" "}
<tspan opacity={0.8}>ms</tspan>
</text>
<RadialBar
background={{ fill: responseProps.bg }}
clockWise
dataKey="response"
stroke="none"
>
<Cell fill="transparent" background={false} barSize={0} />
<Cell fill={responseProps.main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
);
};
+1 -18
View File
@@ -1,18 +1 @@
.monitor-details h1.MuiTypography-root {
font-size: var(--env-var-font-size-large-plus);
font-weight: 600;
}
.monitor-details h2.MuiTypography-root {
font-size: var(--env-var-font-size-large);
}
.monitor-details h2.MuiTypography-root {
font-weight: 600;
}
.monitor-details button.MuiButtonBase-root {
height: var(--env-var-height-2);
line-height: 1;
}
.monitor-details p.MuiTypography-root,
.monitor-details p.MuiTypography-root span.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
}
+403 -268
View File
@@ -1,140 +1,41 @@
import PropTypes from "prop-types";
import { useEffect, useState, useCallback } from "react";
import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import {
Box,
Button,
Popover,
Stack,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { networkService } from "../../../main";
import { logger } from "../../../Utils/Logger";
import {
formatDate,
formatDuration,
formatDurationRounded,
formatDurationSplit,
} from "../../../Utils/timeUtils";
import MonitorDetailsAreaChart from "../../../Components/Charts/MonitorDetailsAreaChart";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "../../../Components/Button";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import CertificateIcon from "../../../assets/icons/certificate.svg?react";
import UptimeIcon from "../../../assets/icons/uptime-icon.svg?react";
import ResponseTimeIcon from "../../../assets/icons/response-time-icon.svg?react";
import AverageResponseIcon from "../../../assets/icons/average-response-icon.svg?react";
import IncidentsIcon from "../../../assets/icons/incidents.svg?react";
import HistoryIcon from "../../../assets/icons/history-icon.svg?react";
import PaginationTable from "./PaginationTable";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import PulseDot from "../../../Components/Animated/PulseDot";
import { StatBox, ChartBox, IconBox } from "./styled";
import { DownBarChart, ResponseGaugeChart, UpBarChart } from "./Charts";
import SkeletonLayout from "./skeleton";
import "./index.css";
const StatBox = ({ title, value }) => {
const theme = useTheme();
return (
<Box
className="stat-box"
flex="20%"
minWidth="100px"
px={theme.spacing(8)}
py={theme.spacing(4)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
>
<Typography
variant="h6"
mb={theme.spacing(2)}
fontSize={14}
fontWeight={500}
color={theme.palette.common.main}
sx={{
"& span": {
color: theme.palette.text.accent,
fontSize: 13,
fontStyle: "italic",
},
}}
>
{title}
</Typography>
<Typography
variant="h4"
fontWeight={500}
fontSize={13}
color={theme.palette.text.secondary}
>
{value}
</Typography>
</Box>
);
};
StatBox.propTypes = {
title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="20%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)}>
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton variant="rounded" width="50%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.spacing(4) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
>
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(8)}
>
<Skeleton
variant="rounded"
width="20%"
height={24}
sx={{ alignSelf: "flex-end" }}
/>
<Skeleton variant="rounded" width="20%" height={34} />
</Stack>
<Box sx={{ height: "200px" }}>
<Skeleton variant="rounded" width="100%" height="100%" />
</Box>
</Box>
<Stack gap={theme.spacing(8)}>
<Skeleton variant="rounded" width="20%" height={24} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={50} />
</Stack>
</Stack>
</>
);
};
/**
* Details page component displaying monitor details and related information.
* @component
@@ -148,6 +49,14 @@ const DetailsPage = ({ isAdmin }) => {
const [certificateExpiry, setCertificateExpiry] = useState("N/A");
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState(null);
const openCertificate = (event) => {
setAnchorEl(event.currentTarget);
};
const closeCertificate = () => {
setAnchorEl(null);
};
const fetchMonitor = useCallback(async () => {
try {
const res = await networkService.getStatsByMonitorId(
@@ -180,7 +89,16 @@ const DetailsPage = ({ isAdmin }) => {
authToken,
monitorId
);
setCertificateExpiry(res?.data?.data?.certificateDate ?? "N/A");
let [month, day, year] = res?.data?.data?.certificateDate.split("/");
const date = new Date(year, month - 1, day);
setCertificateExpiry(
formatDate(date, {
hour: undefined,
minute: undefined,
}) ?? "N/A"
);
} catch (error) {
console.error(error);
}
@@ -188,7 +106,33 @@ const DetailsPage = ({ isAdmin }) => {
fetchCertificate();
}, [authToken, monitorId, monitor]);
const splitDuration = (duration) => {
const { time, format } = formatDurationSplit(duration);
return (
<>
{time}
<Typography component="span">{format}</Typography>
</>
);
};
let loading = Object.keys(monitor).length === 0;
const [hoveredUptimeData, setHoveredUptimeData] = useState(null);
const [hoveredIncidentsData, setHoveredIncidentsData] = useState(null);
const statusColor = {
true: theme.palette.success.main,
false: theme.palette.error.main,
undefined: theme.palette.warning.main,
};
const statusMsg = {
true: "Your site is up.",
false: "Your site is down.",
undefined: "Pending...",
};
return (
<Box className="monitor-details">
{loading ? (
@@ -201,187 +145,378 @@ const DetailsPage = ({ isAdmin }) => {
{ name: "details", path: `/monitors/${monitorId}` },
]}
/>
<Stack gap={theme.spacing(12)} mt={theme.spacing(12)}>
<Stack gap={theme.spacing(10)} mt={theme.spacing(10)}>
<Stack direction="row" gap={theme.spacing(2)}>
<PulseDot
color={
monitor?.status
? theme.palette.success.main
: theme.palette.error.main
}
/>
<Box>
<Typography
component="h1"
fontSize={22}
fontWeight={500}
color={theme.palette.text.primary}
lineHeight={1}
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
{monitor.name}
</Typography>
<Typography
mt={theme.spacing(4)}
color={theme.palette.text.tertiary}
<Stack
direction="row"
alignItems="flex-end"
gap={theme.spacing(2)}
>
<Typography
component="span"
sx={{
color: monitor?.status
? theme.palette.success.main
: theme.palette.success.text,
<Tooltip
title={statusMsg[monitor?.status ?? undefined]}
disableInteractive
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -8],
},
},
],
},
}}
>
Your site is {monitor?.status ? "up" : "down"}.
</Typography>{" "}
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
<Box>
<PulseDot
color={statusColor[monitor?.status ?? undefined]}
/>
</Box>
</Tooltip>
<Typography
component="h2"
color={theme.palette.text.secondary}
>
{monitor.url?.replace(/^https?:\/\//, "") || "..."}
</Typography>
<Typography
ml={theme.spacing(6)}
lineHeight="20px"
fontSize={12}
position="relative"
color={theme.palette.text.tertiary}
sx={{
"&:before": {
position: "absolute",
content: `""`,
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.palette.text.tertiary,
opacity: 0.8,
left: -9,
top: "42%",
},
}}
>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Box>
{isAdmin && (
<Button
level="tertiary"
label="Configure"
animate="rotate90"
img={
<SettingsIcon
style={{
minWidth: theme.spacing(10),
minHeight: theme.spacing(10),
}}
/>
}
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
<Stack
direction="row"
height={34}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<IconBox
mr={theme.spacing(4)}
onClick={openCertificate}
sx={{
ml: "auto",
alignSelf: "flex-end",
backgroundColor: theme.palette.background.fill,
px: theme.spacing(6),
"& svg": {
mr: theme.spacing(3),
cursor: "pointer",
}}
>
<CertificateIcon />
</IconBox>
<Popover
id="certificate-dropdown"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={closeCertificate}
disableScrollLock
marginThreshold={null}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
slotProps={{
paper: {
sx: {
mt: theme.spacing(4),
py: theme.spacing(2),
px: theme.spacing(4),
width: 140,
backgroundColor: theme.palette.background.accent,
},
},
}}
/>
)}
>
<Typography fontSize={12} color={theme.palette.text.tertiary}>
Certificate Expiry
</Typography>
<Typography
component="span"
fontSize={13}
color={theme.palette.text.primary}
>
{certificateExpiry}
</Typography>
</Popover>
{isAdmin && (
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/monitors/configure/${monitorId}`)}
sx={{
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.text.tertiary,
},
},
}}
>
<SettingsIcon /> Configure
</Button>
)}
</Stack>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
flexWrap="wrap"
>
<Stack direction="row" gap={theme.spacing(8)}>
<StatBox
title="Currently up for"
value={formatDuration(monitor?.uptimeDuration)}
/>
<StatBox
title="Last check"
value={`${formatDurationRounded(monitor?.lastChecked)} ago`}
/>
<StatBox title="Incidents" value={monitor?.incidents} />
<StatBox title="Certificate Expiry" value={certificateExpiry} />
<StatBox
title="Latest response time"
value={monitor?.latestResponseTime}
/>
<StatBox
title={
<>
Avg. Response Time{" "}
<Typography component="span">(24-hr)</Typography>
</>
sx={
monitor?.status === undefined
? {
backgroundColor: theme.palette.warning.light,
borderColor: theme.palette.warning.border,
"& h2": { color: theme.palette.warning.main },
}
: monitor?.status
? {
backgroundColor: theme.palette.success.bg,
borderColor: theme.palette.success.light,
"& h2": { color: theme.palette.success.main },
}
: {
backgroundColor: theme.palette.error.bg,
borderColor: theme.palette.error.light,
"& h2": { color: theme.palette.error.main },
}
}
value={parseFloat(monitor?.avgResponseTime24hours)
.toFixed(2)
.replace(/\.?0+$/, "")}
/>
<StatBox
title={
<>
Uptime <Typography component="span">(24-hr)</Typography>
</>
}
value={`${parseFloat(monitor?.uptime24Hours)
.toFixed(2)
.replace(/\.?0+$/, "")}%`}
/>
<StatBox
title={
<>
Uptime <Typography component="span">(30-day)</Typography>
</>
}
value={`${parseFloat(monitor?.uptime30Days)
.toFixed(2)
.replace(/\.?0+$/, "")}%`}
/>
>
<Typography component="h2">active for</Typography>
<Typography>
{splitDuration(monitor?.uptimeDuration)}
</Typography>
</StatBox>
<StatBox>
<Typography component="h2">last check</Typography>
<Typography>
{splitDuration(monitor?.lastChecked)}
<Typography component="span">ago</Typography>
</Typography>
</StatBox>
<StatBox>
<Typography component="h2">last response time</Typography>
<Typography>
{monitor?.latestResponseTime}
<Typography component="span">ms</Typography>
</Typography>
</StatBox>
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-end"
gap={theme.spacing(4)}
mb={theme.spacing(8)}
>
<Typography
component="h2"
alignSelf="flex-end"
color={theme.palette.text.secondary}
>
Response Times
<Typography fontSize={12} color={theme.palette.text.tertiary}>
Showing statistics for past{" "}
{dateRange === "day"
? "24 hours"
: dateRange === "week"
? "7 days"
: "30 days"}
.
</Typography>
<ButtonGroup
<ButtonGroup sx={{ height: 32 }}>
<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>
</Stack>
<Stack direction="row" flexWrap="wrap" gap={theme.spacing(8)}>
<ChartBox>
<Stack>
<IconBox>
<UptimeIcon />
</IconBox>
<Typography component="h2">Uptime</Typography>
</Stack>
<Stack justifyContent="space-between">
<Box position="relative">
<Typography>Total Checks</Typography>
<Typography component="span">
{hoveredUptimeData !== null
? hoveredUptimeData.totalChecks
: monitor?.periodTotalChecks}
</Typography>
{hoveredUptimeData !== null &&
hoveredUptimeData.time !== null && (
<Typography
component="h5"
position="absolute"
top="100%"
fontSize={11}
color={theme.palette.text.tertiary}
>
{formatDate(new Date(hoveredUptimeData.time), {
month: "short",
year: undefined,
minute: undefined,
hour: dateRange === "day" ? "numeric" : undefined,
})}
</Typography>
)}
</Box>
<Box>
<Typography>Uptime Percentage</Typography>
<Typography component="span">
{hoveredUptimeData !== null
? Math.floor(
hoveredUptimeData.uptimePercentage * 10
) / 10
: Math.floor(monitor?.periodUptime * 10) / 10}
<Typography component="span">%</Typography>
</Typography>
</Box>
</Stack>
<UpBarChart
data={monitor?.aggregateData}
type={dateRange}
onBarHover={setHoveredUptimeData}
/>
</ChartBox>
<ChartBox>
<Stack>
<IconBox>
<IncidentsIcon />
</IconBox>
<Typography component="h2">Incidents</Typography>
</Stack>
<Box position="relative">
<Typography>Total Incidents</Typography>
<Typography component="span">
{hoveredIncidentsData !== null
? hoveredIncidentsData.totalIncidents
: monitor?.periodIncidents}
</Typography>
{hoveredIncidentsData !== null &&
hoveredIncidentsData.time !== null && (
<Typography
component="h5"
position="absolute"
top="100%"
fontSize={11}
color={theme.palette.text.tertiary}
>
{formatDate(new Date(hoveredIncidentsData.time), {
month: "short",
year: undefined,
minute: undefined,
hour: dateRange === "day" ? "numeric" : undefined,
})}
</Typography>
)}
</Box>
<DownBarChart
data={monitor?.aggregateData}
type={dateRange}
onBarHover={setHoveredIncidentsData}
/>
</ChartBox>
<ChartBox justifyContent="space-between">
<Stack>
<IconBox>
<AverageResponseIcon />
</IconBox>
<Typography component="h2">
Average Response Time
</Typography>
</Stack>
<ResponseGaugeChart
data={[{ response: monitor?.periodAvgResponseTime }]}
/>
</ChartBox>
<ChartBox
sx={{
"& .MuiButtonBase-root": {
borderColor: theme.palette.border.light,
"& tspan": {
fontSize: 11,
},
}}
>
<Button
level="secondary"
label="Day"
onClick={() => setDateRange("day")}
sx={{
backgroundColor:
dateRange === "day" && theme.palette.background.fill,
}}
<Stack>
<IconBox>
<ResponseTimeIcon />
</IconBox>
<Typography component="h2">Response Times</Typography>
</Stack>
<MonitorDetailsAreaChart
checks={[...monitor.checks].reverse()}
/>
<Button
level="secondary"
label="Week"
onClick={() => setDateRange("week")}
sx={{
backgroundColor:
dateRange === "week" && theme.palette.background.fill,
}}
</ChartBox>
<ChartBox
gap={theme.spacing(8)}
sx={{
flex: "100%",
height: "fit-content",
"& nav": { mt: theme.spacing(12) },
}}
>
<Stack mb={theme.spacing(8)}>
<IconBox>
<HistoryIcon />
</IconBox>
<Typography
component="h2"
color={theme.palette.text.secondary}
>
History
</Typography>
</Stack>
<PaginationTable
monitorId={monitorId}
dateRange={dateRange}
/>
<Button
level="secondary"
label="Month"
onClick={() => setDateRange("month")}
sx={{
backgroundColor:
dateRange === "month" && theme.palette.background.fill,
}}
/>
</ButtonGroup>
</ChartBox>
</Stack>
<Box
p={theme.spacing(10)}
pb={theme.spacing(2)}
backgroundColor={theme.palette.background.main}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
sx={{ height: "250px" }}
>
<MonitorDetailsAreaChart
checks={[...monitor.checks].reverse()}
/>
</Box>
</Box>
<Stack gap={theme.spacing(8)}>
<Typography component="h2" color={theme.palette.text.secondary}>
History
</Typography>
<PaginationTable monitorId={monitorId} dateRange={dateRange} />
</Stack>
</Stack>
</>
)}
@@ -0,0 +1,73 @@
import { Box, Skeleton, Stack, useTheme } from "@mui/material";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="20%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)}>
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton variant="rounded" width="50%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={18}
sx={{ mt: theme.spacing(4) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack
direction="row"
justifyContent="space-between"
gap={theme.spacing(12)}
>
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
<Skeleton variant="rounded" width="100%" height={80} />
</Stack>
<Box>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(8)}
>
<Skeleton
variant="rounded"
width="20%"
height={24}
sx={{ alignSelf: "flex-end" }}
/>
<Skeleton variant="rounded" width="20%" height={34} />
</Stack>
<Box sx={{ height: "200px" }}>
<Skeleton variant="rounded" width="100%" height="100%" />
</Box>
</Box>
<Stack gap={theme.spacing(8)}>
<Skeleton variant="rounded" width="20%" height={24} />
<Skeleton variant="rounded" width="100%" height={200} />
<Skeleton variant="rounded" width="100%" height={50} />
</Stack>
</Stack>
</>
);
};
export default SkeletonLayout;
@@ -0,0 +1,95 @@
import { Box, Stack, styled } from "@mui/material";
export const ChartBox = styled(Stack)(({ theme }) => ({
flex: "1 30%",
gap: theme.spacing(8),
height: 300,
minWidth: 250,
padding: theme.spacing(8),
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
backgroundColor: theme.palette.background.main,
"& h2": {
color: theme.palette.text.secondary,
fontSize: 15,
fontWeight: 500,
},
"& .MuiBox-root:not(.area-tooltip) p": {
color: theme.palette.text.tertiary,
fontSize: 13,
},
"& .MuiBox-root > span": {
color: theme.palette.text.primary,
fontSize: 20,
"& span": {
opacity: 0.8,
marginLeft: 2,
fontSize: 15,
},
},
"& .MuiStack-root": {
flexDirection: "row",
gap: theme.spacing(6),
},
"& .MuiStack-root:first-of-type": {
alignItems: "center",
},
"& tspan, & text": {
fill: theme.palette.text.tertiary,
},
"& path": {
transition: "fill 300ms ease",
},
}));
export const IconBox = styled(Box)(({ theme }) => ({
height: 34,
minWidth: 34,
width: 34,
position: "relative",
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
borderRadius: 4,
backgroundColor: theme.palette.background.accent,
"& svg": {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 20,
height: 20,
"& path": {
stroke: theme.palette.text.tertiary,
},
},
}));
export const StatBox = styled(Box)(({ theme }) => ({
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
minWidth: 200,
width: 225,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
borderRadius: 4,
backgroundColor: theme.palette.background.main,
"& h2": {
fontSize: 13,
fontWeight: 500,
color: theme.palette.text.secondary,
textTransform: "uppercase",
},
"& p": {
fontSize: 18,
color: theme.palette.text.primary,
marginTop: theme.spacing(2),
"& span": {
color: theme.palette.text.tertiary,
marginLeft: theme.spacing(2),
fontSize: 15,
},
},
}));
@@ -0,0 +1,352 @@
import PropTypes from "prop-types";
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Box,
TablePagination,
Stack,
Typography,
Button,
} from "@mui/material";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import { setRowsPerPage } from "../../../../Features/UI/uiSlice";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import { logger } from "../../../../Utils/Logger";
import Host from "../host";
import { StatusLabel } from "../../../../Components/Label";
import { jwtDecode } from "jwt-decode";
import { useDispatch, useSelector } from "react-redux";
import { networkService } from "../../../../main";
import { useTheme } from "@emotion/react";
import BarChart from "../../../../Components/Charts/BarChart";
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 SelectorVertical from "../../../../assets/icons/selector-vertical.svg?react";
import ActionsMenu from "../actionsMenu";
/**
* 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.
*/
const TablePaginationActions = (props) => {
const { count, page, rowsPerPage, onPageChange } = props;
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));
};
return (
<Box sx={{ flexShrink: 0, ml: "24px" }}>
<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>
);
};
TablePaginationActions.propTypes = {
count: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
const MonitorTable = ({ isAdmin }) => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const { rowsPerPage } = useSelector((state) => state.ui.monitors);
const [page, setPage] = useState(0);
const [monitors, setMonitors] = useState([]);
const [monitorCount, setMonitorCount] = useState(0);
const authState = useSelector((state) => state.auth);
const [updateTrigger, setUpdateTrigger] = useState(false);
const handleActionMenuDelete = () => {
setUpdateTrigger((prev) => !prev);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "monitors",
})
);
setPage(0);
};
useEffect(() => {
const fetchPage = async () => {
try {
const { authToken } = authState;
const user = jwtDecode(authToken);
const res = await networkService.getMonitorsByTeamId(
authToken,
user.teamId,
25,
["http", "ping"],
null,
"desc",
true,
page,
rowsPerPage
);
setMonitors(res?.data?.data?.monitors ?? []);
setMonitorCount(res?.data?.data?.monitorCount ?? 0);
} catch (error) {
logger.error(error);
}
};
fetchPage();
}, [updateTrigger, authState, page, rowsPerPage]);
/**
* Helper function to calculate the range of displayed rows.
* @returns {string}
*/
const getRange = () => {
let start = page * rowsPerPage + 1;
let end = Math.min(page * rowsPerPage + rowsPerPage, monitorCount);
return `${start} - ${end}`;
};
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Host</TableCell>
<TableCell>
{" "}
<Box width="max-content">
Status
<span>
<ArrowDownwardRoundedIcon />
</span>
</Box>
</TableCell>
<TableCell>Response Time</TableCell>
<TableCell>Type</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{monitors.map((monitor) => {
let uptimePercentage = "";
let percentageColor = theme.palette.percentage.uptimeExcellent;
// Determine uptime percentage and color based on the monitor's uptimePercentage value
if (monitor.uptimePercentage !== undefined) {
uptimePercentage =
monitor.uptimePercentage === 0
? "0"
: (monitor.uptimePercentage * 100).toFixed(2);
percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.percentage.uptimePoor
: monitor.uptimePercentage < 0.5
? theme.palette.percentage.uptimeFair
: monitor.uptimePercentage < 0.75
? theme.palette.percentage.uptimeGood
: theme.palette.percentage.uptimeExcellent;
}
const params = {
url: monitor.url,
title: monitor.name,
percentage: uptimePercentage,
percentageColor,
status:
monitor.status === undefined
? "pending"
: monitor.status === true
? "up"
: "down",
};
return (
<TableRow
key={monitor._id}
sx={{
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
}}
onClick={() => {
navigate(`/monitors/${monitor._id}`);
}}
>
<TableCell>
<Host key={monitor._id} params={params} />
</TableCell>
<TableCell>
<StatusLabel
status={params.status}
text={params.status}
customStyles={{ textTransform: "capitalize" }}
/>
</TableCell>
<TableCell>
<BarChart checks={monitor.checks.slice().reverse()} />
</TableCell>
<TableCell>
<span style={{ textTransform: "uppercase" }}>
{monitor.type}
</span>
</TableCell>
<TableCell>
<ActionsMenu
monitor={monitor}
isAdmin={isAdmin}
updateCallback={handleActionMenuDelete}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
px={theme.spacing(4)}
sx={{
"& p": {
color: theme.palette.text.tertiary,
},
}}
>
<Typography px={theme.spacing(2)} fontSize={12} sx={{ opacity: 0.7 }}>
Showing {getRange()} of {monitorCount} monitor(s)
</Typography>
<TablePagination
component="div"
count={monitorCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 15, 25]}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
labelRowsPerPage="Rows per page"
labelDisplayedRows={({ page, count }) =>
`Page ${page + 1} of ${Math.max(0, Math.ceil(count / rowsPerPage))}`
}
slotProps={{
select: {
MenuProps: {
keepMounted: true,
PaperProps: {
className: "pagination-dropdown",
sx: {
mt: 0,
mb: theme.spacing(2),
},
},
transformOrigin: { vertical: "bottom", horizontal: "left" },
anchorOrigin: { vertical: "top", horizontal: "left" },
sx: { mt: theme.spacing(-2) },
},
inputProps: { id: "pagination-dropdown" },
IconComponent: SelectorVertical,
sx: {
ml: theme.spacing(4),
mr: theme.spacing(12),
minWidth: theme.spacing(20),
textAlign: "left",
"&.Mui-focused > div": {
backgroundColor: theme.palette.background.main,
},
},
},
}}
sx={{
mt: theme.spacing(6),
color: theme.palette.text.secondary,
"& svg path": {
stroke: theme.palette.text.tertiary,
strokeWidth: 1.3,
},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.border.light,
borderRadius: theme.shape.borderRadius,
},
}}
/>
</Stack>
</>
);
};
MonitorTable.propTypes = {
isAdmin: PropTypes.bool,
};
export default MonitorTable;
+15 -14
View File
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import Arrow from "../../../assets/icons/top-right-arrow.svg?react";
import background from "../../../assets/Images/background-grid.svg";
import Background from "../../../assets/Images/background-grid.svg?react";
import ClockSnooze from "../../../assets/icons/clock-snooze.svg?react";
const StatusBox = ({ title, value }) => {
@@ -48,26 +48,27 @@ const StatusBox = ({ title, value }) => {
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
px={theme.spacing(12)}
py={theme.spacing(8)}
p={theme.spacing(8)}
overflow="hidden"
sx={{
"&:hover": {
backgroundColor: theme.palette.background.accent,
},
"&:after": {
position: "absolute",
content: `""`,
backgroundImage: `url(${background})`,
width: "400px",
height: "200px",
top: "-10%",
left: "5%",
zIndex: 10000,
pointerEvents: "none",
},
}}
>
<Box
sx={{
position: "absolute",
top: "-10%",
left: "5%",
pointerEvents: "none",
"& svg g g:last-of-type path": {
stroke: theme.palette.other.grid,
},
}}
>
<Background />
</Box>
<Box
textTransform="uppercase"
fontSize={15}
+16 -19
View File
@@ -4,6 +4,7 @@ import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { createToast } from "../../../Utils/toastUtils";
import {
Button,
IconButton,
Menu,
MenuItem,
@@ -16,10 +17,9 @@ import {
getUptimeMonitorsByTeamId,
} from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import Settings from "../../../assets/icons/settings-bold.svg?react";
import Button from "../../../Components/Button";
import PropTypes from "prop-types";
const ActionsMenu = ({ monitor, isAdmin }) => {
const ActionsMenu = ({ monitor, isAdmin, updateCallback }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
@@ -37,6 +37,7 @@ const ActionsMenu = ({ monitor, isAdmin }) => {
if (action.meta.requestStatus === "fulfilled") {
setIsOpen(false); // close modal
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
updateCallback();
createToast({ body: "Monitor deleted successfully." });
} else {
createToast({ body: "Failed to delete monitor." });
@@ -93,17 +94,8 @@ const ActionsMenu = ({ monitor, isAdmin }) => {
slotProps={{
paper: {
sx: {
"& ul": {
p: theme.spacing(2.5),
minWidth: "100px",
},
"& li": {
color: theme.palette.text.secondary,
fontSize: 13,
px: theme.spacing(4),
py: theme.spacing(2.5),
borderRadius: theme.shape.borderRadius,
},
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.text,
},
@@ -206,21 +198,25 @@ const ActionsMenu = ({ monitor, isAdmin }) => {
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
variant="text"
color="info"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
/>
>
Cancel
</Button>
<Button
level="error"
label="Delete"
variant="contained"
color="error"
onClick={(e) => {
e.stopPropagation(e);
handleRemove(e);
}}
/>
>
Delete
</Button>
</Stack>
</Stack>
</Modal>
@@ -235,6 +231,7 @@ ActionsMenu.propTypes = {
type: PropTypes.string,
}).isRequired,
isAdmin: PropTypes.bool,
updateCallback: PropTypes.func,
};
export default ActionsMenu;
+6 -5
View File
@@ -1,6 +1,5 @@
import { Stack, Typography } from "@mui/material";
import { Button, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import Button from "../../../Components/Button";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
@@ -25,13 +24,15 @@ const Fallback = ({ isAdmin }) => {
</Typography>
{isAdmin && (
<Button
level="primary"
label="Create your first monitor"
variant="contained"
color="primary"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ mt: theme.spacing(12) }}
/>
>
Create your first monitor
</Button>
)}
</Stack>
);
+2 -1
View File
@@ -53,7 +53,8 @@ Host.propTypes = {
params: PropTypes.shape({
title: PropTypes.string,
percentageColor: PropTypes.string,
percentage: PropTypes.number,
percentage: PropTypes.string,
url: PropTypes.string,
}).isRequired,
};
+38 -85
View File
@@ -3,16 +3,15 @@ import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { getUptimeMonitorsByTeamId } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { useNavigate } from "react-router-dom";
import Button from "../../../Components/Button";
import { useTheme } from "@emotion/react";
import BasicTable from "../../../Components/BasicTable";
import { Box, Stack, Typography } from "@mui/material";
import { Box, Button, Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import SkeletonLayout from "./skeleton";
import Fallback from "./fallback";
import StatusBox from "./StatusBox";
import { buildData } from "./monitorData";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import Greeting from "../../../Utils/greeting";
import MonitorTable from "./MonitorTable";
const Monitors = ({ isAdmin }) => {
const theme = useTheme();
@@ -24,43 +23,11 @@ const Monitors = ({ isAdmin }) => {
useEffect(() => {
dispatch(getUptimeMonitorsByTeamId(authState.authToken));
}, [authState.authToken, dispatch]);
const monitorStats = monitorState.monitors.reduce(
(acc, monitor) => {
if (monitor.isActive === false) {
acc["paused"] += 1;
} else if (monitor.status === true) {
acc["up"] += 1;
} else {
acc["down"] += 1;
}
return acc;
},
{ paused: 0, up: 0, down: 0 }
);
const data = buildData(monitorState.monitors, isAdmin, navigate);
let loading = monitorState.isLoading && monitorState.monitors.length === 0;
const now = new Date();
const hour = now.getHours();
let greeting = "";
let emoji = "";
if (hour < 12) {
greeting = "morning";
emoji = "🌅";
} else if (hour < 18) {
greeting = "afternoon";
emoji = "🌞";
} else {
greeting = "evening";
emoji = "🌙";
}
let loading =
monitorState?.isLoading &&
monitorState?.monitorsSummary?.monitors?.length === 0;
return (
<Stack className="monitors" gap={theme.spacing(12)}>
<Stack className="monitors" gap={theme.spacing(8)}>
{loading ? (
<SkeletonLayout />
) : (
@@ -73,67 +40,52 @@ const Monitors = ({ isAdmin }) => {
alignItems="center"
mt={theme.spacing(5)}
>
<Box>
<Typography
component="h1"
lineHeight={1}
color={theme.palette.text.primary}
>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.text.secondary}
>
Good {greeting},{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
fontWeight="inherit"
>
{authState.user.firstName} {emoji}
</Typography>
</Typography>
<Typography
sx={{ opacity: 0.8 }}
lineHeight={1}
fontWeight={300}
color={theme.palette.text.secondary}
>
Heres an overview of your uptime monitors.
</Typography>
</Box>
{monitorState.monitors?.length !== 0 && (
<Greeting type="uptime" />
{monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<Button
level="primary"
label="Create monitor"
variant="contained"
color="primary"
onClick={() => {
navigate("/monitors/create");
}}
sx={{ fontWeight: 500 }}
/>
>
Create monitor
</Button>
)}
</Stack>
</Box>
{isAdmin && monitorState.monitors?.length === 0 && (
{isAdmin && monitorState?.monitorsSummary?.monitors?.length === 0 && (
<Fallback isAdmin={isAdmin} />
)}
{monitorState.monitors?.length !== 0 && (
{monitorState?.monitorsSummary?.monitors?.length !== 0 && (
<>
<Stack
gap={theme.spacing(12)}
gap={theme.spacing(8)}
direction="row"
justifyContent="space-between"
>
<StatusBox title="up" value={monitorStats.up} />
<StatusBox title="down" value={monitorStats.down} />
<StatusBox title="paused" value={monitorStats.paused} />
<StatusBox
title="up"
value={monitorState?.monitorsSummary?.monitorCounts?.up ?? 0}
/>
<StatusBox
title="down"
value={
monitorState?.monitorsSummary?.monitorCounts?.down ?? 0
}
/>
<StatusBox
title="paused"
value={
monitorState?.monitorsSummary?.monitorCounts?.paused ?? 0
}
/>
</Stack>
<Box
flex={1}
px={theme.spacing(16)}
py={theme.spacing(12)}
p={theme.spacing(10)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
@@ -142,7 +94,7 @@ const Monitors = ({ isAdmin }) => {
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(12)}
mb={theme.spacing(8)}
>
<Typography
component="h2"
@@ -158,11 +110,12 @@ const Monitors = ({ isAdmin }) => {
borderColor={theme.palette.border.light}
backgroundColor={theme.palette.background.accent}
>
{monitorState.monitors.length}
{monitorState?.monitorsSummary?.monitors?.monitors
?.length || 0}
</Box>
{/* TODO - add search bar */}
</Stack>
<BasicTable data={data} paginated={true} table={"monitors"} />
<MonitorTable isAdmin={isAdmin} />
</Box>
</>
)}
+37 -7
View File
@@ -27,22 +27,52 @@ const data = {
rows: [],
};
/**
* Builds table data for a list of monitors.
*
* @param {Array} monitors - An array of monitor objects containing information about each monitor.
* @param {boolean} isAdmin - Flag indicating if the current user is an admin.
* @param {Function} navigate - A function to navigate to the monitor detail page.
* @returns {Object} The data structure containing columns and rows for the table.
*/
export const buildData = (monitors, isAdmin, navigate) => {
const theme = useTheme();
data.rows = monitors.map((monitor, idx) => {
let uptimePercentage = "";
let percentageColor = theme.palette.percentage.uptimeExcellent;
// Determine uptime percentage and color based on the monitor's uptimePercentage value
if (monitor.uptimePercentage !== undefined) {
uptimePercentage =
monitor.uptimePercentage === 0
? "0"
: (monitor.uptimePercentage * 100).toFixed(2);
percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.percentage.uptimePoor
: monitor.uptimePercentage < 0.5
? theme.palette.percentage.uptimeFair
: monitor.uptimePercentage < 0.75
? theme.palette.percentage.uptimeGood
: theme.palette.percentage.uptimeExcellent;
}
const params = {
url: monitor.url,
title: monitor.name,
percentage: 100,
percentageColor:
monitor.status === true
? theme.palette.success.main
: theme.palette.error.text,
status: monitor.status === true ? "up" : "down",
percentage: uptimePercentage,
percentageColor,
status:
monitor.status === undefined
? "pending"
: monitor.status === true
? "up"
: "down",
};
// Reverse checks so latest check is on the right
// Reverse checks so the latest check is on the right
const reversedChecks = monitor.checks.slice().reverse();
return {
+6 -5
View File
@@ -1,8 +1,7 @@
import Button from "../../Components/Button";
import React from "react";
import PropTypes from "prop-types";
import NotFoundSvg from "../../../src/assets/Images/sushi_404.svg";
import { Stack, Typography } from "@mui/material";
import { Button, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router";
import { useTheme } from "@emotion/react";
@@ -48,11 +47,13 @@ const NotFound = ({ title = DefaultValue.title, desc = DefaultValue.desc }) => {
</Typography>
<Typography fontSize={13}>{desc}</Typography>
<Button
label="Go to the main dashboard"
level="primary"
variant="contained"
color="primary"
sx={{ mt: theme.spacing(10) }}
onClick={() => navigate("/")}
/>
>
Go to the main dashboard
</Button>
</Stack>
</Stack>
);
+88 -86
View File
@@ -1,80 +1,36 @@
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { Box, Modal, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Button, Modal, Stack, Typography } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router";
import {
deletePageSpeed,
getPagespeedMonitorById,
getPageSpeedByTeamId,
updatePageSpeed,
pausePageSpeed,
} from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import Button from "../../../Components/Button";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Checkbox from "../../../Components/Inputs/Checkbox";
import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import PulseDot from "../../../Components/Animated/PulseDot";
import LoadingButton from "@mui/lab/LoadingButton";
import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded";
import SkeletonLayout from "./skeleton";
import "./index.css";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)} maxWidth="1000px">
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.spacing(4) }}
/>
<Skeleton variant="rounded" width="50%" height={18} />
</Box>
<Stack
direction="row"
gap={theme.spacing(6)}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton variant="rounded" width={100} height={34} />
<Skeleton variant="rounded" width={100} height={34} />
</Stack>
</Stack>
<Skeleton variant="rounded" width="100%" height={500} />
<Stack direction="row" justifyContent="flex-end">
<Skeleton variant="rounded" width="15%" height={34} />
</Stack>
</Stack>
</>
);
};
const PageSpeedConfigure = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const MS_PER_MINUTE = 60000;
const { authToken } = useSelector((state) => state.auth);
const { monitors } = useSelector((state) => state.pageSpeedMonitors);
const { isLoading } = useSelector((state) => state.pageSpeedMonitors);
const { monitorId } = useParams();
const [monitor, setMonitor] = useState({});
const [errors, setErrors] = useState({});
@@ -90,15 +46,25 @@ const PageSpeedConfigure = () => {
];
useEffect(() => {
const data = monitors.find((monitor) => monitor._id === monitorId);
if (!data) {
logger.error("Error fetching pagespeed monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
setMonitor({
...data,
});
}, [monitorId, monitors, navigate]);
const fetchMonitor = async () => {
try {
const action = await dispatch(
getPagespeedMonitorById({ authToken, monitorId })
);
if (getPagespeedMonitorById.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (getPagespeedMonitorById.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error fetching monitor of id: " + monitorId);
navigate("/not-found", { replace: true });
}
};
fetchMonitor();
}, [dispatch, authToken, monitorId, navigate]);
const handleChange = (event, id) => {
let { value } = event.target;
@@ -120,6 +86,21 @@ const PageSpeedConfigure = () => {
});
};
const handlePause = async () => {
try {
const action = await dispatch(pausePageSpeed({ authToken, monitorId }));
if (pausePageSpeed.fulfilled.match(action)) {
const monitor = action.payload.data;
setMonitor(monitor);
} else if (pausePageSpeed.rejected.match(action)) {
throw new Error(action.error.message);
}
} catch (error) {
logger.error("Error pausing monitor: " + monitorId);
createToast({ body: "Failed to pause monitor" });
}
};
const handleSave = async (event) => {
event.preventDefault();
const action = await dispatch(
@@ -144,11 +125,9 @@ const PageSpeedConfigure = () => {
}
};
let loading = Object.keys(monitor).length === 0;
return (
<Stack className="configure-pagespeed" gap={theme.spacing(12)}>
{loading ? (
{Object.keys(monitor).length === 0 ? (
<SkeletonLayout />
) : (
<>
@@ -196,30 +175,46 @@ const PageSpeedConfigure = () => {
</Typography>
</Box>
<Box alignSelf="flex-end" ml="auto">
<Button
level="tertiary"
label="Pause"
animate="rotate180"
img={<PauseCircleOutlineIcon />}
<LoadingButton
onClick={handlePause}
loading={isLoading}
variant="contained"
color="secondary"
sx={{
backgroundColor: theme.palette.background.fill,
pl: theme.spacing(4),
pr: theme.spacing(6),
"& svg": {
mr: theme.spacing(2),
"& path": {
stroke: theme.palette.other.icon,
strokeWidth: 0.1,
},
},
}}
/>
<Button
level="error"
label="Remove"
>
{monitor?.isActive ? (
<>
<PauseCircleOutlineIcon />
Pause
</>
) : (
<>
<PlayCircleOutlineRoundedIcon />
Resume
</>
)}
</LoadingButton>
<LoadingButton
loading={isLoading}
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{
boxShadow: "none",
px: theme.spacing(8),
ml: theme.spacing(6),
}}
onClick={() => setIsOpen(true)}
/>
>
Remove
</LoadingButton>
</Box>
</Stack>
<Stack
@@ -319,13 +314,16 @@ const PageSpeedConfigure = () => {
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" mt="auto">
<Button
<LoadingButton
loading={isLoading}
type="submit"
level="primary"
label="Save"
variant="contained"
color="primary"
onClick={handleSave}
sx={{ px: theme.spacing(12), mt: theme.spacing(12) }}
/>
sx={{ px: theme.spacing(12) }}
>
Save
</LoadingButton>
</Stack>
</Stack>
</>
@@ -376,11 +374,15 @@ const PageSpeedConfigure = () => {
justifyContent="flex-end"
>
<Button
level="tertiary"
label="Cancel"
variant="text"
color="info"
onClick={() => setIsOpen(false)}
/>
<Button level="error" label="Delete" onClick={handleRemove} />
>
Cancel
</Button>
<Button variant="contained" color="error" onClick={handleRemove}>
Delete
</Button>
</Stack>
</Stack>
</Modal>
@@ -0,0 +1,51 @@
import { Box, Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<>
<Skeleton variant="rounded" width="15%" height={34} />
<Stack gap={theme.spacing(20)} mt={theme.spacing(6)} maxWidth="1000px">
<Stack direction="row" gap={theme.spacing(4)} mt={theme.spacing(4)}>
<Skeleton
variant="circular"
style={{ minWidth: 24, minHeight: 24 }}
/>
<Box width="80%">
<Skeleton
variant="rounded"
width="50%"
height={24}
sx={{ mb: theme.spacing(4) }}
/>
<Skeleton variant="rounded" width="50%" height={18} />
</Box>
<Stack
direction="row"
gap={theme.spacing(6)}
sx={{
ml: "auto",
alignSelf: "flex-end",
}}
>
<Skeleton variant="rounded" width={100} height={34} />
<Skeleton variant="rounded" width={100} height={34} />
</Stack>
</Stack>
<Skeleton variant="rounded" width="100%" height={500} />
<Stack direction="row" justifyContent="flex-end">
<Skeleton variant="rounded" width="15%" height={34} />
</Stack>
</Stack>
</>
);
};
export default SkeletonLayout;
@@ -22,6 +22,7 @@
.create-page-speed .section-disabled {
opacity: 0.4;
padding: 10px;
margin-right: -5px;
border-radius: 8px;
user-select: none;
pointer-events: none;
@@ -1,27 +1,136 @@
import { Box, Stack, Typography } from "@mui/material";
import { useState } from "react";
import { Box, Button, ButtonGroup, Stack, Typography } from "@mui/material";
import { useSelector, useDispatch } from "react-redux";
import { monitorValidation } from "../../../Validation/validation";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router";
import { createPageSpeed } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { ConfigBox } from "../../Monitors/styled";
import Radio from "../../../Components/Inputs/Radio";
import Field from "../../../Components/Inputs/Field";
import Select from "../../../Components/Inputs/Select";
import Button from "../../../Components/Button";
import Checkbox from "../../../Components/Inputs/Checkbox";
import { monitorValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { createPageSpeed } from "../../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import Breadcrumbs from "../../../Components/Breadcrumbs";
import "./index.css";
import { logger } from "../../../Utils/Logger";
const CreatePageSpeed = () => {
const theme = useTheme();
const navigate = useNavigate();
const dispatch = useDispatch();
const MS_PER_MINUTE = 60000;
const { user, authToken } = useSelector((state) => state.auth);
const dispatch = useDispatch();
const navigate = useNavigate();
const theme = useTheme();
const idMap = {
"monitor-url": "url",
"monitor-name": "name",
"monitor-checks-http": "type",
"monitor-checks-ping": "type",
"notify-email-default": "notification-email",
};
const [monitor, setMonitor] = useState({
url: "",
name: "",
type: "pagespeed",
notifications: [],
interval: 3,
});
const [https, setHttps] = useState(true);
const [errors, setErrors] = useState({});
const handleChange = (event, name) => {
const { value, id } = event.target;
if (!name) name = idMap[id];
if (name.includes("notification-")) {
name = name.replace("notification-", "");
let hasNotif = monitor.notifications.some(
(notification) => notification.type === name
);
setMonitor((prev) => {
const notifs = [...prev.notifications];
if (hasNotif) {
return {
...prev,
notifications: notifs.filter((notif) => notif.type !== name),
};
} else {
return {
...prev,
notifications: [
...notifs,
name === "email"
? { type: name, address: value }
: // TODO - phone number
{ type: name, phone: value },
],
};
}
});
} else {
setMonitor((prev) => ({
...prev,
[name]: value,
}));
const { error } = monitorValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const updatedErrors = { ...prev };
if (error) updatedErrors[name] = error.details[0].message;
else delete updatedErrors[name];
return updatedErrors;
});
}
};
const handleCreateMonitor = async (event) => {
event.preventDefault();
//obj to submit
let form = {
url: `http${https ? "s" : ""}://` + monitor.url,
name: monitor.name === "" ? monitor.url : monitor.name,
type: monitor.type,
interval: monitor.interval * MS_PER_MINUTE,
};
const { error } = monitorValidation.validate(form, {
abortEarly: false,
});
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validation data." });
} else {
form = {
...form,
description: form.name,
teamId: user.teamId,
userId: user._id,
notifications: monitor.notifications,
};
const action = await dispatch(
createPageSpeed({ authToken, monitor: form })
);
if (action.meta.requestStatus === "fulfilled") {
createToast({ body: "Monitor created successfully!" });
navigate("/pagespeed");
} else {
createToast({ body: "Failed to create monitor." });
}
}
};
//select values
const frequencies = [
{ _id: 3, name: "3 minutes" },
{ _id: 5, name: "5 minutes" },
@@ -31,75 +140,15 @@ const CreatePageSpeed = () => {
{ _id: 1440, name: "1 day" },
{ _id: 10080, name: "1 week" },
];
const [form, setForm] = useState({
name: "",
url: "",
interval: 3,
});
const [errors, setErrors] = useState({});
const handleChange = (event, id) => {
const { value } = event.target;
setForm((prev) => ({ ...prev, [id]: value }));
const { error } = monitorValidation.validate(
{ [id]: value },
{ abortEarly: false }
);
setErrors((prev) => {
const newErrors = { ...prev };
if (error) newErrors[id] = error.details[0].message;
else delete newErrors[id];
return newErrors;
});
};
const handleCreate = async (event) => {
event.preventDefault();
let monitor = {
url: "http://" + form.url,
name: form.name === "" ? form.url : form.name,
};
const { error } = monitorValidation.validate(form, { abortEarly: false });
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
createToast({ body: "Error validating data." });
} else {
monitor = {
...monitor,
description: monitor.name,
userId: user._id,
teamId: user.teamId,
interval: form.interval * MS_PER_MINUTE,
type: "pagespeed",
};
try {
const action = await dispatch(createPageSpeed({ authToken, monitor }));
if (action.meta.requestStatus === "fulfilled") {
navigate("/pagespeed");
}
} catch (error) {
createToast({
body:
error.details && error.details.length > 0
? error.details[0].message
: "Unknown error.",
});
}
}
};
return (
<Stack className="create-page-speed" gap={theme.spacing(6)}>
<Box
className="create-monitor"
sx={{
"& h1": {
color: theme.palette.text.primary,
},
}}
>
<Breadcrumbs
list={[
{ name: "pagespeed", path: "/pagespeed" },
@@ -108,18 +157,14 @@ const CreatePageSpeed = () => {
/>
<Stack
component="form"
className="create-monitor-form"
onSubmit={handleCreateMonitor}
noValidate
spellCheck="false"
onSubmit={handleCreate}
gap={theme.spacing(12)}
flex={1}
mt={theme.spacing(6)}
>
<Typography
component="h1"
lineHeight={1}
fontSize={21}
color={theme.palette.text.primary}
>
<Typography component="h1">
<Typography
component="span"
fontSize="inherit"
@@ -128,115 +173,170 @@ const CreatePageSpeed = () => {
Create your{" "}
</Typography>
<Typography component="span" fontSize="inherit" fontWeight="inherit">
pagespeed{" "}
</Typography>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.text.secondary}
>
monitor
Pagespeed monitor
</Typography>
</Typography>
<Stack
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
p={theme.spacing(20)}
pl={theme.spacing(15)}
gap={theme.spacing(20)}
sx={{
"& h3, & p": {
color: theme.palette.text.secondary,
},
}}
>
<Stack direction="row">
<Typography component="h3">Monitor display name</Typography>
<ConfigBox>
<Box>
<Typography component="h2">General settings</Typography>
<Typography component="p">
Here you can select the URL of the host, together with the type of
monitor.
</Typography>
</Box>
<Stack gap={theme.spacing(15)}>
<Field
type={"url"}
id="monitor-url"
label="URL to monitor"
https={https}
placeholder="google.com"
value={monitor.url}
onChange={handleChange}
error={errors["url"]}
/>
<Field
type="text"
id="monitor-name"
placeholder="Example monitor"
value={form.name}
onChange={(event) => handleChange(event, "name")}
error={errors.name}
label="Display name"
isOptional={true}
placeholder="Google"
value={monitor.name}
onChange={handleChange}
error={errors["name"]}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">URL</Typography>
<Field
type="url"
id="monitor-url"
placeholder="random.website.com"
value={form.url}
onChange={(event) => handleChange(event, "url")}
error={errors.url}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">Check frequency</Typography>
<Select
id="monitor-frequency"
items={frequencies}
value={form.interval}
onChange={(event) => handleChange(event, "interval")}
/>
</Stack>
<Stack direction="row">
<Typography component="h3">
Incidents notifications{" "}
<Typography component="span">(coming soon)</Typography>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Checks to perform</Typography>
<Typography component="p">
You can always add or remove checks after adding your site.
</Typography>
<Stack
className="section-disabled"
backgroundColor={theme.palette.background.fill}
>
<Typography mb={theme.spacing(4)}>
When there is a new incident,
</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
isDisabled={true}
/>
<Checkbox
id="notify-email"
label="Notify via email (to gorkem.cetin@bluewavelabs.ca)"
isChecked={false}
/>
<Checkbox
id="notify-emails"
label="Notify via email to following emails"
isChecked={false}
</Box>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(6)}>
<Radio
id="monitor-checks-http"
title="Website monitoring"
desc="Use HTTP(s) to monitor your website or API endpoint."
size="small"
value="http"
checked={monitor.type === "pagespeed"}
onChange={(event) => handleChange(event)}
/>
<ButtonGroup sx={{ ml: "32px" }}>
<Button
variant="group"
filled={https.toString()}
onClick={() => setHttps(true)}
>
HTTPS
</Button>
<Button
variant="group"
filled={(!https).toString()}
onClick={() => setHttps(false)}
>
HTTP
</Button>
</ButtonGroup>
</Stack>
{errors["type"] ? (
<Box className="error-container">
<Typography
component="p"
className="input-error"
color={theme.palette.error.text}
>
{errors["type"]}
</Typography>
</Box>
) : (
""
)}
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Incident notifications</Typography>
<Typography component="p">
When there is an incident, notify users.
</Typography>
</Box>
<Stack gap={theme.spacing(6)}>
<Typography component="p">When there is a new incident,</Typography>
<Checkbox
id="notify-sms"
label="Notify via SMS (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
<Checkbox
id="notify-email-default"
label={`Notify via email (to ${user.email})`}
isChecked={monitor.notifications.some(
(notification) => notification.type === "email"
)}
value={user?.email}
onChange={(event) => handleChange(event)}
/>
<Checkbox
id="notify-email"
label="Also notify via email to multiple addresses (coming soon)"
isChecked={false}
value=""
onChange={() => logger.warn("disabled")}
isDisabled={true}
/>
{monitor.notifications.some(
(notification) => notification.type === "emails"
) ? (
<Box mx={theme.spacing(16)}>
<Field
id="notify-emails-list"
placeholder="notifications@gmail.com"
id="notify-email-list"
type="text"
placeholder="name@gmail.com"
value=""
onChange={() => logger.warn("disabled")}
error=""
/>
<Typography mt={theme.spacing(4)}>
You can separate multiple emails with a comma
</Typography>
</Box>
</Stack>
) : (
""
)}
</Stack>
</Stack>
<Stack direction="row" justifyContent="flex-end" mt="auto">
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h2">Advanced settings</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<Select
id="monitor-interval"
label="Check frequency"
value={monitor.interval || 3}
onChange={(event) => handleChange(event, "interval")}
items={frequencies}
/>
</Stack>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<Button
type="submit"
level="primary"
label="Create"
onClick={handleCreate}
sx={{ px: theme.spacing(12), mt: theme.spacing(12) }}
/>
variant="contained"
color="primary"
onClick={handleCreateMonitor}
disabled={Object.keys(errors).length !== 0 && true}
>
Create monitor
</Button>
</Stack>
</Stack>
</Stack>
</Box>
);
};
+16 -21
View File
@@ -1,5 +1,5 @@
import PropTypes from "prop-types";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Button, Skeleton, Stack, Typography } from "@mui/material";
import { PieChart } from "@mui/x-charts/PieChart";
import { useDrawingArea } from "@mui/x-charts";
import { useEffect, useState } from "react";
@@ -12,7 +12,6 @@ import {
} from "../../../Utils/timeUtils";
import { logger } from "../../../Utils/Logger";
import { networkService } from "../../../main";
import Button from "../../../Components/Button";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import LastCheckedIcon from "../../../assets/icons/calendar-check.svg?react";
import ClockIcon from "../../../assets/icons/maintenance.svg?react";
@@ -43,7 +42,7 @@ const StatBox = ({ icon, title, value }) => {
<Box>
<Typography
variant="h6"
color={theme.palette.common.main}
color={theme.palette.primary.main}
mb={theme.spacing(6)}
>
{title}
@@ -214,7 +213,7 @@ const PageSpeedDetails = () => {
monitorId,
"desc",
50,
null,
"day",
null,
null
);
@@ -398,28 +397,24 @@ const PageSpeedDetails = () => {
</Typography>
</Box>
<Button
level="tertiary"
label="Configure"
animate="rotate90"
img={
<SettingsIcon
style={{
width: theme.spacing(10),
height: theme.spacing(10),
}}
/>
}
variant="contained"
color="secondary"
onClick={() => navigate(`/pagespeed/configure/${monitorId}`)}
sx={{
ml: "auto",
alignSelf: "flex-end",
backgroundColor: theme.palette.background.fill,
px: theme.spacing(6),
px: theme.spacing(5),
"& svg": {
mr: "6px",
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.other.icon,
},
},
}}
/>
>
<SettingsIcon />
Configure
</Button>
</Stack>
<Stack
direction="row"
@@ -592,7 +587,7 @@ const PageSpeedDetails = () => {
<Typography
component="span"
sx={{
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 500,
textDecoration: "underline",
cursor: "pointer",
@@ -627,7 +622,7 @@ const PageSpeedDetails = () => {
<Typography
component="span"
sx={{
color: theme.palette.common.main,
color: theme.palette.primary.main,
fontWeight: 500,
textDecoration: "underline",
cursor: "pointer",
+78
View File
@@ -0,0 +1,78 @@
import { getLastChecked } from "../../Utils/monitorUtils";
import { formatDate, formatDurationRounded } from "../../Utils/timeUtils";
import PageSpeedIcon from "../../assets/icons/page-speed.svg?react";
import { StatusLabel } from "../../Components/Label";
import { Box, Grid, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const Card = ({ data }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<Grid
item
lg={6}
flexGrow={1}
sx={{
"&:hover > .MuiStack-root": {
backgroundColor: theme.palette.background.accent,
},
}}
>
<Stack
direction="row"
gap={theme.spacing(6)}
p={theme.spacing(8)}
onClick={() => navigate(`/pagespeed/${data._id}`)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
sx={{
cursor: "pointer",
"& svg path": { stroke: theme.palette.other.icon, strokeWidth: 0.8 },
}}
>
<PageSpeedIcon
style={{ width: theme.spacing(8), height: theme.spacing(8) }}
/>
<Box flex={1}>
<Stack direction="row" justifyContent="space-between">
<Typography
component="h2"
mb={theme.spacing(2)}
color={theme.palette.primary.main}
>
{data.name}
</Typography>
<StatusLabel
status={data.status ? "up" : "cannot resolve"}
text={data.status ? "Live (collecting data)" : "Inactive"}
/>
</Stack>
<Typography fontSize={13}>
{data.url.replace(/^https?:\/\//, "")}
</Typography>
<Typography mt={theme.spacing(12)}>
<Typography component="span" fontWeight={600}>
Last checked:{" "}
</Typography>
{formatDate(getLastChecked(data.checks, false))}{" "}
<Typography component="span" fontStyle="italic">
({formatDurationRounded(getLastChecked(data.checks))} ago)
</Typography>
</Typography>
</Box>
</Stack>
</Grid>
);
};
Card.propTypes = {
data: PropTypes.object.isRequired,
};
export default Card;
+3 -5
View File
@@ -1,14 +1,13 @@
.page-speed h2.MuiTypography-root {
line-height: 1.1;
}
.page-speed h1.MuiTypography-root,
.page-speed:not(:has([class*="fallback__"])) h1.MuiTypography-root {
font-size: var(--env-var-font-size-large-plus);
}
.page-speed h2.MuiTypography-root {
font-size: var(--env-var-font-size-large);
font-weight: 600;
}
.page-speed p.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.page-speed p:has(> span.MuiTypography-root),
.page-speed p.MuiTypography-root > span.MuiTypography-root {
font-size: var(--env-var-font-size-small-plus);
@@ -20,5 +19,4 @@
}
.page-speed:not(:has([class*="fallback__"])) button {
height: 34px;
align-self: flex-end;
}
+31 -157
View File
@@ -1,141 +1,16 @@
import { Box, Grid, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Button, Grid, Stack } from "@mui/material";
import { useEffect } from "react";
import { useTheme } from "@emotion/react";
import { formatDate, formatDurationRounded } from "../../Utils/timeUtils";
import { StatusLabel } from "../../Components/Label";
import { useDispatch, useSelector } from "react-redux";
import { getPageSpeedByTeamId } from "../../Features/PageSpeedMonitor/pageSpeedMonitorSlice";
import PageSpeedIcon from "../../assets/icons/page-speed.svg?react";
import Fallback from "../../Components/Fallback";
import "./index.css";
import Button from "../../Components/Button";
import { useNavigate } from "react-router";
import { getLastChecked } from "../../Utils/monitorUtils";
import PropTypes from "prop-types";
const Card = ({ data }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<Grid
item
lg={6}
flexGrow={1}
sx={{
"&:hover > .MuiStack-root": {
backgroundColor: "var(--primary-bg-accent)",
},
}}
>
<Stack
direction="row"
gap={theme.spacing(6)}
p={theme.spacing(8)}
onClick={() => navigate(`/pagespeed/${data._id}`)}
border={1}
borderColor={theme.palette.border.light}
borderRadius={theme.shape.borderRadius}
backgroundColor={theme.palette.background.main}
sx={{ cursor: "pointer" }}
>
<PageSpeedIcon
style={{ width: theme.spacing(8), height: theme.spacing(8) }}
/>
<Box flex={1}>
<Stack direction="row" justifyContent="space-between">
<Typography
component="h2"
mb={theme.spacing(2)}
color={theme.palette.common.main}
>
{data.name}
</Typography>
<StatusLabel
status={data.status ? "up" : "cannot resolve"}
text={data.status ? "Live (collecting data)" : "Inactive"}
/>
</Stack>
<Typography>{data.url.replace(/^https?:\/\//, "")}</Typography>
<Typography mt={theme.spacing(12)}>
<Typography component="span" fontWeight={600}>
Last checked:{" "}
</Typography>
{formatDate(getLastChecked(data.checks, false))}{" "}
<Typography component="span" fontStyle="italic">
({formatDurationRounded(getLastChecked(data.checks))} ago)
</Typography>
</Typography>
</Box>
</Stack>
</Grid>
);
};
Card.propTypes = {
data: PropTypes.object.isRequired,
};
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(2)}>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(12)}
>
<Box width="80%">
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={19.5}
sx={{ mt: theme.spacing(2) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack direction="row" flexWrap="wrap" gap={theme.spacing(12)}>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
</Stack>
</Stack>
);
};
import Breadcrumbs from "../../Components/Breadcrumbs";
import Greeting from "../../Utils/greeting";
import SkeletonLayout from "./skeleton";
import Card from "./card";
const PageSpeed = ({ isAdmin }) => {
const theme = useTheme();
@@ -143,7 +18,7 @@ const PageSpeed = ({ isAdmin }) => {
const navigate = useNavigate();
const { authToken } = useSelector((state) => state.auth);
const { monitors, isLoading } = useSelector(
const { monitorsSummary, isLoading } = useSelector(
(state) => state.pageSpeedMonitors
);
useEffect(() => {
@@ -152,12 +27,11 @@ const PageSpeed = ({ isAdmin }) => {
// will show skeletons only on initial load
// since monitor state is being added to redux persist, there's no reason to display skeletons on every render
let isActuallyLoading = isLoading && monitors.length === 0;
let isActuallyLoading = isLoading && monitorsSummary?.monitors?.length === 0;
return (
<Box
className="page-speed"
pt={theme.spacing(20)}
sx={{
':has(> [class*="fallback__"])': {
position: "relative",
@@ -172,38 +46,38 @@ const PageSpeed = ({ isAdmin }) => {
>
{isActuallyLoading ? (
<SkeletonLayout />
) : monitors?.length !== 0 ? (
<Stack
gap={theme.spacing(2)}
) : monitorsSummary?.monitors?.length !== 0 ? (
<Box
sx={{
"& h1, & span.MuiTypography-root, & p": {
"& p": {
color: theme.palette.text.secondary,
},
}}
>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(12)}
>
<Box>
<Typography component="h1">All page speed monitors</Typography>
<Typography mt={theme.spacing(2)}>
Click on one of the monitors to get more site speed information.
</Typography>
</Box>
<Button
level="primary"
label="Create new"
onClick={() => navigate("/pagespeed/create")}
/>
</Stack>
<Box mb={theme.spacing(12)}>
<Breadcrumbs list={[{ name: `pagespeed`, path: "/pagespeed" }]} />
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
mt={theme.spacing(5)}
>
<Greeting type="pagespeed" />
<Button
variant="contained"
color="primary"
onClick={() => navigate("/pagespeed/create")}
>
Create new
</Button>
</Stack>
</Box>
<Grid container spacing={theme.spacing(12)}>
{monitors?.map((monitor) => (
<Card data={monitor} key={`monitor-${monitor._id}`} />
{monitorsSummary?.monitors?.map((monitor) => (
<Card data={monitor} key={monitor._id} />
))}
</Grid>
</Stack>
</Box>
) : (
<Fallback
title="pagespeed monitor"
+65
View File
@@ -0,0 +1,65 @@
import { Box, Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* Renders a skeleton layout.
*
* @returns {JSX.Element}
*/
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(2)}>
<Stack
direction="row"
justifyContent="space-between"
mb={theme.spacing(12)}
>
<Box width="80%">
<Skeleton variant="rounded" width="25%" height={24} />
<Skeleton
variant="rounded"
width="50%"
height={19.5}
sx={{ mt: theme.spacing(2) }}
/>
</Box>
<Skeleton
variant="rounded"
width="20%"
height={34}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
<Stack direction="row" flexWrap="wrap" gap={theme.spacing(12)}>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
<Skeleton
variant="rounded"
width="100%"
height={120}
sx={{ flex: "35%" }}
/>
</Stack>
</Stack>
);
};
export default SkeletonLayout;
+73 -35
View File
@@ -1,14 +1,40 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, styled, Typography } from "@mui/material";
import Button from "../../Components/Button";
import { Box, Button, Stack, styled, Typography } from "@mui/material";
import Field from "../../Components/Inputs/Field";
import Link from "../../Components/Link";
import Select from "../../Components/Inputs/Select";
import { logger } from "../../Utils/Logger";
import "./index.css";
const Settings = () => {
import { useDispatch, useSelector } from "react-redux";
import { createToast } from "../../Utils/toastUtils";
import { deleteMonitorChecksByTeamId } from "../../Features/UptimeMonitors/uptimeMonitorsSlice";
import PropTypes from "prop-types";
import LoadingButton from "@mui/lab/LoadingButton";
const Settings = ({ isAdmin }) => {
const theme = useTheme();
const { user, authToken } = useSelector((state) => state.auth);
const { isLoading } = useSelector((state) => state.uptimeMonitors);
const dispatch = useDispatch();
// TODO Handle saving
const handleClearStats = async () => {
try {
const action = await dispatch(
deleteMonitorChecksByTeamId({ teamId: user.teamId, authToken })
);
if (deleteMonitorChecksByTeamId.fulfilled.match(action)) {
createToast({ body: "Stats cleared successfully" });
} else {
createToast({ body: "Failed to clear stats" });
}
} catch (error) {
logger.error(error);
createToast({ body: "Failed to clear stats" });
}
};
const ConfigBox = styled("div")({
display: "flex",
@@ -79,35 +105,41 @@ const Settings = () => {
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Box>
<Typography component="h1">History and monitoring</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Define here for how long you want to keep the data. You can also
remove all past data.
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="history-monitoring"
label="The days you want to keep monitoring history."
isOptional={true}
optionalLabel="0 for infinite"
placeholder="90"
value=""
onChange={() => logger.warn("Disabled")}
/>
{isAdmin && (
<ConfigBox>
<Box>
<Typography>Clear all stats. This is irreversible.</Typography>
<Button
level="error"
label="Clear all stats"
sx={{ mt: theme.spacing(4) }}
/>
<Typography component="h1">History and monitoring</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
Define here for how long you want to keep the data. You can also
remove all past data.
</Typography>
</Box>
</Stack>
</ConfigBox>
<Stack gap={theme.spacing(20)}>
<Field
type="text"
id="history-monitoring"
label="The days you want to keep monitoring history."
isOptional={true}
optionalLabel="0 for infinite"
placeholder="90"
value=""
onChange={() => logger.warn("Disabled")}
/>
<Box>
<Typography>Clear all stats. This is irreversible.</Typography>
<LoadingButton
variant="contained"
color="error"
loading={isLoading}
onClick={handleClearStats}
sx={{ mt: theme.spacing(4) }}
>
Clear all stats
</LoadingButton>
</Box>
</Stack>
</ConfigBox>
)}
<ConfigBox>
<Box>
<Typography component="h1">About</Typography>
@@ -127,15 +159,21 @@ const Settings = () => {
</Box>
</ConfigBox>
<Stack direction="row" justifyContent="flex-end">
<Button
level="primary"
label="Save"
<LoadingButton
loading={false}
variant="contained"
color="primary"
sx={{ px: theme.spacing(12), mt: theme.spacing(20) }}
/>
>
Save
</LoadingButton>
</Stack>
</Stack>
</Box>
);
};
Settings.propTypes = {
isAdmin: PropTypes.bool,
};
export default Settings;
+70 -3
View File
@@ -58,9 +58,28 @@ class NetworkService {
});
}
async getMonitorsAndSummaryByTeamId(authToken, teamId, types) {
const params = new URLSearchParams();
if (types) {
types.forEach((type) => {
params.append("type", type);
});
}
return this.axiosInstance.get(
`/monitors/team/summary/${teamId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
}
);
}
/**
* ************************************
* Get all uptime monitors for a user
* Get all uptime monitors for a Team
* ************************************
*
* @async
@@ -80,7 +99,9 @@ class NetworkService {
types,
status,
sortOrder,
normalize
normalize,
page,
rowsPerPage
) {
const params = new URLSearchParams();
@@ -93,6 +114,8 @@ class NetworkService {
if (status) params.append("status", status);
if (sortOrder) params.append("sortOrder", sortOrder);
if (normalize) params.append("normalize", normalize);
if (page) params.append("page", page);
if (rowsPerPage) params.append("rowsPerPage", rowsPerPage);
return this.axiosInstance.get(
`/monitors/team/${teamId}?${params.toString()}`,
@@ -116,7 +139,7 @@ class NetworkService {
* @param {string} [sortOrder] - The order in which to sort the retrieved statistics.
* @param {number} [limit] - The maximum number of statistics to retrieve.
* @param {string} [dateRange] - The date range for which to retrieve statistics.
* @param {number} [numToDisplay] - The number of statistics to display.
* @param {number} [numToDisplay] - The number of checks to display.
* @param {boolean} [normalize] - Whether to normalize the retrieved statistics.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*/
@@ -146,6 +169,31 @@ class NetworkService {
);
}
/**
* ************************************
* Gets aggregate stats by monitor ID
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor whose certificate expiry is to be retrieved.
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*
*/
async getAggregateStatsById(authToken, monitorId, dateRange) {
const params = new URLSearchParams();
if (dateRange) params.append("dateRange", dateRange);
return this.axiosInstance.get(
`/monitors/aggregate/${monitorId}?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
},
}
);
}
/**
* ************************************
* Updates a single monitor
@@ -184,6 +232,25 @@ class NetworkService {
},
});
}
/**
* ************************************
* Deletes all checks for all monitor by teamID
* ************************************
*
* @async
* @param {string} authToken - The authorization token to be used in the request header.
* @param {string} monitorId - The ID of the monitor to be deleted.
* @returns {Promise<AxiosResponse>} The response from the axios DELETE request.
*/
async deleteChecksByTeamId(authToken, teamId) {
return this.axiosInstance.delete(`/checks/team/${teamId}`, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
});
}
/**
* ************************************
* Pauses a single monitor by its ID
+82 -26
View File
@@ -4,16 +4,16 @@ const text = {
primary: "#fafafa",
secondary: "#e6e6e6",
tertiary: "#a1a1aa",
accent: "#e6e6e6",
accent: "#8e8e8f",
disabled: "rgba(172, 172, 172, 0.3)",
};
const background = {
main: "#151518",
alt: "#09090b",
fill: "#2e2e2e",
fill: "#2D2D33",
accent: "#18181a",
};
const border = { light: "#27272a", dark: "#2c2c2c" };
const border = { light: "#27272a", dark: "#36363e" };
const fontFamilyDefault =
'"Inter","system-ui", "Avenir", "Helvetica", "Arial", sans-serif';
@@ -24,13 +24,14 @@ const darkTheme = createTheme({
typography: { fontFamily: fontFamilyDefault, fontSize: 13 },
palette: {
mode: "dark",
common: { main: "#1570ef" },
primary: { main: "#1570ef" },
secondary: { main: "#2D2D33" },
text: text,
background: background,
border: border,
info: {
text: text.primary,
main: text.primary,
main: text.secondary,
bg: background.main,
light: background.main,
border: border.light,
@@ -38,22 +39,29 @@ const darkTheme = createTheme({
success: {
text: "#079455",
main: "#45bb7a",
light: "#93d5aa",
bg: "#27272a",
light: "#1c4428",
bg: "#12261e",
},
error: {
text: "#f04438",
main: "#d32f2f",
light: "#f04438",
bg: "#27272a",
light: "#542426",
bg: "#301a1f",
dark: "#932020",
border: "#f04438",
},
warning: {
text: "#DC6803",
main: "#e88c30",
light: "#fffcf5",
bg: "#ffecbc",
border: "#fec84b",
text: "#e88c30",
main: "#FF9F00",
light: "#272115",
bg: "#624711",
border: "#e88c30",
},
percentage: {
uptimePoor: "#d32f2f",
uptimeFair: "#e88c30",
uptimeGood: "#ffd600",
uptimeExcellent: "#079455",
},
unresolved: { main: "#4e5ba6", light: "#e2eaf7", bg: "#f2f4f7" },
divider: border.light,
@@ -61,30 +69,63 @@ const darkTheme = createTheme({
icon: "#e6e6e6",
line: "#27272a",
fill: "#18181a",
grid: "#454546",
},
// TO BE REMOVED //
primary: {
main: "#1570ef",
},
secondary: {
main: "#e6e6e6",
},
tertiary: {
main: "#e6e6e6",
},
// ----------------- //
},
spacing: 2,
components: {
MuiButtonBase: {
MuiButton: {
defaultProps: {
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
variants: [
{
props: (props) => props.variant === "group",
style: {
color: theme.palette.secondary.contrastText,
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
{
props: (props) =>
props.variant === "group" && props.filled === "true",
style: {
backgroundColor: theme.palette.secondary.main,
},
},
{
props: (props) =>
props.variant === "contained" && props.color === "secondary",
style: {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.dark,
},
},
],
fontWeight: 400,
borderRadius: 4,
boxShadow: "none",
textTransform: "none",
"&:focus": {
outline: "none",
},
"&:hover": {
boxShadow: "none",
},
}),
},
},
MuiIconButton: {
styleOverrides: {
root: {
padding: 4,
transition: "none",
"&:hover": {
backgroundColor: background.fill,
},
@@ -102,6 +143,7 @@ const darkTheme = createTheme({
borderRadius: 4,
boxShadow: shadow,
backgroundColor: background.main,
backgroundImage: "none",
},
},
},
@@ -112,6 +154,13 @@ const darkTheme = createTheme({
},
},
},
MuiListItemButton: {
styleOverrides: {
root: {
transition: "none",
},
},
},
MuiMenuItem: {
styleOverrides: {
root: {
@@ -171,6 +220,13 @@ const darkTheme = createTheme({
},
},
},
MuiSkeleton: {
styleOverrides: {
root: {
backgroundColor: "#151518",
},
},
},
},
shape: {
borderRadius: 2,
+72 -18
View File
@@ -22,16 +22,17 @@ const shadow =
const lightTheme = createTheme({
typography: { fontFamily: fontFamilyDefault, fontSize: 13 },
palette: {
common: { main: "#1570ef" },
primary: { main: "#1570EF" },
secondary: { main: "#F4F4F4", dark: "#e3e3e3", contrastText: "#475467" },
text: text,
background: background,
border: border,
info: {
text: "#475467",
main: "#475467",
bg: "#ffffff",
light: "#ffffff",
border: "#D0D5DD",
text: text.primary,
main: text.tertiary,
bg: background.main,
light: background.main,
border: border.dark,
},
success: {
text: "#079455",
@@ -53,36 +54,75 @@ const lightTheme = createTheme({
bg: "#ffecbc",
border: "#fec84b",
},
percentage: {
uptimePoor: "#d32f2f",
uptimeFair: "#ec8013",
uptimeGood: "#ffb800",
uptimeExcellent: "#079455",
},
unresolved: { main: "#4e5ba6", light: "#e2eaf7", bg: "#f2f4f7" },
divider: border.light,
other: {
icon: "#667085",
line: "#d6d9dd",
fill: "#e3e3e3",
grid: "#a2a3a3",
},
// TO BE REMOVED //
primary: {
main: "#1570EF",
},
secondary: {
main: "#475467",
},
tertiary: {
main: "#475467",
},
// ----------------- //
},
spacing: 2,
components: {
MuiButtonBase: {
MuiButton: {
defaultProps: {
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
variants: [
{
props: (props) => props.variant === "group",
style: {
color: theme.palette.secondary.contrastText,
backgroundColor: theme.palette.background.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
{
props: (props) =>
props.variant === "group" && props.filled === "true",
style: {
backgroundColor: theme.palette.secondary.main,
},
},
{
props: (props) =>
props.variant === "contained" && props.color === "secondary",
style: {
border: 1,
borderStyle: "solid",
borderColor: theme.palette.border.light,
},
},
],
fontWeight: 400,
borderRadius: 4,
boxShadow: "none",
textTransform: "none",
"&:focus": {
outline: "none",
},
"&:hover": {
boxShadow: "none",
},
}),
},
},
MuiIconButton: {
styleOverrides: {
root: {
padding: 4,
transition: "none",
"&:hover": {
backgroundColor: background.fill,
},
@@ -109,6 +149,13 @@ const lightTheme = createTheme({
},
},
},
MuiListItemButton: {
styleOverrides: {
root: {
transition: "none",
},
},
},
MuiMenuItem: {
styleOverrides: {
root: {
@@ -169,6 +216,13 @@ const lightTheme = createTheme({
},
},
},
MuiSkeleton: {
styleOverrides: {
root: {
backgroundColor: "#f2f4f7",
},
},
},
},
shape: {
borderRadius: 2,
+192
View File
@@ -0,0 +1,192 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Typography } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { useEffect } from "react";
import { setGreeting } from "../Features/UI/uiSlice";
const early = [
{
prepend: "Rise and shine",
append: "If youre up this early, you might as well be a legend!",
emoji: "☕",
},
{
prepend: "Good morning",
append: "The worlds still asleep, but youre already awesome!",
emoji: "🦉",
},
{
prepend: "Good morning",
append: "Are you a wizard? Only magical people are up at this hour!",
emoji: "🌄",
},
{
prepend: "Up before the roosters",
append: "Ready to tackle the day before it even starts?",
emoji: "🐓",
},
{
prepend: "Early bird special",
append: "Lets get things done while everyone else is snoozing!",
emoji: "🌟",
},
];
const morning = [
{
prepend: "Good morning",
append: "Is it coffee oclock yet, or should we start with high fives?",
emoji: "☕",
},
{
prepend: "Morning",
append: "The sun is up, and so are you—time to be amazing!",
emoji: "🌞",
},
{
prepend: "Good morning",
append: "Time to make today the best thing since sliced bread!",
emoji: "🥐",
},
{
prepend: "Morning",
append: "Lets kick off the day with more energy than a double espresso!",
emoji: "🚀",
},
{
prepend: "Rise and shine",
append: "Youre about to make today so great, even Monday will be jealous!",
emoji: "🌟",
},
];
const afternoon = [
{
prepend: "Good afternoon",
append: "How about a break to celebrate how awesome youre doing?",
emoji: "🥪",
},
{
prepend: "Afternoon",
append: "If youre still going strong, youre officially a rockstar!",
emoji: "🌞",
},
{
prepend: "Hey there",
append: "The afternoon is your playground—lets make it epic!",
emoji: "🍕",
},
{
prepend: "Good afternoon",
append: "Time to crush the rest of the day like a pro!",
emoji: "🏆",
},
{
prepend: "Afternoon",
append: "Time to turn those afternoon slumps into afternoon triumphs!",
emoji: "🎉",
},
];
const evening = [
{
prepend: "Good evening",
append: "Time to wind down and think about how you crushed today!",
emoji: "🌇",
},
{
prepend: "Evening",
append: "Youve earned a break—lets make the most of these evening vibes!",
emoji: "🍹",
},
{
prepend: "Hey there",
append: "Time to relax and bask in the glow of your days awesomeness!",
emoji: "🌙",
},
{
prepend: "Good evening",
append: "Ready to trade productivity for chill mode?",
emoji: "🛋️ ",
},
{
prepend: "Evening",
append: "Lets call it a day and toast to your success!",
emoji: "🕶️",
},
];
/**
* Greeting component that displays a personalized greeting message
* based on the time of day and the user's first name.
*
* @component
* @example
* return <Greeting type={"pagespeed"} />;
*
* @param {Object} props
* @param {string} props.type - The type of monitor to be displayed in the message
* @returns {JSX.Element} The rendered Greeting component
*/
const Greeting = ({ type = "" }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { firstName } = useSelector((state) => state.auth.user);
const index = useSelector((state) => state.ui.greeting.index);
const lastUpdate = useSelector((state) => state.ui.greeting.lastUpdate);
const now = new Date();
const hour = now.getHours();
useEffect(() => {
const hourDiff = lastUpdate ? hour - lastUpdate : null;
if (!lastUpdate || hourDiff >= 1) {
let random = Math.floor(Math.random() * 5);
dispatch(setGreeting({ index: random, lastUpdate: hour }));
}
}, [dispatch, hour]);
let greetingArray =
hour < 6 ? early : hour < 12 ? morning : hour < 18 ? afternoon : evening;
const { prepend, append, emoji } = greetingArray[index];
return (
<Box>
<Typography
component="h1"
lineHeight={1}
fontWeight={500}
color={theme.palette.text.primary}
mb={theme.spacing(1)}
>
<Typography
component="span"
fontSize="inherit"
color={theme.palette.text.secondary}
>
{prepend},{" "}
</Typography>
<Typography component="span" fontSize="inherit" fontWeight="inherit">
{firstName} {emoji}
</Typography>
</Typography>
<Typography
lineHeight={1}
fontSize={14}
fontWeight={400}
color={theme.palette.text.accent}
>
{append} Heres an overview of your {type} monitors.
</Typography>
</Box>
);
};
Greeting.propTypes = {
type: PropTypes.string,
};
export default Greeting;
+21 -2
View File
@@ -43,6 +43,23 @@ export const formatDurationRounded = (ms) => {
return time;
};
export const formatDurationSplit = (ms) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
return days > 0
? { time: days, format: days === 1 ? "day" : "days" }
: hours > 0
? { time: hours, format: hours === 1 ? "hour" : "hours" }
: minutes > 0
? { time: minutes, format: minutes === 1 ? "minute" : "minutes" }
: seconds > 0
? { time: seconds, format: seconds === 1 ? "second" : "seconds" }
: { time: 0, format: "seconds" };
};
export const formatDate = (date, customOptions) => {
const options = {
year: "numeric",
@@ -51,9 +68,11 @@ export const formatDate = (date, customOptions) => {
hour: "numeric",
minute: "numeric",
hour12: true,
...customOptions
...customOptions,
};
// Return the date using the specified options
return date.toLocaleString("en-US", options);
return date
.toLocaleString("en-US", options)
.replace(/\b(AM|PM)\b/g, (match) => match.toLowerCase());
};
@@ -0,0 +1,36 @@
<svg width="256" height="170" viewBox="0 0 256 170" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_24_2)">
<path d="M248.196 0.390244H7.80489C3.7099 0.390244 0.390259 3.70989 0.390259 7.80487V161.561C0.390259 165.656 3.7099 168.975 7.80489 168.975H248.196C252.291 168.975 255.61 165.656 255.61 161.561V7.80487C255.61 3.70989 252.291 0.390244 248.196 0.390244Z" fill="#151518"/>
<path d="M248.196 0.390244H7.80489C3.7099 0.390244 0.390259 3.70989 0.390259 7.80487V161.561C0.390259 165.656 3.7099 168.975 7.80489 168.975H248.196C252.291 168.975 255.61 165.656 255.61 161.561V7.80487C255.61 3.70989 252.291 0.390244 248.196 0.390244Z" stroke="#27272A" stroke-width="0.780488"/>
<path d="M234.976 18H21.122C19.3977 18 18 19.3977 18 21.122V69.5122C18 71.2364 19.3977 72.6341 21.122 72.6341H234.976C236.7 72.6341 238.098 71.2364 238.098 69.5122V21.122C238.098 19.3977 236.7 18 234.976 18Z" fill="#27272A" fill-opacity="0.8"/>
<path d="M99.9025 78.8293H21.0732C19.3489 78.8293 17.9512 80.227 17.9512 81.9512V85.0732C17.9512 86.7974 19.3489 88.1952 21.0732 88.1952H99.9025C101.627 88.1952 103.024 86.7974 103.024 85.0732V81.9512C103.024 80.227 101.627 78.8293 99.9025 78.8293Z" fill="#27272A" fill-opacity="0.6"/>
<path d="M234.927 93.6584H21.0732C19.3489 93.6584 17.9512 95.0561 17.9512 96.7803V99.9023C17.9512 101.627 19.3489 103.024 21.0732 103.024H234.927C236.651 103.024 238.049 101.627 238.049 99.9023V96.7803C238.049 95.0561 236.651 93.6584 234.927 93.6584Z" fill="#27272A" fill-opacity="0.3"/>
<path d="M234.927 108.488H21.0732C19.3489 108.488 17.9512 109.886 17.9512 111.61V114.732C17.9512 116.456 19.3489 117.854 21.0732 117.854H234.927C236.651 117.854 238.049 116.456 238.049 114.732V111.61C238.049 109.886 236.651 108.488 234.927 108.488Z" fill="#27272A" fill-opacity="0.3"/>
<g filter="url(#filter0_d_24_2)">
<mask id="mask0_24_2" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="174" y="129" width="65" height="21">
<path d="M234.927 129.561H177.171C175.447 129.561 174.049 130.959 174.049 132.683V146.732C174.049 148.456 175.447 149.854 177.171 149.854H234.927C236.651 149.854 238.049 148.456 238.049 146.732V132.683C238.049 130.959 236.651 129.561 234.927 129.561Z" fill="white"/>
<path d="M177.171 130.061H234.927C236.375 130.061 237.549 131.235 237.549 132.683V146.732C237.549 148.18 236.375 149.354 234.927 149.354H177.171C175.723 149.354 174.549 148.18 174.549 146.732V132.683C174.549 131.235 175.723 130.061 177.171 130.061Z" stroke="white"/>
</mask>
<g mask="url(#mask0_24_2)">
<path d="M234.927 129.561H177.171C175.447 129.561 174.049 130.959 174.049 132.683V146.732C174.049 148.456 175.447 149.854 177.171 149.854H234.927C236.651 149.854 238.049 148.456 238.049 146.732V132.683C238.049 130.959 236.651 129.561 234.927 129.561Z" fill="#27272A" fill-opacity="0.2"/>
<path d="M177.171 130.061H234.927C236.375 130.061 237.549 131.235 237.549 132.683V146.732C237.549 148.18 236.375 149.354 234.927 149.354H177.171C175.723 149.354 174.549 148.18 174.549 146.732V132.683C174.549 131.235 175.723 130.061 177.171 130.061Z" stroke="#27272A" stroke-opacity="0.8"/>
</g>
<path d="M234.927 129.951H177.171C175.662 129.951 174.439 131.174 174.439 132.683V146.731C174.439 148.24 175.662 149.463 177.171 149.463H234.927C236.435 149.463 237.658 148.24 237.658 146.731V132.683C237.658 131.174 236.435 129.951 234.927 129.951Z" stroke="#27272A" stroke-opacity="0.8" stroke-width="0.780488"/>
</g>
</g>
<defs>
<filter id="filter0_d_24_2" x="172.049" y="128.561" width="68.0002" height="24.2929" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_24_2"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_24_2" result="shape"/>
</filter>
<clipPath id="clip0_24_2">
<rect width="256" height="170" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 20V13M12 20V10M4 20L4 16M13.4067 5.0275L18.5751 6.96567M10.7988 5.40092L5.20023 9.59983M21.0607 6.43934C21.6464 7.02513 21.6464 7.97487 21.0607 8.56066C20.4749 9.14645 19.5251 9.14645 18.9393 8.56066C18.3536 7.97487 18.3536 7.02513 18.9393 6.43934C19.5251 5.85355 20.4749 5.85355 21.0607 6.43934ZM5.06066 9.43934C5.64645 10.0251 5.64645 10.9749 5.06066 11.5607C4.47487 12.1464 3.52513 12.1464 2.93934 11.5607C2.35355 10.9749 2.35355 10.0251 2.93934 9.43934C3.52513 8.85355 4.47487 8.85355 5.06066 9.43934ZM13.0607 3.43934C13.6464 4.02513 13.6464 4.97487 13.0607 5.56066C12.4749 6.14645 11.5251 6.14645 10.9393 5.56066C10.3536 4.97487 10.3536 4.02513 10.9393 3.43934C11.5251 2.85355 12.4749 2.85355 13.0607 3.43934Z" stroke="black" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 915 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 10H3M21 12.5V8.8C21 7.11984 21 6.27976 20.673 5.63803C20.3854 5.07354 19.9265 4.6146 19.362 4.32698C18.7202 4 17.8802 4 16.2 4H7.8C6.11984 4 5.27976 4 4.63803 4.32698C4.07354 4.6146 3.6146 5.07354 3.32698 5.63803C3 6.27976 3 7.11984 3 8.8V17.2C3 18.8802 3 19.7202 3.32698 20.362C3.6146 20.9265 4.07354 21.3854 4.63803 21.673C5.27976 22 6.11984 22 7.8 22H12M16 2V6M8 2V6M14.5 19L16.5 21L21 16.5" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.07598 7.48282L7.36402 10.5457C7.58715 10.705 7.69872 10.7847 7.81548 10.8031C7.91821 10.8192 8.02343 10.8029 8.11648 10.7565C8.22223 10.7037 8.30449 10.594 8.46901 10.3747L9.37511 9.16652C9.42164 9.10448 9.4449 9.07347 9.47224 9.04671C9.49652 9.02295 9.52315 9.00173 9.55173 8.98338C9.58392 8.9627 9.61935 8.94696 9.6902 8.91546L13.5588 7.19609C13.7192 7.12482 13.7993 7.08918 13.8598 7.03352C13.9133 6.9843 13.9554 6.924 13.9832 6.85684C14.0146 6.78091 14.0204 6.69336 14.0321 6.51826L14.3154 2.2694M13.5 13.5L16.116 14.6211C16.4195 14.7512 16.5713 14.8163 16.6517 14.9243C16.7222 15.0191 16.7569 15.1358 16.7496 15.2537C16.7413 15.3881 16.6497 15.5255 16.4665 15.8002L15.2375 17.6438C15.1507 17.774 15.1072 17.8391 15.0499 17.8863C14.9991 17.928 14.9406 17.9593 14.8777 17.9784C14.8067 18 14.7284 18 14.5719 18H12.5766C12.3693 18 12.2656 18 12.1774 17.9653C12.0995 17.9347 12.0305 17.885 11.9768 17.8208C11.916 17.7481 11.8832 17.6497 11.8177 17.453L11.1048 15.3144C11.0661 15.1983 11.0468 15.1403 11.0417 15.0814C11.0372 15.0291 11.0409 14.9764 11.0528 14.9253C11.0662 14.8677 11.0935 14.813 11.1482 14.7036L11.6897 13.6206C11.7997 13.4005 11.8547 13.2905 11.9395 13.2222C12.0141 13.162 12.1046 13.1246 12.1999 13.1143C12.3081 13.1027 12.4248 13.1416 12.6582 13.2194L13.5 13.5ZM22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="black" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 7L14.1314 14.8686C13.7354 15.2646 13.5373 15.4627 13.309 15.5368C13.1082 15.6021 12.8918 15.6021 12.691 15.5368C12.4627 15.4627 12.2646 15.2646 11.8686 14.8686L9.13137 12.1314C8.73535 11.7354 8.53735 11.5373 8.30902 11.4632C8.10817 11.3979 7.89183 11.3979 7.69098 11.4632C7.46265 11.5373 7.26465 11.7354 6.86863 12.1314L2 17M22 7H15M22 7V14" stroke="black" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 17L17 7M17 7H7M17 7V17" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 17L17 7M17 7H7M17 7V17" stroke="#667085" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 224 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 7H4.6C4.03995 7 3.75992 7 3.54601 7.10899C3.35785 7.20487 3.20487 7.35785 3.10899 7.54601C3 7.75992 3 8.03995 3 8.6V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H9M9 21H15M9 21L9 4.6C9 4.03995 9 3.75992 9.10899 3.54601C9.20487 3.35785 9.35785 3.20487 9.54601 3.10899C9.75992 3 10.0399 3 10.6 3L13.4 3C13.9601 3 14.2401 3 14.454 3.10899C14.6422 3.20487 14.7951 3.35785 14.891 3.54601C15 3.75992 15 4.03995 15 4.6V21M15 11H19.4C19.9601 11 20.2401 11 20.454 11.109C20.6422 11.2049 20.7951 11.3578 20.891 11.546C21 11.7599 21 12.0399 21 12.6V19.4C21 19.9601 21 20.2401 20.891 20.454C20.7951 20.6422 20.6422 20.7951 20.454 20.891C20.2401 21 19.9601 21 19.4 21H15" stroke="black" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 927 B

+4 -4
View File
@@ -4,6 +4,10 @@
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
font-weight: 400;
@@ -86,10 +90,6 @@ body {
display: none;
}
body .MuiSkeleton-root {
background-color: #f2f4f7;
}
@keyframes ripple {
from {
opacity: 1;
+2 -3
View File
@@ -11,9 +11,8 @@
<p align="center"><strong>An open source server monitoring application</strong></p>
https://github.com/user-attachments/assets/c8234cce-edf8-4d7e-8f6b-f18b06669fcc
![Dashboard-dark](https://github.com/user-attachments/assets/db875138-164f-453c-a75e-889f88747578)
(yes, we have a light theme as well, but this looks better on readme.md)
BlueWave Uptime is an open source server monitoring application used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
+26
View File
@@ -6,6 +6,7 @@ const {
getTeamChecksParamValidation,
getTeamChecksQueryValidation,
deleteChecksParamValidation,
deleteChecksByTeamIdParamValidation,
} = require("../validation/joi");
const { successMessages } = require("../utils/messages");
const SERVICE_NAME = "check";
@@ -112,9 +113,34 @@ const deleteChecks = async (req, res, next) => {
}
};
const deleteChecksByTeamId = async (req, res, next) => {
try {
await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
} catch (error) {
error.service = SERVICE_NAME;
error.method = "deleteChecksByTeam";
error.status = 422;
next(error);
}
try {
const deletedCount = await req.db.deleteChecksByTeamId(req.params.teamId);
return res.status(200).json({
success: true,
msg: successMessages.CHECK_DELETE,
data: { deletedCount },
});
} catch (error) {
error.service = SERVICE_NAME;
error.method = "deleteChecksByTeamId";
next(error);
}
};
module.exports = {
createCheck,
getChecks,
getTeamChecks,
deleteChecks,
deleteChecksByTeamId,
};
+105 -12
View File
@@ -4,15 +4,20 @@ const {
getMonitorsByTeamIdValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
getMonitorsAndSummaryByTeamIdParamValidation,
getMonitorsAndSummaryByTeamIdQueryValidation,
getMonitorsByTeamIdQueryValidation,
pauseMonitorParamValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getCertificateParamValidation,
getMonitorAggregateStatsParamValidation,
getMonitorAggregateStatsQueryValidation,
} = require("../validation/joi");
const sslChecker = require("ssl-checker");
const SERVICE_NAME = "monitorController";
const { errorMessages, successMessages } = require("../utils/messages");
const { runInNewContext } = require("vm");
/**
* Returns all monitors
* @async
@@ -35,6 +40,44 @@ const getAllMonitors = async (req, res, next) => {
}
};
/**
* Returns agregate stats for a monitor
* @async
* @param {Express.Request} req
* @param {Express.Response} res
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
const getMonitorAggregateStats = async (req, res, next) => {
try {
await getMonitorAggregateStatsParamValidation.validateAsync(req.params);
await getMonitorAggregateStatsQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const { monitorId } = req.params;
const dateRange = req.query.dateRange;
const aggregateStats = await req.db.getMonitorAggregateStats(
monitorId,
dateRange
);
return res.json({
success: true,
msg: successMessages.MONTIOR_STATS_BY_ID,
data: aggregateStats,
});
} catch (error) {
next(error);
}
};
/**
* Returns monitor stats for monitor with matching ID
* @async
@@ -45,7 +88,8 @@ const getAllMonitors = async (req, res, next) => {
*/
const getMonitorStatsById = async (req, res, next) => {
try {
//Validation
await getMonitorStatsByIdParamValidation.validateAsync(req.params);
await getMonitorStatsByIdQueryValidation.validateAsync(req.query);
} catch (error) {
error.status = 422;
error.message =
@@ -69,7 +113,7 @@ const getMonitorStatsById = async (req, res, next) => {
const getMonitorCertificate = async (req, res, next) => {
try {
//validation
await getCertificateParamValidation.validateAsync(req.params);
} catch (error) {
error.status = 422;
error.message =
@@ -143,7 +187,54 @@ const getMonitorById = async (req, res, next) => {
};
/**
* Returns all monitors belong to User with UserID
* Returns all monitors and a sumamry for a team with TeamID
* @async
* @param {Express.Request} req
* @param {Express.Response} res
* @returns {Promise<Express.Response>}
* @throws {Error}
*/
const getMonitorsAndSummaryByTeamId = async (req, res, next) => {
try {
await getMonitorsAndSummaryByTeamIdParamValidation.validateAsync(
req.params
);
await getMonitorsAndSummaryByTeamIdQueryValidation.validateAsync(req.query);
//validation
} catch (error) {
error.status = 422;
error.service = SERVICE_NAME;
error.method === undefined &&
error.method === "getMonitorsAndSummaryByTeamId";
error.message =
error.details?.[0]?.message || error.message || "Validation Error";
next(error);
return;
}
try {
const { teamId } = req.params;
const { type } = req.query;
const monitorsSummary = await req.db.getMonitorsAndSummaryByTeamId(
teamId,
type
);
return res.status(200).json({
success: true,
msg: successMessages.MONITOR_GET_BY_USER_ID(teamId),
data: monitorsSummary,
});
} catch (error) {
error.service = SERVICE_NAME;
error.method === undefined &&
error.method === "getMonitorsAndSummaryByTeamId";
next(error);
}
};
/**
* Returns all monitors belong to team with TeamID
* @async
* @param {Express.Request} req
* @param {Express.Response} res
@@ -205,12 +296,14 @@ const createMonitor = async (req, res, next) => {
const monitor = await req.db.createMonitor(req, res);
if (notifications && notifications.length !== 0) {
await Promise.all(
const setNotifications = await Promise.all(
notifications.map(async (notification) => {
notification.monitorId = monitor._id;
await req.db.createNotification(notification);
})
);
monitor.notifications = setNotifications;
await monitor.save();
}
// Add monitor to job queue
req.jobQueue.addJob(monitor._id, monitor);
@@ -357,16 +450,14 @@ const pauseMonitor = async (req, res, next) => {
await req.jobQueue.addJob(monitor._id, monitor);
}
monitor.isActive = !monitor.isActive;
const updatedMonitor = await req.db.editMonitor(
req.params.monitorId,
monitor
);
monitor.status = undefined;
monitor.save();
return res.status(200).json({
success: true,
msg: updatedMonitor.isActive
msg: monitor.isActive
? successMessages.MONITOR_RESUME
: successMessages.MONITOR_PAUSE,
data: updatedMonitor,
data: monitor,
});
} catch (error) {
error.service = SERVICE_NAME;
@@ -377,9 +468,11 @@ const pauseMonitor = async (req, res, next) => {
module.exports = {
getAllMonitors,
getMonitorAggregateStats,
getMonitorStatsById,
getMonitorCertificate,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
+4
View File
@@ -66,6 +66,7 @@ const {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
@@ -94,6 +95,7 @@ const {
getChecks,
getTeamChecks,
deleteChecks,
deleteChecksByTeamId,
} = require("./modules/checkModule");
//****************************************
@@ -145,6 +147,7 @@ module.exports = {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
@@ -155,6 +158,7 @@ module.exports = {
getChecks,
getTeamChecks,
deleteChecks,
deleteChecksByTeamId,
createAlert,
getAlertsByUserId,
getAlertsByMonitorId,
+51 -1
View File
@@ -22,7 +22,27 @@ const dateRangeLookup = {
const createCheck = async (checkData) => {
try {
const { monitorId, status } = checkData;
const n = await Check.countDocuments({ monitorId }) + 1;
const check = await new Check({ ...checkData }).save();
const monitor = await Monitor.findById(monitorId);
if (!monitor) {
throw new Error("Monitor not found");
}
if (monitor.uptimePercentage === undefined) {
monitor.uptimePercentage = (status === true) ? 1 : 0;
} else {
monitor.uptimePercentage =
(monitor.uptimePercentage * (n - 1) + (status === true ? 1: 0)) / n;
}
await monitor.save();
return check;
} catch (error) {
throw error;
@@ -170,7 +190,7 @@ const getTeamChecks = async (req) => {
};
/**
* Delete all checks for a user
* Delete all checks for a monitor
* @async
* @param {string} monitorId
* @returns {number}
@@ -185,10 +205,40 @@ const deleteChecks = async (monitorId) => {
throw error;
}
};
/**
* Delete all checks for a team
* @async
* @param {string} monitorId
* @returns {number}
* @throws {Error}
*/
const deleteChecksByTeamId = async (teamId) => {
try {
const teamMonitors = await Monitor.find({ teamId: teamId });
let totalDeletedCount = 0;
await Promise.all(
teamMonitors.map(async (monitor) => {
const result = await Check.deleteMany({ monitorId: monitor._id });
totalDeletedCount += result.deletedCount;
monitor.status = true;
await monitor.save();
})
);
return totalDeletedCount;
} catch (error) {
throw error;
}
};
module.exports = {
createCheck,
getChecksCount,
getChecks,
getTeamChecks,
deleteChecks,
deleteChecksByTeamId,
};
+152 -100
View File
@@ -35,7 +35,7 @@ const calculateUptimeDuration = (checks) => {
const latestCheck = new Date(checks[0].createdAt);
let latestDownCheck = 0;
for (let i = 0; i < checks.length; i++) {
for (let i = checks.length; i <= 0; i--) {
if (checks[i].status === false) {
latestDownCheck = new Date(checks[i].createdAt);
break;
@@ -77,11 +77,11 @@ const getLatestResponseTime = (checks) => {
};
/**
* Helper function to get average 24h response time
* Helper function to get average response time
* @param {Array} checks Array of check objects.
* @returns {number} Timestamp of the most recent check.
*/
const getAverageResponseTime24Hours = (checks) => {
const getAverageResponseTime = (checks) => {
if (!checks || checks.length === 0) {
return 0;
}
@@ -121,28 +121,7 @@ const getIncidents = (checks) => {
return check.status === false ? (acc += 1) : acc;
}, 0);
};
/**
* Helper function to get all incidents
* @param {Array} checks Array of check objects.
* @returns {Array<Boolean>} Array of booleans representing up/down.
*/
const getStatusBarValues = (monitor, checks) => {
const checksIn60Mins = Math.floor((60 * 60 * 1000) / monitor.interval);
const noBlankChecks = checksIn60Mins - checks.length;
const statusBarValues = checks.map((check) => {
return {
status: check.status,
responseTime: check.responseTime,
value: 75,
};
});
for (let i = 0; i < noBlankChecks; i++) {
statusBarValues.push({ status: undefined, responseTime: 0, value: 75 });
}
return statusBarValues.reverse();
};
/**
* Get stats by monitor ID
* @async
@@ -152,42 +131,35 @@ const getStatusBarValues = (monitor, checks) => {
* @throws {Error}
*/
const getMonitorStatsById = async (req) => {
const filterLookup = {
hour: new Date(new Date().getTime() - 60 * 60 * 1000),
const startDates = {
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
};
const endDate = new Date();
try {
const { monitorId } = req.params;
let { status, limit, sortOrder, dateRange, numToDisplay, normalize } =
req.query;
// This effectively removes limit, returning all checks
if (limit === undefined) limit = 0;
// Default sort order is newest -> oldest
sortOrder = sortOrder === "asc" ? 1 : -1;
// Get monitor
const { monitorId } = req.params;
let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query;
const monitor = await Monitor.findById(monitorId);
if (monitor === null || monitor === undefined) {
throw new Error(errorMessages.DB_FIND_MONTIOR_BY_ID(monitorId));
}
// This effectively removes limit, returning all checks
if (limit === undefined) limit = 0;
// Default sort order is newest -> oldest
sortOrder = sortOrder === "asc" ? 1 : -1;
// Determine if this is a pagespeed monitor or an http/ping monitor
let model =
monitor.type === "http" || monitor.type === "ping"
? Check
: PageSpeedCheck;
// Build monitor stats object
const monitorStats = {
...monitor.toObject(),
};
// Start building query
// Build checks query
const checksQuery = { monitorId: monitor._id };
// Get all checks
@@ -195,76 +167,101 @@ const getMonitorStatsById = async (req) => {
createdAt: sortOrder,
});
const checksQueryForDateRange = {
...checksQuery,
createdAt: {
$gte: startDates[dateRange],
$lte: endDate,
},
};
const checksForDateRange = await model
.find(checksQueryForDateRange)
.sort({ createdAt: sortOrder });
if (monitor.type === "http" || monitor.type === "ping") {
const checksQuery24Hours = {
...checksQuery,
createdAt: { $gte: filterLookup.day },
};
const checksQuery30Days = {
...checksQuery,
createdAt: { $gte: filterLookup.month },
};
const checksQuery60Mins = {
...checksQuery,
createdAt: { $gte: filterLookup.hour },
};
const [checks24Hours, checks30Days, checks60Mins] = await Promise.all([
model.find(checksQuery24Hours).sort({ createdAt: sortOrder }),
model.find(checksQuery30Days).sort({ createdAt: sortOrder }),
model.find(checksQuery60Mins).sort({ createdAt: sortOrder }),
]);
// HTTP/PING Specific stats
monitorStats.avgResponseTime24hours =
getAverageResponseTime24Hours(checks24Hours);
monitorStats.uptime24Hours = getUptimePercentage(checks24Hours);
monitorStats.uptime30Days = getUptimePercentage(checks30Days);
monitorStats.statusBar = getStatusBarValues(monitor, checks60Mins);
monitorStats.periodAvgResponseTime =
getAverageResponseTime(checksForDateRange);
monitorStats.periodUptime = getUptimePercentage(checksForDateRange);
// Aggregate data
let groupedChecks;
// Group checks by hour if range is day
if (dateRange === "day") {
groupedChecks = checksForDateRange.reduce((acc, check) => {
const time = new Date(check.createdAt);
time.setMinutes(0, 0, 0);
if (!acc[time]) {
acc[time] = { time, checks: [] };
}
acc[time].checks.push(check);
return acc;
}, {});
} else {
groupedChecks = checksForDateRange.reduce((acc, check) => {
const time = new Date(check.createdAt).toISOString().split("T")[0]; // Extract the date part
if (!acc[time]) {
acc[time] = { time, checks: [] };
}
acc[time].checks.push(check);
return acc;
}, {});
}
// Map grouped checks to stats
const aggregateData = Object.values(groupedChecks).map((group) => {
const totalChecks = group.checks.length;
const uptimePercentage = getUptimePercentage(group.checks);
const totalIncidents = group.checks.filter(
(check) => check.status === false
).length;
const avgResponseTime =
group.checks.reduce((sum, check) => sum + check.responseTime, 0) /
totalChecks;
return {
time: group.time,
uptimePercentage,
totalChecks,
totalIncidents,
avgResponseTime,
};
});
monitorStats.aggregateData = aggregateData;
}
//Get checks for dateRange
if (status !== undefined) {
checksQuery.status = status;
}
// Filter checks by "day", "week", or "month"
if (dateRange !== undefined) {
checksQuery.createdAt = { $gte: filterLookup[dateRange] };
}
let dateRangeChecks = await model
.find(checksQuery)
.sort({
createdAt: sortOrder,
})
.limit(limit);
const incidents = getIncidents(dateRangeChecks);
monitorStats.periodIncidents = getIncidents(checksForDateRange);
monitorStats.periodTotalChecks = checksForDateRange.length;
// If more than numToDisplay checks, pick every nth check
let nthChecks = checksForDateRange;
if (
numToDisplay !== undefined &&
dateRangeChecks &&
dateRangeChecks.length > numToDisplay
checksForDateRange &&
checksForDateRange.length > numToDisplay
) {
const n = Math.ceil(dateRangeChecks.length / numToDisplay);
dateRangeChecks = dateRangeChecks.filter((_, index) => index % n === 0);
const n = Math.ceil(checksForDateRange.length / numToDisplay);
nthChecks = checksForDateRange.filter((_, index) => index % n === 0);
}
// Normalize checks if requested
if (normalize !== undefined) {
dateRangeChecks = NormalizeData(dateRangeChecks, 1, 100);
const normailzedChecks = NormalizeData(nthChecks, 1, 100);
monitorStats.checks = normailzedChecks;
} else {
monitorStats.checks = nthChecks;
}
// Add common stats and stats that depend on the dateRange
monitorStats.uptimeDuration = calculateUptimeDuration(checksAll);
monitorStats.lastChecked = getLastChecked(checksAll);
monitorStats.latestResponseTime = getLatestResponseTime(checksAll);
monitorStats.incidents = incidents;
monitorStats.checks = dateRangeChecks;
return monitorStats;
} catch (error) {
error.methodName = "getMonitorStatsById";
throw error;
}
};
@@ -280,12 +277,58 @@ const getMonitorStatsById = async (req) => {
const getMonitorById = async (monitorId) => {
try {
const monitor = await Monitor.findById(monitorId);
return monitor;
if (monitor === null || monitor === undefined) {
throw new Error(errorMessages.DB_FIND_MONTIOR_BY_ID(monitorId));
}
// Get notifications
const notifications = await Notification.find({
monitorId: monitorId,
});
monitor.notifications = notifications;
const monitorWithNotifications = await monitor.save();
return monitorWithNotifications;
} catch (error) {
throw error;
}
};
/**
* Get monitors and Summary by TeamID
* @async
* @param {Express.Request} req
* @param {Express.Response} res
* @returns {Promise<Array<Monitor>>}
* @throws {Error}
*/
const getMonitorsAndSummaryByTeamId = async (teamId, type) => {
try {
const monitors = await Monitor.find({ teamId, type });
const monitorCounts = monitors.reduce(
(acc, monitor) => {
if (monitor.status === true) {
acc.up += 1;
}
if (monitor.status === false) {
acc.down += 1;
}
if (monitor.isActive === false) {
acc.paused += 1;
}
return acc;
},
{ up: 0, down: 0, paused: 0 }
);
monitorCounts.total = monitors.length;
return { monitors, monitorCounts };
} catch (error) {
error.method = "getMonitorsAndSummaryByTeamId";
throw error;
}
};
/**
* Get monitors by TeamID
* @async
@@ -296,8 +339,19 @@ const getMonitorById = async (monitorId) => {
*/
const getMonitorsByTeamId = async (req, res) => {
try {
let { limit, type, status, sortOrder, normalize } = req.query || {};
let { limit, type, status, sortOrder, normalize, page, rowsPerPage } =
req.query || {};
const monitorQuery = { teamId: req.params.teamId };
const monitorsCount = await Monitor.countDocuments({
teamId: req.params.teamId,
type,
});
// Pagination
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
if (type !== undefined) {
const types = Array.isArray(type) ? type : [type];
@@ -314,7 +368,9 @@ const getMonitorsByTeamId = async (req, res) => {
// This effectively removes limit, returning all checks
if (limit === undefined) limit = 0;
const monitors = await Monitor.find(monitorQuery);
const monitors = await Monitor.find(monitorQuery)
.skip(skip)
.limit(rowsPerPage);
// Map each monitor to include its associated checks
const monitorsWithChecks = await Promise.all(
monitors.map(async (monitor) => {
@@ -340,15 +396,10 @@ const getMonitorsByTeamId = async (req, res) => {
if (normalize !== undefined) {
checks = NormalizeData(checks, 10, 100);
}
// Get notifications
const notifications = await Notification.find({
monitorId: monitor._id,
});
return { ...monitor.toObject(), checks, notifications };
return { ...monitor.toObject(), checks };
})
);
return monitorsWithChecks;
return { monitors: monitorsWithChecks, monitorCount: monitorsCount };
} catch (error) {
throw error;
}
@@ -450,6 +501,7 @@ module.exports = {
getAllMonitors,
getMonitorStatsById,
getMonitorById,
getMonitorsAndSummaryByTeamId,
getMonitorsByTeamId,
createMonitor,
deleteMonitor,
+4 -1
View File
@@ -5,7 +5,10 @@ const handleErrors = (error, req, res, next) => {
const status = error.status || 500;
const message = error.message || errorMessages.FRIENDLY_ERROR;
const service = error.service || errorMessages.UNKNOWN_SERVICE;
logger.error(error.message, { service: service });
logger.error(error.message, {
service: service,
methodName: error.methodName,
});
res.status(status).json({ success: false, msg: message });
};
+12 -1
View File
@@ -1,4 +1,5 @@
const mongoose = require("mongoose");
const Notification = require("./Notification");
const MonitorSchema = mongoose.Schema(
{
@@ -23,7 +24,7 @@ const MonitorSchema = mongoose.Schema(
},
status: {
type: Boolean,
default: true,
default: undefined,
},
type: {
type: String,
@@ -43,6 +44,16 @@ const MonitorSchema = mongoose.Schema(
type: Number,
default: 60000,
},
uptimePercentage: {
type: Number,
default: undefined,
},
notifications: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Notification",
},
],
},
{
timestamps: true,
+835
View File
@@ -0,0 +1,835 @@
{
"openapi": "3.1.0",
"info": {
"title": "BlueWave Uptime",
"summary": "BlueWave Uptime OpenAPI Specifications",
"description": "BlueWave Uptime is an open source server monitoring application used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.",
"contact": {
"name": "API Support",
"url": "mailto:support@bluewavelabs.ca",
"email": "support@bluewavelabs.ca"
},
"license": {
"name": "AGPLv3",
"url": "https://github.com/bluewave-labs/bluewave-uptime/tree/HEAD/LICENSE"
},
"version": "1.0"
},
"servers": [
{
"url": "http://localhost:{PORT}/{API_PATH}",
"description": "Local Development Server",
"variables": {
"PORT": {
"description": "API Port",
"enum": ["5000", "3000", "8080"],
"default": "5000"
},
"API_PATH": {
"description": "API Base Path",
"enum": ["api/v1", "api/v1.1", "api/v2"],
"default": "api/v1"
}
}
}
],
"tags": [
{
"name": "auth",
"description": "Authentication"
},
{
"name": "invite",
"description": "Invite"
},
{
"name": "monitors",
"description": "Monitors"
},
{
"name": "checks",
"description": "Checks"
},
{
"name": "alerts",
"description": "Alerts"
},
{
"name": "pagespeed",
"description": "Page Speed"
},
{
"name": "maintenance-window",
"description": "Maintenance window"
},
{
"name": "healthy",
"description": "Health check"
},
{
"name": "mail",
"description": "Mail service"
}
],
"paths": {
"/auth/register": {
"post": {
"tags": ["auth"],
"description": "Register a new user",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["firstName", "lastName", "email", "password", "role", "teamId"],
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"format": "password"
},
"profileImage": {
"type": "file",
"format": "file"
},
"role": {
"type": "array",
"enum": [["user"], ["admin"], ["superadmin"]],
"default": ["superadmin"]
},
"teamId": {
"type": "string",
"format": "uuid"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/login": {
"post": {
"tags": ["auth"],
"description": "Login with credentials",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"format": "password"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/user/:userId": {
"put": {
"tags": ["auth"],
"description": "Change user informations",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"delete": {
"tags": ["auth"],
"description": "Delete user",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/users/admin": {
"get": {
"tags": ["auth"],
"description": "Checks to see if an admin account exists",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/users": {
"get": {
"tags": ["auth"],
"description": "Get all users",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/invite": {
"post": {
"tags": ["auth"],
"description": "Invite people to application",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/invite/verify": {
"post": {
"tags": ["auth"],
"description": "Verify the invite",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/recovery/request": {
"post": {
"tags": ["auth"],
"description": "Request a recovery token",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/recovery/validate": {
"post": {
"tags": ["auth"],
"description": "Validate recovery token",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/auth/recovery/reset": {
"post": {
"tags": ["auth"],
"description": "Password reset",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSuccessResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/healthy": {
"get": {
"tags": ["healthy"],
"description": "Health check",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ErrorResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
}
}
},
"SuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object"
}
}
},
"UserSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/UserDataType"
}
}
},
"MonitorSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/MonitorDataType"
}
}
},
"CheckDataSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/CheckDataType"
}
}
},
"AlertDataSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"$ref": "#/components/schemas/AlertDataType"
}
}
},
"UserDataType": {
"type": "object",
"required": [
"firstname",
"lastname",
"email",
"profilePicUrl",
"isActive",
"isVerified",
"updatedAt",
"createdAt"
],
"properties": {
"firstname": {
"type": "string"
},
"lastname": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"profilePicUrl": {
"type": "string"
},
"isActive": {
"type": "boolean"
},
"isVerified": {
"type": "boolean"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
},
"MonitorDataType": {
"type": "object",
"required": [
"userId",
"name",
"description",
"url",
"isActive",
"interval",
"updatedAt",
"createdAt"
],
"properties": {
"userId": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"url": {
"type": "string"
},
"isActive": {
"type": "boolean"
},
"interval": {
"type": "integer"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
},
"CheckDataType": {
"type": "object",
"required": [
"monitorId",
"status",
"responseTime",
"statusCode",
"message",
"updatedAt",
"createdAt"
],
"properties": {
"monitorId": {
"type": "string"
},
"status": {
"type": "string"
},
"responseTime": {
"type": "integer"
},
"statusCode": {
"type": "integer"
},
"message": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
},
"AlertDataType": {
"type": "object",
"required": [
"checkId",
"monitorId",
"userId",
"status",
"message",
"notifiedStatus",
"acknowledgedStatus",
"updatedAt",
"createdAt"
],
"properties": {
"checkId": {
"type": "string"
},
"monitorId": {
"type": "string"
},
"userId": {
"type": "string"
},
"status": {
"type": "string"
},
"message": {
"type": "string"
},
"notifiedStatus": {
"type": "boolean"
},
"acknowledgedStatus": {
"type": "boolean"
},
"updatedAt": {
"type": "string"
},
"createdAt": {
"type": "string"
}
}
}
}
}
}
+7
View File
@@ -1,6 +1,7 @@
const router = require("express").Router();
const checkController = require("../controllers/checkController");
const { verifyOwnership } = require("../middleware/verifyOwnership");
const { isAllowed } = require("../middleware/isAllowed");
const Monitor = require("../models/Monitor");
router.post(
@@ -19,4 +20,10 @@ router.delete(
checkController.deleteChecks
);
router.delete(
"/team/:teamId",
isAllowed(["admin", "superadmin"]),
checkController.deleteChecksByTeamId
);
module.exports = router;
+5
View File
@@ -3,9 +3,14 @@ const monitorController = require("../controllers/monitorController");
const { isAllowed } = require("../middleware/isAllowed");
router.get("/", monitorController.getAllMonitors);
router.get("/aggregate/:monitorId", monitorController.getMonitorAggregateStats);
router.get("/stats/:monitorId", monitorController.getMonitorStatsById);
router.get("/certificate/:monitorId", monitorController.getMonitorCertificate);
router.get("/:monitorId", monitorController.getMonitorById);
router.get(
"/team/summary/:teamId",
monitorController.getMonitorsAndSummaryByTeamId
);
router.get("/team/:teamId", monitorController.getMonitorsByTeamId);
router.post(
+3 -2
View File
@@ -39,7 +39,9 @@ class JobQueue {
queue.networkService = networkService;
const monitors = await db.getAllMonitors();
for (const monitor of monitors) {
await queue.addJob(monitor.id, monitor);
if (monitor.active) {
await queue.addJob(monitor.id, monitor);
}
}
const workerStats = await queue.getWorkerStats();
await queue.scaleWorkers(workerStats);
@@ -238,7 +240,6 @@ class JobQueue {
console.log("Adding job", payload.url);
// Execute job immediately
await this.queue.add(jobName, payload);
await this.queue.add(jobName, payload, {
repeat: {
every: payload.interval,
+45 -22
View File
@@ -15,28 +15,15 @@ class NetworkService {
this.NETWORK_ERROR = 5000;
}
async handleNotification(job, isAlive) {
const { _id } = job.data;
const monitor = await this.db.getMonitorById(_id);
if (monitor === null || monitor === undefined) {
logger.error(`Null Monitor: ${_id}`, {
method: "handleNotification",
service: this.SERVICE_NAME,
jobId: job.id,
});
return;
}
// If monitor status changes, update monitor status and send notification
if (monitor.status !== isAlive) {
monitor.status = !monitor.status;
await monitor.save();
async handleNotification(monitor, isAlive) {
try {
let template =
isAlive === true ? "serverIsUpTemplate" : "serverIsDownTemplate";
let status = isAlive === true ? "up" : "down";
const notifications = await this.db.getNotificationsByMonitorId(_id);
const notifications = await this.db.getNotificationsByMonitorId(
monitor._id
);
for (const notification of notifications) {
if (notification.type === "email") {
await this.emailService.buildAndSendEmail(
@@ -47,6 +34,43 @@ class NetworkService {
);
}
}
} catch (error) {
logger.error(error.message, {
method: "handleNotification",
service: this.SERVICE_NAME,
monitorId: monitor._id,
});
}
}
async handleStatusUpdate(job, isAlive) {
try {
const { _id } = job.data;
const monitor = await this.db.getMonitorById(_id);
if (monitor === null || monitor === undefined) {
logger.error(`Null Monitor: ${_id}`, {
method: "handleStatusUpdate",
service: this.SERVICE_NAME,
jobId: job.id,
});
return;
}
if (monitor.status === undefined || monitor.status !== isAlive) {
const oldStatus = monitor.status;
monitor.status = isAlive;
await monitor.save();
if (oldStatus !== undefined && oldStatus !== isAlive) {
this.handleNotification(monitor, isAlive);
}
}
} catch (error) {
logger.error(error.message, {
method: "handleStatusUpdate",
service: this.SERVICE_NAME,
jobId: job.id,
});
}
}
@@ -105,7 +129,7 @@ class NetworkService {
};
return await this.logAndStoreCheck(checkData, this.db.createCheck);
} finally {
this.handleNotification(job, isAlive);
this.handleStatusUpdate(job, isAlive);
}
}
@@ -153,7 +177,7 @@ class NetworkService {
return await this.logAndStoreCheck(checkData, this.db.createCheck);
} finally {
this.handleNotification(job, isAlive);
this.handleStatusUpdate(job, isAlive);
}
}
@@ -173,7 +197,6 @@ class NetworkService {
*/
async handlePagespeed(job) {
let isAlive;
try {
const url = job.data.url;
const response = await axios.get(
@@ -233,7 +256,7 @@ class NetworkService {
};
this.logAndStoreCheck(checkData, this.db.createPageSpeedCheck);
} finally {
this.handleNotification(job, isAlive);
this.handleStatusUpdate(job, isAlive);
}
}
+51
View File
@@ -1,4 +1,5 @@
const joi = require("joi");
const { normalize } = require("path");
//****************************************
// Custom Validators
@@ -160,6 +161,19 @@ const getMonitorByIdQueryValidation = joi.object({
normalize: joi.boolean(),
});
const getMonitorsAndSummaryByTeamIdParamValidation = joi.object({
teamId: joi.string().required(),
});
const getMonitorsAndSummaryByTeamIdQueryValidation = joi.object({
type: joi
.alternatives()
.try(
joi.string().valid("http", "ping", "pagespeed"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed"))
),
});
const getMonitorsByTeamIdValidation = joi.object({
teamId: joi.string().required(),
});
@@ -175,6 +189,24 @@ const getMonitorsByTeamIdQueryValidation = joi.object({
joi.string().valid("http", "ping", "pagespeed"),
joi.array().items(joi.string().valid("http", "ping", "pagespeed"))
),
page: joi.number(),
rowsPerPage: joi.number(),
});
const getMonitorStatsByIdParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorStatsByIdQueryValidation = joi.object({
status: joi.string(),
limit: joi.number(),
sortOrder: joi.string().valid("asc", "desc"),
dateRange: joi.string().valid("day", "week", "month"),
numToDisplay: joi.number(),
normalize: joi.boolean(),
});
const getCertificateParamValidation = joi.object({
monitorId: joi.string().required(),
});
const createMonitorBodyValidation = joi.object({
@@ -201,6 +233,13 @@ const pauseMonitorParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorAggregateStatsParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorAggregateStatsQueryValidation = joi.object({
dateRange: joi.string().valid("day", "week", "month"),
});
//****************************************
// Alerts
//****************************************
@@ -292,6 +331,10 @@ const deleteChecksParamValidation = joi.object({
monitorId: joi.string().required(),
});
const deleteChecksByTeamIdParamValidation = joi.object({
teamId: joi.string().required(),
});
//****************************************
// PageSpeedCheckValidation
//****************************************
@@ -351,8 +394,15 @@ module.exports = {
createMonitorBodyValidation,
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsAndSummaryByTeamIdParamValidation,
getMonitorsAndSummaryByTeamIdQueryValidation,
getMonitorsByTeamIdValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getCertificateParamValidation,
getMonitorAggregateStatsParamValidation,
getMonitorAggregateStatsQueryValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
editUserParamValidation,
@@ -372,6 +422,7 @@ module.exports = {
getTeamChecksParamValidation,
getTeamChecksQueryValidation,
deleteChecksParamValidation,
deleteChecksByTeamIdParamValidation,
deleteUserParamValidation,
getPageSpeedCheckParamValidation,
createPageSpeedCheckParamValidation,