diff --git a/Client/src/Components/Charts/StatusPageBarChart/index.jsx b/Client/src/Components/Charts/StatusPageBarChart/index.jsx
new file mode 100644
index 000000000..5acf4c3d6
--- /dev/null
+++ b/Client/src/Components/Charts/StatusPageBarChart/index.jsx
@@ -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 (
+ 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
+
+ ) : (
+
+
+ {formatDateWithTz(
+ check.createdAt,
+ "ddd, MMMM D, YYYY, HH:mm A",
+ uiTimezone
+ )}
+
+
+
+
+
+ Response Time
+
+
+ {check.originalResponseTime}
+
+ {" "}
+ ms
+
+
+
+
+ >
+ }
+ 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,
+ },
+ },
+ },
+ }}
+ >
+
+
+
+
+ )
+ )}
+
+ );
+};
+
+export default StatusPageBarChart;
diff --git a/Client/src/Components/Image/index.jsx b/Client/src/Components/Image/index.jsx
new file mode 100644
index 000000000..98d8b7707
--- /dev/null
+++ b/Client/src/Components/Image/index.jsx
@@ -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 (
+
+ );
+};
+
+Image.propTypes = {
+ src: PropTypes.string,
+ alt: PropTypes.string.isRequired,
+ width: PropTypes.string,
+ height: PropTypes.string,
+};
+
+export default Image;
diff --git a/Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx b/Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
index 6dd08acb0..8eda4fe0a 100644
--- a/Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
+++ b/Client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
@@ -47,19 +47,6 @@ const Content = ({
direction="row"
justifyContent="space-between"
>
- {/*
- {" "}
- Servers list{" "}
-
- */}
diff --git a/Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx b/Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
index 010ca30db..87e18ed4d 100644
--- a/Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
+++ b/Client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
@@ -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,
diff --git a/Client/src/Pages/StatusPage/Create/index.jsx b/Client/src/Pages/StatusPage/Create/index.jsx
index 326eb3005..f3b30069b 100644
--- a/Client/src/Pages/StatusPage/Create/index.jsx
+++ b/Client/src/Pages/StatusPage/Create/index.jsx
@@ -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;
}
diff --git a/Client/src/Pages/StatusPage/PublicStatus/Hooks/useStatusPageDelete.jsx b/Client/src/Pages/StatusPage/PublicStatus/Hooks/useStatusPageDelete.jsx
new file mode 100644
index 000000000..05577c2e1
--- /dev/null
+++ b/Client/src/Pages/StatusPage/PublicStatus/Hooks/useStatusPageDelete.jsx
@@ -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 };
diff --git a/Client/src/Pages/StatusPage/PublicStatus/Hooks/useStatusPageFetch.jsx b/Client/src/Pages/StatusPage/PublicStatus/Hooks/useStatusPageFetch.jsx
index 8bc7e4f11..f9691e69a 100644
--- a/Client/src/Pages/StatusPage/PublicStatus/Hooks/useStatusPageFetch.jsx
+++ b/Client/src/Pages/StatusPage/PublicStatus/Hooks/useStatusPageFetch.jsx
@@ -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 };
diff --git a/Client/src/Pages/StatusPage/PublicStatus/index.jsx b/Client/src/Pages/StatusPage/PublicStatus/index.jsx
index 6e8f6ffb2..faf1688df 100644
--- a/Client/src/Pages/StatusPage/PublicStatus/index.jsx
+++ b/Client/src/Pages/StatusPage/PublicStatus/index.jsx
@@ -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 = (
+
+
+ Administrator?
+
+ navigate("/login")}
+ >
+ Login here
+
+
+ );
+ }
+
+ // 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 = ;
+ }
+
+ 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 (
@@ -41,7 +124,118 @@ const PublicStatus = () => {
);
}
- return Content Here;
+ return (
+
+
+
+
+ {statusPage?.companyName}
+
+
+
+
+
+
+
+
+
+
+ Service status
+
+ {status?.icon}
+ {status?.msg}
+
+ {monitors?.map((monitor) => {
+ const status = determineState(monitor);
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ })}
+ {AdminLink}
+
+ );
};
export default PublicStatus;
diff --git a/Client/src/Routes/index.jsx b/Client/src/Routes/index.jsx
index 9f63d2243..e83380437 100644
--- a/Client/src/Routes/index.jsx
+++ b/Client/src/Routes/index.jsx
@@ -183,6 +183,10 @@ const Routes = () => {
path="/new-password-confirmed"
element={}
/>
+ }
+ />
{
try {
const statusPage = await this.db.getStatusPage();
@@ -43,6 +44,17 @@ class StatusPageController {
next(handleError(error, SERVICE_NAME, "getStatusPage"));
}
};
+
+ deleteStatusPage = async (req, res, next) => {
+ try {
+ await this.db.deleteStatusPage();
+ return res.success({
+ msg: successMessages.STATUS_PAGE_DELETE,
+ });
+ } catch (error) {
+ next(handleError(error, SERVICE_NAME, "deleteStatusPage"));
+ }
+ };
}
export default StatusPageController;
diff --git a/Server/db/mongo/modules/statusPageModule.js b/Server/db/mongo/modules/statusPageModule.js
index b6af6a6da..3e647190b 100644
--- a/Server/db/mongo/modules/statusPageModule.js
+++ b/Server/db/mongo/modules/statusPageModule.js
@@ -1,5 +1,6 @@
import StatusPage from "../../models/StatusPage.js";
import { errorMessages } from "../../../utils/messages.js";
+import { NormalizeData } from "../../../utils/dataUtils.js";
const SERVICE_NAME = "statusPageModule";
@@ -28,14 +29,75 @@ const createStatusPage = async (statusPageData, image) => {
const getStatusPage = async () => {
try {
- const statusPage = await StatusPage.findOne();
- if (statusPage === null || statusPage === undefined) {
+ // const statusPage = await StatusPage.findOne();
+ // if (statusPage === null || statusPage === undefined) {
+ // const error = new Error(errorMessages.STATUS_PAGE_NOT_FOUND);
+ // error.status = 404;
+
+ // throw error;
+ // }
+ // const ids = statusPage.monitors;
+ // const monitors = await Monitor.find({ _id: { $in: ids } }).lean();
+ // return { statusPage, monitors };
+
+ const statusPageQuery = await StatusPage.aggregate([
+ { $limit: 1 },
+ {
+ $lookup: {
+ from: "monitors",
+ localField: "monitors",
+ foreignField: "_id",
+ as: "monitors",
+ },
+ },
+ {
+ $unwind: "$monitors",
+ },
+ {
+ $lookup: {
+ from: "checks",
+ let: { monitorId: "$monitors._id" },
+ pipeline: [
+ {
+ $match: {
+ $expr: { $eq: ["$monitorId", "$$monitorId"] },
+ },
+ },
+ { $sort: { createdAt: -1 } },
+ { $limit: 25 },
+ ],
+ as: "monitors.checks",
+ },
+ },
+ {
+ $group: {
+ _id: "$_id",
+ statusPage: { $first: "$$ROOT" },
+ monitors: { $push: "$monitors" },
+ },
+ },
+ {
+ $project: {
+ statusPage: 1,
+ monitors: 1,
+ },
+ },
+ ]);
+ if (!statusPageQuery.length) {
const error = new Error(errorMessages.STATUS_PAGE_NOT_FOUND);
error.status = 404;
-
throw error;
}
- return statusPage;
+
+ const { statusPage, monitors } = statusPageQuery[0];
+ const normalizedMonitors = monitors.map((monitor) => {
+ return {
+ ...monitor,
+ checks: NormalizeData(monitor.checks, 10, 100),
+ };
+ });
+
+ return { statusPage, monitors: normalizedMonitors };
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getStatusPageByUrl";
@@ -43,4 +105,14 @@ const getStatusPage = async () => {
}
};
-export { createStatusPage, getStatusPage };
+const deleteStatusPage = async () => {
+ try {
+ await StatusPage.deleteOne({});
+ } catch (error) {
+ error.service = SERVICE_NAME;
+ error.method = "createStatusPage";
+ throw error;
+ }
+};
+
+export { createStatusPage, getStatusPage, deleteStatusPage };
diff --git a/Server/routes/statusPageRoute.js b/Server/routes/statusPageRoute.js
index 2e43ee52d..2217bc9f6 100644
--- a/Server/routes/statusPageRoute.js
+++ b/Server/routes/statusPageRoute.js
@@ -18,6 +18,7 @@ class StatusPageRoutes {
verifyJWT,
this.statusPageController.createStatusPage
);
+ this.router.delete("/", verifyJWT, this.statusPageController.deleteStatusPage);
}
getRouter() {
diff --git a/Server/utils/messages.js b/Server/utils/messages.js
index 5fc3c70bc..932585bc7 100644
--- a/Server/utils/messages.js
+++ b/Server/utils/messages.js
@@ -136,7 +136,7 @@ const successMessages = {
// Status Page
STATUS_PAGE_BY_URL: "Got status page by url successfully",
STATUS_PAGE_CREATE: "Status page created successfully",
-
+ STATUS_PAGE_DELETE: "Status page deleted successfully",
// Docker
DOCKER_SUCCESS: "Docker container status fetched successfully",