mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-02-05 00:28:30 -06:00
Merge remote-tracking branch 'checkmate/develop' into fix/fe/profilepanel-ui
This commit is contained in:
@@ -1 +1,2 @@
|
||||
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
|
||||
VITE_STATUS_PAGE_SUBDOMAIN_PREFIX=UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX
|
||||
|
||||
670
Client/package-lock.json
generated
670
Client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,25 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource/roboto": "^5.0.13",
|
||||
"@mui/icons-material": "6.4.2",
|
||||
"@mui/lab": "6.0.0-beta.25",
|
||||
"@mui/material": "6.4.2",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@mui/icons-material": "6.4.3",
|
||||
"@mui/lab": "6.0.0-beta.26",
|
||||
"@mui/material": "6.4.3",
|
||||
"@mui/x-charts": "^7.5.1",
|
||||
"@mui/x-data-grid": "7.24.1",
|
||||
"@mui/x-date-pickers": "7.24.1",
|
||||
"@mui/x-data-grid": "7.25.0",
|
||||
"@mui/x-date-pickers": "7.25.0",
|
||||
"@reduxjs/toolkit": "2.5.1",
|
||||
"axios": "^1.7.4",
|
||||
"dayjs": "1.11.13",
|
||||
"flag-icons": "7.3.2",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"joi": "17.13.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"maplibre-gl": "5.1.0",
|
||||
"mui-color-input": "^5.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "9.2.0",
|
||||
"react-router": "^6.23.0",
|
||||
|
||||
@@ -11,6 +11,7 @@ const ChartBox = ({
|
||||
justifyContent = "space-between",
|
||||
Legend,
|
||||
borderRadiusRight = 4,
|
||||
sx,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
@@ -70,8 +71,8 @@ const ChartBox = ({
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<IconBox>{icon}</IconBox>
|
||||
<Typography component="h2">{header}</Typography>
|
||||
{icon && <IconBox>{icon}</IconBox>}
|
||||
{header && <Typography component="h2">{header}</Typography>}
|
||||
</Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
@@ -84,7 +85,7 @@ export default ChartBox;
|
||||
|
||||
ChartBox.propTypes = {
|
||||
children: PropTypes.node,
|
||||
icon: PropTypes.node.isRequired,
|
||||
header: PropTypes.string.isRequired,
|
||||
icon: PropTypes.node,
|
||||
header: PropTypes.string,
|
||||
height: PropTypes.string,
|
||||
};
|
||||
|
||||
175
Client/src/Components/Charts/StatusPageBarChart/index.jsx
Normal file
175
Client/src/Components/Charts/StatusPageBarChart/index.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Box, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import { formatDateWithTz } from "../../../Utils/timeUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
/* TODO add prop validation and jsdocs */
|
||||
const StatusPageBarChart = ({ checks = [] }) => {
|
||||
const theme = useTheme();
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
useEffect(() => {
|
||||
setAnimate(true);
|
||||
}, []);
|
||||
|
||||
// set responseTime to average if there's only one check
|
||||
if (checks.length === 1) {
|
||||
checks[0] = { ...checks[0], responseTime: 50 };
|
||||
}
|
||||
|
||||
if (checks.length !== 25) {
|
||||
const placeholders = Array(25 - checks.length).fill("placeholder");
|
||||
checks = [...checks, ...placeholders];
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexWrap="nowrap"
|
||||
height="50px"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
sx={{
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
{checks.map((check, index) =>
|
||||
check === "placeholder" ? (
|
||||
/* TODO what is the purpose of this box? */
|
||||
// CAIO_REVIEW the purpose of this box is to make sure there are always at least 25 bars
|
||||
// even if there are less than 25 checks
|
||||
<Box
|
||||
key={`${check}-${index}`}
|
||||
position="relative"
|
||||
width="calc(30vw / 25)"
|
||||
height="100%"
|
||||
backgroundColor={theme.palette.primary.lowContrast}
|
||||
sx={{
|
||||
borderRadius: theme.spacing(1.5),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
<Typography>
|
||||
{formatDateWithTz(
|
||||
check.createdAt,
|
||||
"ddd, MMMM D, YYYY, HH:mm A",
|
||||
uiTimezone
|
||||
)}
|
||||
</Typography>
|
||||
<Box mt={theme.spacing(2)}>
|
||||
<Box
|
||||
display="inline-block"
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
backgroundColor={
|
||||
check.status
|
||||
? theme.palette.success.lowContrast
|
||||
: theme.palette.error.lowContrast
|
||||
}
|
||||
sx={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<Stack
|
||||
display="inline-flex"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
ml={theme.spacing(2)}
|
||||
gap={theme.spacing(12)}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
Response Time
|
||||
</Typography>
|
||||
<Typography component="span">
|
||||
{check.originalResponseTime}
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{" "}
|
||||
ms
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
placement="top"
|
||||
key={`check-${check?._id}`}
|
||||
slotProps={{
|
||||
popper: {
|
||||
className: "bar-tooltip",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, -10],
|
||||
},
|
||||
},
|
||||
],
|
||||
sx: {
|
||||
"& .MuiTooltip-tooltip": {
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxShadow: theme.shape.boxShadow,
|
||||
px: theme.spacing(4),
|
||||
py: theme.spacing(3),
|
||||
},
|
||||
"& .MuiTooltip-tooltip p": {
|
||||
/* TODO Font size should point to theme */
|
||||
fontSize: 12,
|
||||
color: theme.palette.secondary.contrastText,
|
||||
fontWeight: 500,
|
||||
},
|
||||
"& .MuiTooltip-tooltip span": {
|
||||
/* TODO Font size should point to theme */
|
||||
fontSize: 11,
|
||||
color: theme.palette.secondary.contrastText,
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="relative"
|
||||
width="calc(30vw / 25)"
|
||||
height="100%"
|
||||
backgroundColor={theme.palette.primary.lowContrast} // CAIO_REVIEW
|
||||
sx={{
|
||||
borderRadius: theme.spacing(1.5),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
width="100%"
|
||||
height={`${animate ? check.responseTime : 0}%`}
|
||||
backgroundColor={
|
||||
check.status
|
||||
? theme.palette.success.lowContrast
|
||||
: theme.palette.error.lowContrast
|
||||
}
|
||||
sx={{
|
||||
borderRadius: theme.spacing(1.5),
|
||||
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusPageBarChart;
|
||||
@@ -29,10 +29,7 @@ const ConfigBox = styled(Stack)(({ theme }) => ({
|
||||
},
|
||||
"& h1, & h2": {
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
},
|
||||
"& p": {
|
||||
color: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
||||
export default ConfigBox;
|
||||
|
||||
20
Client/src/Components/GenericFallback/NetworkError.jsx
Normal file
20
Client/src/Components/GenericFallback/NetworkError.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
|
||||
const NetworkError = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkError;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { Box, Stack } from "@mui/material";
|
||||
import Skeleton from "../../assets/Images/create-placeholder.svg?react";
|
||||
import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react";
|
||||
import Background from "../../assets/Images/background-grid.svg?react";
|
||||
@@ -11,13 +11,13 @@ import { useSelector } from "react-redux";
|
||||
* @returns {JSX.Element} The rendered fallback UI.
|
||||
*/
|
||||
|
||||
const NetworkErrorFallback = () => {
|
||||
const GenericFallback = ({ children }) => {
|
||||
const theme = useTheme();
|
||||
const mode = useSelector((state) => state.ui.mode);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="page-speed"
|
||||
padding={theme.spacing(16)}
|
||||
position="relative"
|
||||
border={1}
|
||||
borderColor={theme.palette.primary.lowContrast}
|
||||
@@ -68,18 +68,11 @@ const NetworkErrorFallback = () => {
|
||||
maxWidth={"300px"}
|
||||
zIndex={1}
|
||||
>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkErrorFallback;
|
||||
export default GenericFallback;
|
||||
57
Client/src/Components/Image/index.jsx
Normal file
57
Client/src/Components/Image/index.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Box } from "@mui/material";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const isValidBase64Image = (data) => {
|
||||
return /^[A-Za-z0-9+/=]+$/.test(data);
|
||||
};
|
||||
|
||||
const Image = ({
|
||||
shouldRender = true,
|
||||
src,
|
||||
alt,
|
||||
width = "auto",
|
||||
height = "auto",
|
||||
maxWidth = "auto",
|
||||
maxHeight = "auto",
|
||||
base64,
|
||||
sx,
|
||||
}) => {
|
||||
if (shouldRender === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof src !== "undefined" && typeof base64 !== "undefined") {
|
||||
console.warn("base64 takes precedence over src and overwrites it");
|
||||
}
|
||||
|
||||
if (typeof base64 !== "undefined" && isValidBase64Image(base64)) {
|
||||
src = `data:image/png;base64,${base64}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
maxWidth={maxWidth}
|
||||
maxHeight={maxHeight}
|
||||
width={width}
|
||||
height={height}
|
||||
sx={{ ...sx }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Image.propTypes = {
|
||||
shouldRender: PropTypes.bool,
|
||||
src: PropTypes.string,
|
||||
alt: PropTypes.string.isRequired,
|
||||
width: PropTypes.string,
|
||||
height: PropTypes.string,
|
||||
maxWidth: PropTypes.string,
|
||||
maxHeight: PropTypes.string,
|
||||
base64: PropTypes.string,
|
||||
sx: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Image;
|
||||
@@ -75,7 +75,6 @@ const Checkbox = ({
|
||||
sx={{
|
||||
"&:hover": { backgroundColor: "transparent" },
|
||||
"& svg": { width: sizes[size], height: sizes[size] },
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -111,7 +110,7 @@ Checkbox.propTypes = {
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
size: PropTypes.oneOf(["small", "medium", "large"]),
|
||||
isChecked: PropTypes.bool.isRequired,
|
||||
value: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
onChange: PropTypes.func,
|
||||
isDisabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
63
Client/src/Components/Inputs/ColorPicker/index.jsx
Normal file
63
Client/src/Components/Inputs/ColorPicker/index.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { MuiColorInput } from "mui-color-input";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} id The ID of the component
|
||||
* @param {*} value The color value of the component
|
||||
* @param {*} error The error of the component
|
||||
* @param {*} onChange The Change handler function
|
||||
* @param {*} onBlur The Blur handler function
|
||||
* @returns The ColorPicker component
|
||||
* Example usage:
|
||||
* <ColorPicker
|
||||
* id="color"
|
||||
* value={form.color}
|
||||
* error={errors["color"]}
|
||||
* onChange={handleColorChange}
|
||||
* onBlur={handleBlur}
|
||||
* >
|
||||
* </ColorPicker>
|
||||
*/
|
||||
const ColorPicker = ({ id, name, value, error, onChange, onBlur }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<MuiColorInput
|
||||
format="hex"
|
||||
name={name}
|
||||
type="color-picker"
|
||||
value={value}
|
||||
id={id}
|
||||
onChange={(color) => onChange({ target: { name, value: color } })}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{error && (
|
||||
<Typography
|
||||
component="span"
|
||||
className="input-error"
|
||||
color={theme.palette.error.main}
|
||||
mt={theme.spacing(2)}
|
||||
sx={{
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
ColorPicker.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onBlur: PropTypes.func,
|
||||
name: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -11,11 +11,16 @@ import { checkImage } from "../../../Utils/fileUtils";
|
||||
* @param {string} props.id - The unique identifier for the input field.
|
||||
* @param {string} props.src - The URL of the image to display.
|
||||
* @param {function} props.onChange - The function to handle file input change.
|
||||
* @param {boolean} props.isRound - Whether the shape of the image to display is round.
|
||||
* @param {string} props.maxSize - Custom message for the max uploaded file size
|
||||
* @returns {JSX.Element} The rendered component.
|
||||
*/
|
||||
|
||||
const ImageField = ({ id, src, loading, onChange }) => {
|
||||
const ImageField = ({ id, src, loading, onChange, error, isRound = true, maxSize }) => {
|
||||
const theme = useTheme();
|
||||
const error_border_style = error ? { borderColor: theme.palette.error.main } : {};
|
||||
|
||||
const roundShape = isRound ? { borderRadius: "50%" } : {};
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const handleDragEnter = () => {
|
||||
@@ -46,6 +51,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
|
||||
},
|
||||
...error_border_style,
|
||||
}}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -62,6 +68,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
|
||||
cursor: "pointer",
|
||||
maxWidth: "500px",
|
||||
minHeight: "175px",
|
||||
zIndex: 1,
|
||||
},
|
||||
"& fieldset": {
|
||||
padding: 0,
|
||||
@@ -78,7 +85,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: "-1",
|
||||
zIndex: 0,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
@@ -111,7 +118,7 @@ const ImageField = ({ id, src, loading, onChange }) => {
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
(maximum size: 3MB)
|
||||
(maximum size: {maxSize ?? "3MB"})
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -122,6 +129,19 @@ const ImageField = ({ id, src, loading, onChange }) => {
|
||||
>
|
||||
Supported formats: JPG, PNG
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography
|
||||
component="span"
|
||||
className="input-error"
|
||||
color={theme.palette.error.main}
|
||||
mt={theme.spacing(2)}
|
||||
sx={{
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Stack
|
||||
@@ -132,10 +152,10 @@ const ImageField = ({ id, src, loading, onChange }) => {
|
||||
sx={{
|
||||
width: "250px",
|
||||
height: "250px",
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
backgroundImage: `url(${src})`,
|
||||
backgroundSize: "cover",
|
||||
...roundShape,
|
||||
}}
|
||||
></Box>
|
||||
</Stack>
|
||||
@@ -148,6 +168,8 @@ ImageField.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
src: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isRound: PropTypes.bool,
|
||||
maxSize: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ImageField;
|
||||
|
||||
@@ -12,6 +12,7 @@ import SearchIcon from "../../../assets/icons/search.svg?react";
|
||||
* @param {string} props.filteredBy - Key to access the option label from the options
|
||||
* @param {string} props.value - Current input value for the Autocomplete
|
||||
* @param {Function} props.handleChange - Function to call when the input changes
|
||||
* @param {Function} Prop.onBlur - Function to call when the input is blured
|
||||
* @param {Object} props.sx - Additional styles to apply to the component
|
||||
* @returns {JSX.Element} The rendered Search component
|
||||
*/
|
||||
@@ -54,10 +55,14 @@ const Search = ({
|
||||
isAdorned = true,
|
||||
error,
|
||||
disabled,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
onBlur,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Autocomplete
|
||||
onBlur={onBlur}
|
||||
multiple={multiple}
|
||||
id={id}
|
||||
value={value}
|
||||
@@ -66,7 +71,7 @@ const Search = ({
|
||||
handleInputChange(newValue);
|
||||
}}
|
||||
onChange={(_, newValue) => {
|
||||
handleChange && handleChange(newValue);
|
||||
handleChange(newValue);
|
||||
}}
|
||||
fullWidth
|
||||
freeSolo
|
||||
@@ -74,6 +79,7 @@ const Search = ({
|
||||
disableClearable
|
||||
options={options}
|
||||
getOptionLabel={(option) => option[filteredBy]}
|
||||
isOptionEqualToValue={(option, value) => option._id === value._id} // Compare by unique identifier
|
||||
renderInput={(params) => (
|
||||
<Stack>
|
||||
<Typography
|
||||
@@ -88,9 +94,13 @@ const Search = ({
|
||||
{...params}
|
||||
error={Boolean(error)}
|
||||
placeholder="Type to search"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
...(isAdorned && { startAdornment: <SearchAdornment /> }),
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
...(isAdorned && { startAdornment: <SearchAdornment /> }),
|
||||
...(startAdornment && { startAdornment: startAdornment }),
|
||||
...(endAdornment && { endAdornment: endAdornment }),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
"& fieldset": {
|
||||
@@ -212,6 +222,9 @@ Search.propTypes = {
|
||||
sx: PropTypes.object,
|
||||
error: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
startAdornment: PropTypes.object,
|
||||
endAdornment: PropTypes.object,
|
||||
onBlur: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Search;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Stack, Typography, InputAdornment, IconButton } from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import VisibilityOff from "@mui/icons-material/VisibilityOff";
|
||||
import Visibility from "@mui/icons-material/Visibility";
|
||||
import ReorderRoundedIcon from "@mui/icons-material/ReorderRounded";
|
||||
import DeleteIcon from "../../../../assets/icons/trash-bin.svg?react";
|
||||
|
||||
export const HttpAdornment = ({ https }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
@@ -23,7 +25,7 @@ export const HttpAdornment = ({ https }) => {
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
sx={{ lineHeight: 1, opacity: 0.8 }}
|
||||
>
|
||||
{https ? "https" : "http"}://
|
||||
{https ? "https" : "http"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
@@ -31,6 +33,7 @@ export const HttpAdornment = ({ https }) => {
|
||||
|
||||
HttpAdornment.propTypes = {
|
||||
https: PropTypes.bool.isRequired,
|
||||
prefix: PropTypes.string,
|
||||
};
|
||||
|
||||
export const PasswordEndAdornment = ({ fieldType, setFieldType }) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
|
||||
const CreateMonitorHeader = ({ isAdmin, shouldRender, path }) => {
|
||||
const CreateMonitorHeader = ({ isAdmin, shouldRender = true, path }) => {
|
||||
const navigate = useNavigate();
|
||||
if (!isAdmin) return null;
|
||||
if (!shouldRender) return <SkeletonLayout />;
|
||||
@@ -28,6 +28,6 @@ export default CreateMonitorHeader;
|
||||
|
||||
CreateMonitorHeader.propTypes = {
|
||||
isAdmin: PropTypes.bool.isRequired,
|
||||
shouldRender: PropTypes.bool.isRequired,
|
||||
shouldRender: PropTypes.bool,
|
||||
path: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "@emotion/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
|
||||
import PropTypes from "prop-types";
|
||||
const ConfigButton = ({ shouldRender, monitorId }) => {
|
||||
const ConfigButton = ({ shouldRender = true, monitorId, path }) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -14,7 +14,7 @@ const ConfigButton = ({ shouldRender, monitorId }) => {
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/uptime/configure/${monitorId}`)}
|
||||
onClick={() => navigate(`/${path}/configure/${monitorId}`)}
|
||||
sx={{
|
||||
px: theme.spacing(5),
|
||||
"& svg": {
|
||||
@@ -34,7 +34,8 @@ const ConfigButton = ({ shouldRender, monitorId }) => {
|
||||
|
||||
ConfigButton.propTypes = {
|
||||
shouldRender: PropTypes.bool,
|
||||
monitorId: PropTypes.string,
|
||||
monitorId: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ConfigButton;
|
||||
|
||||
@@ -8,7 +8,7 @@ import ConfigButton from "./ConfigButton";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const MonitorStatusHeader = ({ shouldRender = true, isAdmin, monitor }) => {
|
||||
const MonitorStatusHeader = ({ path, shouldRender = true, isAdmin, monitor }) => {
|
||||
const theme = useTheme();
|
||||
const { statusColor, statusMsg, determineState } = useUtils();
|
||||
if (!shouldRender) {
|
||||
@@ -38,6 +38,7 @@ const MonitorStatusHeader = ({ shouldRender = true, isAdmin, monitor }) => {
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ConfigButton
|
||||
path={path}
|
||||
shouldRender={isAdmin}
|
||||
monitorId={monitor?._id}
|
||||
/>
|
||||
@@ -46,6 +47,7 @@ const MonitorStatusHeader = ({ shouldRender = true, isAdmin, monitor }) => {
|
||||
};
|
||||
|
||||
MonitorStatusHeader.propTypes = {
|
||||
path: PropTypes.string.isRequired,
|
||||
shouldRender: PropTypes.bool,
|
||||
isAdmin: PropTypes.bool,
|
||||
monitor: PropTypes.object,
|
||||
|
||||
@@ -3,25 +3,22 @@ import { useTheme } from "@emotion/react";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const TimeFramePicker = ({ shouldRender = true, dateRange, setDateRange }) => {
|
||||
const MonitorTimeFrameHeader = ({
|
||||
shouldRender = true,
|
||||
hasDateRange = true,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!shouldRender) {
|
||||
return <SkeletonLayout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
gap={theme.spacing(4)}
|
||||
mb={theme.spacing(8)}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Showing statistics for past{" "}
|
||||
{dateRange === "day" ? "24 hours" : dateRange === "week" ? "7 days" : "30 days"}.
|
||||
</Typography>
|
||||
let timeFramePicker = null;
|
||||
|
||||
if (hasDateRange) {
|
||||
timeFramePicker = (
|
||||
<ButtonGroup sx={{ height: 32 }}>
|
||||
<Button
|
||||
variant="group"
|
||||
@@ -45,14 +42,31 @@ const TimeFramePicker = ({ shouldRender = true, dateRange, setDateRange }) => {
|
||||
Month
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
gap={theme.spacing(4)}
|
||||
mb={theme.spacing(8)}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Showing statistics for past{" "}
|
||||
{dateRange === "day" ? "24 hours" : dateRange === "week" ? "7 days" : "30 days"}.
|
||||
</Typography>
|
||||
{timeFramePicker}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
TimeFramePicker.propTypes = {
|
||||
MonitorTimeFrameHeader.propTypes = {
|
||||
shouldRender: PropTypes.bool,
|
||||
hasDateRange: PropTypes.bool,
|
||||
dateRange: PropTypes.string,
|
||||
setDateRange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default TimeFramePicker;
|
||||
export default MonitorTimeFrameHeader;
|
||||
@@ -168,7 +168,7 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick, error }) =>
|
||||
|
||||
ProgressUpload.propTypes = {
|
||||
icon: PropTypes.element, // JSX element for the icon (optional)
|
||||
label: PropTypes.string.isRequired, // Label text for the progress item
|
||||
label: PropTypes.string, // Label text for the progress item
|
||||
size: PropTypes.string.isRequired, // Size information for the progress item
|
||||
progress: PropTypes.number.isRequired, // Current progress value (0-100)
|
||||
onClick: PropTypes.func.isRequired, // Function to handle click events on the remove button
|
||||
|
||||
@@ -43,7 +43,9 @@ import DotsVertical from "../../assets/icons/dots-vertical.svg?react";
|
||||
import ChangeLog from "../../assets/icons/changeLog.svg?react";
|
||||
import Docs from "../../assets/icons/docs.svg?react";
|
||||
import Folder from "../../assets/icons/folder.svg?react";
|
||||
import StatusPages from "../../assets/icons/status-pages.svg?react";
|
||||
import ChatBubbleOutlineRoundedIcon from "@mui/icons-material/ChatBubbleOutlineRounded";
|
||||
import Groups from "../../assets/icons/groups.svg?react";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
@@ -51,8 +53,10 @@ const menu = [
|
||||
{ name: "Uptime", path: "uptime", icon: <Monitors /> },
|
||||
{ name: "Pagespeed", path: "pagespeed", icon: <PageSpeed /> },
|
||||
{ name: "Infrastructure", path: "infrastructure", icon: <Integrations /> },
|
||||
{ name: "Distributed Uptime", path: "distributed-uptime", icon: <Groups /> },
|
||||
{ name: "Incidents", path: "incidents", icon: <Incidents /> },
|
||||
// { name: "Status pages", path: "status", icon: <StatusPages /> },
|
||||
|
||||
{ name: "Status pages", path: "status", icon: <StatusPages /> },
|
||||
{ name: "Maintenance", path: "maintenance", icon: <Maintenance /> },
|
||||
// { name: "Integrations", path: "integrations", icon: <Integrations /> },
|
||||
{
|
||||
@@ -97,6 +101,7 @@ const PATH_MAP = {
|
||||
monitors: "Dashboard",
|
||||
pagespeed: "Dashboard",
|
||||
infrastructure: "Dashboard",
|
||||
["distributed-uptime"]: "Dashboard",
|
||||
account: "Account",
|
||||
settings: "Settings",
|
||||
};
|
||||
@@ -335,15 +340,13 @@ function Sidebar() {
|
||||
disableInteractive
|
||||
>
|
||||
<ListItemButton
|
||||
className={location.pathname.includes(item.path) ? "selected-path" : ""}
|
||||
className={location.pathname === `/${item.path}` ? "selected-path" : ""}
|
||||
onClick={() => navigate(`/${item.path}`)}
|
||||
sx={{
|
||||
/*
|
||||
TODO we do not need this height
|
||||
minHeight: "37px", */
|
||||
p: theme.spacing(5),
|
||||
height: "37px",
|
||||
gap: theme.spacing(4),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import Image from "../Image";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import PropTypes from "prop-types";
|
||||
import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
|
||||
@@ -29,7 +30,15 @@ import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
|
||||
* @returns {React.ReactElement} A styled box containing the statistic
|
||||
*/
|
||||
|
||||
const StatBox = ({ heading, subHeading, gradient = false, status = "", sx }) => {
|
||||
const StatBox = ({
|
||||
img,
|
||||
alt,
|
||||
heading,
|
||||
subHeading,
|
||||
gradient = false,
|
||||
status = "",
|
||||
sx,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { statusToTheme } = useUtils();
|
||||
const themeColor = statusToTheme[status];
|
||||
@@ -70,7 +79,8 @@ const StatBox = ({ heading, subHeading, gradient = false, status = "", sx }) =>
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
/* TODO why are we using width and min width here? */
|
||||
@@ -95,9 +105,20 @@ const StatBox = ({ heading, subHeading, gradient = false, status = "", sx }) =>
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Typography component="h2">{heading}</Typography>
|
||||
<Typography>{subHeading}</Typography>
|
||||
</Box>
|
||||
{img && (
|
||||
<Image
|
||||
src={img}
|
||||
height={"30px"}
|
||||
width={"30px"}
|
||||
alt={alt}
|
||||
sx={{ marginRight: theme.spacing(8) }}
|
||||
/>
|
||||
)}
|
||||
<Stack>
|
||||
<Typography component="h2">{heading}</Typography>
|
||||
<Typography>{subHeading}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,15 +4,21 @@ import SkeletonLayout from "./skeleton";
|
||||
// Utils
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import PropTypes from "prop-types";
|
||||
const StatusBoxes = ({ shouldRender, children }) => {
|
||||
const StatusBoxes = ({ shouldRender, flexWrap = "nowrap", children }) => {
|
||||
const theme = useTheme();
|
||||
if (!shouldRender) {
|
||||
return <SkeletonLayout numBoxes={children?.length ?? 1} />;
|
||||
return (
|
||||
<SkeletonLayout
|
||||
numBoxes={children?.length ?? 1}
|
||||
flexWrap={flexWrap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap={flexWrap}
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Stack, Skeleton } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PropTypes from "prop-types";
|
||||
const SkeletonLayout = ({ numBoxes }) => {
|
||||
const SkeletonLayout = ({ numBoxes, flexWrap }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap={flexWrap}
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(4)}
|
||||
>
|
||||
{Array.from({ length: numBoxes }).map((_, index) => {
|
||||
const width = `${100 / numBoxes}%`;
|
||||
const width = `${200 / numBoxes}%`;
|
||||
return (
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width={width}
|
||||
height={50}
|
||||
height={100}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
@@ -25,6 +26,7 @@ const SkeletonLayout = ({ numBoxes }) => {
|
||||
};
|
||||
|
||||
SkeletonLayout.propTypes = {
|
||||
flexWrap: PropTypes.string,
|
||||
numBoxes: PropTypes.number,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Pagination.propTypes = {
|
||||
paginationLabel: PropTypes.string, // Label for the pagination.
|
||||
itemCount: PropTypes.number, // Total number of items for pagination.
|
||||
page: PropTypes.number, // Current page index.
|
||||
rowsPerPage: PropTypes.number.isRequired, // Number of rows displayed per page.
|
||||
rowsPerPage: PropTypes.number, // Number of rows displayed per page.
|
||||
handleChangePage: PropTypes.func.isRequired, // Function to handle page changes.
|
||||
handleChangeRowsPerPage: PropTypes.func, // Function to handle changes in rows per page.
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTheme } from "@emotion/react";
|
||||
|
||||
@@ -33,6 +34,7 @@ import { useTheme } from "@emotion/react";
|
||||
*/
|
||||
|
||||
const DataTable = ({
|
||||
shouldRender = true,
|
||||
headers = [],
|
||||
data = [],
|
||||
config = {
|
||||
@@ -41,6 +43,10 @@ const DataTable = ({
|
||||
},
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
if (!shouldRender) {
|
||||
return <SkeletonLayout />;
|
||||
}
|
||||
|
||||
if ((headers?.length ?? 0) === 0) {
|
||||
return "No data";
|
||||
}
|
||||
@@ -117,6 +123,7 @@ const DataTable = ({
|
||||
};
|
||||
|
||||
DataTable.propTypes = {
|
||||
shouldRender: PropTypes.bool,
|
||||
headers: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
const UptimeDataTableSkeleton = () => {
|
||||
const TableSkeleton = () => {
|
||||
return (
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
width="100%"
|
||||
height="100%"
|
||||
height="80%"
|
||||
flex={1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UptimeDataTableSkeleton;
|
||||
export default TableSkeleton;
|
||||
@@ -87,33 +87,6 @@ const Account = ({ open = "profile" }) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => handleFocus(label.toLowerCase())}
|
||||
tabIndex={index}
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.tertiary.contrastText,
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
textTransform: "none",
|
||||
minWidth: "fit-content",
|
||||
paddingY: theme.spacing(6),
|
||||
fontWeight: 400,
|
||||
borderBottom: "2px solid transparent",
|
||||
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
"&:first-child": { borderTopLeftRadius: "8px" },
|
||||
"&:last-child": { borderTopRightRadius: "8px", borderRight: 0 },
|
||||
"&:focus-visible": {
|
||||
color: theme.palette.primary.contrastText,
|
||||
borderColor: theme.palette.tertiary.contrastText,
|
||||
borderRightColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
color: theme.palette.secondary.contrastText,
|
||||
borderColor: theme.palette.secondary.contrastText,
|
||||
borderRightColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
307
Client/src/Pages/DistributedUptime/Create/index.jsx
Normal file
307
Client/src/Pages/DistributedUptime/Create/index.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
// Components
|
||||
import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import ConfigBox from "../../../Components/ConfigBox";
|
||||
import TextInput from "../../../Components/Inputs/TextInput";
|
||||
import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments";
|
||||
import Radio from "../../../Components/Inputs/Radio";
|
||||
import Checkbox from "../../../Components/Inputs/Checkbox";
|
||||
import Select from "../../../Components/Inputs/Select";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
|
||||
// Utility
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { monitorValidation } from "../../../Validation/validation";
|
||||
import { createUptimeMonitor } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
|
||||
|
||||
// Constants
|
||||
const BREADCRUMBS = [
|
||||
{ name: `distributed uptime`, path: "/distributed-uptime" },
|
||||
{ name: "create", path: `/distributed-uptime/create` },
|
||||
];
|
||||
const MS_PER_MINUTE = 60000;
|
||||
const SELECT_VALUES = [
|
||||
{ _id: 1, name: "1 minute" },
|
||||
{ _id: 2, name: "2 minutes" },
|
||||
{ _id: 3, name: "3 minutes" },
|
||||
{ _id: 4, name: "4 minutes" },
|
||||
{ _id: 5, name: "5 minutes" },
|
||||
];
|
||||
|
||||
const CreateDistributedUptime = () => {
|
||||
// Redux state
|
||||
const { user, authToken } = useSelector((state) => state.auth);
|
||||
const isLoading = useSelector((state) => state.uptimeMonitors.isLoading);
|
||||
|
||||
// Local state
|
||||
const [https, setHttps] = useState(true);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [monitor, setMonitor] = useState({
|
||||
type: "distributed_http",
|
||||
name: "",
|
||||
url: "",
|
||||
interval: 1,
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
//utils
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handlers
|
||||
const handleCreateMonitor = async (event) => {
|
||||
const monitorToSubmit = { ...monitor };
|
||||
|
||||
// Prepend protocol to url
|
||||
monitorToSubmit.url = `http${https ? "s" : ""}://` + monitorToSubmit.url;
|
||||
|
||||
const { error } = monitorValidation.validate(monitorToSubmit, {
|
||||
abortEarly: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const newErrors = {};
|
||||
error.details.forEach((err) => {
|
||||
newErrors[err.path[0]] = err.message;
|
||||
});
|
||||
setErrors(newErrors);
|
||||
createToast({ body: "Please check the form for errors." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Append needed fields
|
||||
monitorToSubmit.description = monitor.name;
|
||||
monitorToSubmit.interval = monitor.interval * MS_PER_MINUTE;
|
||||
monitorToSubmit.teamId = user.teamId;
|
||||
monitorToSubmit.userId = user._id;
|
||||
monitorToSubmit.notifications = notifications;
|
||||
|
||||
const action = await dispatch(
|
||||
createUptimeMonitor({ authToken, monitor: monitorToSubmit })
|
||||
);
|
||||
if (action.meta.requestStatus === "fulfilled") {
|
||||
createToast({ body: "Monitor created successfully!" });
|
||||
navigate("/distributed-uptime");
|
||||
} else {
|
||||
createToast({ body: "Failed to create monitor." });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (event) => {
|
||||
const { name, value } = event.target;
|
||||
setMonitor({
|
||||
...monitor,
|
||||
[name]: value,
|
||||
});
|
||||
const { error } = monitorValidation.validate(
|
||||
{ [name]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
console.log(name);
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
...(error ? { [name]: error.details[0].message } : { [name]: undefined }),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNotifications = (event, type) => {
|
||||
const { value } = event.target;
|
||||
let currentNotifications = [...notifications];
|
||||
const notificationAlreadyExists = notifications.some((notification) => {
|
||||
if (notification.type === type && notification.address === value) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (notificationAlreadyExists) {
|
||||
currentNotifications = currentNotifications.filter((notification) => {
|
||||
if (notification.type === type && notification.address === value) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
currentNotifications.push({ type, address: value });
|
||||
}
|
||||
setNotifications(currentNotifications);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<Stack
|
||||
component="form"
|
||||
gap={theme.spacing(12)}
|
||||
mt={theme.spacing(6)}
|
||||
onSubmit={() => console.log("submit")}
|
||||
>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
>
|
||||
Create your{" "}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="h2"
|
||||
fontSize="inherit"
|
||||
fontWeight="inherit"
|
||||
>
|
||||
monitor
|
||||
</Typography>
|
||||
</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)}>
|
||||
<TextInput
|
||||
type={"url"}
|
||||
id="monitor-url"
|
||||
startAdornment={<HttpAdornment https={https} />}
|
||||
label="URL to monitor"
|
||||
https={https}
|
||||
placeholder={"www.google.com"}
|
||||
value={monitor.url}
|
||||
name="url"
|
||||
onChange={handleChange}
|
||||
error={errors["url"] ? true : false}
|
||||
helperText={errors["url"]}
|
||||
/>
|
||||
<TextInput
|
||||
type="text"
|
||||
id="monitor-name"
|
||||
label="Display name"
|
||||
isOptional={true}
|
||||
placeholder={"Google"}
|
||||
value={monitor.name}
|
||||
name="name"
|
||||
onChange={handleChange}
|
||||
error={errors["name"] ? true : false}
|
||||
helperText={errors["name"]}
|
||||
/>
|
||||
</Stack>
|
||||
</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>
|
||||
</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"
|
||||
name="type"
|
||||
checked={true}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{monitor.type === "http" || monitor.type === "distributed_http" ? (
|
||||
<ButtonGroup sx={{ ml: theme.spacing(16) }}>
|
||||
<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.contrastText}
|
||||
>
|
||||
{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)}>
|
||||
<Checkbox
|
||||
id="notify-email-default"
|
||||
label={`Notify via email (to ${user.email})`}
|
||||
isChecked={notifications.some(
|
||||
(notification) => notification.type === "email"
|
||||
)}
|
||||
value={user?.email}
|
||||
onChange={(event) => handleNotifications(event, "email")}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Box>
|
||||
<Typography component="h2">Advanced settings</Typography>
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(12)}>
|
||||
<Select
|
||||
id="monitor-interval"
|
||||
label="Check frequency"
|
||||
name="interval"
|
||||
value={monitor.interval || 1}
|
||||
onChange={handleChange}
|
||||
items={SELECT_VALUES}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<LoadingButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleCreateMonitor()}
|
||||
disabled={!Object.values(errors).every((value) => value === undefined)}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create monitor
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateDistributedUptime;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Stack, Typography, List, ListItem } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PulseDot from "../../../../../Components/Animated/PulseDot";
|
||||
import "/node_modules/flag-icons/css/flag-icons.min.css";
|
||||
|
||||
const BASE_BOX_PADDING_VERTICAL = 16;
|
||||
const BASE_BOX_PADDING_HORIZONTAL = 8;
|
||||
|
||||
const DeviceTicker = ({ data, width = "100%", connectionStatus }) => {
|
||||
const theme = useTheme();
|
||||
const statusColor = {
|
||||
up: theme.palette.success.main,
|
||||
down: theme.palette.error.main,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(2)}
|
||||
width={width}
|
||||
sx={{
|
||||
padding: `${theme.spacing(BASE_BOX_PADDING_VERTICAL)} ${theme.spacing(BASE_BOX_PADDING_HORIZONTAL)}`,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={"center"}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<PulseDot color={statusColor[connectionStatus]} />
|
||||
|
||||
<Typography
|
||||
variant="h1"
|
||||
mb={theme.spacing(8)}
|
||||
sx={{ alignSelf: "center" }}
|
||||
>
|
||||
{connectionStatus === "up" ? "Connected" : "No connection"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<List>
|
||||
{data.slice(Math.max(data.length - 5, 0)).map((dataPoint) => {
|
||||
const countryCode = dataPoint?.countryCode?.toLowerCase() ?? null;
|
||||
const flag = countryCode ? `fi fi-${countryCode}` : null;
|
||||
return (
|
||||
<ListItem key={Math.random()}>
|
||||
<Stack direction="column">
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
{flag && <span className={flag} />}
|
||||
<Typography variant="h2">{dataPoint?.city || "Unknown"}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="p">{`Response time: ${Math.floor(dataPoint?.responseTime ?? 0)} ms`}</Typography>
|
||||
<Typography variant="p">{`UPT burned: ${dataPoint.uptBurnt}`}</Typography>
|
||||
<Typography variant="p">{`${dataPoint?.device?.manufacturer} ${dataPoint?.device?.model}`}</Typography>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceTicker;
|
||||
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"id": "43f36e14-e3f5-43c1-84c0-50a9c80dc5c7",
|
||||
"name": "MapLibre",
|
||||
"zoom": 0.861983335785597,
|
||||
"pitch": 0,
|
||||
"center": [17.6543171043124, 32.9541203267468],
|
||||
"glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": {
|
||||
"background-color": "#121217"
|
||||
},
|
||||
"filter": ["all"],
|
||||
"layout": {
|
||||
"visibility": "visible"
|
||||
},
|
||||
"maxzoom": 24
|
||||
},
|
||||
{
|
||||
"id": "coastline",
|
||||
"type": "line",
|
||||
"paint": {
|
||||
"line-blur": 0.5,
|
||||
"line-color": "#000000",
|
||||
"line-width": {
|
||||
"stops": [
|
||||
[0, 2],
|
||||
[6, 6],
|
||||
[14, 9],
|
||||
[22, 18]
|
||||
]
|
||||
}
|
||||
},
|
||||
"filter": ["all"],
|
||||
"layout": {
|
||||
"line-cap": "round",
|
||||
"line-join": "round",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"source": "maplibre",
|
||||
"maxzoom": 24,
|
||||
"minzoom": 0,
|
||||
"source-layer": "countries"
|
||||
},
|
||||
{
|
||||
"id": "countries-fill",
|
||||
"type": "fill",
|
||||
"paint": {
|
||||
"fill-color": "#292929"
|
||||
},
|
||||
"filter": ["all"],
|
||||
"layout": {
|
||||
"visibility": "visible"
|
||||
},
|
||||
"source": "maplibre",
|
||||
"maxzoom": 24,
|
||||
"source-layer": "countries"
|
||||
},
|
||||
{
|
||||
"id": "countries-boundary",
|
||||
"type": "line",
|
||||
"paint": {
|
||||
"line-color": "#484848",
|
||||
"line-width": {
|
||||
"stops": [
|
||||
[1, 1],
|
||||
[6, 2],
|
||||
[14, 6],
|
||||
[22, 12]
|
||||
]
|
||||
},
|
||||
"line-opacity": {
|
||||
"stops": [
|
||||
[3, 0.5],
|
||||
[6, 1]
|
||||
]
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"line-cap": "round",
|
||||
"line-join": "round",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"source": "maplibre",
|
||||
"maxzoom": 24,
|
||||
"source-layer": "countries"
|
||||
},
|
||||
{
|
||||
"id": "countries-label",
|
||||
"type": "symbol",
|
||||
"paint": {
|
||||
"text-color": "rgba(8, 37, 77, 1)",
|
||||
"text-halo-blur": {
|
||||
"stops": [
|
||||
[2, 0.2],
|
||||
[6, 0]
|
||||
]
|
||||
},
|
||||
"text-halo-color": "rgba(255, 255, 255, 1)",
|
||||
"text-halo-width": {
|
||||
"stops": [
|
||||
[2, 1],
|
||||
[6, 1.6]
|
||||
]
|
||||
}
|
||||
},
|
||||
"filter": ["all"],
|
||||
"layout": {
|
||||
"text-font": ["Open Sans Semibold"],
|
||||
"text-size": {
|
||||
"stops": [
|
||||
[2, 10],
|
||||
[4, 12],
|
||||
[6, 16]
|
||||
]
|
||||
},
|
||||
"text-field": {
|
||||
"stops": [
|
||||
[2, "{ABBREV}"],
|
||||
[4, "{NAME}"]
|
||||
]
|
||||
},
|
||||
"visibility": "visible",
|
||||
"text-max-width": 10,
|
||||
"text-transform": {
|
||||
"stops": [
|
||||
[0, "uppercase"],
|
||||
[2, "none"]
|
||||
]
|
||||
}
|
||||
},
|
||||
"source": "maplibre",
|
||||
"maxzoom": 24,
|
||||
"minzoom": 2,
|
||||
"source-layer": "centroids"
|
||||
},
|
||||
{
|
||||
"id": "data-dots",
|
||||
"type": "circle",
|
||||
"source": "data-dots",
|
||||
"paint": {
|
||||
"circle-radius": 3,
|
||||
"circle-color": ["get", "color"],
|
||||
"circle-opacity": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"bearing": 0,
|
||||
"sources": {
|
||||
"maplibre": {
|
||||
"url": "https://demotiles.maplibre.org/tiles/tiles.json",
|
||||
"type": "vector"
|
||||
},
|
||||
"data-dots": {
|
||||
"type": "geojson",
|
||||
"data": {
|
||||
"type": "FeatureCollection",
|
||||
"features": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": 8,
|
||||
"metadata": {
|
||||
"maptiler:copyright": "This style was generated on MapTiler Cloud. Usage is governed by the license terms in https://github.com/maplibre/demotiles/blob/gh-pages/LICENSE",
|
||||
"openmaptiles:version": "3.x"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import PropTypes from "prop-types";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import style from "./DistributedUptimeMapStyle.json";
|
||||
import maplibregl from "maplibre-gl";
|
||||
const DistributedUptimeMap = ({ width = "100%", height = "100%", checks }) => {
|
||||
const mapContainer = useRef(null);
|
||||
const map = useRef(null);
|
||||
const theme = useTheme();
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
|
||||
const colorLookup = (avgResponseTime) => {
|
||||
if (avgResponseTime <= 150) {
|
||||
return "#00FF00"; // Green
|
||||
} else if (avgResponseTime <= 250) {
|
||||
return "#FFFF00"; // Yellow
|
||||
} else {
|
||||
return "#FF0000"; // Red
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mapContainer.current && !map.current) {
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style,
|
||||
center: [0, 20],
|
||||
zoom: 0.8,
|
||||
});
|
||||
}
|
||||
map.current.on("load", () => {
|
||||
setMapLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
map.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current && checks?.length > 0) {
|
||||
// Convert dots to GeoJSON
|
||||
const geojson = {
|
||||
type: "FeatureCollection",
|
||||
features: checks.map((check) => {
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [check.lng, check.lat],
|
||||
},
|
||||
properties: {
|
||||
color: theme.palette.accent.main,
|
||||
// color: colorLookup(check.avgResponseTime) || "blue", // Default to blue if no color specified
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Update the source with new dots
|
||||
const source = map.current.getSource("data-dots");
|
||||
if (source) {
|
||||
source.setData(geojson);
|
||||
}
|
||||
}
|
||||
}, [checks, theme, mapLoaded]);
|
||||
return (
|
||||
<div
|
||||
ref={mapContainer}
|
||||
style={{
|
||||
width: width,
|
||||
height: height,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DistributedUptimeMap.propTypes = {
|
||||
checks: PropTypes.array,
|
||||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
};
|
||||
|
||||
export default DistributedUptimeMap;
|
||||
@@ -0,0 +1,214 @@
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Text,
|
||||
} from "recharts";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { formatDateWithTz } from "../../../../../Utils/timeUtils";
|
||||
|
||||
const CustomToolTip = ({ active, payload, label }) => {
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
const theme = useTheme();
|
||||
if (active && payload && payload.length) {
|
||||
const responseTime = payload[0]?.payload?.originalAvgResponseTime
|
||||
? payload[0]?.payload?.originalAvgResponseTime
|
||||
: (payload[0]?.payload?.avgResponseTime ?? 0);
|
||||
return (
|
||||
<Box
|
||||
className="area-tooltip"
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
py: theme.spacing(2),
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
<Box mt={theme.spacing(1)}>
|
||||
<Box
|
||||
display="inline-block"
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
backgroundColor={theme.palette.primary.main}
|
||||
sx={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<Stack
|
||||
display="inline-flex"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
ml={theme.spacing(3)}
|
||||
sx={{
|
||||
"& span": {
|
||||
color: theme.palette.text.tertiary,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
Response Time
|
||||
</Typography>{" "}
|
||||
<Typography component="span">
|
||||
{Math.floor(responseTime)}
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{" "}
|
||||
ms
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
{/* Display original value */}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
CustomToolTip.propTypes = {
|
||||
active: PropTypes.bool,
|
||||
payload: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
value: PropTypes.number,
|
||||
payload: PropTypes.shape({
|
||||
_id: PropTypes.string,
|
||||
avgResponseTime: PropTypes.number,
|
||||
originalAvgResponseTime: PropTypes.number,
|
||||
}),
|
||||
})
|
||||
),
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
};
|
||||
const CustomTick = ({ x, y, payload, index }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
// Render nothing for the first tick
|
||||
if (index === 0) return null;
|
||||
return (
|
||||
<Text
|
||||
x={x}
|
||||
y={y + 10}
|
||||
textAnchor="middle"
|
||||
fill={theme.palette.text.tertiary}
|
||||
fontSize={11}
|
||||
fontWeight={400}
|
||||
>
|
||||
{formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
CustomTick.propTypes = {
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number,
|
||||
payload: PropTypes.object,
|
||||
index: PropTypes.number,
|
||||
};
|
||||
|
||||
const DistributedUptimeResponseChart = ({ checks }) => {
|
||||
const theme = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
if (checks.length === 0) return null;
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
minWidth={25}
|
||||
height={220}
|
||||
>
|
||||
<AreaChart
|
||||
width="100%"
|
||||
height="100%"
|
||||
data={checks}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
onMouseMove={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.primary.lowContrast}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="colorUv"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={theme.palette.accent.darker}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={theme.palette.accent.main}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
stroke={theme.palette.primary.lowContrast}
|
||||
dataKey="_id"
|
||||
tick={<CustomTick />}
|
||||
minTickGap={0}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
height={20}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: theme.palette.primary.lowContrast }}
|
||||
content={<CustomToolTip />}
|
||||
wrapperStyle={{ pointerEvents: "none" }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="avgResponseTime"
|
||||
stroke={theme.palette.primary.accent}
|
||||
fill="url(#colorUv)"
|
||||
strokeWidth={isHovered ? 2.5 : 1.5}
|
||||
activeDot={{ stroke: theme.palette.background.main, r: 5 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
DistributedUptimeResponseChart.propTypes = {
|
||||
checks: PropTypes.array,
|
||||
};
|
||||
|
||||
export default DistributedUptimeResponseChart;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Stack, Typography, Box } from "@mui/material";
|
||||
import SolanaLogo from "../../../../../assets/icons/solana_logo.svg?react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
const Footer = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
>
|
||||
<Typography variant="h2">Made with ❤️ by UpRock & Bluewave Labs</Typography>
|
||||
<Stack
|
||||
width="100%"
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography variant="h2">Built on</Typography>
|
||||
<SolanaLogo
|
||||
width={15}
|
||||
height={15}
|
||||
/>
|
||||
<Typography variant="h2">Solana</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useState, useEffect } from "react";
|
||||
const LastUpdate = ({ suffix, lastUpdateTime, trigger }) => {
|
||||
const [elapsedMs, setElapsedMs] = useState(lastUpdateTime);
|
||||
|
||||
useEffect(() => {
|
||||
setElapsedMs(lastUpdateTime);
|
||||
const timer = setInterval(() => {
|
||||
setElapsedMs((prev) => prev + 1000);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [lastUpdateTime, trigger]);
|
||||
|
||||
return `${Math.floor(elapsedMs / 1000)} ${suffix}`;
|
||||
};
|
||||
export default LastUpdate;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LinearProgress } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const NextExpectedCheck = ({ lastUpdateTime, interval, trigger }) => {
|
||||
const [elapsedMs, setElapsedMs] = useState(lastUpdateTime);
|
||||
|
||||
useEffect(() => {
|
||||
setElapsedMs(lastUpdateTime);
|
||||
const timer = setInterval(() => {
|
||||
setElapsedMs((prev) => {
|
||||
const newElapsedMs = prev + 100;
|
||||
return newElapsedMs;
|
||||
});
|
||||
}, 100);
|
||||
return () => clearInterval(timer);
|
||||
}, [interval, trigger]);
|
||||
|
||||
return (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
color="accent"
|
||||
value={Math.min((elapsedMs / interval) * 100, 100)}
|
||||
sx={{
|
||||
transition: "width 1s linear", // Smooth transition over 1 second
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextExpectedCheck;
|
||||
325
Client/src/Pages/DistributedUptime/Details/index.jsx
Normal file
325
Client/src/Pages/DistributedUptime/Details/index.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
//Components
|
||||
import DistributedUptimeMap from "./Components/DistributedUptimeMap";
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import { Stack, Typography, Box, Button, ButtonGroup } from "@mui/material";
|
||||
import ChartBox from "../../../Components/Charts/ChartBox";
|
||||
import StatBox from "../../../Components/StatBox";
|
||||
import ResponseTimeIcon from "../../../assets/icons/response-time-icon.svg?react";
|
||||
import DeviceTicker from "./Components/DeviceTicker";
|
||||
import DistributedUptimeResponseChart from "./Components/DistributedUptimeResponseChart";
|
||||
import UptLogo from "../../../assets/icons/upt_logo.png";
|
||||
import LastUpdate from "./Components/LastUpdate";
|
||||
import NextExpectedCheck from "./Components/NextExpectedCheck";
|
||||
import Footer from "./Components/Footer";
|
||||
//Utils
|
||||
import { networkService } from "../../../main";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
//Constants
|
||||
const BASE_BOX_PADDING_VERTICAL = 8;
|
||||
const BASE_BOX_PADDING_HORIZONTAL = 8;
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_DELAY = 5000;
|
||||
|
||||
function getRandomDevice() {
|
||||
const manufacturers = {
|
||||
Apple: ["iPhone 15 Pro Max", "iPhone 15", "iPhone 14 Pro", "iPhone 14", "iPhone 13"],
|
||||
Samsung: [
|
||||
"Galaxy S23 Ultra",
|
||||
"Galaxy S23+",
|
||||
"Galaxy S23",
|
||||
"Galaxy Z Fold5",
|
||||
"Galaxy Z Flip5",
|
||||
],
|
||||
Google: ["Pixel 8 Pro", "Pixel 8", "Pixel 7a", "Pixel 7", "Pixel 6a"],
|
||||
OnePlus: [
|
||||
"OnePlus 11",
|
||||
"OnePlus 10T",
|
||||
"OnePlus Nord 3",
|
||||
"OnePlus 10 Pro",
|
||||
"OnePlus Nord 2T",
|
||||
],
|
||||
Xiaomi: ["13 Pro", "13", "Redmi Note 12", "POCO F5", "Redmi 12"],
|
||||
Huawei: ["P60 Pro", "Mate X3", "Nova 11", "P50 Pro", "Mate 50"],
|
||||
Sony: ["Xperia 1 V", "Xperia 5 V", "Xperia 10 V", "Xperia Pro-I", "Xperia 1 IV"],
|
||||
Motorola: ["Edge 40 Pro", "Edge 40", "G84", "G54", "Razr 40 Ultra"],
|
||||
ASUS: [
|
||||
"ROG Phone 7",
|
||||
"Zenfone 10",
|
||||
"ROG Phone 6",
|
||||
"Zenfone 9",
|
||||
"ROG Phone 7 Ultimate",
|
||||
],
|
||||
};
|
||||
|
||||
const manufacturerNames = Object.keys(manufacturers);
|
||||
const randomManufacturer =
|
||||
manufacturerNames[Math.floor(Math.random() * manufacturerNames.length)];
|
||||
|
||||
const models = manufacturers[randomManufacturer];
|
||||
const randomModel = models[Math.floor(Math.random() * models.length)];
|
||||
|
||||
return {
|
||||
manufacturer: randomManufacturer,
|
||||
model: randomModel,
|
||||
};
|
||||
}
|
||||
|
||||
// export const StatBox = ({ heading, value, img, altTxt }) => {
|
||||
// const theme = useTheme();
|
||||
// return (
|
||||
// <Stack
|
||||
// direction="row"
|
||||
// width={"25%"}
|
||||
// justifyContent="center"
|
||||
// sx={{
|
||||
// padding: `${theme.spacing(BASE_BOX_PADDING_VERTICAL)} ${theme.spacing(BASE_BOX_PADDING_HORIZONTAL)}`,
|
||||
// backgroundColor: theme.palette.background.main,
|
||||
// border: 1,
|
||||
// borderStyle: "solid",
|
||||
// borderColor: theme.palette.primary.lowContrast,
|
||||
// }}
|
||||
// >
|
||||
// {img && (
|
||||
// <img
|
||||
// style={{ marginRight: theme.spacing(8) }}
|
||||
// height={30}
|
||||
// width={30}
|
||||
// src={img}
|
||||
// alt={altTxt}
|
||||
// />
|
||||
// )}
|
||||
// <Stack direction="column">
|
||||
// <Typography variant="h2">{heading}</Typography>
|
||||
// <Typography>{value}</Typography>
|
||||
// </Stack>
|
||||
// </Stack>
|
||||
// );
|
||||
// };
|
||||
|
||||
const DistributedUptimeDetails = () => {
|
||||
// Redux State
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
const { mode } = useSelector((state) => state.ui);
|
||||
|
||||
// Local State
|
||||
// const [hoveredUptimeData, setHoveredUptimeData] = useState(null);
|
||||
// const [hoveredIncidentsData, setHoveredIncidentsData] = useState(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [connectionStatus, setConnectionStatus] = useState("down");
|
||||
const [lastUpdateTrigger, setLastUpdateTrigger] = useState(Date.now());
|
||||
const [dateRange, setDateRange] = useState("day");
|
||||
const [monitor, setMonitor] = useState(null);
|
||||
const [devices, setDevices] = useState([]);
|
||||
|
||||
// Refs
|
||||
const prevDateRangeRef = useRef(dateRange);
|
||||
|
||||
// Utils
|
||||
const theme = useTheme();
|
||||
const { monitorId } = useParams();
|
||||
|
||||
// Constants
|
||||
const BREADCRUMBS = [
|
||||
{ name: "Distributed Uptime", path: "/distributed-uptime" },
|
||||
{ name: "Details", path: `/distributed-uptime/${monitorId}` },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const hasDateRangeChanged = prevDateRangeRef.current !== dateRange;
|
||||
prevDateRangeRef.current = dateRange; // Update the ref to the current dateRange
|
||||
|
||||
if (!hasDateRangeChanged) {
|
||||
setDevices(Array.from({ length: 5 }, getRandomDevice));
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const connectToService = useCallback(() => {
|
||||
return networkService.subscribeToDistributedUptimeDetails({
|
||||
authToken,
|
||||
monitorId,
|
||||
dateRange: dateRange,
|
||||
normalize: true,
|
||||
onUpdate: (data) => {
|
||||
setLastUpdateTrigger(Date.now());
|
||||
const latestChecksWithDevice = data?.monitor?.latestChecks.map((check, idx) => {
|
||||
check.device = devices[idx];
|
||||
return check;
|
||||
});
|
||||
const monitorWithDevice = {
|
||||
...data.monitor,
|
||||
latestChecks: latestChecksWithDevice,
|
||||
};
|
||||
setMonitor(monitorWithDevice);
|
||||
},
|
||||
onOpen: () => {
|
||||
setConnectionStatus("up");
|
||||
setRetryCount(0); // Reset retry count on successful connection
|
||||
},
|
||||
onError: () => {
|
||||
setConnectionStatus("down");
|
||||
console.log("Error, attempting reconnect...");
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
setTimeout(() => {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
connectToService();
|
||||
}, RETRY_DELAY);
|
||||
} else {
|
||||
console.log("Max retries reached");
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [authToken, monitorId, dateRange, retryCount, devices]);
|
||||
|
||||
useEffect(() => {
|
||||
const devices = Array.from({ length: 5 }, getRandomDevice);
|
||||
const cleanup = connectToService(devices);
|
||||
return cleanup;
|
||||
}, [connectToService]);
|
||||
|
||||
return (
|
||||
monitor && (
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
{monitor?.url !== "https://jup.ag/" &&
|
||||
monitor?.url !== "https://explorer.solana.com/" && (
|
||||
<Box>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<Typography variant="h2">
|
||||
Distributed Uptime Monitoring powered by DePIN
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<StatBox
|
||||
heading="Avg Response Time"
|
||||
subHeading={`${Math.floor(monitor?.avgResponseTime ?? 0)} ms`}
|
||||
/>
|
||||
<StatBox
|
||||
heading="Checking every"
|
||||
subHeading={`${(monitor?.interval ?? 0) / 1000} seconds`}
|
||||
/>
|
||||
<StatBox
|
||||
heading={"Last check"}
|
||||
subHeading={
|
||||
<LastUpdate
|
||||
lastUpdateTime={monitor?.timeSinceLastCheck ?? 0}
|
||||
suffix={"seconds ago"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
heading="Last server push"
|
||||
subHeading={
|
||||
<LastUpdate
|
||||
suffix={"seconds ago"}
|
||||
lastUpdateTime={0}
|
||||
trigger={lastUpdateTrigger}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<StatBox
|
||||
heading="UPT Burned"
|
||||
subHeading={monitor?.totalUptBurnt ?? 0}
|
||||
img={UptLogo}
|
||||
alt="Upt Logo"
|
||||
/>
|
||||
</Stack>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<NextExpectedCheck
|
||||
lastUpdateTime={monitor?.timeSinceLastCheck ?? 0}
|
||||
interval={monitor?.interval ?? 0}
|
||||
trigger={lastUpdateTrigger}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
gap={theme.spacing(4)}
|
||||
mb={theme.spacing(8)}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Showing statistics for past{" "}
|
||||
{dateRange === "day"
|
||||
? "24 hours"
|
||||
: dateRange === "week"
|
||||
? "7 days"
|
||||
: "30 days"}
|
||||
.
|
||||
</Typography>
|
||||
<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>
|
||||
<ChartBox
|
||||
icon={<ResponseTimeIcon />}
|
||||
header="Response Times"
|
||||
sx={{ padding: 0 }}
|
||||
>
|
||||
<DistributedUptimeResponseChart checks={monitor?.groupedChecks ?? []} />
|
||||
</ChartBox>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<DistributedUptimeMap
|
||||
checks={monitor?.groupedMapChecks ?? []}
|
||||
height={"100%"}
|
||||
width={"100%"}
|
||||
/>
|
||||
<DeviceTicker
|
||||
width={"25vw"}
|
||||
data={monitor?.latestChecks ?? []}
|
||||
connectionStatus={connectionStatus}
|
||||
/>
|
||||
</Stack>
|
||||
<Footer />
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DistributedUptimeDetails;
|
||||
187
Client/src/Pages/DistributedUptime/Monitors/index.jsx
Normal file
187
Client/src/Pages/DistributedUptime/Monitors/index.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
// Components
|
||||
import { Stack, Box, Button } from "@mui/material";
|
||||
import DataTable from "../../../Components/Table";
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import Host from "../../Uptime/Monitors/Components/Host";
|
||||
import BarChart from "../../../Components/Charts/BarChart";
|
||||
import ActionsMenu from "../../Uptime/Monitors/Components/ActionsMenu";
|
||||
import { StatusLabel } from "../../../Components/Label";
|
||||
// Utils
|
||||
import { networkService } from "../../../main";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useUtils from "../../Uptime/Monitors/Hooks/useUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
// Constants
|
||||
const BREADCRUMBS = [{ name: `Distributed Uptime`, path: "/distributed-uptime" }];
|
||||
const TYPE_MAP = {
|
||||
distributed_http: "Distributed HTTP",
|
||||
};
|
||||
|
||||
const DistributedUptimeMonitors = () => {
|
||||
// Redux state
|
||||
const { authToken, user } = useSelector((state) => state.auth);
|
||||
// Local state
|
||||
const [monitors, setMonitors] = useState([]);
|
||||
const [filteredMonitors, setFilteredMonitors] = useState([]);
|
||||
const [monitorsSummary, setMonitorsSummary] = useState({});
|
||||
// Utils
|
||||
|
||||
const { determineState } = useUtils();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const headers = [
|
||||
{
|
||||
id: "name",
|
||||
content: <Box>Host</Box>,
|
||||
render: (row) => (
|
||||
<Host
|
||||
key={row._id}
|
||||
url={row.url}
|
||||
title={row.title}
|
||||
percentageColor={row.percentageColor}
|
||||
percentage={row.percentage}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: <Box width="max-content"> Status</Box>,
|
||||
render: (row) => {
|
||||
const status = determineState(row?.monitor);
|
||||
return (
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={status}
|
||||
customStyles={{ textTransform: "capitalize" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "responseTime",
|
||||
content: "Response Time",
|
||||
render: (row) => <BarChart checks={row?.monitor?.checks.slice().reverse()} />,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
content: "Type",
|
||||
render: (row) => <span>{TYPE_MAP[row.monitor.type]}</span>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
content: "Actions",
|
||||
render: (row) => (
|
||||
<ActionsMenu
|
||||
monitor={row.monitor}
|
||||
isAdmin={true}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const getMonitorWithPercentage = (monitor, theme) => {
|
||||
let uptimePercentage = "";
|
||||
let percentageColor = "";
|
||||
|
||||
if (monitor.uptimePercentage !== undefined) {
|
||||
uptimePercentage =
|
||||
monitor.uptimePercentage === 0
|
||||
? "0"
|
||||
: (monitor.uptimePercentage * 100).toFixed(2);
|
||||
|
||||
percentageColor =
|
||||
monitor.uptimePercentage < 0.25
|
||||
? theme.palette.error.main
|
||||
: monitor.uptimePercentage < 0.5
|
||||
? theme.palette.warning.main
|
||||
: monitor.uptimePercentage < 0.75
|
||||
? theme.palette.success.main
|
||||
: theme.palette.success.main;
|
||||
}
|
||||
|
||||
return {
|
||||
id: monitor._id,
|
||||
name: monitor.name,
|
||||
url: monitor.url,
|
||||
title: monitor.name,
|
||||
percentage: uptimePercentage,
|
||||
percentageColor,
|
||||
monitor: monitor,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = networkService.subscribeToDistributedUptimeMonitors({
|
||||
authToken: authToken,
|
||||
teamId: user.teamId,
|
||||
limit: 25,
|
||||
types: ["distributed_http"],
|
||||
page: 0,
|
||||
rowsPerPage: 10,
|
||||
filter: null,
|
||||
field: null,
|
||||
order: null,
|
||||
onUpdate: (data) => {
|
||||
const res = data.monitors;
|
||||
const { monitors, filteredMonitors, summary } = res;
|
||||
const mappedMonitors = filteredMonitors.map((monitor) =>
|
||||
getMonitorWithPercentage(monitor, theme)
|
||||
);
|
||||
setMonitors(monitors);
|
||||
setFilteredMonitors(mappedMonitors);
|
||||
setMonitorsSummary(summary);
|
||||
},
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [user.teamId, authToken, theme]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
mt={theme.spacing(5)}
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
navigate("/distributed-uptime/create");
|
||||
}}
|
||||
sx={{ fontWeight: 500, whiteSpace: "nowrap" }}
|
||||
>
|
||||
Create new
|
||||
</Button>
|
||||
</Stack>
|
||||
{monitors.length > 0 && (
|
||||
<DataTable
|
||||
headers={headers}
|
||||
data={filteredMonitors}
|
||||
config={{
|
||||
rowSX: {
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.background.accent,
|
||||
},
|
||||
},
|
||||
onRowClick: (row) => {
|
||||
navigate(`/distributed-uptime/${row.id}`);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DistributedUptimeMonitors;
|
||||
126
Client/src/Pages/Incidents/Components/IncidentTable/index.jsx
Normal file
126
Client/src/Pages/Incidents/Components/IncidentTable/index.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
//Components
|
||||
import Table from "../../../../Components/Table";
|
||||
import TableSkeleton from "../../../../Components/Table/skeleton";
|
||||
import Pagination from "../../../../Components/Table/TablePagination";
|
||||
import { StatusLabel } from "../../../../Components/Label";
|
||||
import { HttpStatusLabel } from "../../../../Components/HttpStatusLabel";
|
||||
import GenericFallback from "../../../../Components/GenericFallback";
|
||||
import NetworkError from "../../../../Components/GenericFallback/NetworkError";
|
||||
|
||||
//Utils
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtils";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useState } from "react";
|
||||
import useChecksFetch from "../../Hooks/useChecksFetch";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const IncidentTable = ({
|
||||
shouldRender,
|
||||
monitors,
|
||||
selectedMonitor,
|
||||
filter,
|
||||
dateRange,
|
||||
}) => {
|
||||
//Redux state
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
//Local state
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const { isLoading, networkError, checks, checksCount } = useChecksFetch({
|
||||
selectedMonitor,
|
||||
filter,
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
});
|
||||
|
||||
//Handlers
|
||||
const handleChangePage = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(event.target.value);
|
||||
};
|
||||
|
||||
const headers = [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: "Monitor Name",
|
||||
render: (row) => monitors[row.monitorId]?.name ?? "N/A",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: "Status",
|
||||
render: (row) => {
|
||||
const status = row.status === true ? "up" : "down";
|
||||
return (
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={status}
|
||||
customStyles={{ textTransform: "capitalize" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dateTime",
|
||||
content: "Date & Time",
|
||||
render: (row) => {
|
||||
const formattedDate = formatDateWithTz(
|
||||
row.createdAt,
|
||||
"YYYY-MM-DD HH:mm:ss A",
|
||||
uiTimezone
|
||||
);
|
||||
return formattedDate;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: "Status Code",
|
||||
render: (row) => <HttpStatusLabel status={row.statusCode} />,
|
||||
},
|
||||
{ id: "message", content: "Message", render: (row) => row.message },
|
||||
];
|
||||
|
||||
if (!shouldRender || isLoading) return <TableSkeleton />;
|
||||
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && typeof checksCount === "undefined") {
|
||||
return <GenericFallback>No incidents recorded</GenericFallback>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={checks}
|
||||
/>
|
||||
<Pagination
|
||||
paginationLabel="incidents"
|
||||
itemCount={checksCount}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentTable.propTypes = {
|
||||
shouldRender: PropTypes.bool,
|
||||
monitors: PropTypes.object,
|
||||
selectedMonitor: PropTypes.string,
|
||||
filter: PropTypes.string,
|
||||
dateRange: PropTypes.string,
|
||||
};
|
||||
export default IncidentTable;
|
||||
162
Client/src/Pages/Incidents/Components/OptionsHeader/index.jsx
Normal file
162
Client/src/Pages/Incidents/Components/OptionsHeader/index.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
// Components
|
||||
import { Stack, Typography, Button, ButtonGroup } from "@mui/material";
|
||||
import Select from "../../../../Components/Inputs/Select";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
|
||||
const OptionsHeader = ({
|
||||
shouldRender,
|
||||
selectedMonitor = 0,
|
||||
setSelectedMonitor,
|
||||
monitors,
|
||||
filter = "all",
|
||||
setFilter,
|
||||
dateRange = "hour",
|
||||
setDateRange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
|
||||
|
||||
if (!shouldRender) return <SkeletonLayout />;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
display="inline-block"
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
Incidents for
|
||||
</Typography>
|
||||
<Select
|
||||
id="incidents-select-monitor"
|
||||
placeholder="All servers"
|
||||
value={selectedMonitor}
|
||||
onChange={(e) => setSelectedMonitor(e.target.value)}
|
||||
items={monitorNames}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
display="inline-block"
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
Filter by:
|
||||
</Typography>
|
||||
<ButtonGroup
|
||||
sx={{
|
||||
ml: "auto",
|
||||
"& .MuiButtonBase-root, & .MuiButtonBase-root:hover": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(filter === "all").toString()}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(filter === "down").toString()}
|
||||
onClick={() => setFilter("down")}
|
||||
>
|
||||
Down
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(filter === "resolve").toString()}
|
||||
onClick={() => setFilter("resolve")}
|
||||
>
|
||||
Cannot resolve
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
display="inline-block"
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
Show:
|
||||
</Typography>
|
||||
<ButtonGroup
|
||||
sx={{
|
||||
ml: "auto",
|
||||
"& .MuiButtonBase-root, & .MuiButtonBase-root:hover": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "hour").toString()}
|
||||
onClick={() => setDateRange("hour")}
|
||||
>
|
||||
Last hour
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "day").toString()}
|
||||
onClick={() => setDateRange("day")}
|
||||
>
|
||||
Last day
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "week").toString()}
|
||||
onClick={() => setDateRange("week")}
|
||||
>
|
||||
Last week
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "all").toString()}
|
||||
onClick={() => setDateRange("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
OptionsHeader.propTypes = {
|
||||
shouldRender: PropTypes.bool,
|
||||
selectedMonitor: PropTypes.string,
|
||||
setSelectedMonitor: PropTypes.func,
|
||||
monitors: PropTypes.object,
|
||||
filter: PropTypes.string,
|
||||
setFilter: PropTypes.func,
|
||||
dateRange: PropTypes.string,
|
||||
setDateRange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default OptionsHeader;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Stack, Skeleton } from "@mui/material";
|
||||
|
||||
const SkeletonLayout = () => {
|
||||
return (
|
||||
<Stack>
|
||||
<Skeleton height={40} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
60
Client/src/Pages/Incidents/Hooks/useChecksFetch.jsx
Normal file
60
Client/src/Pages/Incidents/Hooks/useChecksFetch.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { networkService } from "../../../main";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
import { useSelector } from "react-redux";
|
||||
const useChecksFetch = ({ selectedMonitor, filter, dateRange, page, rowsPerPage }) => {
|
||||
//Redux
|
||||
const { authToken, user } = useSelector((state) => state.auth);
|
||||
|
||||
//Local
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const [checks, setChecks] = useState(undefined);
|
||||
const [checksCount, setChecksCount] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChecks = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
let res;
|
||||
|
||||
if (selectedMonitor === "0") {
|
||||
res = await networkService.getChecksByTeam({
|
||||
authToken: authToken,
|
||||
status: false,
|
||||
teamId: user.teamId,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter,
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
});
|
||||
} else {
|
||||
res = await networkService.getChecksByMonitor({
|
||||
authToken: authToken,
|
||||
status: false,
|
||||
monitorId: selectedMonitor,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter,
|
||||
page,
|
||||
rowsPerPage,
|
||||
});
|
||||
}
|
||||
setChecks(res.data.data.checks);
|
||||
setChecksCount(res.data.data.checksCount);
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
createToast({ body: error.message });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchChecks();
|
||||
}, [authToken, user, dateRange, page, rowsPerPage, filter, selectedMonitor]);
|
||||
return { isLoading, networkError, checks, checksCount };
|
||||
};
|
||||
|
||||
export default useChecksFetch;
|
||||
51
Client/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx
Normal file
51
Client/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { networkService } from "../../../main";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
const useMonitorsFetch = ({ authToken, teamId }) => {
|
||||
//Local state
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
|
||||
const [monitors, setMonitors] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await networkService.getMonitorsByTeamId({
|
||||
authToken,
|
||||
teamId,
|
||||
limit: null,
|
||||
types: null,
|
||||
status: null,
|
||||
checkOrder: null,
|
||||
normalize: null,
|
||||
page: null,
|
||||
rowsPerPage: null,
|
||||
filter: null,
|
||||
field: null,
|
||||
order: null,
|
||||
});
|
||||
if (res?.data?.data?.monitors?.length > 0) {
|
||||
const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => {
|
||||
acc[monitor._id] = monitor;
|
||||
return acc;
|
||||
}, {});
|
||||
setMonitors(monitorLookup);
|
||||
}
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
createToast({
|
||||
body: error.message,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMonitors();
|
||||
}, [authToken, teamId]);
|
||||
return { isLoading, monitors, networkError };
|
||||
};
|
||||
|
||||
export { useMonitorsFetch };
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PlaceholderLight from "../../../../assets/Images/data_placeholder.svg?react";
|
||||
import PlaceholderDark from "../../../../assets/Images/data_placeholder_dark.svg?react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Empty = ({ styles, mode }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box sx={{ ...styles }}>
|
||||
<Box
|
||||
textAlign="center"
|
||||
pb={theme.spacing(20)}
|
||||
>
|
||||
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
|
||||
</Box>
|
||||
<Typography
|
||||
textAlign="center"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
No incidents recorded yet.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Empty.propTypes = {
|
||||
styles: PropTypes.object,
|
||||
mode: PropTypes.string,
|
||||
};
|
||||
|
||||
export { Empty };
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Skeleton /* , Stack */ } from "@mui/material";
|
||||
const IncidentSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<Skeleton
|
||||
animation={"wave"}
|
||||
variant="rounded"
|
||||
width="100%"
|
||||
height={300}
|
||||
/>
|
||||
<Skeleton
|
||||
animation={"wave"}
|
||||
variant="rounded"
|
||||
width="100%"
|
||||
height={100}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncidentSkeleton };
|
||||
@@ -1,187 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Typography, Box } from "@mui/material";
|
||||
|
||||
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";
|
||||
import { formatDateWithTz } from "../../../Utils/timeUtils";
|
||||
import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react";
|
||||
import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react";
|
||||
import { HttpStatusLabel } from "../../../Components/HttpStatusLabel";
|
||||
import { Empty } from "./Empty/Empty";
|
||||
import { IncidentSkeleton } from "./Skeleton/Skeleton";
|
||||
import DataTable from "../../../Components/Table";
|
||||
import Pagination from "../../../Components/Table/TablePagination";
|
||||
|
||||
const IncidentTable = ({ monitors, selectedMonitor, filter, dateRange }) => {
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
const theme = useTheme();
|
||||
const { authToken, user } = useSelector((state) => state.auth);
|
||||
const mode = useSelector((state) => state.ui.mode);
|
||||
const [checks, setChecks] = useState([]);
|
||||
const [checksCount, setChecksCount] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPage = async () => {
|
||||
if (!monitors || Object.keys(monitors).length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsLoading(true);
|
||||
let res;
|
||||
if (selectedMonitor === "0") {
|
||||
res = await networkService.getChecksByTeam({
|
||||
authToken: authToken,
|
||||
status: false,
|
||||
teamId: user.teamId,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter,
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
});
|
||||
} else {
|
||||
res = await networkService.getChecksByMonitor({
|
||||
authToken: authToken,
|
||||
status: false,
|
||||
monitorId: selectedMonitor,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter,
|
||||
page,
|
||||
rowsPerPage,
|
||||
});
|
||||
}
|
||||
|
||||
setChecks(res.data.data.checks);
|
||||
setChecksCount(res.data.data.checksCount);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPage();
|
||||
}, [authToken, user, monitors, selectedMonitor, filter, page, rowsPerPage, dateRange]);
|
||||
|
||||
const handlePageChange = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(event.target.value);
|
||||
};
|
||||
|
||||
const headers = [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: "Monitor Name",
|
||||
render: (row) => monitors[row.monitorId]?.name ?? "N/A",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: "Status",
|
||||
render: (row) => {
|
||||
const status = row.status === true ? "up" : "down";
|
||||
return (
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={status}
|
||||
customStyles={{ textTransform: "capitalize" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dateTime",
|
||||
content: "Date & Time",
|
||||
render: (row) => {
|
||||
const formattedDate = formatDateWithTz(
|
||||
row.createdAt,
|
||||
"YYYY-MM-DD HH:mm:ss A",
|
||||
uiTimezone
|
||||
);
|
||||
return formattedDate;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: "Status Code",
|
||||
render: (row) => <HttpStatusLabel status={row.statusCode} />,
|
||||
},
|
||||
{ id: "message", content: "Message", render: (row) => row.message },
|
||||
];
|
||||
|
||||
let sharedStyles = {
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
p: theme.spacing(30),
|
||||
};
|
||||
|
||||
const hasChecks = checks?.length === 0;
|
||||
const noIncidentsRecordedYet = hasChecks && selectedMonitor === "0";
|
||||
const noIncidentsForThatMonitor = hasChecks && selectedMonitor !== "0";
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<IncidentSkeleton />
|
||||
) : noIncidentsRecordedYet ? (
|
||||
<Empty
|
||||
mode={mode}
|
||||
styles={sharedStyles}
|
||||
/>
|
||||
) : noIncidentsForThatMonitor ? (
|
||||
<Box sx={{ ...sharedStyles }}>
|
||||
<Box
|
||||
textAlign="center"
|
||||
pb={theme.spacing(20)}
|
||||
>
|
||||
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
|
||||
</Box>
|
||||
<Typography
|
||||
textAlign="center"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
The monitor you have selected has no recorded incidents yet.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
headers={headers}
|
||||
data={checks}
|
||||
/>
|
||||
<Pagination
|
||||
paginationLabel="incidents"
|
||||
itemCount={checksCount}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handlePageChange}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentTable.propTypes = {
|
||||
monitors: PropTypes.object.isRequired,
|
||||
selectedMonitor: PropTypes.string.isRequired,
|
||||
filter: PropTypes.string.isRequired,
|
||||
dateRange: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default IncidentTable;
|
||||
@@ -1,211 +1,63 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { ButtonGroup, Stack, Typography, Button } from "@mui/material";
|
||||
import { useParams } from "react-router-dom";
|
||||
// Components
|
||||
import { Stack } from "@mui/material";
|
||||
import Breadcrumbs from "../../Components/Breadcrumbs";
|
||||
|
||||
import { networkService } from "../../main";
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import Select from "../../Components/Inputs/Select";
|
||||
import IncidentTable from "./IncidentTable";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
import { useMonitorsFetch } from "./Hooks/useMonitorsFetch";
|
||||
import { useSelector } from "react-redux";
|
||||
import OptionsHeader from "./Components/OptionsHeader";
|
||||
import { useState } from "react";
|
||||
import IncidentTable from "./Components/IncidentTable";
|
||||
import GenericFallback from "../../Components/GenericFallback";
|
||||
import NetworkError from "../../Components/GenericFallback/NetworkError";
|
||||
//Constants
|
||||
const BREADCRUMBS = [{ name: `Incidents`, path: "/incidents" }];
|
||||
|
||||
const Incidents = () => {
|
||||
const theme = useTheme();
|
||||
const authState = useSelector((state) => state.auth);
|
||||
const { monitorId } = useParams();
|
||||
// Redux state
|
||||
const { authToken, user } = useSelector((state) => state.auth);
|
||||
|
||||
const [monitors, setMonitors] = useState({});
|
||||
// Local state
|
||||
const [selectedMonitor, setSelectedMonitor] = useState("0");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filter, setFilter] = useState(undefined);
|
||||
const [dateRange, setDateRange] = useState(undefined);
|
||||
//Utils
|
||||
const theme = useTheme();
|
||||
|
||||
// TODO do something with these filters
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState("hour");
|
||||
const { monitors, isLoading, networkError } = useMonitorsFetch({
|
||||
authToken,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await networkService.getMonitorsByTeamId({
|
||||
authToken: authState.authToken,
|
||||
teamId: authState.user.teamId,
|
||||
limit: null,
|
||||
types: null,
|
||||
status: null,
|
||||
checkOrder: null,
|
||||
normalize: null,
|
||||
page: null,
|
||||
rowsPerPage: null,
|
||||
filter: null,
|
||||
field: null,
|
||||
order: null,
|
||||
});
|
||||
if (res?.data?.data?.monitors?.length > 0) {
|
||||
const monitorLookup = res.data.data.monitors.reduce((acc, monitor) => {
|
||||
acc[monitor._id] = monitor;
|
||||
return acc;
|
||||
}, {});
|
||||
setMonitors(monitorLookup);
|
||||
monitorId !== undefined && setSelectedMonitor(monitorId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.info(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMonitors();
|
||||
}, [authState, monitorId]);
|
||||
|
||||
useEffect(() => {}, [monitors]);
|
||||
|
||||
const handleSelect = (event) => {
|
||||
setSelectedMonitor(event.target.value);
|
||||
};
|
||||
|
||||
const isActuallyLoading = isLoading && Object.keys(monitors)?.length === 0;
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="incidents"
|
||||
pt={theme.spacing(6)}
|
||||
gap={theme.spacing(12)}
|
||||
>
|
||||
{isActuallyLoading ? (
|
||||
<SkeletonLayout />
|
||||
) : (
|
||||
<>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
display="inline-block"
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
Incidents for
|
||||
</Typography>
|
||||
<Select
|
||||
id="incidents-select-monitor"
|
||||
placeholder="All servers"
|
||||
value={selectedMonitor}
|
||||
onChange={handleSelect}
|
||||
items={Object.values(monitors)}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
display="inline-block"
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
Filter by:
|
||||
</Typography>
|
||||
<ButtonGroup
|
||||
sx={{
|
||||
ml: "auto",
|
||||
"& .MuiButtonBase-root, & .MuiButtonBase-root:hover": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(filter === "all").toString()}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(filter === "down").toString()}
|
||||
onClick={() => setFilter("down")}
|
||||
>
|
||||
Down
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(filter === "resolve").toString()}
|
||||
onClick={() => setFilter("resolve")}
|
||||
>
|
||||
Cannot resolve
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(6)}
|
||||
>
|
||||
<Typography
|
||||
display="inline-block"
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
Show:
|
||||
</Typography>
|
||||
<ButtonGroup
|
||||
sx={{
|
||||
ml: "auto",
|
||||
"& .MuiButtonBase-root, & .MuiButtonBase-root:hover": {
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "hour").toString()}
|
||||
onClick={() => setDateRange("hour")}
|
||||
>
|
||||
Last hour
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "day").toString()}
|
||||
onClick={() => setDateRange("day")}
|
||||
>
|
||||
Last day
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "week").toString()}
|
||||
onClick={() => setDateRange("week")}
|
||||
>
|
||||
Last week
|
||||
</Button>
|
||||
<Button
|
||||
variant="group"
|
||||
filled={(dateRange === "all").toString()}
|
||||
onClick={() => setDateRange("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<IncidentTable
|
||||
monitors={monitors}
|
||||
selectedMonitor={selectedMonitor}
|
||||
filter={filter}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<OptionsHeader
|
||||
shouldRender={!isLoading}
|
||||
monitors={monitors}
|
||||
selectedMonitor={selectedMonitor}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
<IncidentTable
|
||||
shouldRender={!isLoading}
|
||||
monitors={monitors}
|
||||
selectedMonitor={selectedMonitor}
|
||||
filter={filter}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
@@ -117,7 +117,7 @@ CustomThreshold.propTypes = {
|
||||
fieldName: PropTypes.string,
|
||||
fieldValue: PropTypes.string.isRequired,
|
||||
onFieldChange: PropTypes.func.isRequired,
|
||||
onFieldBlur: PropTypes.func.isRequired,
|
||||
onFieldBlur: PropTypes.func,
|
||||
alertUnit: PropTypes.string.isRequired,
|
||||
infrastructureMonitor: PropTypes.object.isRequired,
|
||||
errors: PropTypes.object.isRequired,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Components
|
||||
import { Typography } from "@mui/material";
|
||||
import BaseContainer from "../BaseContainer";
|
||||
import AreaChart from "../../../../../Components/Charts/AreaChart";
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils";
|
||||
const InfraAreaChart = ({ config }) => {
|
||||
const theme = useTheme();
|
||||
const { getDimensions } = useHardwareUtils();
|
||||
return (
|
||||
<BaseContainer>
|
||||
<Typography
|
||||
component="h2"
|
||||
padding={theme.spacing(8)}
|
||||
>
|
||||
{config.heading}
|
||||
</Typography>
|
||||
<AreaChart
|
||||
height={getDimensions().areaChartHeight}
|
||||
data={config.data}
|
||||
dataKeys={config.dataKeys}
|
||||
xKey="_id"
|
||||
yDomain={config.yDomain}
|
||||
customTooltip={config.toolTip}
|
||||
xTick={config.xTick}
|
||||
yTick={config.yTick}
|
||||
strokeColor={config.strokeColor}
|
||||
gradient={true}
|
||||
gradientStartColor={config.gradientStartColor}
|
||||
gradientEndColor="#ffffff"
|
||||
/>
|
||||
</BaseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfraAreaChart;
|
||||
@@ -0,0 +1,138 @@
|
||||
// Components
|
||||
import { Stack } from "@mui/material";
|
||||
import InfraAreaChart from "./InfraAreaChart";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
|
||||
// Utils
|
||||
import {
|
||||
PercentTick,
|
||||
TzTick,
|
||||
InfrastructureTooltip,
|
||||
TemperatureTooltip,
|
||||
} from "../../../../../Components/Charts/Utils/chartUtils";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils";
|
||||
const AreaChartBoxes = ({ shouldRender, monitor, dateRange }) => {
|
||||
const theme = useTheme();
|
||||
const { buildTemps } = useHardwareUtils();
|
||||
|
||||
if (!shouldRender) {
|
||||
return <SkeletonLayout />;
|
||||
}
|
||||
|
||||
const { stats } = monitor ?? {};
|
||||
const { checks } = stats;
|
||||
|
||||
let latestCheck = checks[0];
|
||||
const { temps, tempKeys } = buildTemps(checks);
|
||||
|
||||
const configs = [
|
||||
{
|
||||
type: "memory",
|
||||
data: checks,
|
||||
dataKeys: ["avgMemoryUsage"],
|
||||
heading: "Memory usage",
|
||||
strokeColor: theme.palette.accent.main, // CAIO_REVIEW
|
||||
gradientStartColor: theme.palette.accent.main, // CAIO_REVIEW
|
||||
yLabel: "Memory usage",
|
||||
yDomain: [0, 1],
|
||||
yTick: <PercentTick />,
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.primary.main}
|
||||
yKey={"avgMemoryUsage"}
|
||||
yLabel={"Memory usage"}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "cpu",
|
||||
data: checks,
|
||||
dataKeys: ["avgCpuUsage"],
|
||||
heading: "CPU usage",
|
||||
strokeColor: theme.palette.success.main,
|
||||
gradientStartColor: theme.palette.success.main,
|
||||
yLabel: "CPU usage",
|
||||
yDomain: [0, 1],
|
||||
yTick: <PercentTick />,
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.success.main}
|
||||
yKey={"avgCpuUsage"}
|
||||
yLabel={"CPU usage"}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "temperature",
|
||||
data: temps,
|
||||
dataKeys: tempKeys,
|
||||
strokeColor: theme.palette.error.main,
|
||||
gradientStartColor: theme.palette.error.main,
|
||||
heading: "CPU Temperature",
|
||||
yLabel: "Temperature",
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
yDomain: [
|
||||
0,
|
||||
Math.max(Math.max(...temps.flatMap((t) => tempKeys.map((k) => t[k]))) * 1.1, 200),
|
||||
],
|
||||
toolTip: (
|
||||
<TemperatureTooltip
|
||||
keys={tempKeys}
|
||||
dotColor={theme.palette.error.main}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(latestCheck?.disks?.map((disk, idx) => ({
|
||||
type: "disk",
|
||||
data: checks,
|
||||
diskIndex: idx,
|
||||
dataKeys: [`disks[${idx}].usagePercent`],
|
||||
heading: `Disk${idx} usage`,
|
||||
strokeColor: theme.palette.warning.main,
|
||||
gradientStartColor: theme.palette.warning.main,
|
||||
yLabel: "Disk Usage",
|
||||
yDomain: [0, 1],
|
||||
yTick: <PercentTick />,
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.warning.main}
|
||||
yKey={`disks.usagePercent`}
|
||||
yLabel={"Disc usage"}
|
||||
yIdx={idx}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
})) || []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
// height={chartContainerHeight} // FE team HELP! Possibly no longer needed?
|
||||
gap={theme.spacing(8)} // FE team HELP!
|
||||
flexWrap="wrap" // //FE team HELP! Better way to do this?
|
||||
sx={{
|
||||
"& > *": {
|
||||
flexBasis: `calc(50% - ${theme.spacing(8)})`,
|
||||
maxWidth: `calc(50% - ${theme.spacing(8)})`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{configs.map((config) => (
|
||||
<InfraAreaChart
|
||||
key={`${config.type}-${config.diskIndex ?? ""}`}
|
||||
config={config}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaChartBoxes;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Stack, Skeleton } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
const SkeletonLayout = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<Skeleton
|
||||
height={"33vh"}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<Skeleton
|
||||
height={"33vh"}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Renders a base box with consistent styling
|
||||
* @param {Object} props - Component properties
|
||||
* @param {React.ReactNode} props.children - Child components to render inside the box
|
||||
* @param {Object} props.sx - Additional styling for the box
|
||||
* @returns {React.ReactElement} Styled box component
|
||||
*/
|
||||
|
||||
// Components
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const BaseContainer = ({ children, sx = {} }) => {
|
||||
const theme = useTheme();
|
||||
const { getDimensions } = useHardwareUtils();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: `${theme.spacing(getDimensions().baseBoxPaddingVertical)} ${theme.spacing(getDimensions().baseBoxPaddingHorizontal)}`,
|
||||
minWidth: 200,
|
||||
width: 225,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
BaseContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
sx: PropTypes.object,
|
||||
};
|
||||
|
||||
export default BaseContainer;
|
||||
@@ -0,0 +1,62 @@
|
||||
// Components
|
||||
import CustomGauge from "../../../../../Components/Charts/CustomGauge";
|
||||
import BaseContainer from "../BaseContainer";
|
||||
import { Stack, Typography, Box } from "@mui/material";
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Gauge = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<BaseContainer>
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(2)}
|
||||
alignItems="center"
|
||||
>
|
||||
<CustomGauge
|
||||
progress={value}
|
||||
radius={100}
|
||||
color={theme.palette.primary.main}
|
||||
/>
|
||||
<Typography component="h2">{heading}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderTop: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricOne}</Typography>
|
||||
<Typography>{valueOne}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricTwo}</Typography>
|
||||
<Typography>{valueTwo}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</BaseContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Gauge.propTypes = {
|
||||
value: PropTypes.number,
|
||||
heading: PropTypes.string,
|
||||
metricOne: PropTypes.string,
|
||||
valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
metricTwo: PropTypes.string,
|
||||
valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
};
|
||||
|
||||
export default Gauge;
|
||||
@@ -0,0 +1,80 @@
|
||||
// Components
|
||||
import { Stack } from "@mui/material";
|
||||
import Gauge from "./Gauge";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
|
||||
// Utils
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils";
|
||||
import { useTheme } from "@emotion/react";
|
||||
|
||||
const Gauges = ({ shouldRender, monitor }) => {
|
||||
const { decimalToPercentage, formatBytes } = useHardwareUtils();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!shouldRender) {
|
||||
return <SkeletonLayout />;
|
||||
}
|
||||
|
||||
const { stats } = monitor ?? {};
|
||||
let latestCheck = stats?.aggregateData?.latestCheck;
|
||||
const memoryUsagePercent = latestCheck?.memory?.usage_percent ?? 0;
|
||||
const memoryUsedBytes = latestCheck?.memory?.used_bytes ?? 0;
|
||||
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
||||
const cpuUsagePercent = latestCheck?.cpu?.usage_percent ?? 0;
|
||||
const cpuPhysicalCores = latestCheck?.cpu?.physical_core ?? 0;
|
||||
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
|
||||
|
||||
const gauges = [
|
||||
{
|
||||
type: "memory",
|
||||
value: decimalToPercentage(memoryUsagePercent),
|
||||
heading: "Memory usage",
|
||||
metricOne: "Used",
|
||||
valueOne: formatBytes(memoryUsedBytes, true),
|
||||
metricTwo: "Total",
|
||||
valueTwo: formatBytes(memoryTotalBytes, true),
|
||||
},
|
||||
{
|
||||
type: "cpu",
|
||||
value: decimalToPercentage(cpuUsagePercent),
|
||||
heading: "CPU usage",
|
||||
metricOne: "Cores",
|
||||
valueOne: cpuPhysicalCores ?? 0,
|
||||
metricTwo: "Frequency",
|
||||
valueTwo: `${(cpuFrequency / 1000).toFixed(2)} Ghz`,
|
||||
},
|
||||
...(latestCheck?.disk ?? []).map((disk, idx) => ({
|
||||
type: "disk",
|
||||
diskIndex: idx,
|
||||
value: decimalToPercentage(disk.usage_percent),
|
||||
heading: `Disk${idx} usage`,
|
||||
metricOne: "Used",
|
||||
valueOne: formatBytes(disk.total_bytes - disk.free_bytes, true),
|
||||
metricTwo: "Total",
|
||||
valueTwo: formatBytes(disk.total_bytes, true),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
{gauges.map((gauge) => {
|
||||
return (
|
||||
<Gauge
|
||||
key={`${gauge.type}-${gauge.diskIndex ?? ""}`}
|
||||
value={gauge.value}
|
||||
heading={gauge.heading}
|
||||
metricOne={gauge.metricOne}
|
||||
valueOne={gauge.valueOne}
|
||||
metricTwo={gauge.metricTwo}
|
||||
valueTwo={gauge.valueTwo}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Gauges;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Stack, Skeleton } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
|
||||
const SkeletonLayout = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
{Array.from({ length: 3 }).map((_, idx) => {
|
||||
return (
|
||||
<Skeleton
|
||||
key={`gauge-${idx}`}
|
||||
variant="rectangular"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
@@ -0,0 +1,109 @@
|
||||
// Components
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import StatusBoxes from "../../../../../Components/StatusBoxes";
|
||||
import StatBox from "../../../../../Components/StatBox";
|
||||
|
||||
//Utils
|
||||
import useUtils from "../../../../../Pages/Uptime/Monitors/Hooks/useUtils";
|
||||
import { useHardwareUtils } from "../../Hooks/useHardwareUtils";
|
||||
|
||||
const InfraStatBoxes = ({ shouldRender, monitor }) => {
|
||||
// Utils
|
||||
const { formatBytes } = useHardwareUtils();
|
||||
const { statusStyles, determineState } = useUtils();
|
||||
|
||||
const { stats, uptimePercentage } = monitor ?? {};
|
||||
const latestCheck = stats?.aggregateData?.latestCheck;
|
||||
|
||||
// Get data from latest check
|
||||
const physicalCores = latestCheck?.cpu?.physical_core ?? 0;
|
||||
const logicalCores = latestCheck?.cpu?.logical_core ?? 0;
|
||||
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
|
||||
const cpuTemperature =
|
||||
latestCheck?.cpu?.temperature?.length > 0
|
||||
? latestCheck.cpu.temperature.reduce((acc, curr) => acc + curr, 0) /
|
||||
latestCheck.cpu.temperature.length
|
||||
: 0;
|
||||
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
||||
const diskTotalBytes = latestCheck?.disk[0]?.total_bytes ?? 0;
|
||||
const os = latestCheck?.host?.os ?? undefined;
|
||||
const platform = latestCheck?.host?.platform ?? undefined;
|
||||
const osPlatform =
|
||||
typeof os === "undefined" && typeof platform === "undefined"
|
||||
? undefined
|
||||
: `${os} ${platform}`;
|
||||
|
||||
return (
|
||||
<StatusBoxes
|
||||
shouldRender={shouldRender}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<StatBox
|
||||
sx={statusStyles[determineState(monitor)]}
|
||||
heading="Status"
|
||||
subHeading={determineState(monitor)}
|
||||
/>
|
||||
<StatBox
|
||||
heading="CPU (Physical)"
|
||||
subHeading={
|
||||
<>
|
||||
{physicalCores}
|
||||
<Typography component="span">cores</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
key={2}
|
||||
heading="CPU (Logical)"
|
||||
subHeading={
|
||||
<>
|
||||
{logicalCores}
|
||||
<Typography component="span">cores</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
heading="CPU Frequency"
|
||||
subHeading={
|
||||
<>
|
||||
{(cpuFrequency / 1000).toFixed(2)}
|
||||
<Typography component="span">Ghz</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
heading="Average CPU Temperature"
|
||||
subHeading={
|
||||
<>
|
||||
{cpuTemperature.toFixed(2)}
|
||||
<Typography component="span">C</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
heading="Memory"
|
||||
subHeading={formatBytes(memoryTotalBytes)}
|
||||
/>
|
||||
<StatBox
|
||||
heading="Disk"
|
||||
subHeading={formatBytes(diskTotalBytes)}
|
||||
/>
|
||||
<StatBox
|
||||
heading="Uptime"
|
||||
subHeading={
|
||||
<>
|
||||
{(uptimePercentage * 100).toFixed(2)}
|
||||
<Typography component="span">%</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
key={8}
|
||||
heading="OS"
|
||||
subHeading={osPlatform}
|
||||
/>
|
||||
</StatusBoxes>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfraStatBoxes;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { networkService } from "../../../../main";
|
||||
|
||||
const useHardwareMonitorsFetch = ({ monitorId, dateRange }) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const [monitor, setMonitor] = useState(undefined);
|
||||
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await networkService.getHardwareDetailsByMonitorId({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
dateRange: dateRange,
|
||||
});
|
||||
response.data.data;
|
||||
setMonitor(response.data.data);
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [monitorId, dateRange, authToken]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
networkError,
|
||||
monitor,
|
||||
};
|
||||
};
|
||||
|
||||
export { useHardwareMonitorsFetch };
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
|
||||
// Constants
|
||||
const BASE_BOX_PADDING_VERTICAL = 4;
|
||||
const BASE_BOX_PADDING_HORIZONTAL = 8;
|
||||
const TYPOGRAPHY_PADDING = 8;
|
||||
const CHART_CONTAINER_HEIGHT = 300;
|
||||
|
||||
const useHardwareUtils = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const getDimensions = () => {
|
||||
const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2;
|
||||
const totalChartContainerPadding =
|
||||
parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2;
|
||||
return {
|
||||
baseBoxPaddingVertical: BASE_BOX_PADDING_VERTICAL,
|
||||
baseBoxPaddingHorizontal: BASE_BOX_PADDING_HORIZONTAL,
|
||||
totalContainerPadding: parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2,
|
||||
areaChartHeight:
|
||||
CHART_CONTAINER_HEIGHT - totalChartContainerPadding - totalTypographyPadding,
|
||||
};
|
||||
};
|
||||
|
||||
const formatBytes = (bytes, space = false) => {
|
||||
if (bytes === undefined || bytes === null)
|
||||
return (
|
||||
<>
|
||||
{0}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
if (typeof bytes !== "number")
|
||||
return (
|
||||
<>
|
||||
{0}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
if (bytes === 0)
|
||||
return (
|
||||
<>
|
||||
{0}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
const GB = bytes / (1024 * 1024 * 1024);
|
||||
const MB = bytes / (1024 * 1024);
|
||||
|
||||
if (GB >= 1) {
|
||||
return (
|
||||
<>
|
||||
{Number(GB.toFixed(0))}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{Number(MB.toFixed(0))}
|
||||
<Typography component="span">MB</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a decimal value to a percentage
|
||||
*
|
||||
* @function decimalToPercentage
|
||||
* @param {number} value - Decimal value to convert
|
||||
* @returns {number} Percentage representation
|
||||
*
|
||||
* @example
|
||||
* decimalToPercentage(0.75) // Returns 75
|
||||
* decimalToPercentage(null) // Returns 0
|
||||
*/
|
||||
const decimalToPercentage = (value) => {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return value * 100;
|
||||
};
|
||||
|
||||
const buildTemps = (checks) => {
|
||||
let numCores = 1;
|
||||
if (checks === null) return { temps: [], tempKeys: [] };
|
||||
|
||||
for (const check of checks) {
|
||||
if (check?.avgTemperature?.length > numCores) {
|
||||
numCores = check.avgTemperature.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const temps = checks.map((check) => {
|
||||
// If there's no data, set the temperature to 0
|
||||
if (
|
||||
check?.avgTemperature?.length === 0 ||
|
||||
check?.avgTemperature === undefined ||
|
||||
check?.avgTemperature === null
|
||||
) {
|
||||
check.avgTemperature = Array(numCores).fill(0);
|
||||
}
|
||||
const res = check?.avgTemperature?.reduce(
|
||||
(acc, cur, idx) => {
|
||||
acc[`core${idx + 1}`] = cur;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
_id: check._id,
|
||||
}
|
||||
);
|
||||
return res;
|
||||
});
|
||||
if (temps.length === 0 || !temps[0]) {
|
||||
return { temps: [], tempKeys: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "_id"),
|
||||
temps,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
formatBytes,
|
||||
decimalToPercentage,
|
||||
buildTemps,
|
||||
getDimensions,
|
||||
};
|
||||
};
|
||||
|
||||
export { useHardwareUtils };
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import PlaceholderLight from "../../../assets/Images/data_placeholder.svg?react";
|
||||
import PlaceholderDark from "../../../assets/Images/data_placeholder_dark.svg?react";
|
||||
import { Box, Typography, Stack } from "@mui/material";
|
||||
import PropTypes from "prop-types";
|
||||
import { useSelector } from "react-redux";
|
||||
const Empty = ({ styles }) => {
|
||||
const theme = useTheme();
|
||||
const mode = useSelector((state) => state.ui.mode);
|
||||
return (
|
||||
<Box sx={{ ...styles, marginTop: theme.spacing(24) }}>
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(8)}
|
||||
alignItems="center"
|
||||
>
|
||||
{mode === "light" ? <PlaceholderLight /> : <PlaceholderDark />}
|
||||
|
||||
<Typography variant="h2">Your infrastructure dashboard will show here</Typography>
|
||||
<Typography
|
||||
textAlign="center"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
Hang tight! Data is loading
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Empty.propTypes = {
|
||||
styles: PropTypes.object,
|
||||
mode: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
@@ -1,681 +1,100 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
// Components
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import { Button, ButtonGroup, Stack, Box, Typography } from "@mui/material";
|
||||
import MonitorStatusHeader from "../../../Components/MonitorStatusHeader";
|
||||
import MonitorTimeFrameHeader from "../../../Components/MonitorTimeFrameHeader";
|
||||
import StatusBoxes from "./Components/StatusBoxes";
|
||||
import GaugeBoxes from "./Components/GaugeBoxes";
|
||||
import AreaChartBoxes from "./Components/AreaChartBoxes";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import CustomGauge from "../../../Components/Charts/CustomGauge";
|
||||
import AreaChart from "../../../Components/Charts/AreaChart";
|
||||
import { useSelector } from "react-redux";
|
||||
import { networkService } from "../../../main";
|
||||
import PulseDot from "../../../Components/Animated/PulseDot";
|
||||
import useUtils from "../../Uptime/Monitors/Hooks/useUtils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Empty from "./empty";
|
||||
import { logger } from "../../../Utils/Logger";
|
||||
import { formatDurationRounded, formatDurationSplit } from "../../../Utils/timeUtils";
|
||||
import { TzTick, PercentTick } from "../../../Components/Charts/Utils/chartUtils";
|
||||
import {
|
||||
InfrastructureTooltip,
|
||||
TemperatureTooltip,
|
||||
} from "../../../Components/Charts/Utils/chartUtils";
|
||||
import PropTypes from "prop-types";
|
||||
import StatBox from "../../../Components/StatBox";
|
||||
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
|
||||
import { useHardwareMonitorsFetch } from "./Hooks/useHardwareMonitorsFetch";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const BASE_BOX_PADDING_VERTICAL = 4;
|
||||
const BASE_BOX_PADDING_HORIZONTAL = 8;
|
||||
const TYPOGRAPHY_PADDING = 8;
|
||||
/**
|
||||
* Converts bytes to gigabytes
|
||||
* @param {number} bytes - Number of bytes to convert
|
||||
* @returns {number} Converted value in gigabytes
|
||||
*/
|
||||
const formatBytes = (bytes, space = false) => {
|
||||
if (bytes === undefined || bytes === null)
|
||||
return (
|
||||
<>
|
||||
{0}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
if (typeof bytes !== "number")
|
||||
return (
|
||||
<>
|
||||
{0}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
if (bytes === 0)
|
||||
return (
|
||||
<>
|
||||
{0}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
// Constants
|
||||
const BREADCRUMBS = [
|
||||
{ name: "infrastructure monitors", path: "/infrastructure" },
|
||||
{ name: "details", path: "" },
|
||||
];
|
||||
const InfrastructureDetails = () => {
|
||||
// Redux state
|
||||
|
||||
const GB = bytes / (1024 * 1024 * 1024);
|
||||
const MB = bytes / (1024 * 1024);
|
||||
// Local state
|
||||
const [dateRange, setDateRange] = useState("day");
|
||||
|
||||
if (GB >= 1) {
|
||||
// Utils
|
||||
const theme = useTheme();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { monitorId } = useParams();
|
||||
|
||||
const { isLoading, networkError, monitor } = useHardwareMonitorsFetch({
|
||||
monitorId,
|
||||
dateRange,
|
||||
});
|
||||
|
||||
if (networkError === true) {
|
||||
return (
|
||||
<>
|
||||
{Number(GB.toFixed(0))}
|
||||
{space ? " " : ""}
|
||||
<Typography component="span">GB</Typography>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{Number(MB.toFixed(0))}
|
||||
<Typography component="span">MB</Typography>
|
||||
</>
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a decimal value to a percentage
|
||||
*
|
||||
* @function decimalToPercentage
|
||||
* @param {number} value - Decimal value to convert
|
||||
* @returns {number} Percentage representation
|
||||
*
|
||||
* @example
|
||||
* decimalToPercentage(0.75) // Returns 75
|
||||
* decimalToPercentage(null) // Returns 0
|
||||
*/
|
||||
const decimalToPercentage = (value) => {
|
||||
if (value === null || value === undefined) return 0;
|
||||
return value * 100;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a base box with consistent styling
|
||||
* @param {Object} props - Component properties
|
||||
* @param {React.ReactNode} props.children - Child components to render inside the box
|
||||
* @param {Object} props.sx - Additional styling for the box
|
||||
* @returns {React.ReactElement} Styled box component
|
||||
*/
|
||||
const BaseBox = ({ children, sx = {} }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
padding: `${theme.spacing(BASE_BOX_PADDING_VERTICAL)} ${theme.spacing(BASE_BOX_PADDING_HORIZONTAL)}`,
|
||||
minWidth: 200,
|
||||
width: 225,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
BaseBox.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
sx: PropTypes.object,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a gauge box with usage visualization
|
||||
* @param {Object} props - Component properties
|
||||
* @param {number} props.value - Percentage value for gauge
|
||||
* @param {string} props.heading - Box heading
|
||||
* @param {string} props.metricOne - First metric label
|
||||
* @param {string} props.valueOne - First metric value
|
||||
* @param {string} props.metricTwo - Second metric label
|
||||
* @param {string} props.valueTwo - Second metric value
|
||||
* @returns {React.ReactElement} Gauge box component
|
||||
*/
|
||||
const GaugeBox = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<BaseBox>
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(2)}
|
||||
alignItems="center"
|
||||
>
|
||||
<CustomGauge
|
||||
progress={value}
|
||||
radius={100}
|
||||
color={theme.palette.primary.main}
|
||||
if (!isLoading && monitor?.stats?.checks?.length === 0) {
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<MonitorStatusHeader
|
||||
path={"infrastructure"}
|
||||
isAdmin={false}
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
/>
|
||||
<Typography component="h2">{heading}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderTop: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricOne}</Typography>
|
||||
<Typography>{valueOne}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Typography>{metricTwo}</Typography>
|
||||
<Typography>{valueTwo}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<GenericFallback>
|
||||
<Typography>No check history for htis monitor yet.</Typography>
|
||||
</GenericFallback>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
GaugeBox.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
heading: PropTypes.string.isRequired,
|
||||
metricOne: PropTypes.string.isRequired,
|
||||
valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.element])
|
||||
.isRequired,
|
||||
metricTwo: PropTypes.string.isRequired,
|
||||
valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.element])
|
||||
.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the infrastructure details page
|
||||
* @returns {React.ReactElement} Infrastructure details page component
|
||||
*/
|
||||
const InfrastructureDetails = () => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { monitorId } = useParams();
|
||||
const navList = [
|
||||
{ name: "infrastructure monitors", path: "/infrastructure" },
|
||||
{ name: "details", path: `/infrastructure/${monitorId}` },
|
||||
];
|
||||
const [monitor, setMonitor] = useState(null);
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
const [dateRange, setDateRange] = useState("day");
|
||||
const { statusColor, statusStyles, determineState } = useUtils();
|
||||
// These calculations are needed because ResponsiveContainer
|
||||
// doesn't take padding of parent/siblings into account
|
||||
// when calculating height.
|
||||
const chartContainerHeight = 300;
|
||||
const totalChartContainerPadding =
|
||||
parseInt(theme.spacing(BASE_BOX_PADDING_VERTICAL), 10) * 2;
|
||||
const totalTypographyPadding = parseInt(theme.spacing(TYPOGRAPHY_PADDING), 10) * 2;
|
||||
const areaChartHeight =
|
||||
(chartContainerHeight - totalChartContainerPadding - totalTypographyPadding) * 0.95;
|
||||
// end height calculations
|
||||
|
||||
const buildStatBoxes = (stats, uptime) => {
|
||||
if (Object.keys(stats).length === 0) return [];
|
||||
let latestCheck = stats?.aggregateData?.latestCheck ?? null;
|
||||
if (latestCheck === null) return [];
|
||||
|
||||
// Extract values from latest check
|
||||
const physicalCores = latestCheck?.cpu?.physical_core ?? 0;
|
||||
const logicalCores = latestCheck?.cpu?.logical_core ?? 0;
|
||||
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
|
||||
const cpuTemperature =
|
||||
latestCheck?.cpu?.temperature?.length > 0
|
||||
? latestCheck.cpu.temperature.reduce((acc, curr) => acc + curr, 0) /
|
||||
latestCheck.cpu.temperature.length
|
||||
: 0;
|
||||
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
||||
const diskTotalBytes = latestCheck?.disk[0]?.total_bytes ?? 0;
|
||||
const os = latestCheck?.host?.os ?? null;
|
||||
const platform = latestCheck?.host?.platform ?? null;
|
||||
const osPlatform = os === null && platform === null ? null : `${os} ${platform}`;
|
||||
return [
|
||||
{
|
||||
id: 7,
|
||||
sx: statusStyles[determineState(monitor)],
|
||||
heading: "Status",
|
||||
subHeading: monitor?.status === true ? "Active" : "Inactive",
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
heading: "CPU (Physical)",
|
||||
subHeading: (
|
||||
<>
|
||||
{physicalCores}
|
||||
<Typography component="span">cores</Typography>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
heading: "CPU (Logical)",
|
||||
subHeading: (
|
||||
<>
|
||||
{logicalCores}
|
||||
<Typography component="span">cores</Typography>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
heading: "CPU Frequency",
|
||||
subHeading: (
|
||||
<>
|
||||
{(cpuFrequency / 1000).toFixed(2)}
|
||||
<Typography component="span">Ghz</Typography>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
heading: "Average CPU Temperature",
|
||||
subHeading: (
|
||||
<>
|
||||
{cpuTemperature.toFixed(2)}
|
||||
<Typography component="span">C</Typography>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
heading: "Memory",
|
||||
subHeading: formatBytes(memoryTotalBytes),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
heading: "Disk",
|
||||
subHeading: formatBytes(diskTotalBytes),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
heading: "Uptime",
|
||||
subHeading: (
|
||||
<>
|
||||
{(uptime * 100).toFixed(2)}
|
||||
<Typography component="span">%</Typography>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: 8,
|
||||
heading: "OS",
|
||||
subHeading: osPlatform,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const buildGaugeBoxConfigs = (stats) => {
|
||||
if (Object.keys(stats).length === 0) return [];
|
||||
|
||||
let latestCheck = stats?.aggregateData?.latestCheck ?? null;
|
||||
if (latestCheck === null) return [];
|
||||
|
||||
// Extract values from latest check
|
||||
const memoryUsagePercent = latestCheck?.memory?.usage_percent ?? 0;
|
||||
const memoryUsedBytes = latestCheck?.memory?.used_bytes ?? 0;
|
||||
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
||||
const cpuUsagePercent = latestCheck?.cpu?.usage_percent ?? 0;
|
||||
const cpuPhysicalCores = latestCheck?.cpu?.physical_core ?? 0;
|
||||
const cpuFrequency = latestCheck?.cpu?.frequency ?? 0;
|
||||
return [
|
||||
{
|
||||
type: "memory",
|
||||
value: decimalToPercentage(memoryUsagePercent),
|
||||
heading: "Memory usage",
|
||||
metricOne: "Used",
|
||||
valueOne: formatBytes(memoryUsedBytes, true),
|
||||
metricTwo: "Total",
|
||||
valueTwo: formatBytes(memoryTotalBytes, true),
|
||||
},
|
||||
{
|
||||
type: "cpu",
|
||||
value: decimalToPercentage(cpuUsagePercent),
|
||||
heading: "CPU usage",
|
||||
metricOne: "Cores",
|
||||
valueOne: cpuPhysicalCores ?? 0,
|
||||
metricTwo: "Frequency",
|
||||
valueTwo: `${(cpuFrequency / 1000).toFixed(2)} Ghz`,
|
||||
},
|
||||
...(latestCheck?.disk ?? []).map((disk, idx) => ({
|
||||
type: "disk",
|
||||
diskIndex: idx,
|
||||
value: decimalToPercentage(disk.usage_percent),
|
||||
heading: `Disk${idx} usage`,
|
||||
metricOne: "Used",
|
||||
valueOne: formatBytes(disk.total_bytes - disk.free_bytes, true),
|
||||
metricTwo: "Total",
|
||||
valueTwo: formatBytes(disk.total_bytes, true),
|
||||
})),
|
||||
];
|
||||
};
|
||||
|
||||
const buildTemps = (checks) => {
|
||||
let numCores = 1;
|
||||
if (checks === null) return { temps: [], tempKeys: [] };
|
||||
|
||||
for (const check of checks) {
|
||||
if (check?.avgTemperature?.length > numCores) {
|
||||
numCores = check.avgTemperature.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const temps = checks.map((check) => {
|
||||
// If there's no data, set the temperature to 0
|
||||
if (
|
||||
check?.avgTemperature?.length === 0 ||
|
||||
check?.avgTemperature === undefined ||
|
||||
check?.avgTemperature === null
|
||||
) {
|
||||
check.avgTemperature = Array(numCores).fill(0);
|
||||
}
|
||||
const res = check?.avgTemperature?.reduce(
|
||||
(acc, cur, idx) => {
|
||||
acc[`core${idx + 1}`] = cur;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
_id: check._id,
|
||||
}
|
||||
);
|
||||
return res;
|
||||
});
|
||||
if (temps.length === 0 || !temps[0]) {
|
||||
return { temps: [], tempKeys: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
tempKeys: Object.keys(temps[0] || {}).filter((key) => key !== "_id"),
|
||||
temps,
|
||||
};
|
||||
};
|
||||
|
||||
const buildAreaChartConfigs = (checks) => {
|
||||
let latestCheck = checks[0] ?? null;
|
||||
if (latestCheck === null) return [];
|
||||
const { temps, tempKeys } = buildTemps(checks);
|
||||
return [
|
||||
{
|
||||
type: "memory",
|
||||
data: checks,
|
||||
dataKeys: ["avgMemoryUsage"],
|
||||
heading: "Memory usage",
|
||||
strokeColor: theme.palette.accent.main, // CAIO_REVIEW
|
||||
gradientStartColor: theme.palette.accent.main, // CAIO_REVIEW
|
||||
yLabel: "Memory usage",
|
||||
yDomain: [0, 1],
|
||||
yTick: <PercentTick />,
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.primary.main}
|
||||
yKey={"avgMemoryUsage"}
|
||||
yLabel={"Memory usage"}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "cpu",
|
||||
data: checks,
|
||||
dataKeys: ["avgCpuUsage"],
|
||||
heading: "CPU usage",
|
||||
strokeColor: theme.palette.success.main,
|
||||
gradientStartColor: theme.palette.success.main,
|
||||
yLabel: "CPU usage",
|
||||
yDomain: [0, 1],
|
||||
yTick: <PercentTick />,
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.success.main}
|
||||
yKey={"avgCpuUsage"}
|
||||
yLabel={"CPU usage"}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "temperature",
|
||||
data: temps,
|
||||
dataKeys: tempKeys,
|
||||
strokeColor: theme.palette.error.main,
|
||||
gradientStartColor: theme.palette.error.main,
|
||||
heading: "CPU Temperature",
|
||||
yLabel: "Temperature",
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
yDomain: [
|
||||
0,
|
||||
Math.max(
|
||||
Math.max(...temps.flatMap((t) => tempKeys.map((k) => t[k]))) * 1.1,
|
||||
200
|
||||
),
|
||||
],
|
||||
toolTip: (
|
||||
<TemperatureTooltip
|
||||
keys={tempKeys}
|
||||
dotColor={theme.palette.error.main}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(latestCheck?.disks?.map((disk, idx) => ({
|
||||
type: "disk",
|
||||
data: checks,
|
||||
diskIndex: idx,
|
||||
dataKeys: [`disks[${idx}].usagePercent`],
|
||||
heading: `Disk${idx} usage`,
|
||||
strokeColor: theme.palette.warning.main,
|
||||
gradientStartColor: theme.palette.warning.main,
|
||||
yLabel: "Disk Usage",
|
||||
yDomain: [0, 1],
|
||||
yTick: <PercentTick />,
|
||||
xTick: <TzTick dateRange={dateRange} />,
|
||||
toolTip: (
|
||||
<InfrastructureTooltip
|
||||
dotColor={theme.palette.warning.main}
|
||||
yKey={`disks.usagePercent`}
|
||||
yLabel={"Disc usage"}
|
||||
yIdx={idx}
|
||||
dateRange={dateRange}
|
||||
/>
|
||||
),
|
||||
})) || []),
|
||||
];
|
||||
};
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await networkService.getHardwareDetailsByMonitorId({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
dateRange: dateRange,
|
||||
});
|
||||
response.data.data;
|
||||
setMonitor(response.data.data);
|
||||
} catch (error) {
|
||||
navigate("/not-found", { replace: true });
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [authToken, monitorId, dateRange, navigate]);
|
||||
|
||||
const statBoxConfigs = buildStatBoxes(
|
||||
monitor?.stats ?? {},
|
||||
monitor?.uptimePercentage ?? "Unknown"
|
||||
);
|
||||
const gaugeBoxConfigs = buildGaugeBoxConfigs(monitor?.stats ?? {});
|
||||
const areaChartConfigs = buildAreaChartConfigs(monitor?.stats?.checks ?? []);
|
||||
const lastChecked =
|
||||
Date.now() - new Date(monitor?.stats?.aggregateData?.latestCheck?.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Breadcrumbs list={navList} />
|
||||
{monitor?.stats.checks?.length > 0 ? (
|
||||
<Stack
|
||||
direction="column"
|
||||
gap={theme.spacing(10)}
|
||||
mt={theme.spacing(10)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<Box>
|
||||
<PulseDot color={statusColor[determineState(monitor)]} />
|
||||
</Box>
|
||||
<Typography
|
||||
alignSelf="end"
|
||||
component="h1"
|
||||
variant="h1"
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Typography alignSelf="end">{monitor.url || "..."}</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Typography alignSelf="end">
|
||||
Checking every {formatDurationRounded(monitor?.interval)}
|
||||
</Typography>
|
||||
<Typography alignSelf="end">
|
||||
Last checked {formatDurationSplit(lastChecked).time}{" "}
|
||||
{formatDurationSplit(lastChecked).format} ago
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
{statBoxConfigs.map((statBox) => (
|
||||
<StatBox
|
||||
key={statBox.id}
|
||||
{...statBox}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
{gaugeBoxConfigs.map((config) => {
|
||||
return (
|
||||
<GaugeBox
|
||||
key={`${config.type}-${config.diskIndex ?? ""}`}
|
||||
value={config.value}
|
||||
heading={config.heading}
|
||||
metricOne={config.metricOne}
|
||||
valueOne={config.valueOne}
|
||||
metricTwo={config.metricTwo}
|
||||
valueTwo={config.valueTwo}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
gap={theme.spacing(8)}
|
||||
mb={theme.spacing(8)}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Showing statistics for past{" "}
|
||||
{dateRange === "day"
|
||||
? "24 hours"
|
||||
: dateRange === "week"
|
||||
? "7 days"
|
||||
: "30 days"}
|
||||
.
|
||||
</Typography>
|
||||
<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"}
|
||||
// height={chartContainerHeight} // FE team HELP! Possibly no longer needed?
|
||||
gap={theme.spacing(8)} // FE team HELP!
|
||||
flexWrap="wrap" // //FE team HELP! Better way to do this?
|
||||
sx={{
|
||||
"& > *": {
|
||||
flexBasis: `calc(50% - ${theme.spacing(8)})`,
|
||||
maxWidth: `calc(50% - ${theme.spacing(8)})`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{areaChartConfigs.map((config) => {
|
||||
return (
|
||||
<BaseBox key={`${config.type}-${config.diskIndex ?? ""}`}>
|
||||
<Typography
|
||||
component="h2"
|
||||
padding={theme.spacing(8)}
|
||||
>
|
||||
{config.heading}
|
||||
</Typography>
|
||||
<AreaChart
|
||||
height={areaChartHeight}
|
||||
data={config.data}
|
||||
dataKeys={config.dataKeys}
|
||||
xKey="_id"
|
||||
yDomain={config.yDomain}
|
||||
customTooltip={config.toolTip}
|
||||
xTick={config.xTick}
|
||||
yTick={config.yTick}
|
||||
strokeColor={config.strokeColor}
|
||||
gradient={true}
|
||||
gradientStartColor={config.gradientStartColor}
|
||||
gradientEndColor="#ffffff"
|
||||
/>
|
||||
</BaseBox>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Empty
|
||||
styles={{
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
p: theme.spacing(30),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<MonitorStatusHeader
|
||||
path={"infrastructure"}
|
||||
isAdmin={false}
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
/>
|
||||
<StatusBoxes
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
/>
|
||||
<GaugeBoxes
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
/>
|
||||
<MonitorTimeFrameHeader
|
||||
shouldRender={!isLoading}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
<AreaChartBoxes
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Components
|
||||
import DataTable from "../../../../../Components/Table";
|
||||
import Host from "../../../../Uptime/Monitors/Components/Host";
|
||||
import { StatusLabel } from "../../../../../Components/Label";
|
||||
import { Stack } from "@mui/material";
|
||||
import { InfrastructureMenu } from "../MonitorsTableMenu";
|
||||
// Assets
|
||||
import CPUChipIcon from "../../../../../assets/icons/cpu-chip.svg?react";
|
||||
import CustomGauge from "../../../../../Components/Charts/CustomGauge";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import useUtils from "../../../../Uptime/Monitors/Hooks/useUtils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const MonitorsTable = ({ shouldRender, monitors, isAdmin, handleActionMenuDelete }) => {
|
||||
// Utils
|
||||
const theme = useTheme();
|
||||
const { determineState } = useUtils();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handlers
|
||||
const openDetails = (id) => {
|
||||
navigate(`/infrastructure/${id}`);
|
||||
};
|
||||
const headers = [
|
||||
{
|
||||
id: "host",
|
||||
content: "Host",
|
||||
render: (row) => (
|
||||
<Host
|
||||
title={row.name}
|
||||
url={row.url}
|
||||
percentage={row.uptimePercentage}
|
||||
percentageColor={row.percentageColor}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: "Status",
|
||||
render: (row) => (
|
||||
<StatusLabel
|
||||
status={row.status}
|
||||
text={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "frequency",
|
||||
content: "Frequency",
|
||||
render: (row) => (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
gap=".25rem"
|
||||
>
|
||||
<CPUChipIcon
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
{row.processor}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: "cpu", content: "CPU", render: (row) => <CustomGauge progress={row.cpu} /> },
|
||||
{ id: "mem", content: "Mem", render: (row) => <CustomGauge progress={row.mem} /> },
|
||||
{ id: "disk", content: "Disk", render: (row) => <CustomGauge progress={row.disk} /> },
|
||||
{
|
||||
id: "actions",
|
||||
content: "Actions",
|
||||
render: (row) => (
|
||||
<InfrastructureMenu
|
||||
monitor={row}
|
||||
isAdmin={isAdmin}
|
||||
updateCallback={handleActionMenuDelete}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const data = monitors?.map((monitor) => {
|
||||
const processor =
|
||||
((monitor.checks[0]?.cpu?.frequency ?? 0) / 1000).toFixed(2) + " GHz";
|
||||
const cpu = (monitor?.checks[0]?.cpu.usage_percent ?? 0) * 100;
|
||||
const mem = (monitor?.checks[0]?.memory.usage_percent ?? 0) * 100;
|
||||
const disk = (monitor?.checks[0]?.disk[0]?.usage_percent ?? 0) * 100;
|
||||
const status = determineState(monitor);
|
||||
const uptimePercentage = ((monitor?.uptimePercentage ?? 0) * 100)
|
||||
.toFixed(2)
|
||||
.toString();
|
||||
const percentageColor =
|
||||
monitor.uptimePercentage < 0.25
|
||||
? theme.palette.error.main
|
||||
: monitor.uptimePercentage < 0.5
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main;
|
||||
|
||||
return {
|
||||
id: monitor._id,
|
||||
name: monitor.name,
|
||||
url: monitor.url,
|
||||
processor,
|
||||
cpu,
|
||||
mem,
|
||||
disk,
|
||||
status,
|
||||
uptimePercentage,
|
||||
percentageColor,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
shouldRender={shouldRender}
|
||||
headers={headers}
|
||||
data={data}
|
||||
config={{
|
||||
/* TODO this behavior seems to be repeated. Put it on the root table? */
|
||||
rowSX: {
|
||||
cursor: "pointer",
|
||||
"&:hover td": {
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
transition: "background-color .3s ease",
|
||||
},
|
||||
},
|
||||
onRowClick: (row) => openDetails(row.id),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorsTable;
|
||||
@@ -4,12 +4,12 @@ import { useRef, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
import { createToast } from "../../../../../Utils/toastUtils";
|
||||
import { IconButton, Menu, MenuItem } from "@mui/material";
|
||||
import Settings from "../../../../assets/icons/settings-bold.svg?react";
|
||||
import Settings from "../../../../../assets/icons/settings-bold.svg?react";
|
||||
import PropTypes from "prop-types";
|
||||
import Dialog from "../../../../Components/Dialog";
|
||||
import { networkService } from "../../../../Utils/NetworkService.js";
|
||||
import Dialog from "../../../../../Components/Dialog";
|
||||
import { networkService } from "../../../../../Utils/NetworkService.js";
|
||||
|
||||
/**
|
||||
* InfrastructureMenu Component
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { networkService } from "../../../../main";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
|
||||
const useMonitorFetch = ({ page, rowsPerPage, updateTrigger }) => {
|
||||
// Redux state
|
||||
const { authToken, user } = useSelector((state) => state.auth);
|
||||
|
||||
// Local state
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const [monitors, setMonitors] = useState(undefined);
|
||||
const [summary, setSummary] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
const response = await networkService.getMonitorsByTeamId({
|
||||
authToken,
|
||||
teamId: user.teamId,
|
||||
limit: 1,
|
||||
types: ["hardware"],
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
});
|
||||
setMonitors(response?.data?.data?.filteredMonitors ?? []);
|
||||
setSummary(response?.data?.data?.summary ?? {});
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
createToast({
|
||||
body: error.message,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMonitors();
|
||||
}, [page, rowsPerPage, authToken, user.teamId, updateTrigger]);
|
||||
|
||||
return { monitors, summary, isLoading, networkError };
|
||||
};
|
||||
|
||||
export { useMonitorFetch };
|
||||
109
Client/src/Pages/Infrastructure/Monitors/index.jsx
Normal file
109
Client/src/Pages/Infrastructure/Monitors/index.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// Components
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import MonitorCountHeader from "../../../Components/MonitorCountHeader";
|
||||
import MonitorCreateHeader from "../../../Components/MonitorCreateHeader";
|
||||
import MonitorsTable from "./Components/MonitorsTable";
|
||||
import Pagination from "../../..//Components/Table/TablePagination";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
import Fallback from "../../../Components/Fallback";
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useMonitorFetch } from "./Hooks/useMonitorFetch";
|
||||
import { useState } from "react";
|
||||
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
|
||||
// Constants
|
||||
const BREADCRUMBS = [{ name: `infrastructure`, path: "/infrastructure" }];
|
||||
|
||||
const InfrastructureMonitors = () => {
|
||||
// Redux state
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||
const [updateTrigger, setUpdateTrigger] = useState(false);
|
||||
|
||||
// Utils
|
||||
const theme = useTheme();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// Handlers
|
||||
const handleActionMenuDelete = () => {
|
||||
setUpdateTrigger(!updateTrigger);
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(event.target.value);
|
||||
};
|
||||
|
||||
const { monitors, summary, isLoading, networkError } = useMonitorFetch({
|
||||
page,
|
||||
rowsPerPage,
|
||||
updateTrigger,
|
||||
});
|
||||
|
||||
if (networkError === true) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && monitors?.length === 0) {
|
||||
return (
|
||||
<Fallback
|
||||
vowelStart={true}
|
||||
title="infrastructure monitor"
|
||||
checks={[
|
||||
"Track the performance of your servers",
|
||||
"Identify bottlenecks and optimize usage",
|
||||
"Ensure reliability with real-time monitoring",
|
||||
]}
|
||||
link="/infrastructure/create"
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<MonitorCreateHeader
|
||||
isAdmin={isAdmin}
|
||||
shouldRender={!isLoading}
|
||||
path="/infrastructure/create"
|
||||
/>
|
||||
<MonitorCountHeader
|
||||
shouldRender={!isLoading}
|
||||
heading="Infrastructure monitors"
|
||||
monitorCount={summary?.totalMonitors ?? 0}
|
||||
/>
|
||||
<MonitorsTable
|
||||
shouldRender={!isLoading}
|
||||
monitors={monitors}
|
||||
isAdmin={isAdmin}
|
||||
handleActionMenuDelete={handleActionMenuDelete}
|
||||
/>
|
||||
<Pagination
|
||||
itemCount={summary?.totalMonitors}
|
||||
paginationLabel="monitors"
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfrastructureMonitors;
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { /* useDispatch, */ useSelector } from "react-redux";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import useUtils from "../Uptime/Monitors/Hooks/useUtils.jsx";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import SkeletonLayout from "./skeleton";
|
||||
import Fallback from "../../Components/Fallback";
|
||||
// import GearIcon from "../../Assets/icons/settings-bold.svg?react";
|
||||
import CPUChipIcon from "../../assets/icons/cpu-chip.svg?react";
|
||||
import DataTable from "../../Components/Table";
|
||||
import { Box, Button, IconButton, Stack } from "@mui/material";
|
||||
import Breadcrumbs from "../../Components/Breadcrumbs";
|
||||
import { StatusLabel } from "../../Components/Label";
|
||||
import { Heading } from "../../Components/Heading";
|
||||
import Pagination from "../../Components/Table/TablePagination/index.jsx";
|
||||
// import { getInfrastructureMonitorsByTeamId } from "../../Features/InfrastructureMonitors/infrastructureMonitorsSlice";
|
||||
import { networkService } from "../../Utils/NetworkService.js";
|
||||
import CustomGauge from "../../Components/Charts/CustomGauge/index.jsx";
|
||||
import Host from "../Uptime/Monitors/Components/Host/index.jsx";
|
||||
import { useIsAdmin } from "../../Hooks/useIsAdmin.js";
|
||||
import { InfrastructureMenu } from "./components/Menu";
|
||||
|
||||
const BREADCRUMBS = [{ name: `infrastructure`, path: "/infrastructure" }];
|
||||
|
||||
/**
|
||||
* This is the Infrastructure monitoring page. This is a work in progress
|
||||
*
|
||||
* @param - Define params.
|
||||
* @returns {JSX.Element} The infrastructure monitoring page.
|
||||
*/
|
||||
|
||||
function Infrastructure() {
|
||||
/* Adding this custom hook so we can avoid using the HOC approach that can lower performance (we are calling the admin logic N times on initializing the project. using a custom hook will cal it ass needed ) */
|
||||
const isAdmin = useIsAdmin();
|
||||
const theme = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigateToCreate = () => navigate("/infrastructure/create");
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
/* TODO refactor this, so it is not aware of the MUI implementation. First argument only exists because of MUI. This should require onlu the new page. Adapting for MUI should happen inside of table pagination component */
|
||||
const handleChangePage = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value));
|
||||
setPage(0);
|
||||
};
|
||||
const [monitors, setMonitors] = useState([]);
|
||||
const [summary, setSummary] = useState({});
|
||||
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
const user = jwtDecode(authToken);
|
||||
|
||||
const fetchMonitors = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await networkService.getMonitorsByTeamId({
|
||||
authToken,
|
||||
teamId: user.teamId,
|
||||
limit: 1,
|
||||
types: ["hardware"],
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
});
|
||||
setMonitors(response?.data?.data?.filteredMonitors ?? []);
|
||||
setSummary(response?.data?.data?.summary ?? {});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [page, rowsPerPage, authToken, user.teamId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonitors();
|
||||
}, [fetchMonitors]);
|
||||
|
||||
const { determineState } = useUtils();
|
||||
// do it here
|
||||
function openDetails(id) {
|
||||
navigate(`/infrastructure/${id}`);
|
||||
}
|
||||
function handleActionMenuDelete() {
|
||||
fetchMonitors();
|
||||
}
|
||||
|
||||
const headers = [
|
||||
{
|
||||
id: "host",
|
||||
content: "Host",
|
||||
render: (row) => (
|
||||
<Host
|
||||
title={row.name}
|
||||
url={row.url}
|
||||
percentage={row.uptimePercentage}
|
||||
percentageColor={row.percentageColor}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: "Status",
|
||||
render: (row) => (
|
||||
<StatusLabel
|
||||
status={row.status}
|
||||
text={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "frequency",
|
||||
content: "Frequency",
|
||||
render: (row) => (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
gap=".25rem"
|
||||
>
|
||||
<CPUChipIcon
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
{row.processor}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: "cpu", content: "CPU", render: (row) => <CustomGauge progress={row.cpu} /> },
|
||||
{ id: "mem", content: "Mem", render: (row) => <CustomGauge progress={row.mem} /> },
|
||||
{ id: "disk", content: "Disk", render: (row) => <CustomGauge progress={row.disk} /> },
|
||||
{
|
||||
id: "actions",
|
||||
content: "Actions",
|
||||
render: (row) => (
|
||||
<InfrastructureMenu
|
||||
monitor={row}
|
||||
isAdmin={isAdmin}
|
||||
updateCallback={handleActionMenuDelete}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const monitorsAsRows = monitors.map((monitor) => {
|
||||
const processor =
|
||||
((monitor.checks[0]?.cpu?.frequency ?? 0) / 1000).toFixed(2) + " GHz";
|
||||
const cpu = (monitor?.checks[0]?.cpu.usage_percent ?? 0) * 100;
|
||||
const mem = (monitor?.checks[0]?.memory.usage_percent ?? 0) * 100;
|
||||
const disk = (monitor?.checks[0]?.disk[0]?.usage_percent ?? 0) * 100;
|
||||
const status = determineState(monitor);
|
||||
const uptimePercentage = ((monitor?.uptimePercentage ?? 0) * 100)
|
||||
.toFixed(2)
|
||||
.toString();
|
||||
const percentageColor =
|
||||
monitor.uptimePercentage < 0.25
|
||||
? theme.palette.error.main
|
||||
: monitor.uptimePercentage < 0.5
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main;
|
||||
|
||||
return {
|
||||
id: monitor._id,
|
||||
name: monitor.name,
|
||||
url: monitor.url,
|
||||
processor,
|
||||
cpu,
|
||||
mem,
|
||||
disk,
|
||||
status,
|
||||
uptimePercentage,
|
||||
percentageColor,
|
||||
};
|
||||
});
|
||||
|
||||
let isActuallyLoading = isLoading && monitors?.length === 0;
|
||||
return (
|
||||
<Box
|
||||
className="infrastructure-monitor"
|
||||
sx={{
|
||||
':has(> [class*="fallback__"])': {
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isActuallyLoading ? (
|
||||
<SkeletonLayout />
|
||||
) : monitors?.length !== 0 ? (
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Box>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
mt={theme.spacing(5)}
|
||||
>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={navigateToCreate}
|
||||
sx={{ fontWeight: 500, whiteSpace: "nowrap" }}
|
||||
>
|
||||
Create new
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack
|
||||
sx={{
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
gap: ".25rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Heading component="h2">Infrastructure monitors</Heading>
|
||||
{/* TODO Same as the one in UptimaDataTable. Create component */}
|
||||
<Box
|
||||
component="span"
|
||||
color={theme.palette.tertiary.contrastText}
|
||||
border={2}
|
||||
borderColor={theme.palette.accent.main}
|
||||
backgroundColor={theme.palette.tertiary.main}
|
||||
sx={{
|
||||
padding: ".25em .75em",
|
||||
borderRadius: "10000px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{summary?.totalMonitors ?? 0}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<DataTable
|
||||
config={{
|
||||
/* TODO this behavior seems to be repeated. Put it on the root table? */
|
||||
rowSX: {
|
||||
cursor: "pointer",
|
||||
"&:hover td": {
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
transition: "background-color .3s ease",
|
||||
},
|
||||
},
|
||||
onRowClick: (row) => openDetails(row.id),
|
||||
}}
|
||||
headers={headers}
|
||||
data={monitorsAsRows}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
itemCount={summary?.totalMonitors ?? 0}
|
||||
paginationLabel="monitors"
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Fallback
|
||||
vowelStart={true}
|
||||
title="infrastructure monitor"
|
||||
checks={[
|
||||
"Track the performance of your servers",
|
||||
"Identify bottlenecks and optimize usage",
|
||||
"Ensure reliability with real-time monitoring",
|
||||
]}
|
||||
link="/infrastructure/create"
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { Infrastructure };
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
@@ -181,7 +181,7 @@ const CreateMaintenance = () => {
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
const handleSelectMonitors = (monitors) => {
|
||||
const handleSelectMonitors = (_, monitors) => {
|
||||
setForm({ ...form, monitors });
|
||||
const { error } = maintenanceWindowValidation.validate(
|
||||
{ monitors },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { networkService } from "../../../../main";
|
||||
import { logger } from "../../../../Utils/Logger";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
const useMonitorFetch = ({ authToken, monitorId }) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -8,10 +8,10 @@ const useMonitorFetch = ({ authToken, monitorId }) => {
|
||||
const [monitor, setMonitor] = useState(undefined);
|
||||
const [audits, setAudits] = useState(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchMonitor = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await networkService.getStatsByMonitorId({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
@@ -24,8 +24,8 @@ const useMonitorFetch = ({ authToken, monitorId }) => {
|
||||
setMonitor(res?.data?.data ?? undefined);
|
||||
setAudits(res?.data?.data?.checks?.[0]?.audits ?? undefined);
|
||||
} catch (error) {
|
||||
logger.error(logger);
|
||||
navigate("/not-found", { replace: true });
|
||||
setNetworkError(true);
|
||||
createToast({ body: error.message });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Components
|
||||
import { Stack, Typography, Skeleton } from "@mui/material";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import MonitorTimeFrameHeader from "../../../Components/MonitorTimeFrameHeader";
|
||||
import MonitorStatusHeader from "../../../Components/MonitorStatusHeader";
|
||||
import PageSpeedStatusBoxes from "./Components/PageSpeedStatusBoxes";
|
||||
import PageSpeedAreaChart from "./Components/PageSpeedAreaChart";
|
||||
import PerformanceReport from "./Components/PerformanceReport";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
|
||||
@@ -25,7 +27,7 @@ const PageSpeedDetails = () => {
|
||||
const { monitorId } = useParams();
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
|
||||
const { monitor, audits, isLoading } = useMonitorFetch({
|
||||
const { monitor, audits, isLoading, networkError } = useMonitorFetch({
|
||||
authToken,
|
||||
monitorId,
|
||||
});
|
||||
@@ -37,14 +39,48 @@ const PageSpeedDetails = () => {
|
||||
seo: true,
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleMetrics = (id) => {
|
||||
setMetrics((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
if (networkError === true) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty view, displayed when loading is complete and there are no checks
|
||||
if (!isLoading && monitor?.checks?.length === 0) {
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<MonitorStatusHeader
|
||||
path={"pagespeed"}
|
||||
isAdmin={isAdmin}
|
||||
monitor={monitor}
|
||||
/>
|
||||
<GenericFallback>
|
||||
<Typography>There is no check history for this monitor yet.</Typography>
|
||||
</GenericFallback>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<MonitorStatusHeader
|
||||
path={"pagespeed"}
|
||||
isAdmin={isAdmin}
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
@@ -53,12 +89,12 @@ const PageSpeedDetails = () => {
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
my={theme.spacing(8)}
|
||||
>
|
||||
Showing statistics for past 24 hours.
|
||||
</Typography>
|
||||
<MonitorTimeFrameHeader
|
||||
shouldRender={!isLoading}
|
||||
dateRange={"day"}
|
||||
hasDateRange={false}
|
||||
/>
|
||||
|
||||
<PageSpeedAreaChart
|
||||
shouldRender={!isLoading}
|
||||
monitor={monitor}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Components
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import { Stack } from "@mui/material";
|
||||
import CreateMonitorHeader from "../../../Components/CreateMonitorHeader";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import CreateMonitorHeader from "../../../Components/MonitorCreateHeader";
|
||||
import MonitorCountHeader from "../../../Components/MonitorCountHeader";
|
||||
import MonitorGrid from "./Components/MonitorGrid";
|
||||
import Fallback from "../../../Components/Fallback";
|
||||
@@ -11,7 +11,7 @@ import { useTheme } from "@emotion/react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
|
||||
import useMonitorsFetch from "./Hooks/useMonitorsFetch";
|
||||
import NetworkErrorFallback from "../../../Components/NetworkErrorFallback";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
|
||||
// Constants
|
||||
const BREADCRUMBS = [{ name: `pagespeed`, path: "/pagespeed" }];
|
||||
@@ -27,7 +27,18 @@ const PageSpeed = () => {
|
||||
});
|
||||
|
||||
if (networkError === true) {
|
||||
return <NetworkErrorFallback />;
|
||||
return (
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && monitors?.length === 0) {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import Fallback from "../../Components/Fallback";
|
||||
|
||||
const Status = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="status"
|
||||
sx={{
|
||||
':has(> [class*="fallback__"])': {
|
||||
position: "relative",
|
||||
border: 1,
|
||||
borderColor: theme.palette.primary.lowContrast,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Fallback
|
||||
title="status page"
|
||||
checks={[
|
||||
"Share your uptime publicly",
|
||||
"Keep your users informed about incidents",
|
||||
"Build trust with your customers",
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Status;
|
||||
@@ -0,0 +1,106 @@
|
||||
// Components
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import ReorderRoundedIcon from "@mui/icons-material/ReorderRounded";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
|
||||
// Utils
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import { useTheme } from "@emotion/react";
|
||||
const MonitorListItem = ({
|
||||
monitor,
|
||||
innerRef,
|
||||
draggableProps,
|
||||
dragHandleProps,
|
||||
onDelete,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
{...draggableProps}
|
||||
{...dragHandleProps}
|
||||
ref={innerRef}
|
||||
gap={theme.spacing(4)}
|
||||
margin={theme.spacing(4)}
|
||||
padding={theme.spacing(4)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
alignItems={"center"}
|
||||
justifyContent={"start"}
|
||||
border={`1px solid ${theme.palette.primary.lowContrast}`}
|
||||
>
|
||||
<ReorderRoundedIcon />
|
||||
<Typography>{monitor.name}</Typography>
|
||||
<DeleteIcon
|
||||
sx={{ marginLeft: "auto" }}
|
||||
onClick={() => {
|
||||
onDelete(monitor);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
|
||||
const onDelete = (monitorToDelete) => {
|
||||
const newMonitors = selectedMonitors.filter(
|
||||
(monitor) => monitor._id !== monitorToDelete._id
|
||||
);
|
||||
setSelectedMonitors(newMonitors);
|
||||
};
|
||||
const reorder = (list, startIndex, endIndex) => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const onDragEnd = (result) => {
|
||||
// dropped outside the list
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedMonitors = reorder(
|
||||
selectedMonitors,
|
||||
result.source.index,
|
||||
result.destination.index
|
||||
);
|
||||
|
||||
setSelectedMonitors(reorderedMonitors);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided, snapshot) => (
|
||||
<Stack
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{selectedMonitors?.map((monitor, index) => (
|
||||
<Draggable
|
||||
key={monitor._id}
|
||||
draggableId={monitor._id}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<MonitorListItem
|
||||
monitor={monitor}
|
||||
innerRef={provided.innerRef}
|
||||
draggableProps={provided.draggableProps}
|
||||
dragHandleProps={provided.dragHandleProps}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorList;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, Box } from "@mui/material";
|
||||
import ProgressUpload from "../../../../../Components/ProgressBars";
|
||||
import ImageIcon from "@mui/icons-material/Image";
|
||||
|
||||
import { formatBytes } from "../../../../../Utils/fileUtils";
|
||||
const Progress = ({ isLoading, progressValue, logo, logoType, removeLogo, errors }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ProgressUpload
|
||||
icon={<ImageIcon />}
|
||||
label={logo?.name}
|
||||
size={formatBytes(logo?.size)}
|
||||
progress={progressValue}
|
||||
onClick={removeLogo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && logoType) {
|
||||
return (
|
||||
<Box
|
||||
width="fit-content"
|
||||
alignSelf="center"
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={removeLogo}
|
||||
>
|
||||
Remove Logo
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Progress;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack, Skeleton } from "@mui/material";
|
||||
|
||||
const SkeletonLayout = () => {
|
||||
return (
|
||||
<Stack>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
height={"90vh"}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
104
Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
Normal file
104
Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
// Components
|
||||
import { Stack, Typography, Button } from "@mui/material";
|
||||
import { TabPanel } from "@mui/lab";
|
||||
import ConfigBox from "../../../../../Components/ConfigBox";
|
||||
import MonitorList from "../MonitorList";
|
||||
import Search from "../../../../../Components/Inputs/Search";
|
||||
import Checkbox from "../../../../../Components/Inputs/Checkbox";
|
||||
// Utils
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
const Content = ({
|
||||
tabValue,
|
||||
form,
|
||||
monitors,
|
||||
handleFormChange,
|
||||
errors,
|
||||
selectedMonitors,
|
||||
setSelectedMonitors,
|
||||
}) => {
|
||||
// Local state
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Handlers
|
||||
const handleMonitorsChange = (selectedMonitors) => {
|
||||
handleFormChange({
|
||||
target: { name: "monitors", value: selectedMonitors.map((monitor) => monitor._id) },
|
||||
});
|
||||
setSelectedMonitors(selectedMonitors);
|
||||
};
|
||||
|
||||
// Utils
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TabPanel value={tabValue}>
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<ConfigBox>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Status page servers</Typography>
|
||||
<Typography component="p">
|
||||
You can add any number of servers that you monitor to your status page. You
|
||||
can also reorder them for the best viewing experience.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Search
|
||||
options={monitors}
|
||||
multiple={true}
|
||||
filteredBy="name"
|
||||
value={selectedMonitors}
|
||||
inputValue={search}
|
||||
handleInputChange={setSearch}
|
||||
handleChange={handleMonitorsChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography
|
||||
component="span"
|
||||
className="input-error"
|
||||
color={theme.palette.error.main}
|
||||
sx={{
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{errors["monitors"]}
|
||||
</Typography>
|
||||
<MonitorList
|
||||
selectedMonitors={selectedMonitors}
|
||||
setSelectedMonitors={handleMonitorsChange}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>{" "}
|
||||
<ConfigBox>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Features</Typography>
|
||||
<Typography component="p">Show more details on the status page</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ margin: theme.spacing(6) }}>
|
||||
<Checkbox
|
||||
id="showCharts"
|
||||
name="showCharts"
|
||||
label={`Show charts`}
|
||||
isChecked={form.showCharts}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
<Checkbox
|
||||
id="showUptimePercentage"
|
||||
name="showUptimePercentage"
|
||||
label={`Show uptime percentage`}
|
||||
isChecked={form.showUptimePercentage}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(6)}></Stack>
|
||||
</TabPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
140
Client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx
Normal file
140
Client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
// Components
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { TabPanel } from "@mui/lab";
|
||||
import ConfigBox from "../../../../../Components/ConfigBox";
|
||||
import Checkbox from "../../../../../Components/Inputs/Checkbox";
|
||||
import TextInput from "../../../../../Components/Inputs/TextInput";
|
||||
import Select from "../../../../../Components/Inputs/Select";
|
||||
import ImageField from "../../../../../Components/Inputs/Image";
|
||||
import ColorPicker from "../../../../../Components/Inputs/ColorPicker";
|
||||
import Progress from "../Progress";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import timezones from "../../../../../Utils/timezones.json";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const TabSettings = ({
|
||||
tabValue,
|
||||
form,
|
||||
handleFormChange,
|
||||
handleImageChange,
|
||||
progress,
|
||||
removeLogo,
|
||||
errors,
|
||||
}) => {
|
||||
// Utils
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TabPanel value={tabValue}>
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<ConfigBox>
|
||||
<Stack>
|
||||
<Typography component="h2">Access</Typography>
|
||||
<Typography component="p">
|
||||
If your status page is ready, you can mark it as published.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(18)}>
|
||||
<Checkbox
|
||||
id="publish"
|
||||
name="isPublished"
|
||||
label={`Published and visible to the public`}
|
||||
isChecked={form.isPublished}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Basic Information</Typography>
|
||||
<Typography component="p">
|
||||
Define company name and the subdomain that your status page points to.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(18)}>
|
||||
<TextInput
|
||||
id="companyName"
|
||||
name="companyName"
|
||||
type="text"
|
||||
label="Company name"
|
||||
value={form.companyName}
|
||||
onChange={handleFormChange}
|
||||
helperText={errors["companyName"]}
|
||||
error={errors["companyName"] ? true : false}
|
||||
/>
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
type="url"
|
||||
label="Your status page address"
|
||||
disabled
|
||||
value={form.url}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Timezone</Typography>
|
||||
<Typography component="p">
|
||||
Select the timezone that your status page will be displayed in.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Select
|
||||
id="timezone"
|
||||
name="timezone"
|
||||
label="Display timezone"
|
||||
items={timezones}
|
||||
value={form.timezone}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
<ConfigBox>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Typography component="h2">Appearance</Typography>
|
||||
<Typography component="p">
|
||||
Define the default look and feel of your public status page.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<ImageField
|
||||
id="logo"
|
||||
src={form?.logo?.src}
|
||||
isRound={false}
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
<Progress
|
||||
isLoading={progress.isLoading}
|
||||
progressValue={progress.value}
|
||||
logo={form.logo}
|
||||
logoType={form.logo?.type}
|
||||
removeLogo={removeLogo}
|
||||
/>
|
||||
<ColorPicker
|
||||
id="color"
|
||||
name="color"
|
||||
value={form.color}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Stack>
|
||||
</ConfigBox>
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
);
|
||||
};
|
||||
|
||||
TabSettings.propTypes = {
|
||||
tabValue: PropTypes.string,
|
||||
form: PropTypes.object,
|
||||
handleFormChange: PropTypes.func,
|
||||
handleImageChange: PropTypes.func,
|
||||
progress: PropTypes.object,
|
||||
removeLogo: PropTypes.func,
|
||||
errors: PropTypes.object,
|
||||
};
|
||||
|
||||
export default TabSettings;
|
||||
81
Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
Normal file
81
Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// Components
|
||||
import { TabContext, TabList } from "@mui/lab";
|
||||
import { Tab } from "@mui/material";
|
||||
import Settings from "./Settings";
|
||||
import Content from "./Content";
|
||||
|
||||
// Utils
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Tabs = ({
|
||||
form,
|
||||
errors,
|
||||
monitors,
|
||||
selectedMonitors,
|
||||
setSelectedMonitors,
|
||||
handleFormChange,
|
||||
handleImageChange,
|
||||
progress,
|
||||
removeLogo,
|
||||
tab,
|
||||
setTab,
|
||||
TAB_LIST,
|
||||
}) => {
|
||||
return (
|
||||
<TabContext value={TAB_LIST[tab]}>
|
||||
<TabList
|
||||
onChange={(_, tab) => {
|
||||
setTab(TAB_LIST.indexOf(tab));
|
||||
}}
|
||||
>
|
||||
{TAB_LIST.map((tab, idx) => {
|
||||
return (
|
||||
<Tab
|
||||
key={tab}
|
||||
label={TAB_LIST[idx]}
|
||||
value={TAB_LIST[idx]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
{tab === 0 ? (
|
||||
<Settings
|
||||
tabValue={TAB_LIST[0]}
|
||||
form={form}
|
||||
handleFormChange={handleFormChange}
|
||||
handleImageChange={handleImageChange}
|
||||
progress={progress}
|
||||
removeLogo={removeLogo}
|
||||
errors={errors}
|
||||
/>
|
||||
) : (
|
||||
<Content
|
||||
tabValue={TAB_LIST[1]}
|
||||
form={form}
|
||||
monitors={monitors}
|
||||
handleFormChange={handleFormChange}
|
||||
errors={errors}
|
||||
selectedMonitors={selectedMonitors}
|
||||
setSelectedMonitors={setSelectedMonitors}
|
||||
/>
|
||||
)}
|
||||
</TabContext>
|
||||
);
|
||||
};
|
||||
|
||||
Tabs.propTypes = {
|
||||
form: PropTypes.object,
|
||||
errors: PropTypes.object,
|
||||
monitors: PropTypes.array,
|
||||
selectedMonitors: PropTypes.array,
|
||||
setSelectedMonitors: PropTypes.func,
|
||||
handleFormChange: PropTypes.func,
|
||||
handleImageChange: PropTypes.func,
|
||||
progress: PropTypes.object,
|
||||
removeLogo: PropTypes.func,
|
||||
tab: PropTypes.number,
|
||||
setTab: PropTypes.func,
|
||||
TAB_LIST: PropTypes.array,
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { networkService } from "../../../../main";
|
||||
import { useSelector } from "react-redux";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
|
||||
const useCreateStatusPage = (isCreate) => {
|
||||
const { authToken, user } = useSelector((state) => state.auth);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const createStatusPage = async ({ form }) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await networkService.createStatusPage({ authToken, user, form, isCreate });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
createToast({ body: error?.response?.data?.msg ?? error.message });
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return [createStatusPage, isLoading, networkError];
|
||||
};
|
||||
|
||||
export { useCreateStatusPage };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { networkService } from "../../../../main";
|
||||
import { useSelector } from "react-redux";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
|
||||
const useMonitorsFetch = () => {
|
||||
const { user, authToken } = useSelector((state) => state.auth);
|
||||
|
||||
const [monitors, setMonitors] = useState(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
const response = await networkService.getMonitorsByTeamId({
|
||||
authToken: authToken,
|
||||
teamId: user.teamId,
|
||||
limit: null, // donot return any checks for the monitors
|
||||
types: ["http"], // status page is available only for the uptime type
|
||||
});
|
||||
setMonitors(response.data.data.monitors);
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
createToast({ body: error.message });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMonitors();
|
||||
}, [authToken, user]);
|
||||
|
||||
return [monitors, isLoading, networkError];
|
||||
};
|
||||
|
||||
export { useMonitorsFetch };
|
||||
238
Client/src/Pages/StatusPage/Create/index.jsx
Normal file
238
Client/src/Pages/StatusPage/Create/index.jsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// Components
|
||||
import { Stack, Button, Typography } from "@mui/material";
|
||||
import Tabs from "./Components/Tabs";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
import SkeletonLayout from "./Components/Skeleton";
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { statusPageValidation } from "../../../Validation/validation";
|
||||
import { buildErrors } from "../../../Validation/error";
|
||||
import { useMonitorsFetch } from "./Hooks/useMonitorsFetch";
|
||||
import { useCreateStatusPage } from "./Hooks/useCreateStatusPage";
|
||||
import { createToast } from "../../../Utils/toastUtils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useStatusPageFetch } from "../Status/Hooks/useStatusPageFetch";
|
||||
|
||||
//Constants
|
||||
const TAB_LIST = ["General settings", "Contents"];
|
||||
|
||||
const ERROR_TAB_MAPPING = [
|
||||
["companyName", "url", "timezone", "color", "isPublished", "logo"],
|
||||
["monitors", "showUptimePercentage", "showCharts"],
|
||||
];
|
||||
|
||||
const CreateStatusPage = () => {
|
||||
//Local state
|
||||
const [tab, setTab] = useState(0);
|
||||
const [progress, setProgress] = useState({ value: 0, isLoading: false });
|
||||
const [form, setForm] = useState({
|
||||
isPublished: false,
|
||||
companyName: "",
|
||||
url: "/status/public",
|
||||
logo: undefined,
|
||||
timezone: "America/Toronto",
|
||||
color: "#4169E1",
|
||||
monitors: [],
|
||||
showCharts: true,
|
||||
showUptimePercentage: true,
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [selectedMonitors, setSelectedMonitors] = useState([]);
|
||||
// Refs
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
// Setup
|
||||
const location = useLocation();
|
||||
const isCreate = location.pathname === "/status/create";
|
||||
|
||||
//Utils
|
||||
const theme = useTheme();
|
||||
const [monitors, isLoading, networkError] = useMonitorsFetch();
|
||||
const [createStatusPage, createStatusIsLoading, createStatusPageNetworkError] =
|
||||
useCreateStatusPage(isCreate);
|
||||
const navigate = useNavigate();
|
||||
const [statusPage, statusPageMonitors, statusPageIsLoading, statusPageNetworkError] =
|
||||
useStatusPageFetch(isCreate);
|
||||
|
||||
// Handlers
|
||||
const handleFormChange = (e) => {
|
||||
let { type, name, value, checked } = e.target;
|
||||
// Handle errors
|
||||
const { error } = statusPageValidation.validate(
|
||||
{ [name]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
setErrors((prev) => {
|
||||
return buildErrors(prev, name, error);
|
||||
});
|
||||
|
||||
//Handle checkbox
|
||||
if (type === "checkbox") {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: checked,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other inputs
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageChange = useCallback((event) => {
|
||||
const img = event.target?.files?.[0];
|
||||
const newLogo = {
|
||||
src: URL.createObjectURL(img),
|
||||
name: img.name,
|
||||
type: img.type,
|
||||
size: img.size,
|
||||
};
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
logo: newLogo,
|
||||
}));
|
||||
intervalRef.current = setInterval(() => {
|
||||
const buffer = 12;
|
||||
setProgress((prev) => {
|
||||
if (prev.value + buffer >= 100) {
|
||||
clearInterval(intervalRef.current);
|
||||
return { value: 100, isLoading: false };
|
||||
}
|
||||
return { ...prev, value: prev.value + buffer };
|
||||
});
|
||||
}, 120);
|
||||
}, []);
|
||||
|
||||
const removeLogo = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
logo: undefined,
|
||||
}));
|
||||
// interrupt interval if image upload is canceled prior to completing the process
|
||||
clearInterval(intervalRef.current);
|
||||
setProgress({ value: 0, isLoading: false });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let toSubmit = {
|
||||
...form,
|
||||
logo: { type: form.logo?.type ?? null, size: form.logo?.size ?? null },
|
||||
};
|
||||
const { error } = statusPageValidation.validate(toSubmit, {
|
||||
abortEarly: false,
|
||||
});
|
||||
|
||||
if (typeof error === "undefined") {
|
||||
const success = await createStatusPage({ form });
|
||||
if (success) {
|
||||
createToast({ body: "Status page created successfully" });
|
||||
navigate("/status");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newErrors = {};
|
||||
error?.details?.forEach((err) => {
|
||||
newErrors[err.path[0]] = err.message;
|
||||
});
|
||||
setErrors((prev) => ({ ...prev, ...newErrors }));
|
||||
|
||||
const errorTabs = Object.keys(newErrors).map((err) => {
|
||||
return ERROR_TAB_MAPPING.findIndex((tab) => tab.includes(err));
|
||||
});
|
||||
|
||||
// If there's an error in the current tab, don't change the tab
|
||||
if (errorTabs.some((errorTab) => errorTab === tab)) {
|
||||
return;
|
||||
}
|
||||
// Otherwise go to tab with error
|
||||
setTab(errorTabs[0]);
|
||||
};
|
||||
|
||||
// If we are configuring, populate fields
|
||||
useEffect(() => {
|
||||
if (isCreate) return;
|
||||
if (typeof statusPage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let newLogo = undefined;
|
||||
if (statusPage.logo) {
|
||||
newLogo = {
|
||||
src: `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`,
|
||||
name: "logo",
|
||||
type: statusPage.logo.contentType,
|
||||
size: null,
|
||||
};
|
||||
}
|
||||
|
||||
setForm((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
companyName: statusPage?.companyName,
|
||||
isPublished: statusPage?.isPublished,
|
||||
timezone: statusPage?.timezone,
|
||||
monitors: statusPageMonitors.map((monitor) => monitor._id),
|
||||
color: statusPage?.color,
|
||||
logo: newLogo,
|
||||
};
|
||||
});
|
||||
setSelectedMonitors(statusPageMonitors);
|
||||
}, [isCreate, statusPage, statusPageMonitors]);
|
||||
|
||||
if (networkError === true) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <SkeletonLayout />;
|
||||
|
||||
// Load fields
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Tabs
|
||||
form={form}
|
||||
errors={errors}
|
||||
monitors={monitors}
|
||||
selectedMonitors={selectedMonitors}
|
||||
setSelectedMonitors={setSelectedMonitors}
|
||||
handleFormChange={handleFormChange}
|
||||
handleImageChange={handleImageChange}
|
||||
progress={progress}
|
||||
removeLogo={removeLogo}
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
TAB_LIST={TAB_LIST}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateStatusPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
// Components
|
||||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const AdminLink = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
className="forgot-p"
|
||||
display="inline-block"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>
|
||||
Administrator?
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.accent.main}
|
||||
ml={theme.spacing(2)}
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
Login here
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLink;
|
||||
@@ -0,0 +1,102 @@
|
||||
// Components
|
||||
import { Box, Stack, Typography, Button } from "@mui/material";
|
||||
import Image from "../../../../../Components/Image";
|
||||
import SettingsIcon from "../../../../../assets/icons/settings-bold.svg?react";
|
||||
|
||||
//Utils
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const Controls = ({ deleteStatusPage, isDeleting }) => {
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname;
|
||||
const navigate = useNavigate();
|
||||
if (currentPath === "/status/public") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={deleteStatusPage}
|
||||
loading={isDeleting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/status/configure`)}
|
||||
sx={{
|
||||
px: theme.spacing(5),
|
||||
"& svg": {
|
||||
mr: theme.spacing(3),
|
||||
"& path": {
|
||||
stroke: theme.palette.secondary.contrastText,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SettingsIcon /> Configure
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Controls.propTypes = {
|
||||
deleteStatusPage: PropTypes.func,
|
||||
isDeleting: PropTypes.bool,
|
||||
};
|
||||
|
||||
const ControlsHeader = ({ statusPage, deleteStatusPage, isDeleting }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
alignSelf="flex-start"
|
||||
direction="row"
|
||||
width="100%"
|
||||
gap={theme.spacing(2)}
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Image
|
||||
shouldRender={statusPage?.logo?.data ? true : false}
|
||||
alt={"Company logo"}
|
||||
maxWidth={"100px"}
|
||||
base64={statusPage?.logo?.data}
|
||||
/>
|
||||
<Typography variant="h2">{statusPage?.companyName}</Typography>
|
||||
</Stack>
|
||||
<Controls
|
||||
deleteStatusPage={deleteStatusPage}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
ControlsHeader.propTypes = {
|
||||
statusPage: PropTypes.object,
|
||||
deleteStatusPage: PropTypes.func,
|
||||
isDeleting: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ControlsHeader;
|
||||
@@ -0,0 +1,58 @@
|
||||
// Components
|
||||
import { Stack, Box } from "@mui/material";
|
||||
import Host from "../../../../Uptime/Monitors/Components/Host";
|
||||
import StatusPageBarChart from "../../../../../Components/Charts/StatusPageBarChart";
|
||||
import { StatusLabel } from "../../../../../Components/Label";
|
||||
|
||||
//Utils
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useUtils from "../../../../Uptime/Monitors/Hooks/useUtils";
|
||||
import PropTypes from "prop-types";
|
||||
const MonitorsList = ({ monitors = [] }) => {
|
||||
const theme = useTheme();
|
||||
const { determineState } = useUtils();
|
||||
return (
|
||||
<>
|
||||
{monitors?.map((monitor) => {
|
||||
const status = determineState(monitor);
|
||||
return (
|
||||
<Stack
|
||||
key={monitor._id}
|
||||
width="100%"
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Host
|
||||
key={monitor._id}
|
||||
url={monitor.url}
|
||||
title={monitor.title}
|
||||
percentageColor={monitor.percentageColor}
|
||||
percentage={monitor.percentage}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(20)}
|
||||
>
|
||||
<Box flex={9}>
|
||||
<StatusPageBarChart checks={monitor.checks.slice().reverse()} />
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={status}
|
||||
customStyles={{ textTransform: "capitalize" }}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MonitorsList.propTypes = {
|
||||
monitors: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
export default MonitorsList;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack, Skeleton } from "@mui/material";
|
||||
|
||||
const SkeletonLayout = () => {
|
||||
return (
|
||||
<Stack>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
height={"90vh"}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonLayout;
|
||||
@@ -0,0 +1,74 @@
|
||||
// Components
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const getMonitorStatus = (monitors, theme) => {
|
||||
const monitorsStatus = {
|
||||
icon: (
|
||||
<ErrorOutlineIcon
|
||||
sx={{ color: theme.palette.primary.contrastTextSecondaryDarkBg }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
if (monitors.every((monitor) => monitor.status === true)) {
|
||||
monitorsStatus.msg = "All systems operational";
|
||||
monitorsStatus.color = theme.palette.success.lowContrast;
|
||||
monitorsStatus.icon = (
|
||||
<CheckCircleIcon
|
||||
sx={{ color: theme.palette.primary.contrastTextSecondaryDarkBg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (monitors.every((monitor) => monitor.status === false)) {
|
||||
monitorsStatus.msg = "All systems down";
|
||||
monitorsStatus.color = theme.palette.error.lowContrast;
|
||||
}
|
||||
|
||||
if (monitors.some((monitor) => monitor.status === false)) {
|
||||
monitorsStatus.msg = "Degraded performance";
|
||||
monitorsStatus.color = theme.palette.warning.lowContrast;
|
||||
}
|
||||
|
||||
// Paused or unknown
|
||||
if (monitors.some((monitor) => typeof monitor.status === "undefined")) {
|
||||
monitorsStatus.msg = "Unknown status";
|
||||
monitorsStatus.color = theme.palette.warning.lowContrast;
|
||||
}
|
||||
return monitorsStatus;
|
||||
};
|
||||
|
||||
const StatusBar = ({ monitors }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (typeof monitors === "undefined") return;
|
||||
|
||||
const monitorsStatus = getMonitorStatus(monitors, theme);
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={theme.spacing(2)}
|
||||
height={theme.spacing(30)}
|
||||
width={"100%"}
|
||||
backgroundColor={monitorsStatus.color}
|
||||
borderRadius={theme.spacing(2)}
|
||||
>
|
||||
{monitorsStatus.icon}
|
||||
{/* CAIO_REVIEW */}
|
||||
<Typography variant="h2DarkBg">{monitorsStatus.msg}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBar;
|
||||
|
||||
StatusBar.propTypes = {
|
||||
status: PropTypes.object,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useState } from "react";
|
||||
import { networkService } from "../../../../main";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const useStatusPageDelete = (fetchStatusPage) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
|
||||
const deleteStatusPage = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await networkService.deleteStatusPage({ authToken });
|
||||
fetchStatusPage?.();
|
||||
return true;
|
||||
} catch (error) {
|
||||
createToast({
|
||||
body: error.message,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return [deleteStatusPage, isLoading];
|
||||
};
|
||||
|
||||
export { useStatusPageDelete };
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { networkService } from "../../../../main";
|
||||
import { useSelector } from "react-redux";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
import { useTheme } from "@emotion/react";
|
||||
const getMonitorWithPercentage = (monitor, theme) => {
|
||||
let uptimePercentage = "";
|
||||
let percentageColor = "";
|
||||
|
||||
if (monitor.uptimePercentage !== undefined) {
|
||||
uptimePercentage =
|
||||
monitor.uptimePercentage === 0 ? "0" : (monitor.uptimePercentage * 100).toFixed(2);
|
||||
|
||||
percentageColor =
|
||||
monitor.uptimePercentage < 0.25
|
||||
? theme.palette.error.main
|
||||
: monitor.uptimePercentage < 0.5
|
||||
? theme.palette.warning.main
|
||||
: monitor.uptimePercentage < 0.75
|
||||
? theme.palette.success.main
|
||||
: theme.palette.success.main;
|
||||
}
|
||||
|
||||
return {
|
||||
...monitor,
|
||||
percentage: uptimePercentage,
|
||||
percentageColor,
|
||||
};
|
||||
};
|
||||
|
||||
const useStatusPageFetch = (isCreate = false) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const [statusPage, setStatusPage] = useState(undefined);
|
||||
const [monitors, setMonitors] = useState(undefined);
|
||||
const { authToken } = useSelector((state) => state.auth);
|
||||
const theme = useTheme();
|
||||
|
||||
const fetchStatusPage = useCallback(async () => {
|
||||
try {
|
||||
const response = await networkService.getStatusPage({ authToken });
|
||||
if (!response?.data?.data) return;
|
||||
const { statusPage, monitors } = response.data.data;
|
||||
setStatusPage(statusPage);
|
||||
|
||||
const monitorsWithPercentage = monitors.map((monitor) =>
|
||||
getMonitorWithPercentage(monitor, theme)
|
||||
);
|
||||
setMonitors(monitorsWithPercentage);
|
||||
} catch (error) {
|
||||
// If there is a 404, status page is not found
|
||||
if (error?.response?.status === 404) {
|
||||
setStatusPage(undefined);
|
||||
return;
|
||||
}
|
||||
createToast({ body: error.message });
|
||||
setNetworkError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authToken, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreate === true) {
|
||||
return;
|
||||
}
|
||||
fetchStatusPage();
|
||||
}, [isCreate, fetchStatusPage]);
|
||||
|
||||
return [statusPage, monitors, isLoading, networkError, fetchStatusPage];
|
||||
};
|
||||
|
||||
export { useStatusPageFetch };
|
||||
141
Client/src/Pages/StatusPage/Status/index.jsx
Normal file
141
Client/src/Pages/StatusPage/Status/index.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// Components
|
||||
import { Typography, Stack } from "@mui/material";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
import Fallback from "../../../Components/Fallback";
|
||||
import AdminLink from "./Components/AdminLink";
|
||||
import ControlsHeader from "./Components/ControlsHeader";
|
||||
import SkeletonLayout from "./Components/Skeleton";
|
||||
import StatusBar from "./Components/StatusBar";
|
||||
import MonitorsList from "./Components/MonitorsList";
|
||||
// Utils
|
||||
import { useStatusPageFetch } from "./Hooks/useStatusPageFetch";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useStatusPageDelete } from "./Hooks/useStatusPageDelete";
|
||||
|
||||
const PublicStatus = () => {
|
||||
// Local state
|
||||
|
||||
// Utils
|
||||
const theme = useTheme();
|
||||
const isAdmin = useIsAdmin();
|
||||
const [statusPage, monitors, isLoading, networkError, fetchStatusPage] =
|
||||
useStatusPageFetch();
|
||||
const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage);
|
||||
const location = useLocation();
|
||||
|
||||
// Setup
|
||||
const currentPath = location.pathname;
|
||||
let sx = { paddingLeft: theme.spacing(20), paddingRight: theme.spacing(20) };
|
||||
let link = undefined;
|
||||
// Public status page
|
||||
if (currentPath === "/status/public") {
|
||||
sx = {
|
||||
paddingTop: theme.spacing(20),
|
||||
paddingLeft: "20vw",
|
||||
paddingRight: "20vw",
|
||||
};
|
||||
link = <AdminLink />;
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return <SkeletonLayout />;
|
||||
}
|
||||
|
||||
// Error fetching data
|
||||
if (networkError === true) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
// Public status page fallback
|
||||
if (
|
||||
!isLoading &&
|
||||
typeof statusPage === "undefined" &&
|
||||
currentPath === "/status/public"
|
||||
) {
|
||||
return (
|
||||
<Stack sx={sx}>
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
A public status page is not set up.
|
||||
</Typography>
|
||||
<Typography>Please contact to your administrator</Typography>
|
||||
</GenericFallback>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Finished loading, but status page is not public
|
||||
if (
|
||||
!isLoading &&
|
||||
currentPath === "/status/public" &&
|
||||
statusPage.isPublished === false
|
||||
) {
|
||||
return (
|
||||
<Stack sx={sx}>
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
This status page is not public.
|
||||
</Typography>
|
||||
<Typography>Please contact to your administrator</Typography>
|
||||
</GenericFallback>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Status page doesn't exist
|
||||
if (!isLoading && typeof statusPage === "undefined") {
|
||||
return (
|
||||
<Fallback
|
||||
title="status page"
|
||||
checks={[
|
||||
"Display a list of monitors to track",
|
||||
"Share your monitors with the public",
|
||||
]}
|
||||
link="/status/create"
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(10)}
|
||||
alignItems="center"
|
||||
sx={sx}
|
||||
>
|
||||
<ControlsHeader
|
||||
statusPage={statusPage}
|
||||
deleteStatusPage={deleteStatusPage}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
<Typography variant="h2">Service status</Typography>
|
||||
<StatusBar monitors={monitors} />
|
||||
<MonitorsList monitors={monitors} />
|
||||
{link}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicStatus;
|
||||
@@ -347,7 +347,7 @@ const Configure = () => {
|
||||
<TextInput
|
||||
type={monitor?.type === "http" ? "url" : "text"}
|
||||
https={protocol === "https"}
|
||||
startAdornment={<HttpAdornment https={protocol === "https"} />}
|
||||
startAdornment={monitor?.type === "http" && <HttpAdornment https={protocol === "https"} />}
|
||||
id="monitor-url"
|
||||
label="URL to monitor"
|
||||
placeholder="google.com"
|
||||
|
||||
@@ -88,7 +88,7 @@ const CreateMonitor = () => {
|
||||
? `http${https ? "s" : ""}://` + monitor.url
|
||||
: monitor.url,
|
||||
port: monitor.type === "port" ? monitor.port : undefined,
|
||||
name: monitor.name === "" ? monitor.url : monitor.name,
|
||||
name: monitor.name || monitor.url.substring(0, 50),
|
||||
type: monitor.type,
|
||||
interval: monitor.interval * MS_PER_MINUTE,
|
||||
};
|
||||
@@ -122,7 +122,7 @@ const CreateMonitor = () => {
|
||||
|
||||
form = {
|
||||
...form,
|
||||
description: form.name,
|
||||
description: monitor.name || monitor.url,
|
||||
teamId: user.teamId,
|
||||
userId: user._id,
|
||||
notifications: monitor.notifications,
|
||||
@@ -138,17 +138,23 @@ const CreateMonitor = () => {
|
||||
|
||||
const handleChange = (event, formName) => {
|
||||
const { value } = event.target;
|
||||
setMonitor({
|
||||
|
||||
const newMonitor = {
|
||||
...monitor,
|
||||
[formName]: value,
|
||||
});
|
||||
};
|
||||
if (formName === 'type') {
|
||||
newMonitor.url = '';
|
||||
}
|
||||
setMonitor(newMonitor);
|
||||
|
||||
const { error } = monitorValidation.validate(
|
||||
{ [formName]: value },
|
||||
{ type: monitor.type, [formName]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
url: undefined,
|
||||
...(error ? { [formName]: error.details[0].message } : { [formName]: undefined }),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ const ChartBoxes = ({
|
||||
header="Uptime"
|
||||
>
|
||||
<Stack
|
||||
width={"100%"}
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
>
|
||||
@@ -93,26 +94,28 @@ const ChartBoxes = ({
|
||||
icon={<IncidentsIcon />}
|
||||
header="Incidents"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Typography component="span">
|
||||
{hoveredIncidentsData !== null
|
||||
? hoveredIncidentsData.totalChecks
|
||||
: (monitor?.groupedDownChecks?.reduce((count, checkGroup) => {
|
||||
return count + checkGroup.totalChecks;
|
||||
}, 0) ?? 0)}
|
||||
</Typography>
|
||||
{hoveredIncidentsData !== null && hoveredIncidentsData.time !== null && (
|
||||
<Typography
|
||||
component="h5"
|
||||
position="absolute"
|
||||
top="100%"
|
||||
fontSize={11}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
{formatDateWithTz(hoveredIncidentsData._id, dateFormat, uiTimezone)}
|
||||
<Stack width={"100%"}>
|
||||
<Box position="relative">
|
||||
<Typography component="span">
|
||||
{hoveredIncidentsData !== null
|
||||
? hoveredIncidentsData.totalChecks
|
||||
: (monitor?.groupedDownChecks?.reduce((count, checkGroup) => {
|
||||
return count + checkGroup.totalChecks;
|
||||
}, 0) ?? 0)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{hoveredIncidentsData !== null && hoveredIncidentsData.time !== null && (
|
||||
<Typography
|
||||
component="h5"
|
||||
position="absolute"
|
||||
top="100%"
|
||||
fontSize={11}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
{formatDateWithTz(hoveredIncidentsData._id, dateFormat, uiTimezone)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
<DownBarChart
|
||||
monitor={monitor}
|
||||
type={dateRange}
|
||||
|
||||
@@ -11,7 +11,7 @@ const useCertificateFetch = ({
|
||||
uiTimezone,
|
||||
}) => {
|
||||
const [certificateExpiry, setCertificateExpiry] = useState(undefined);
|
||||
const [certificateIsLoading, setCertificateIsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCertificate = async () => {
|
||||
@@ -20,7 +20,7 @@ const useCertificateFetch = ({
|
||||
}
|
||||
|
||||
try {
|
||||
setCertificateIsLoading(true);
|
||||
setIsLoading(true);
|
||||
const res = await networkService.getCertificateExpiry({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
@@ -35,12 +35,12 @@ const useCertificateFetch = ({
|
||||
setCertificateExpiry("N/A");
|
||||
logger.error(error);
|
||||
} finally {
|
||||
setCertificateIsLoading(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCertificate();
|
||||
}, [authToken, monitorId, certificateDateFormat, uiTimezone, monitor]);
|
||||
return { certificateExpiry, certificateIsLoading };
|
||||
return [certificateExpiry, isLoading];
|
||||
};
|
||||
|
||||
export default useCertificateFetch;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { logger } from "../../../../Utils/Logger";
|
||||
import { networkService } from "../../../../main";
|
||||
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
export const useChecksFetch = ({
|
||||
authToken,
|
||||
monitorId,
|
||||
@@ -12,12 +11,13 @@ export const useChecksFetch = ({
|
||||
}) => {
|
||||
const [checks, setChecks] = useState(undefined);
|
||||
const [checksCount, setChecksCount] = useState(undefined);
|
||||
const [checksAreLoading, setChecksAreLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChecks = async () => {
|
||||
try {
|
||||
setChecksAreLoading(true);
|
||||
setIsLoading(true);
|
||||
const res = await networkService.getChecksByMonitor({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
@@ -31,15 +31,16 @@ export const useChecksFetch = ({
|
||||
setChecks(res.data.data.checks);
|
||||
setChecksCount(res.data.data.checksCount);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
setNetworkError(true);
|
||||
createToast({ body: error.message });
|
||||
} finally {
|
||||
setChecksAreLoading(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchChecks();
|
||||
}, [authToken, monitorId, dateRange, page, rowsPerPage]);
|
||||
|
||||
return { checks, checksCount, checksAreLoading };
|
||||
return [checks, checksCount, isLoading, networkError];
|
||||
};
|
||||
|
||||
export default useChecksFetch;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { networkService } from "../../../../main";
|
||||
import { logger } from "../../../../Utils/Logger";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createToast } from "../../../../Utils/toastUtils";
|
||||
|
||||
export const useMonitorFetch = ({ authToken, monitorId, dateRange }) => {
|
||||
const [monitorIsLoading, setMonitorsIsLoading] = useState(false);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [monitor, setMonitor] = useState(undefined);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitors = async () => {
|
||||
try {
|
||||
setMonitorsIsLoading(true);
|
||||
const res = await networkService.getUptimeDetailsById({
|
||||
authToken: authToken,
|
||||
monitorId: monitorId,
|
||||
@@ -20,15 +20,15 @@ export const useMonitorFetch = ({ authToken, monitorId, dateRange }) => {
|
||||
});
|
||||
setMonitor(res?.data?.data ?? {});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
navigate("/not-found", { replace: true });
|
||||
setNetworkError(true);
|
||||
createToast({ body: error.message });
|
||||
} finally {
|
||||
setMonitorsIsLoading(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMonitors();
|
||||
}, [authToken, dateRange, monitorId, navigate]);
|
||||
return { monitor, monitorIsLoading };
|
||||
return [monitor, isLoading, networkError];
|
||||
};
|
||||
|
||||
export default useMonitorFetch;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// Components
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs";
|
||||
import MonitorStatusHeader from "../../../Components/MonitorStatusHeader";
|
||||
import TimeFramePicker from "./Components/TimeFramePicker";
|
||||
import MonitorTimeFrameHeader from "../../../Components/MonitorTimeFrameHeader";
|
||||
import ChartBoxes from "./Components/ChartBoxes";
|
||||
import ResponseTimeChart from "./Components/Charts/ResponseTimeChart";
|
||||
import ResponseTable from "./Components/ResponseTable";
|
||||
import UptimeStatusBoxes from "./Components/UptimeStatusBoxes";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
// MUI Components
|
||||
import { Stack } from "@mui/material";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
|
||||
// Utils
|
||||
import { useState } from "react";
|
||||
@@ -46,13 +47,13 @@ const UptimeDetails = () => {
|
||||
const theme = useTheme();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { monitor, monitorIsLoading } = useMonitorFetch({
|
||||
const [monitor, monitorIsLoading, monitorNetworkError] = useMonitorFetch({
|
||||
authToken,
|
||||
monitorId,
|
||||
dateRange,
|
||||
});
|
||||
|
||||
const { certificateExpiry, certificateIsLoading } = useCertificateFetch({
|
||||
const [certificateExpiry, certificateIsLoading] = useCertificateFetch({
|
||||
monitor,
|
||||
authToken,
|
||||
monitorId,
|
||||
@@ -60,7 +61,7 @@ const UptimeDetails = () => {
|
||||
uiTimezone,
|
||||
});
|
||||
|
||||
const { checks, checksCount, checksAreLoading } = useChecksFetch({
|
||||
const [checks, checksCount, checksAreLoading, checksNetworkError] = useChecksFetch({
|
||||
authToken,
|
||||
monitorId,
|
||||
dateRange,
|
||||
@@ -77,10 +78,44 @@ const UptimeDetails = () => {
|
||||
setRowsPerPage(event.target.value);
|
||||
};
|
||||
|
||||
if (monitorNetworkError || checksNetworkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
Network error
|
||||
</Typography>
|
||||
<Typography>Please check your connection</Typography>
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty view, displayed when loading is complete and there are no checks
|
||||
if (!monitorIsLoading && !checksAreLoading && checksCount === 0) {
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<MonitorStatusHeader
|
||||
path={"uptime"}
|
||||
isAdmin={isAdmin}
|
||||
shouldRender={!monitorIsLoading}
|
||||
monitor={monitor}
|
||||
/>
|
||||
<GenericFallback>
|
||||
<Typography>There is no check history for this monitor yet.</Typography>
|
||||
</GenericFallback>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<MonitorStatusHeader
|
||||
path={"uptime"}
|
||||
isAdmin={isAdmin}
|
||||
shouldRender={!monitorIsLoading}
|
||||
monitor={monitor}
|
||||
@@ -90,8 +125,9 @@ const UptimeDetails = () => {
|
||||
monitor={monitor}
|
||||
certificateExpiry={certificateExpiry}
|
||||
/>
|
||||
<TimeFramePicker
|
||||
<MonitorTimeFrameHeader
|
||||
shouldRender={!monitorIsLoading}
|
||||
hasDateRange={true}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import BarChart from "../../../../../Components/Charts/BarChart";
|
||||
import ActionsMenu from "../ActionsMenu";
|
||||
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import UptimeDataTableSkeleton from "./skeleton";
|
||||
import TableSkeleton from "../../../../../Components/Table/skeleton";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
@@ -175,7 +175,7 @@ const UptimeDataTable = ({
|
||||
];
|
||||
|
||||
if (monitorsAreLoading) {
|
||||
return <UptimeDataTableSkeleton />;
|
||||
return <TableSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user