Merge remote-tracking branch 'checkmate/develop' into fix/fe/profilepanel-ui

This commit is contained in:
Aryaman Kumar Sharma
2025-02-06 10:46:48 +05:30
137 changed files with 6910 additions and 1994 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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,
};

View 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;

View File

@@ -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;

View File

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

View File

@@ -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;

View 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;

View File

@@ -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,
};

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 }) => {

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -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,
};

View File

@@ -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.
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View 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;

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;

View File

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

View 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;

View 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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,26 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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

View File

@@ -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 };

View 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;

View File

@@ -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 };

View File

@@ -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;

View File

@@ -181,7 +181,7 @@ const CreateMaintenance = () => {
setSearch(value);
};
const handleSelectMonitors = (monitors) => {
const handleSelectMonitors = (_, monitors) => {
setForm({ ...form, monitors });
const { error } = maintenanceWindowValidation.validate(
{ monitors },

View File

@@ -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);
}

View File

@@ -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}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View 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;

View 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;

View 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;

View File

@@ -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 };

View File

@@ -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 };

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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 };

View 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;

View File

@@ -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"

View File

@@ -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 }),
}));
};

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}
/>

View File

@@ -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