diff --git a/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx
index ec9ad4d26..c046e05b5 100644
--- a/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx
+++ b/client/src/Components/v2/common/charts/HeatmapResponseTimeTooltip.tsx
@@ -7,6 +7,7 @@ import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { useTranslation } from "react-i18next";
+
type HeatmapCheck =
| CheckSnapshot
| { status: "placeholder"; responseTime: 0; createdAt: "" };
@@ -55,12 +56,9 @@ export const HeatmapResponseTimeTooltip = ({
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
- {check?.responseTime && (
-
- {t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms
-
- )}
-
+
+ {t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
+
Status:{" "}
diff --git a/client/src/Components/v2/inputs/ColorPicker.tsx b/client/src/Components/v2/inputs/ColorPicker.tsx
new file mode 100644
index 000000000..f52e66317
--- /dev/null
+++ b/client/src/Components/v2/inputs/ColorPicker.tsx
@@ -0,0 +1,48 @@
+import { MuiColorInput } from "mui-color-input";
+import type { MuiColorInputProps } from "mui-color-input";
+import { typographyLevels } from "@/Utils/Theme/v2Palette";
+import { useTheme } from "@mui/material";
+import Stack from "@mui/material/Stack";
+import { FieldLabel } from "@/Components/v2/inputs/FieldLabel";
+
+interface ColorPickerProps extends MuiColorInputProps {
+ fieldLabel?: string;
+ required?: boolean;
+}
+
+export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps) => {
+ const theme = useTheme();
+ const input = (
+
+ );
+ if (fieldLabel) {
+ return (
+
+ {fieldLabel}
+ {input}
+
+ );
+ }
+
+ return input;
+};
diff --git a/client/src/Components/v2/inputs/ImageUpload.tsx b/client/src/Components/v2/inputs/ImageUpload.tsx
new file mode 100644
index 000000000..6c9fc1783
--- /dev/null
+++ b/client/src/Components/v2/inputs/ImageUpload.tsx
@@ -0,0 +1,164 @@
+import { Box, Stack, Typography, IconButton } from "@mui/material";
+import { useTheme } from "@mui/material/styles";
+import { Upload, X } from "lucide-react";
+import { useState, useRef, useCallback } from "react";
+import { useTranslation } from "react-i18next";
+
+interface FileObject {
+ src: string;
+ name: string;
+ file: File;
+}
+
+interface ImageUploadProps {
+ src?: string;
+ onChange?: (file: FileObject | undefined) => void;
+ maxSize?: number;
+ accept?: string[];
+ error?: string;
+}
+
+export const ImageUpload = ({
+ src,
+ onChange,
+ maxSize = 3 * 1024 * 1024,
+ accept = ["jpg", "jpeg", "png"],
+ error,
+}: ImageUploadProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const inputRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [localError, setLocalError] = useState(null);
+ const [preview, setPreview] = useState(null);
+
+ const handleFile = useCallback(
+ (file: File | undefined) => {
+ if (!file) return;
+ const isValidType = accept.some((type) => file.type.includes(type));
+ const isValidSize = file.size <= maxSize;
+ if (!isValidType) {
+ setLocalError(t("common.errors.invalidFileFormat"));
+ return;
+ }
+ if (!isValidSize) {
+ setLocalError(t("common.errors.invalidFileSize"));
+ return;
+ }
+ setLocalError(null);
+ const fileObj: FileObject = {
+ src: URL.createObjectURL(file),
+ name: file.name,
+ file,
+ };
+ setPreview(fileObj);
+ onChange?.(fileObj);
+ },
+ [maxSize, accept, onChange, t]
+ );
+
+ const handleClear = () => {
+ setPreview(null);
+ setLocalError(null);
+ onChange?.(undefined);
+ if (inputRef.current) inputRef.current.value = "";
+ };
+
+ const displaySrc = src || preview?.src;
+ const displayError = localError || error;
+
+ return (
+
+ {displaySrc ? (
+
+
+
+
+
+
+ ) : (
+ {
+ e.preventDefault();
+ setIsDragging(true);
+ }}
+ onDragLeave={() => setIsDragging(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setIsDragging(false);
+ handleFile(e.dataTransfer.files?.[0]);
+ }}
+ alignItems="center"
+ justifyContent="center"
+ gap={1}
+ sx={{
+ position: "relative",
+ width: "100%",
+ minHeight: 150,
+ border: "2px dashed",
+ borderRadius: 1,
+ borderColor: isDragging ? theme.palette.primary.main : theme.palette.divider,
+ backgroundColor: isDragging ? theme.palette.action.hover : "transparent",
+ transition: "0.2s",
+ cursor: "pointer",
+ "&:hover": {
+ borderColor: theme.palette.primary.main,
+ backgroundColor: theme.palette.action.hover,
+ },
+ }}
+ >
+ `.${ext}`).join(",")}
+ onChange={(e) => handleFile(e.target.files?.[0])}
+ style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
+ />
+
+
+
+ {t("common.imageUpload.clickToUpload")}
+ {" "}
+ {t("common.imageUpload.orDragAndDrop")}
+
+
+ {accept.join(", ").toUpperCase()} • {t("common.imageUpload.maxSize")}{" "}
+ {Math.round(maxSize / 1024 / 1024)}MB
+
+
+ )}
+ {displayError && (
+
+ {displayError}
+
+ )}
+
+ );
+};
diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx
index 1618daf76..fa04903c9 100644
--- a/client/src/Components/v2/inputs/index.tsx
+++ b/client/src/Components/v2/inputs/index.tsx
@@ -12,3 +12,5 @@ export { DialogInput as Dialog } from "./Dialog";
export * from "./Radio";
export * from "./Switch";
export * from "./Slider";
+export * from "./ImageUpload";
+export * from "./ColorPicker";
diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts
new file mode 100644
index 000000000..a079095cc
--- /dev/null
+++ b/client/src/Hooks/useStatusPageForm.ts
@@ -0,0 +1,42 @@
+import { useMemo } from "react";
+import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage";
+import type { StatusPage } from "@/Types/StatusPage";
+import type { Monitor } from "@/Types/Monitor";
+
+interface UseStatusPageFormOptions {
+ data?: StatusPage | null;
+ monitors?: Monitor[] | null;
+}
+
+const generateDefaultUrl = () => Math.floor(Math.random() * 1000000).toString();
+
+const transformLogo = (logo: StatusPage["logo"]): StatusPageFormData["logo"] => {
+ if (!logo || !logo.data) return null;
+ return {
+ data: `data:${logo.contentType};base64,${logo.data}`,
+ contentType: logo.contentType,
+ };
+};
+
+export const useStatusPageForm = ({
+ data = null,
+ monitors = null,
+}: UseStatusPageFormOptions = {}) => {
+ return useMemo(() => {
+ const defaults: StatusPageFormData = {
+ companyName: data?.companyName || "",
+ url: data?.url || generateDefaultUrl(),
+ timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
+ color: data?.color || "#4169E1",
+ monitors: data?.monitors || [],
+ isPublished: data?.isPublished ?? false,
+ showCharts: data?.showCharts ?? true,
+ showUptimePercentage: data?.showUptimePercentage ?? true,
+ showAdminLoginLink: data?.showAdminLoginLink ?? false,
+ customCSS: data?.customCSS || "",
+ logo: transformLogo(data?.logo),
+ };
+
+ return { schema: statusPageSchema, defaults };
+ }, [data, monitors]);
+};
diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx
index 07ab49073..577c777de 100644
--- a/client/src/Pages/Incidents/Components/CardDetails.tsx
+++ b/client/src/Pages/Incidents/Components/CardDetails.tsx
@@ -27,7 +27,6 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
if (!incident) {
return null;
}
- console.log(incident);
return (
{
const checks: CheckSnapshot[] = [];
for (let i = 0; i < 25; i++) {
const isUp = Math.random() > 0.1;
+ const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({
id: `dummy-${i}`,
status: isUp,
statusCode: isUp ? 200 : 500,
- responseTime: Math.floor(Math.random() * 80) + 20,
+ responseTime: responseTime,
+ originalResponseTime: responseTime,
message: "",
createdAt: new Date(Date.now() - i * 60000).toISOString(),
});
diff --git a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx
new file mode 100644
index 000000000..99ae46cdd
--- /dev/null
+++ b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx
@@ -0,0 +1,35 @@
+import Stack from "@mui/material/Stack";
+import { Icon } from "@/Components/v2/design-elements";
+import { Button } from "@/Components/v2/inputs";
+import { Trash } from "lucide-react";
+
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@mui/material/styles";
+
+interface HeaderConfigStatusControlsProps {
+ onDelete: () => void;
+}
+
+export const HeaderConfigStatusControls = ({
+ onDelete,
+}: React.PropsWithChildren) => {
+ const theme = useTheme();
+ const translate = useTranslation();
+ return (
+
+ }
+ onClick={onDelete}
+ >
+ {translate.t("common.buttons.delete")}
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx b/client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx
deleted file mode 100644
index 349571fe8..000000000
--- a/client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx
+++ /dev/null
@@ -1,110 +0,0 @@
-// Components
-import { Stack, Typography } from "@mui/material";
-import Icon from "@/Components/v1/Icon";
-
-// 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 (
-
-
- {monitor.name}
- {
- onDelete(monitor);
- }}
- />
-
- );
-};
-
-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 (
-
-
- {(provided, snapshot) => (
-
- {selectedMonitors?.map((monitor, index) => (
-
- {(provided, snapshot) => (
-
- )}
-
- ))}
- {provided.placeholder}
-
- )}
-
-
- );
-};
-
-export default MonitorList;
diff --git a/client/src/Pages/StatusPage/Create/Components/Progress/index.jsx b/client/src/Pages/StatusPage/Create/Components/Progress/index.jsx
deleted file mode 100644
index 594fd9709..000000000
--- a/client/src/Pages/StatusPage/Create/Components/Progress/index.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Button, Box } from "@mui/material";
-import ProgressUpload from "@/Components/v1/ProgressBars/index.jsx";
-import Icon from "@/Components/v1/Icon";
-import { useTranslation } from "react-i18next";
-import { formatBytes } from "../../../../../Utils/fileUtils.js";
-const Progress = ({ isLoading, progressValue, logo, logoType, removeLogo, errors }) => {
- const { t } = useTranslation();
- if (isLoading) {
- return (
-
- }
- label={logo?.name}
- size={formatBytes(logo?.size)}
- progress={progressValue}
- onClick={removeLogo}
- />
- );
- }
-
- if (logo && logoType) {
- return (
-
-
-
- );
- }
-};
-
-export default Progress;
diff --git a/client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx b/client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx
deleted file mode 100644
index 482c490fb..000000000
--- a/client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Stack, Skeleton } from "@mui/material";
-
-const SkeletonLayout = () => {
- return (
-
-
-
- );
-};
-
-export default SkeletonLayout;
diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx
deleted file mode 100644
index f31c456ef..000000000
--- a/client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Stack, Typography } from "@mui/material";
-import ConfigBox from "@/Components/v1/ConfigBox/index.jsx";
-import PropTypes from "prop-types";
-import { useTheme } from "@emotion/react";
-
-// This can be used to add any extra/additional section/stacks on top of existing sections on the tab
-const ConfigStack = ({ title, description, children }) => {
- const theme = useTheme();
- return (
-
-
-
- {title}
-
- {description}
-
- {children}
-
- );
-};
-
-ConfigStack.propTypes = {
- title: PropTypes.string.isRequired, // Title must be a string and is required
- description: PropTypes.string.isRequired, // Description must be a string and is required
- children: PropTypes.node.isRequired,
-};
-
-export default ConfigStack;
diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
deleted file mode 100644
index e84de16dc..000000000
--- a/client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx
+++ /dev/null
@@ -1,107 +0,0 @@
-// Components
-import { Stack, Typography } from "@mui/material";
-import { TabPanel } from "@mui/lab";
-import MonitorList from "../MonitorList/index.jsx";
-import Search from "@/Components/v1/Inputs/Search/index.jsx";
-import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx";
-// Utils
-import { useState } from "react";
-import { useTheme } from "@emotion/react";
-import { useTranslation } from "react-i18next";
-import ConfigStack from "./ConfigStack.jsx";
-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();
- const { t } = useTranslation();
-
- return (
-
-
-
-
-
-
-
-
- {errors["monitors"]}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default Content;
diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx
deleted file mode 100644
index d4c583d3a..000000000
--- a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx
+++ /dev/null
@@ -1,187 +0,0 @@
-// Components
-import { Stack, Typography } from "@mui/material";
-import { TabPanel } from "@mui/lab";
-import ConfigBox from "@/Components/v1/ConfigBox/index.jsx";
-import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx";
-import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
-import Search from "@/Components/v1/Inputs/Search/index.jsx";
-import ImageUpload from "@/Components/v1/Inputs/ImageUpload/index.jsx";
-import ColorPicker from "@/Components/v1/Inputs/ColorPicker/index.jsx";
-import Progress from "../Progress/index.jsx";
-
-// Utils
-import { useTheme } from "@emotion/react";
-import timezones from "../../../../../Utils/timezones.json";
-import PropTypes from "prop-types";
-import { useTranslation } from "react-i18next";
-import { useMemo, useState, useCallback } from "react";
-
-const TabSettings = ({
- isCreate,
- tabValue,
- form,
- handleFormChange,
- handleImageChange,
- progress,
- removeLogo,
- errors,
-}) => {
- // Utils
- const theme = useTheme();
- const { t } = useTranslation();
- const [rawInput, setRawInput] = useState("");
-
- const selectedTimezone = useMemo(
- () => timezones.find((tz) => tz._id === form.timezone) ?? null,
- [form.timezone]
- );
-
- const handleTimezoneChange = useCallback(
- (newValue) => {
- setRawInput("");
- handleFormChange({
- target: {
- name: "timezone",
- value: newValue?._id ?? "",
- },
- });
- },
- [handleFormChange]
- );
-
- return (
-
-
-
-
-
- {t("access")}
-
- {t("statusPageCreateSettings")}
-
-
-
-
-
-
-
-
- {t("basicInformation")}
-
-
- {t("statusPageCreateBasicInfoDescription")}
-
-
-
-
-
-
-
-
-
-
- {t("timezone")}
-
-
- {t("statusPageCreateSelectTimeZoneDescription")}
-
-
-
- setRawInput(newVal)}
- handleChange={handleTimezoneChange}
- isAdorned={true}
- unit="timezone"
- />
-
-
-
-
-
- {t("statusPageCreateAppearanceTitle")}
-
-
- {t("statusPageCreateAppearanceDescription")}
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-TabSettings.propTypes = {
- isCreate: PropTypes.bool,
- tabValue: PropTypes.string,
- form: PropTypes.object,
- handleFormChange: PropTypes.func,
- handleImageChange: PropTypes.func,
- progress: PropTypes.object,
- removeLogo: PropTypes.func,
- errors: PropTypes.object,
-};
-
-export default TabSettings;
diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
deleted file mode 100644
index e8669c474..000000000
--- a/client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-// Components
-import { TabContext } from "@mui/lab";
-import { Tab } from "@mui/material";
-import Settings from "./Settings.jsx";
-import Content from "./Content.jsx";
-
-// Utils
-import PropTypes from "prop-types";
-import CustomTabList from "@/Components/v1/Tab/index.jsx";
-
-const Tabs = ({
- isCreate,
- form,
- errors,
- monitors,
- selectedMonitors,
- setSelectedMonitors,
- handleFormChange,
- handleImageChange,
- progress,
- removeLogo,
- tab,
- setTab,
- TAB_LIST,
- handleDelete,
- isDeleteOpen,
- setIsDeleteOpen,
- isDeleting,
- isLoading,
-}) => {
- return (
-
- {
- setTab(TAB_LIST.indexOf(selected));
- }}
- aria-label="status page tabs"
- >
- {TAB_LIST.map((tabLabel) => (
-
- ))}
-
- {tab === 0 ? (
-
- ) : (
-
- )}
-
- );
-};
-
-Tabs.propTypes = {
- isCreate: PropTypes.bool,
- 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,
- handleDelete: PropTypes.func,
- isDeleteOpen: PropTypes.bool,
- setIsDeleteOpen: PropTypes.func,
- isDeleting: PropTypes.bool,
- isLoading: PropTypes.bool,
-};
-
-export default Tabs;
diff --git a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx b/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx
deleted file mode 100644
index c0bcf5ab4..000000000
--- a/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useState } from "react";
-import { networkService } from "../../../../main.jsx";
-import { createToast } from "../../../../Utils/toastUtils.jsx";
-
-const useCreateStatusPage = (isCreate) => {
- const [isLoading, setIsLoading] = useState(false);
- const [networkError, setNetworkError] = useState(false);
- const createStatusPage = async ({ form, id }) => {
- setIsLoading(true);
- try {
- await networkService.createStatusPage({ form, isCreate, id });
- 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 };
diff --git a/client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx b/client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx
deleted file mode 100644
index 03ebb07bf..000000000
--- a/client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useEffect, useState } from "react";
-import { networkService } from "../../../../main.jsx";
-import { useSelector } from "react-redux";
-import { createToast } from "../../../../Utils/toastUtils.jsx";
-
-const useMonitorsFetch = () => {
- const { user } = 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({
- limit: null, // donot return any checks for the monitors
- types: ["http", "ping", "port", "game"], // include game servers in status page monitor selection
- });
- setMonitors(response.data.data);
- } catch (error) {
- setNetworkError(true);
- createToast({ body: error.message });
- } finally {
- setIsLoading(false);
- }
- };
- fetchMonitors();
- }, [user]);
-
- return [monitors, isLoading, networkError];
-};
-
-export { useMonitorsFetch };
diff --git a/client/src/Pages/StatusPage/Create/index.jsx b/client/src/Pages/StatusPage/Create/index.jsx
deleted file mode 100644
index c5aee4b0a..000000000
--- a/client/src/Pages/StatusPage/Create/index.jsx
+++ /dev/null
@@ -1,311 +0,0 @@
-// Components
-import { Stack, Button, Typography } from "@mui/material";
-import Tabs from "./Components/Tabs/index.jsx";
-import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
-import SkeletonLayout from "./Components/Skeleton/index.jsx";
-import Dialog from "@/Components/v1/Dialog/index.jsx";
-import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
-//Utils
-import { useTheme } from "@emotion/react";
-import { useState, useEffect, useRef, useCallback } from "react";
-import { statusPageValidation } from "../../../Validation/validation.js";
-import { buildErrors } from "../../../Validation/error.js";
-import { useMonitorsFetch } from "./Hooks/useMonitorsFetch.jsx";
-import { useCreateStatusPage } from "./Hooks/useCreateStatusPage.jsx";
-import { createToast } from "../../../Utils/toastUtils.jsx";
-import { useNavigate } from "react-router-dom";
-import { useStatusPageFetch } from "../Status/Hooks/useStatusPageFetch.jsx";
-import { useParams } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-//Constants
-const ERROR_TAB_MAPPING = [
- ["companyName", "url", "timezone", "color", "isPublished", "logo"],
- ["monitors", "showUptimePercentage", "showCharts", "showAdminLoginLink"],
-];
-
-const CreateStatusPage = () => {
- const { url } = useParams();
- //Local state
- const [tab, setTab] = useState(0);
- const [progress, setProgress] = useState({ value: 0, isLoading: false });
- const [isDeleteOpen, setIsDeleteOpen] = useState(false);
- const [form, setForm] = useState({
- isPublished: false,
- companyName: "",
- url: url ?? Math.floor(Math.random() * 1000000).toFixed(0),
- logo: undefined,
- timezone: "America/Toronto",
- color: "#4169E1",
- type: "uptime",
- monitors: [],
- showCharts: true,
- showUptimePercentage: true,
- showAdminLoginLink: false,
- });
- const [errors, setErrors] = useState({});
- const [selectedMonitors, setSelectedMonitors] = useState([]);
- // Refs
- const intervalRef = useRef(null);
-
- // Setup
- const isCreate = typeof url === "undefined";
-
- //Utils
- const theme = useTheme();
- const [monitors, isLoading, networkError] = useMonitorsFetch();
- const navigate = useNavigate();
- const { t } = useTranslation();
-
- const [createStatusPage] = useCreateStatusPage(isCreate);
- const [statusPage, statusPageMonitors, statusPageIsLoading, , fetchStatusPage] =
- useStatusPageFetch(isCreate, url);
- // const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url);
-
- // 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((fileObj) => {
- if (!fileObj || !fileObj.file) return;
-
- setForm((prev) => ({
- ...prev,
- logo: {
- src: fileObj.src,
- name: fileObj.name,
- type: fileObj.file.type,
- size: fileObj.file.size,
- },
- }));
-
- 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 });
- };
-
- /**
- * Handle status page deletion with optimistic UI update
- * Immediately navigates away without waiting for the deletion to complete
- * to prevent unnecessary network requests for the deleted page
- */
- const handleDelete = async () => {
- setIsDeleteOpen(false);
- // Start deletion process but don't wait for it
- // deleteStatusPage();
- // Immediately navigate away to prevent additional fetches for the deleted page
- navigate("/status");
- };
-
- 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, id: statusPage?.id });
- if (success) {
- createToast({
- body: isCreate ? t("statusPage.createSuccess") : t("statusPage.updateSuccess"),
- });
- navigate(`/status/uptime/${form.url}`);
- }
- 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;
- }
-
- // If we get -1, there's an unknown error
- if (errorTabs[0] === -1) {
- createToast({ body: t("common.toasts.unknownError") });
- 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 && Object.keys(statusPage.logo).length > 0) {
- 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,
- showCharts: statusPage?.showCharts ?? true,
- showUptimePercentage: statusPage?.showUptimePercentage ?? true,
- showAdminLoginLink: statusPage?.showAdminLoginLink ?? false,
- };
- });
- setSelectedMonitors(statusPageMonitors);
- }, [isCreate, statusPage, statusPageMonitors]);
-
- if (networkError === true) {
- return (
-
-
- {t("common.toasts.networkError")}
-
- {t("common.toasts.checkConnection")}
-
- );
- }
-
- if (isLoading) return ;
-
- // Load fields
- return (
-
-
- {!isCreate && (
-
-
-
- )}
-
-
-
-
-
- );
-};
-
-export default CreateStatusPage;
diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx
new file mode 100644
index 000000000..8b4394f14
--- /dev/null
+++ b/client/src/Pages/StatusPage/Create/index.tsx
@@ -0,0 +1,488 @@
+import { BasePage, ConfigBox } from "@/Components/v2/design-elements";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import IconButton from "@mui/material/IconButton";
+import FormControlLabel from "@mui/material/FormControlLabel";
+import { Trash2, GripVertical } from "lucide-react";
+import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
+import type { DropResult } from "@hello-pangea/dnd";
+import {
+ ImageUpload,
+ SwitchComponent,
+ Button,
+ TextField,
+ Autocomplete,
+ Checkbox,
+ Dialog,
+ ColorInput,
+} from "@/Components/v2/inputs";
+
+import { useTheme } from "@mui/material/styles";
+import { useTranslation } from "react-i18next";
+import { useEffect, useState } from "react";
+import { useForm, Controller } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
+import type { StatusPageFormData } from "@/Validation/statusPage";
+import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
+import type { Monitor } from "@/Types/Monitor";
+import type { StatusPageResponse } from "@/Types/StatusPage";
+import timezones from "@/Utils/timezones.json";
+import { useNavigate, useParams } from "react-router-dom";
+import axios from "axios";
+import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
+
+interface TimezoneOption {
+ _id: string;
+ name: string;
+}
+
+const CreateStatusPage = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { url } = useParams<{ url: string }>();
+
+ const isCreate = typeof url === "undefined";
+
+ // Fetch existing status page data when configuring
+ const { data: statusPageData, isLoading: isLoadingStatusPage } =
+ useGet(isCreate ? null : `/status-page/${url}?type=uptime`);
+
+ const { data: monitorsResponse } = useGet(
+ "/monitors/team?type=http&type=ping&type=port&type=docker"
+ );
+ const monitors = monitorsResponse ?? [];
+
+ const { post, loading: isSubmittingPost } = usePost();
+ const { put, loading: isSubmittingPut } = usePut();
+ const { deleteFn, loading: isDeleting } = useDelete();
+
+ // Delete dialog state
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const isSubmitting = isSubmittingPost || isSubmittingPut;
+
+ const { schema, defaults } = useStatusPageForm({
+ data: statusPageData?.statusPage ?? null,
+ monitors: statusPageData?.monitors ?? null,
+ });
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: defaults,
+ });
+
+ const { control, reset, handleSubmit } = form;
+
+ // Reset form when defaults change (from fetched data)
+ useEffect(() => {
+ reset(defaults);
+ }, [defaults, reset]);
+
+ const onError = (errors: any) => {
+ console.log(errors);
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ const result = await deleteFn(`/status-page/${statusPageData?.statusPage?.id}`);
+ if (result) {
+ navigate("/status");
+ }
+ setIsDeleteDialogOpen(false);
+ };
+
+ const handleDeleteCancel = () => {
+ setIsDeleteDialogOpen(false);
+ };
+
+ const onSubmit = async (data: StatusPageFormData) => {
+ const fd = new FormData();
+ fd.append("type", "uptime");
+ fd.append("isPublished", String(data.isPublished));
+ if (data.companyName) fd.append("companyName", data.companyName);
+ if (data.url) fd.append("url", data.url);
+ if (data.timezone) fd.append("timezone", data.timezone);
+ if (data.color) fd.append("color", data.color);
+ fd.append("showCharts", String(data.showCharts));
+ fd.append("showUptimePercentage", String(data.showUptimePercentage));
+ fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
+
+ data.monitors.forEach((monitorId) => {
+ fd.append("monitors[]", monitorId);
+ });
+
+ // Handle logo upload
+ if (data.logo === null) {
+ // Signal to remove the logo
+ fd.append("removeLogo", "true");
+ } else if (data.logo?.data && data.logo.data !== "") {
+ if (data.logo.data.startsWith("blob:")) {
+ try {
+ const imageResult = await axios.get(data.logo.data, {
+ responseType: "blob",
+ });
+ fd.append("logo", imageResult.data);
+ URL.revokeObjectURL(data.logo.data);
+ } catch (e) {
+ console.error("Error fetching logo blob:", e);
+ }
+ }
+ }
+
+ let result;
+ if (isCreate) {
+ result = await post("/status-page", fd, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ } else {
+ result = await put(`/status-page/${statusPageData?.statusPage.id}`, fd, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ }
+
+ if (result) {
+ navigate(`/status/${data.url}`);
+ }
+ };
+
+ if (!isCreate && isLoadingStatusPage) {
+ return Loading...;
+ }
+ return (
+
+ {!isCreate && }
+
+ (
+ field.onChange(e.target.checked)}
+ />
+ )}
+ />
+
+ {t("pages.statusPages.form.access.option.published.name")}
+
+
+ }
+ />
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ }
+ />
+ {
+ const selectedMonitors = field.value
+ .map((id: string) => monitors.find((m) => m.id === id))
+ .filter((m): m is Monitor => m !== undefined);
+
+ const handleDragEnd = (result: DropResult) => {
+ if (!result.destination) return;
+ const reordered = Array.from(field.value);
+ const [removed] = reordered.splice(result.source.index, 1);
+ reordered.splice(result.destination.index, 0, removed);
+ field.onChange(reordered);
+ };
+
+ return (
+
+ option.name}
+ value={selectedMonitors}
+ onChange={(_, newValue) => {
+ field.onChange(newValue.map((m: Monitor) => m.id));
+ }}
+ fieldLabel={t(
+ "pages.statusPages.form.monitors.option.monitors.label"
+ )}
+ renderInput={(params) => (
+
+ )}
+ />
+ {selectedMonitors.length > 0 && (
+
+
+ {(provided) => (
+
+ {selectedMonitors.map((monitor, index) => (
+
+ {(provided) => (
+
+
+ {monitor.name}
+ {
+ field.onChange(
+ field.value.filter(
+ (id: string) => id !== monitor.id
+ )
+ );
+ }}
+ aria-label="Remove monitor"
+ >
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+ )}
+
+ );
+ }}
+ />
+ }
+ />
+ (
+ option.name}
+ value={
+ timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
+ }
+ onChange={(_, newValue: TimezoneOption | null) => {
+ field.onChange(newValue?._id ?? "");
+ }}
+ fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
+ renderInput={(params) => (
+
+ )}
+ />
+ )}
+ />
+ }
+ />
+
+
+ (
+ {
+ if (file) {
+ field.onChange({
+ data: file.src,
+ contentType: file.file.type,
+ });
+ } else {
+ field.onChange(null);
+ }
+ }}
+ />
+ )}
+ />
+
+ (
+
+ )}
+ />
+
+ }
+ />
+
+ (
+
+ }
+ label={t("pages.statusPages.form.features.option.showCharts.label")}
+ />
+ )}
+ />
+ {/* (
+
+ }
+ label={t(
+ "pages.statusPages.form.features.option.showUptimePercentage.label"
+ )}
+ />
+ )}
+ />
+ (
+
+ }
+ label={t(
+ "pages.statusPages.form.features.option.showAdminLoginLink.label"
+ )}
+ />
+ )}
+ /> */}
+
+ }
+ />
+
+
+
+
+
+ );
+};
+
+export default CreateStatusPage;
diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
index 4fc2e41e1..d738671ac 100644
--- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
+++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
@@ -50,7 +50,7 @@ export const HeaderStatusPageControls = ({
{
window.open(
- `/status/uptime/public/${statusPage.url}`,
+ `/status/public/${statusPage.url}`,
"_blank",
"noopener,noreferrer"
);
@@ -77,7 +77,7 @@ export const HeaderStatusPageControls = ({
variant="contained"
color="secondary"
startIcon={}
- onClick={() => navigate(`/status/uptime/configure/${statusPage.url}`)}
+ onClick={() => navigate(`/status/configure/${statusPage.url}`)}
>
{t("common.buttons.configure")}
diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
index d559e7c82..04060673a 100644
--- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
+++ b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
@@ -96,6 +96,8 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
{chartType === "histogram" ? (
) : (
diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
index c5aca4754..8b3343249 100644
--- a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
+++ b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
@@ -16,24 +16,20 @@ const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
monitorsStatus.color = theme.palette.success.main;
monitorsStatus.icon = ;
- }
-
- if (monitors.every((monitor) => monitor.status === false)) {
+ return monitorsStatus;
+ } else if (monitors.every((monitor) => monitor.status === false)) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
monitorsStatus.color = theme.palette.error.main;
- }
-
- if (monitors.some((monitor) => monitor.status === false)) {
+ return monitorsStatus;
+ } else if (monitors.some((monitor) => monitor.status === false)) {
monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
monitorsStatus.color = theme.palette.warning.main;
- }
-
- // Paused or unknown
- if (monitors.some((monitor) => typeof monitor.status === "undefined")) {
+ return monitorsStatus;
+ } else {
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
monitorsStatus.color = theme.palette.warning.main;
+ return monitorsStatus;
}
- return monitorsStatus;
};
interface StatusBarProps {
diff --git a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx b/client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx
deleted file mode 100644
index 73b9a8a5f..000000000
--- a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { useState } from "react";
-import { networkService } from "../../../../Utils/NetworkService.js";
-import { createToast } from "../../../../Utils/toastUtils.jsx";
-import { useTranslation } from "react-i18next";
-
-/**
- * Hook for deleting a status page with optimistic UI update
- * @param {Function} fetchStatusPage - Function to fetch status page data
- * @param {string} url - URL of the status page
- * @returns {Array} - [deleteStatusPage function, isLoading state]
- */
-const useStatusPageDelete = (fetchStatusPage, url) => {
- const [isLoading, setIsLoading] = useState(false);
- const { t } = useTranslation();
-
- /**
- * Delete a status page with optimistic UI update
- * @returns {Promise} - Success status
- */
- const deleteStatusPage = async () => {
- // We don't need to call fetchStatusPage after deletion
- // This prevents the 404 error when trying to fetch a deleted status page
- try {
- setIsLoading(true);
- await networkService.deleteStatusPage({ url });
- createToast({
- body: t("statusPage.deleteSuccess", "Status page deleted successfully"),
- });
- return true;
- } catch (error) {
- createToast({
- body: t("statusPage.deleteFailed", "Failed to delete status page"),
- });
- return false;
- } finally {
- setIsLoading(false);
- }
- };
-
- return [deleteStatusPage, isLoading];
-};
-
-export { useStatusPageDelete };
diff --git a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx b/client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx
deleted file mode 100644
index 7db6b6c5e..000000000
--- a/client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useEffect, useState, useCallback } from "react";
-import { networkService } from "../../../../main.jsx";
-import { useSelector } from "react-redux";
-import { createToast } from "../../../../Utils/toastUtils.jsx";
-import { useTheme } from "@emotion/react";
-import { useMonitorUtils } from "../../../../Hooks/useMonitorUtils.js";
-
-const useStatusPageFetch = (isCreate = false, url) => {
- const [isLoading, setIsLoading] = useState(true);
- const [networkError, setNetworkError] = useState(false);
- const [statusPage, setStatusPage] = useState(undefined);
- const [monitors, setMonitors] = useState(undefined);
- const theme = useTheme();
- const { getMonitorWithPercentage } = useMonitorUtils();
- const fetchStatusPage = useCallback(async () => {
- try {
- const response = await networkService.getStatusPageByUrl({
- url,
- type: "uptime",
- });
- 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);
- }
- }, [theme, getMonitorWithPercentage, url]);
-
- useEffect(() => {
- if (isCreate === true) {
- return;
- }
- fetchStatusPage();
- }, [isCreate, fetchStatusPage]);
-
- return [statusPage, monitors, isLoading, networkError, fetchStatusPage];
-};
-
-export { useStatusPageFetch };
diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx
index 41373b19a..1b509507e 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -1,9 +1,12 @@
-import { BasePage } from "@/Components/v2/design-elements";
+import { BasePage, BaseFallback } from "@/Components/v2/design-elements";
import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar";
import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList";
import Typography from "@mui/material/Typography";
+import { Link } from "react-router-dom";
+import Stack from "@mui/material/Stack";
+import Box from "@mui/material/Box";
-import { useTheme } from "@mui/material";
+import { useMediaQuery, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
@@ -17,7 +20,8 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
- const isPublic = location.pathname.startsWith("/status/uptime/public");
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime` : null;
@@ -28,13 +32,44 @@ const StatusPageView = () => {
if (!statusPage) return null;
+ if (monitors?.length === 0) {
+ return (
+
+
+
+
+ {t("pages.statusPages.details.empty.title")}
+
+ {isAdmin && (
+
+ {t("pages.statusPages.details.empty.addMonitor")}
+
+ )}
+
+
+
+ );
+ }
+
let sx: React.CSSProperties = {};
if (isPublic) {
sx.paddingTop = theme.spacing(20);
- sx.paddingLeft = "20vw";
- sx.paddingRight = "20vw";
+ sx.paddingLeft = isSmall ? "5vw" : "20vw";
+ sx.paddingRight = isSmall ? "5vw" : "20vw";
}
+ const logoSrc = statusPage.logo?.data
+ ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
+ : null;
+
return (
{
statusPage={statusPage}
isPublic={isPublic}
/>
+ {logoSrc && (
+
+ )}
{t("statusPageStatusServiceStatus")}
void;
}
-export const StatusPagesTable = ({ data }: StatusPagesTableProps) => {
+export const StatusPagesTable = ({
+ data,
+ setSelectedStatusPage,
+}: StatusPagesTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
+ const getActions = (row: StatusPage): ActionMenuItem[] => {
+ return [
+ {
+ id: 1,
+ label: t("common.buttons.configure"),
+ action: () => {
+ navigate(`/status/configure/${row.url}`);
+ },
+ closeMenu: true,
+ },
+ {
+ id: 2,
+ label: (
+
+ {t("common.buttons.delete")}
+
+ ),
+ action: () => setSelectedStatusPage(row),
+ closeMenu: true,
+ },
+ ];
+ };
+
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
- const url = `/status/uptime/public/${row.url}`;
+ const url = `/status/public/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
}
};
@@ -87,11 +115,18 @@ export const StatusPagesTable = ({ data }: StatusPagesTableProps) => {
);
},
},
+ {
+ id: "actions",
+ content: t("common.table.headers.actions"),
+ render: (row) => {
+ return ;
+ },
+ },
];
};
const handleRowClick = (statusPage: StatusPage) => {
- navigate(`/status/uptime/${statusPage.url}`);
+ navigate(`/status/${statusPage.url}`);
};
return (
diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx
index 6a34e587d..3ad555512 100644
--- a/client/src/Pages/StatusPage/StatusPages/index.tsx
+++ b/client/src/Pages/StatusPage/StatusPages/index.tsx
@@ -1,6 +1,8 @@
+import { useState } from "react";
import { BasePageWithStates } from "@/Components/v2/design-elements";
+import { Dialog } from "@/Components/v2/inputs";
import { StatusPagesTable } from "./Components/StatusPagesTable";
-import { useGet } from "@/Hooks/UseApi";
+import { useGet, useDelete } from "@/Hooks/UseApi";
import type { StatusPage } from "@/Types/StatusPage";
import { useTranslation } from "react-i18next";
import { HeaderCreate } from "@/Components/v2/common";
@@ -13,10 +15,26 @@ const StatusPages = () => {
data: statusPages,
isLoading,
error,
+ refetch,
} = useGet("/status-page/team");
+ const { deleteFn, loading: isDeleting } = useDelete();
+ const [selectedStatusPage, setSelectedStatusPage] = useState(null);
+ const isDialogOpen = Boolean(selectedStatusPage);
+
const isAdmin = useIsAdmin();
+ const handleConfirm = async () => {
+ if (!selectedStatusPage) return;
+ await deleteFn(`/status-page/${selectedStatusPage.id}`);
+ setSelectedStatusPage(null);
+ refetch();
+ };
+
+ const handleCancel = () => {
+ setSelectedStatusPage(null);
+ };
+
return (
{
error={!!error}
items={statusPages ?? []}
actionButtonText={t("pages.statusPages.fallback.actionButton")}
- actionLink="/status/uptime/create"
+ actionLink="/status/create"
>
-
+
+
);
};
diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx
index bbba8eac0..908d7efe1 100644
--- a/client/src/Routes/index.jsx
+++ b/client/src/Routes/index.jsx
@@ -36,7 +36,7 @@ import Checks from "../Pages/Checks/index";
import Incidents from "../Pages/Incidents/";
// Status pages
-import CreateStatus from "../Pages/StatusPage/Create/index.jsx";
+import CreateStatus from "../Pages/StatusPage/Create/";
import StatusPages from "../Pages/StatusPage/StatusPages";
import Status from "../Pages/StatusPage/Status";
@@ -237,7 +237,7 @@ const Routes = () => {
/>
@@ -248,13 +248,25 @@ const Routes = () => {
/>
}
+ path="status/create"
+ element={
+ <>
+
+
+
+ >
+ }
/>
}
+ path="status/configure/:url"
+ element={
+ <>
+
+
+
+ >
+ }
/>
{
element={}
/>
diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts
index 9b903207c..f204ab2ca 100644
--- a/client/src/Types/Check.ts
+++ b/client/src/Types/Check.ts
@@ -237,7 +237,9 @@ export interface ChecksSummary {
export type CheckSnapshot = Omit<
Check,
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
->;
+> & {
+ originalResponseTime: number;
+};
export interface HasResponseTime {
responseTime: number;
diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts
index 02d4ccf2d..2a0272fdc 100644
--- a/client/src/Types/Monitor.ts
+++ b/client/src/Types/Monitor.ts
@@ -1,5 +1,4 @@
-import type { GroupedCheck } from "@/Types/Check";
-import type { CheckSnapshot } from "@/Types/Check";
+import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
export type MonitorStatus = boolean | undefined;
export const MonitorTypes = [
diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts
new file mode 100644
index 000000000..fbe6a0690
--- /dev/null
+++ b/client/src/Validation/statusPage.ts
@@ -0,0 +1,33 @@
+import { z } from "zod";
+
+export const statusPageSchema = z.object({
+ companyName: z
+ .string()
+ .min(1, "Company name is required")
+ .max(100, "Company name must be at most 100 characters"),
+ url: z
+ .string()
+ .min(1, "URL is required")
+ .max(50, "URL must be at most 50 characters")
+ .regex(
+ /^[a-z0-9-]+$/,
+ "URL can only contain lowercase letters, numbers, and hyphens"
+ ),
+ timezone: z.string().optional(),
+ color: z.string().min(1, "Color is required"),
+ monitors: z.array(z.string()).min(1, "At least one monitor is required"),
+ isPublished: z.boolean(),
+ showCharts: z.boolean(),
+ showUptimePercentage: z.boolean(),
+ showAdminLoginLink: z.boolean(),
+ customCSS: z.string().optional(),
+ logo: z
+ .object({
+ data: z.string(),
+ contentType: z.string(),
+ })
+ .nullable()
+ .optional(),
+});
+
+export type StatusPageFormData = z.infer;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 52f0dff1f..af638e48d 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -242,7 +242,8 @@
"clickToUpload": "Click to upload",
"dragAndDrop": "or drag and drop",
"supportedFormats": "Supported formats",
- "maxSize": "Max size"
+ "maxSize": "Max size",
+ "orDragAndDrop": "or drag and drop"
},
"errors": {
"invalidFileType": "Invalid file type",
@@ -358,7 +359,6 @@
"errors": "Errors",
"expectedValue": "Expected value",
"failedToSendEmail": "Failed to send email",
- "features": "Features",
"FirstName": "First name",
"frequency": "Frequency",
"friendlyNameInput": "Friendly name",
@@ -595,7 +595,9 @@
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
- "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type."
+ "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
+ "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
+ "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
}
},
"ignoreTls": {
@@ -860,10 +862,16 @@
}
},
"statusPages": {
+ "deleteSuccess": "Status page deleted successfully",
"fallback": {
- "actionButton": "Create status page",
- "checks": "No status pages found",
- "title": "Status pages"
+ "title": "A status page is used to:",
+ "checks": [
+ "Communicate system status to users and stakeholders",
+ "Display real-time uptime information publicly",
+ "Build trust with transparent service monitoring",
+ "Reduce support requests during incidents"
+ ],
+ "actionButton": "Let's create your first status page!"
},
"monitorsList": {
"chartTypeHeatmap": "Heatmap",
@@ -883,7 +891,86 @@
"published": "Published",
"unpublished": "Unpublished"
},
- "title": "Status pages"
+ "title": "Status pages",
+ "details": {
+ "empty": {
+ "title": "There's nothing here yet",
+ "addMonitor": "Add a monitor to get started"
+ }
+ },
+ "form": {
+ "access": {
+ "title": "Access",
+ "description": "If your status page is ready, you can mark it as published.",
+ "option": {
+ "published": {
+ "name": "Published and visible to the public"
+ }
+ }
+ },
+ "basicInfo": {
+ "title": "Basic information",
+ "description": "Define company name and the subdomain that your status page points to.",
+ "option": {
+ "name": {
+ "label": "Company name",
+ "placeholder": "Acme Inc."
+ },
+ "url": {
+ "label": "Your status page address",
+ "placeholder": "my-status-page"
+ }
+ }
+ },
+ "monitors": {
+ "title": "Monitors",
+ "description": "Select the monitors to display on your status page.",
+ "noMonitors": "No monitors selected",
+ "option": {
+ "monitors": {
+ "label": "Select monitors",
+ "placeholder": "Search and select monitors..."
+ }
+ }
+ },
+ "timezone": {
+ "title": "Timezone",
+ "description": "Select the timezone that your status page will be displayed in.",
+ "option": {
+ "timezone": {
+ "label": "Timezone",
+ "placeholder": "Select timezone..."
+ }
+ }
+ },
+ "appearance": {
+ "title": "Appearance",
+ "description": "Define the default look and feel of your public status page.",
+ "option": {
+ "logo": {
+ "label": "Logo"
+ },
+ "color": {
+ "label": "Brand color"
+ }
+ }
+ },
+ "features": {
+ "title": "Features",
+ "description": "Configure what information is displayed on your status page.",
+ "option": {
+ "showCharts": {
+ "label": "Show response time charts"
+ },
+ "showUptimePercentage": {
+ "label": "Show uptime percentage"
+ },
+ "showAdminLoginLink": {
+ "label": "Show admin login link"
+ }
+ }
+ }
+ }
},
"uptime": {
"filters": {
@@ -958,7 +1045,6 @@
},
"rate": "Rate",
"remove": "Remove",
- "removeLogo": "Remove Logo",
"repeat": "Repeat",
"reset": "Reset",
"response": "RESPONSE",
@@ -972,7 +1058,6 @@
},
"save": "Save",
"selectAll": "Select all",
- "settingsDisplayTimezone": "Display timezone",
"settingsFailedToClearStats": "Failed to clear stats",
"settingsFailedToDeleteMonitors": "Failed to delete all monitors",
"settingsFailedToSave": "Failed to save settings",
@@ -1065,25 +1150,18 @@
"settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}",
"settingsTestEmailSuccess": "Test email sent successfully",
"settingsTestEmailUnknownError": "Unknown error",
- "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page",
- "showCharts": "Show charts",
"shown": "Shown",
- "showUptimePercentage": "Show uptime percentage",
"starPromptDescription": "See the latest releases and help grow the community on GitHub",
"starPromptTitle": "Star Checkmate",
"startTime": "Start time",
"state": "State",
"status": "Status",
- "statusBreadCrumbsDetails": "Details",
- "statusBreadCrumbsStatusPages": "Status Pages",
"statusCode": "Status code",
"statusPage": {
"contents": "Contents",
- "createSuccess": "Status page created successfully",
"deleteFailed": "Failed to delete status page",
"deleteSuccess": "Status page deleted successfully",
"generalSettings": "General settings",
- "updateSuccess": "Status page updated successfully",
"details": {
"statusHeader": {
"allUp": "All systems operational",
@@ -1095,16 +1173,6 @@
"statusPageCreate": {
"buttonSave": "Save"
},
- "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.",
- "statusPageCreateAppearanceTitle": "Appearance",
- "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.",
- "statusPageCreateBasicInfoStatusPageAddress": "Your status page address",
- "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.",
- "statusPageCreateSettings": "If your status page is ready, you can mark it as published.",
- "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public",
- "statusPageCreateTabsContent": "Status page servers",
- "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.",
- "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page",
"statusPageStatusServiceStatus": "Service status",
"submit": "Submit",
"SupportedFormats": "Supported formats",
diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts
index 3271e39a1..f9cca161e 100644
--- a/server/src/controllers/statusPageController.ts
+++ b/server/src/controllers/statusPageController.ts
@@ -82,7 +82,15 @@ class StatusPageController {
const showURL = settings.showURL;
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
- const normalizedMonitors = monitors.map((monitor) => {
+ // Sort monitors according to the order in statusPage.monitors
+ const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index]));
+ const sortedMonitors = [...monitors].sort((a, b) => {
+ const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
+ const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
+ return orderA - orderB;
+ });
+
+ const normalizedMonitors = sortedMonitors.map((monitor) => {
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
if (!showURL) {
const { url, port, secret, notifications, ...rest } = monitor;
diff --git a/server/src/db/models/StatusPage.ts b/server/src/db/models/StatusPage.ts
index 42d11bc46..56c5ee299 100644
--- a/server/src/db/models/StatusPage.ts
+++ b/server/src/db/models/StatusPage.ts
@@ -1,15 +1,15 @@
import { Schema, model, type Types } from "mongoose";
-import type { StatusPage, StatusPageLogo } from "@/types/statusPage.js";
+import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
import { StatusPageTypes } from "@/types/statusPage.js";
type StatusPageDocumentBase = Omit<
StatusPage,
- "id" | "userId" | "teamId" | "monitors" | "subMonitors" | "originalMonitors" | "createdAt" | "updatedAt"
+ "id" | "userId" | "teamId" | "monitors" | "subMonitors" | "originalMonitors" | "logo" | "createdAt" | "updatedAt"
> & {
monitors: Types.ObjectId[];
subMonitors: Types.ObjectId[];
originalMonitors?: Types.ObjectId[];
- logo?: StatusPageLogo | null;
+ logo?: StatusPageLogoDocument | null;
};
interface StatusPageDocument extends StatusPageDocumentBase {
@@ -20,7 +20,7 @@ interface StatusPageDocument extends StatusPageDocumentBase {
updatedAt: Date;
}
-const logoSchema = new Schema(
+const logoSchema = new Schema(
{
data: { type: Buffer },
contentType: { type: String },
diff --git a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts
index d5461bef3..e19676285 100644
--- a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts
+++ b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts
@@ -1,9 +1,13 @@
import { IStatusPagesRepository } from "@/repositories/index.js";
import { type StatusPageDocument, StatusPageModel } from "@/db/models/StatusPage.js";
-import type { StatusPage, StatusPageLogo } from "@/types/statusPage.js";
+import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/types/statusPage.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
+// Type for update data that can include document-level fields (Buffer for logo)
+type StatusPageUpdateData = Partial> & {
+ logo?: StatusPageLogoDocument | null;
+};
class MongoStatusPagesRepository implements IStatusPagesRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
if (!value) {
@@ -23,12 +27,14 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
return values?.map((value) => this.toStringId(value)) ?? [];
};
- private mapLogo = (logo?: StatusPageLogo | null): StatusPageLogo | undefined => {
+ private mapLogo = (logo?: StatusPageLogoDocument | null): StatusPageLogo | undefined => {
if (!logo) {
return undefined;
}
+ // Convert Buffer to base64 string for JSON serialization
+ const base64Data = Buffer.isBuffer(logo.data) ? logo.data.toString("base64") : logo.data;
return {
- data: logo.data,
+ data: base64Data,
contentType: logo.contentType,
};
};
@@ -65,14 +71,15 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
};
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => {
+ const { logo: _logo, ...restData } = data;
const statusPage = new StatusPageModel({
- ...data,
+ ...restData,
userId,
teamId,
});
if (image) {
statusPage.logo = {
- data: image.buffer,
+ data: image.buffer as Buffer,
contentType: image.mimetype,
};
}
@@ -96,17 +103,24 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
return this.mapDocuments(statusPages);
};
- updateById = async (id: string, teamId: string, image: Express.Multer.File | undefined, patch: Partial): Promise => {
+ updateById = async (
+ id: string,
+ teamId: string,
+ image: Express.Multer.File | undefined,
+ patch: Partial & { removeLogo?: string }
+ ): Promise => {
+ const { logo: _logo, removeLogo, ...restPatch } = patch;
+ const updateData: StatusPageUpdateData = { ...restPatch };
if (image) {
- patch.logo = {
- data: image.buffer,
+ updateData.logo = {
+ data: image.buffer as Buffer,
contentType: image.mimetype,
};
- } else {
- patch.logo = null;
+ } else if (removeLogo === "true") {
+ updateData.logo = null;
}
- const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, patch, {
+ const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateData, {
new: true,
});
diff --git a/server/src/routes/statusPageRoute.ts b/server/src/routes/statusPageRoute.ts
index f70802f73..1468f9d16 100755
--- a/server/src/routes/statusPageRoute.ts
+++ b/server/src/routes/statusPageRoute.ts
@@ -19,7 +19,7 @@ class StatusPageRoutes {
this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
this.router.get("/:url", this.statusPageController.getStatusPageByUrl);
- this.router.delete("/:url(*)", verifyJWT, this.statusPageController.deleteStatusPage);
+ this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage);
}
getRouter() {
diff --git a/server/src/types/statusPage.ts b/server/src/types/statusPage.ts
index 64a5ac50b..b11cf53d6 100644
--- a/server/src/types/statusPage.ts
+++ b/server/src/types/statusPage.ts
@@ -2,6 +2,11 @@ export const StatusPageTypes = ["uptime"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export interface StatusPageLogo {
+ data: string;
+ contentType: string;
+}
+
+export interface StatusPageLogoDocument {
data: Buffer;
contentType: string;
}
diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts
index 4ce471c79..d31e01420 100755
--- a/server/src/validation/joi.ts
+++ b/server/src/validation/joi.ts
@@ -480,6 +480,7 @@ const createStatusPageBodyValidation = joi.object({
showCharts: joi.boolean().optional(),
showUptimePercentage: joi.boolean(),
showAdminLoginLink: joi.boolean().optional(),
+ removeLogo: joi.string().valid("true", "false").optional(),
});
const imageValidation = joi