refactor public status page

This commit is contained in:
Alex Holliday
2025-02-04 13:06:42 -08:00
parent cc8d4bc98c
commit dc3fc7628a
15 changed files with 605 additions and 53 deletions
@@ -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;
+32
View File
@@ -0,0 +1,32 @@
import { Box } from "@mui/material";
import PropTypes from "prop-types";
const Image = ({
src,
alt,
width = "auto",
height = "auto",
maxWidth = "auto",
maxHeight = "auto",
}) => {
return (
<Box
component="img"
src={src}
alt={alt}
maxWidth={maxWidth}
maxHeight={maxHeight}
width={width}
height={height}
/>
);
};
Image.propTypes = {
src: PropTypes.string,
alt: PropTypes.string.isRequired,
width: PropTypes.string,
height: PropTypes.string,
};
export default Image;
@@ -47,19 +47,6 @@ const Content = ({
direction="row"
justifyContent="space-between"
>
{/* <Typography
component="p"
alignSelf={"center"}
>
{" "}
Servers list{" "}
</Typography>
<Button
variant="contained"
color="accent"
>
Add new
</Button> */}
<Search
options={monitors}
multiple={true}
@@ -11,6 +11,7 @@ import Progress from "../Progress";
// Utils
import { useTheme } from "@emotion/react";
import timezones from "../../../../../Utils/timezones.json";
import { memo } from "react";
const TabSettings = ({
tabValue,
@@ -101,7 +102,7 @@ const TabSettings = ({
<Stack gap={theme.spacing(6)}>
<ImageField
id="logo"
src={form.logo?.src}
src={form?.logo?.src}
isRound={false}
onChange={handleImageChange}
/>
@@ -6,6 +6,7 @@ import Content from "./Content";
// Utils
import PropTypes from "prop-types";
const Tabs = ({
form,
errors,
@@ -70,7 +71,7 @@ Tabs.propTypes = {
setSelectedMonitors: PropTypes.func,
handleFormChange: PropTypes.func,
handleImageChange: PropTypes.func,
progress: PropTypes.number,
progress: PropTypes.object,
removeLogo: PropTypes.func,
tab: PropTypes.number,
setTab: PropTypes.func,
+7 -9
View File
@@ -5,14 +5,13 @@ import GenericFallback from "../../../Components/GenericFallback";
import SkeletonLayout from "./Components/Skeleton";
//Utils
import { useTheme } from "@emotion/react";
import { useState, useRef } from "react";
import { useState, useRef, useCallback } from "react";
import { statusPageValidation } from "../../../Validation/validation";
import { buildErrors } from "../../../Validation/error";
import { useSelector } from "react-redux";
import { useMonitorsFetch } from "./Hooks/useMonitorsFetch";
import { useCreateStatusPage } from "./Hooks/useCreateStatusPage";
import { createToast } from "../../../Utils/toastUtils";
import { useNavigate } from "react-router-dom";
//Constants
const TAB_LIST = ["General settings", "Contents"];
@@ -22,8 +21,6 @@ const ERROR_TAB_MAPPING = [
];
const CreateStatusPage = () => {
//Redux state
const { authToken, user } = useSelector((state) => state.auth);
//Local state
const [tab, setTab] = useState(0);
const [progress, setProgress] = useState({ value: 0, isLoading: false });
@@ -31,7 +28,7 @@ const CreateStatusPage = () => {
isPublished: false,
companyName: "",
url: "/status/public",
logo: null,
logo: undefined,
timezone: "America/Toronto",
color: "#4169E1",
monitors: [],
@@ -40,7 +37,6 @@ const CreateStatusPage = () => {
});
const [errors, setErrors] = useState({});
const [selectedMonitors, setSelectedMonitors] = useState([]);
// Refs
const intervalRef = useRef(null);
@@ -49,6 +45,7 @@ const CreateStatusPage = () => {
const [monitors, isLoading, networkError] = useMonitorsFetch();
const [createStatusPage, createSatusIsLoading, createStatusPageNetworkError] =
useCreateStatusPage();
const navigate = useNavigate();
// Handlers
const handleFormChange = (e) => {
@@ -78,7 +75,7 @@ const CreateStatusPage = () => {
}));
};
const handleImageChange = (event) => {
const handleImageChange = useCallback((event) => {
const img = event.target?.files?.[0];
const newLogo = {
src: URL.createObjectURL(img),
@@ -100,7 +97,7 @@ const CreateStatusPage = () => {
return { ...prev, value: prev.value + buffer };
});
}, 120);
};
}, []);
const removeLogo = () => {
setForm((prev) => ({
@@ -125,6 +122,7 @@ const CreateStatusPage = () => {
const success = await createStatusPage({ form });
if (success) {
createToast({ body: "Status page created successfully" });
navigate("/status");
}
return;
}
@@ -0,0 +1,32 @@
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 });
setIsLoading(false);
fetchStatusPage?.();
return true;
} catch (error) {
createToast({
body: error.message,
});
return false;
} finally {
setIsLoading(false);
}
};
return [deleteStatusPage, isLoading];
};
export { useStatusPageDelete };
@@ -1,35 +1,69 @@
import { useEffect, useState } from "react";
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 = () => {
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();
useEffect(() => {
const fetchStatusPage = async () => {
try {
const res = await networkService.getStatusPage({ authToken });
setStatusPage(res.data.data);
} 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);
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(() => {
fetchStatusPage();
}, [authToken]);
}, [fetchStatusPage]);
return [statusPage, isLoading, networkError];
return [statusPage, monitors, isLoading, networkError, fetchStatusPage];
};
export { useStatusPageFetch };
@@ -1,17 +1,100 @@
// Components
import { Typography, Stack } from "@mui/material";
import { Typography, Stack, Box, Button } from "@mui/material";
import GenericFallback from "../../../Components/GenericFallback";
import Fallback from "../../../Components/Fallback";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import StatusPageBarChart from "../../../Components/Charts/StatusPageBarChart";
import Host from "../../Uptime/Monitors/Components/Host";
import { StatusLabel } from "../../../Components/Label";
import SettingsIcon from "../../../assets/icons/settings-bold.svg?react";
import Image from "../../../Components/Image";
// Utils
import { useState, useEffect } from "react";
import { useStatusPageFetch } from "./Hooks/useStatusPageFetch";
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
import useUtils from "../../Uptime/Monitors/Hooks/useUtils";
import { useLocation } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useStatusPageDelete } from "./Hooks/useStatusPageDelete";
const PublicStatus = () => {
// Local state
const [status, setStatus] = useState(undefined);
// Utils
const theme = useTheme();
const isAdmin = useIsAdmin();
const [statusPage, isLoading, networkError] = useStatusPageFetch();
const [statusPage, monitors, isLoading, networkError, fetchStatusPage] =
useStatusPageFetch();
const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage);
const { determineState } = useUtils();
const location = useLocation();
const navigate = useNavigate();
// Setup
const currentPath = location.pathname;
let sx = { paddingLeft: theme.spacing(20), paddingRight: theme.spacing(20) };
let AdminLink = undefined;
// Public status page
if (currentPath === "/status/public") {
sx = {
paddingTop: theme.spacing(20),
paddingLeft: "20vw",
paddingRight: "20vw",
};
AdminLink = (
<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>
);
}
// Effects
useEffect(() => {
if (typeof monitors === "undefined") return;
const monitorsStatus = {};
if (monitors.every((monitor) => monitor.status === true)) {
monitorsStatus.msg = "All systems operational";
monitorsStatus.color = theme.palette.success.lowContrast;
monitorsStatus.icon = <CheckCircleIcon />;
}
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;
}
setStatus(monitorsStatus);
}, [monitors, theme]);
//Handlers
if (networkError === true) {
return (
<GenericFallback>
@@ -41,7 +124,118 @@ const PublicStatus = () => {
);
}
return <Stack gap={theme.spacing(10)}>Content Here</Stack>;
return (
<Stack
gap={theme.spacing(10)}
alignItems="center"
sx={sx}
>
<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
src={
statusPage?.logo?.data
? `data:image/png;base64,${statusPage?.logo?.data}`
: undefined
}
alt={"Company logo"}
maxWidth={"100px"}
/>
<Typography variant="h2">{statusPage?.companyName}</Typography>
</Stack>
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Box>
<Button
variant="contained"
color="error"
onClick={deleteStatusPage}
>
Delete
</Button>
</Box>
<Box>
<Button
variant="contained"
color="secondary"
onClick={() => navigate(`/status/create`)}
sx={{
px: theme.spacing(5),
"& svg": {
mr: theme.spacing(3),
"& path": {
stroke: theme.palette.secondary.contrastText,
},
},
}}
>
<SettingsIcon /> Configure
</Button>
</Box>
</Stack>
</Stack>
<Typography variant="h2">Service status</Typography>
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(2)}
height={theme.spacing(30)}
width={"100%"}
backgroundColor={status?.color}
borderRadius={theme.spacing(2)}
>
{status?.icon}
<Typography>{status?.msg}</Typography>
</Stack>
{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)}
>
<StatusPageBarChart checks={monitor.checks.slice().reverse()} />
<Box>
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
</Box>
</Stack>
</Stack>
);
})}
{AdminLink}
</Stack>
);
};
export default PublicStatus;
+4
View File
@@ -183,6 +183,10 @@ const Routes = () => {
path="/new-password-confirmed"
element={<AuthNewPasswordConfirmed />}
/>
<Route
path="/status/public"
element={<PublicStatus />}
/>
<Route
path="*"
+9
View File
@@ -902,6 +902,15 @@ class NetworkService {
},
});
}
async deleteStatusPage(config) {
const { authToken } = config;
return this.axiosInstance.delete(`/status-page`, {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
}
}
export default NetworkService;