Merge pull request #3238 from bluewave-labs/feat/v2-create-config-status-page

feat: v2 create config status page
This commit is contained in:
Alexander Holliday
2026-02-03 15:31:39 -08:00
committed by GitHub
38 changed files with 1117 additions and 1142 deletions
@@ -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 = ({
<Typography>
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
{check?.responseTime && (
<Typography>
{t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms
</Typography>
)}
<Typography>
{t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
<span style={{ color: getColor(check?.status) }}>
@@ -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 = (
<MuiColorInput
{...props}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
marginTop: theme.spacing(1),
},
}}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{input}
</Stack>
);
}
return input;
};
@@ -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<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [preview, setPreview] = useState<FileObject | null>(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 (
<Stack sx={{ width: "100%", maxWidth: 500 }}>
{displaySrc ? (
<Stack
alignItems="center"
gap={1}
>
<Box
component="img"
src={displaySrc}
alt="Preview"
sx={{ maxWidth: 250, maxHeight: 250, objectFit: "contain", borderRadius: 1 }}
/>
<IconButton
size="small"
onClick={handleClear}
sx={{ color: theme.palette.error.main }}
>
<X size={18} />
</IconButton>
</Stack>
) : (
<Stack
onDragOver={(e) => {
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,
},
}}
>
<input
ref={inputRef}
type="file"
accept={accept.map((ext) => `.${ext}`).join(",")}
onChange={(e) => handleFile(e.target.files?.[0])}
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
/>
<Upload size={24} />
<Typography
variant="body2"
color="text.secondary"
>
<Typography
component="span"
variant="body2"
color="primary"
fontWeight={500}
>
{t("common.imageUpload.clickToUpload")}
</Typography>{" "}
{t("common.imageUpload.orDragAndDrop")}
</Typography>
<Typography
variant="caption"
color="text.disabled"
>
{accept.join(", ").toUpperCase()} {t("common.imageUpload.maxSize")}{" "}
{Math.round(maxSize / 1024 / 1024)}MB
</Typography>
</Stack>
)}
{displayError && (
<Typography
variant="caption"
color="error"
sx={{ mt: 1 }}
>
{displayError}
</Typography>
)}
</Stack>
);
};
@@ -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";
+42
View File
@@ -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]);
};
@@ -27,7 +27,6 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
if (!incident) {
return null;
}
console.log(incident);
return (
<Stack
gap={theme.spacing(4)}
+3 -1
View File
@@ -12,11 +12,13 @@ const generateDummyChecks = (): CheckSnapshot[] => {
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(),
});
@@ -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<HeaderConfigStatusControlsProps>) => {
const theme = useTheme();
const translate = useTranslation();
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"end"}
>
<Button
variant="contained"
color="error"
startIcon={<Icon icon={Trash} />}
onClick={onDelete}
>
{translate.t("common.buttons.delete")}
</Button>
</Stack>
);
};
@@ -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 (
<Stack
direction={"row"}
{...draggableProps}
{...dragHandleProps}
ref={innerRef}
gap={theme.spacing(4)}
margin={theme.spacing(4)}
padding={theme.spacing(4)}
borderRadius={theme.shape.borderRadius}
alignItems={"center"}
justifyContent={"start"}
border={`1px solid ${theme.palette.primary.lowContrast}`}
>
<Icon
name="GripVertical"
size={20}
/>
<Typography>{monitor.name}</Typography>
<Icon
name="Trash2"
size={20}
style={{ marginLeft: "auto", cursor: "pointer" }}
onClick={() => {
onDelete(monitor);
}}
/>
</Stack>
);
};
const MonitorList = ({ selectedMonitors, setSelectedMonitors }) => {
const onDelete = (monitorToDelete) => {
const newMonitors = selectedMonitors.filter(
(monitor) => monitor.id !== monitorToDelete.id
);
setSelectedMonitors(newMonitors);
};
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const onDragEnd = (result) => {
// dropped outside the list
if (!result.destination) {
return;
}
const reorderedMonitors = reorder(
selectedMonitors,
result.source.index,
result.destination.index
);
setSelectedMonitors(reorderedMonitors);
};
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided, snapshot) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedMonitors?.map((monitor, index) => (
<Draggable
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided, snapshot) => (
<MonitorListItem
monitor={monitor}
innerRef={provided.innerRef}
draggableProps={provided.draggableProps}
dragHandleProps={provided.dragHandleProps}
onDelete={onDelete}
/>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
);
};
export default MonitorList;
@@ -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 (
<ProgressUpload
icon={
<Icon
name="Image"
size={20}
/>
}
label={logo?.name}
size={formatBytes(logo?.size)}
progress={progressValue}
onClick={removeLogo}
/>
);
}
if (logo && logoType) {
return (
<Box
width="fit-content"
alignSelf="center"
>
<Button
variant="contained"
color="secondary"
onClick={removeLogo}
>
{t("removeLogo")}
</Button>
</Box>
);
}
};
export default Progress;
@@ -1,14 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack>
<Skeleton
variant="rectangular"
height={"90vh"}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -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 (
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography
component="h2"
variant="h2"
>
{title}
</Typography>
<Typography component="p">{description}</Typography>
</Stack>
{children}
</ConfigBox>
);
};
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;
@@ -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 (
<TabPanel value={tabValue}>
<Stack gap={theme.spacing(10)}>
<ConfigStack
title={t("statusPageCreateTabsContent")}
description={t("statusPageCreateTabsContentDescription")}
>
<Stack>
<Stack
direction="row"
justifyContent="space-between"
>
<Search
options={monitors}
multiple={true}
filteredBy="name"
value={selectedMonitors}
inputValue={search}
handleInputChange={setSearch}
handleChange={handleMonitorsChange}
/>
</Stack>
<Typography
component="span"
className="input-error"
color={theme.palette.error.main}
sx={{
opacity: 0.8,
}}
>
{errors["monitors"]}
</Typography>
<MonitorList
selectedMonitors={selectedMonitors}
setSelectedMonitors={handleMonitorsChange}
/>
</Stack>
</ConfigStack>
<ConfigStack
title={t("features")}
description={t("statusPageCreateTabsContentFeaturesDescription")}
>
<Stack>
<Checkbox
id="showCharts"
name="showCharts"
label={t("showCharts")}
isChecked={form.showCharts}
onChange={handleFormChange}
/>
<Checkbox
id="showUptimePercentage"
name="showUptimePercentage"
label={t("showUptimePercentage")}
isChecked={form.showUptimePercentage}
onChange={handleFormChange}
/>
<Checkbox
id="showAdminLoginLink"
name="showAdminLoginLink"
label={t("showAdminLoginLink")}
isChecked={form.showAdminLoginLink}
onChange={handleFormChange}
/>
</Stack>
</ConfigStack>
</Stack>
</TabPanel>
);
};
export default Content;
@@ -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 (
<TabPanel value={tabValue}>
<Stack gap={theme.spacing(10)}>
<ConfigBox>
<Stack>
<Typography
component="h2"
variant="h2"
>
{t("access")}
</Typography>
<Typography component="p">{t("statusPageCreateSettings")}</Typography>
</Stack>
<Stack gap={theme.spacing(18)}>
<Checkbox
id="publish"
name="isPublished"
label={t("statusPageCreateSettingsCheckboxLabel")}
isChecked={form.isPublished}
onChange={handleFormChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography
component="h2"
variant="h2"
>
{t("basicInformation")}
</Typography>
<Typography component="p">
{t("statusPageCreateBasicInfoDescription")}
</Typography>
</Stack>
<Stack gap={theme.spacing(18)}>
<TextInput
id="companyName"
name="companyName"
type="text"
label={t("companyName")}
value={form.companyName}
onChange={handleFormChange}
helperText={errors["companyName"]}
error={errors["companyName"] ? true : false}
/>
<TextInput
id="url"
name="url"
type="url"
disabled={!isCreate}
label={t("statusPageCreateBasicInfoStatusPageAddress")}
value={form.url}
onChange={handleFormChange}
helperText={errors["url"]}
error={errors["url"] ? true : false}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography
component="h2"
variant="h2"
>
{t("timezone")}
</Typography>
<Typography component="p">
{t("statusPageCreateSelectTimeZoneDescription")}
</Typography>
</Stack>
<Stack gap={theme.spacing(6)}>
<Search
id="timezone"
label={t("settingsDisplayTimezone")}
options={timezones}
filteredBy="name"
value={selectedTimezone}
inputValue={rawInput}
handleInputChange={(newVal) => setRawInput(newVal)}
handleChange={handleTimezoneChange}
isAdorned={true}
unit="timezone"
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography
component="h2"
variant="h2"
>
{t("statusPageCreateAppearanceTitle")}
</Typography>
<Typography component="p">
{t("statusPageCreateAppearanceDescription")}
</Typography>
</Stack>
<Stack gap={theme.spacing(6)}>
<ImageUpload
src={form?.logo?.src}
onChange={handleImageChange}
previewIsRound={false}
/>
<Progress
isLoading={progress.isLoading}
progressValue={progress.value}
logo={form.logo}
logoType={form.logo?.type}
removeLogo={removeLogo}
/>
<ColorPicker
id="color"
name="color"
value={form.color}
onChange={handleFormChange}
/>
</Stack>
</ConfigBox>
</Stack>
</TabPanel>
);
};
TabSettings.propTypes = {
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;
@@ -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 (
<TabContext value={TAB_LIST[tab]}>
<CustomTabList
onChange={(_, selected) => {
setTab(TAB_LIST.indexOf(selected));
}}
aria-label="status page tabs"
>
{TAB_LIST.map((tabLabel) => (
<Tab
key={tabLabel}
label={tabLabel}
value={tabLabel}
/>
))}
</CustomTabList>
{tab === 0 ? (
<Settings
tabValue={TAB_LIST[0]}
form={form}
handleFormChange={handleFormChange}
handleImageChange={handleImageChange}
progress={progress}
removeLogo={removeLogo}
errors={errors}
isCreate={isCreate}
handleDelete={handleDelete}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
isDeleting={isDeleting}
isLoading={isLoading}
/>
) : (
<Content
tabValue={TAB_LIST[1]}
form={form}
monitors={monitors}
handleFormChange={handleFormChange}
errors={errors}
selectedMonitors={selectedMonitors}
setSelectedMonitors={setSelectedMonitors}
/>
)}
</TabContext>
);
};
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;
@@ -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 };
@@ -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 };
@@ -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 (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
{t("common.toasts.networkError")}
</Typography>
<Typography>{t("common.toasts.checkConnection")}</Typography>
</GenericFallback>
);
}
if (isLoading) return <SkeletonLayout />;
// Load fields
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs
list={[
{ name: t("statusBreadCrumbsStatusPages", "Status"), path: "/status" },
{
name: t("statusBreadCrumbsDetails", "Details"),
path: `/status/uptime/${url}`,
},
{ name: t("configure", "Configure"), path: `/status/create/${url}` },
]}
/>
{!isCreate && (
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
// loading={isDeleting}
variant="contained"
color="error"
onClick={() => setIsDeleteOpen(true)}
>
{t("remove")}
</Button>
<Dialog
title={t("deleteStatusPage")}
onConfirm={handleDelete}
onCancel={() => setIsDeleteOpen(false)}
open={isDeleteOpen}
confirmationButtonLabel={t("deleteStatusPageConfirm")}
description={t("deleteStatusPageDescription")}
// isLoading={isDeleting || statusPageIsLoading}
/>
</Stack>
)}
<Tabs
form={form}
errors={errors}
monitors={monitors}
selectedMonitors={selectedMonitors}
setSelectedMonitors={setSelectedMonitors}
handleFormChange={handleFormChange}
handleImageChange={handleImageChange}
progress={progress}
removeLogo={removeLogo}
tab={tab}
setTab={setTab}
TAB_LIST={[
t("statusPage.generalSettings", "General settings"),
t("statusPage.contents", "Contents"),
]}
isCreate={isCreate}
handleDelete={handleDelete}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
// isDeleting={isDeleting}
isLoading={statusPageIsLoading}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="accent"
onClick={handleSubmit}
>
{t("statusPageCreate.buttonSave")}
</Button>
</Stack>
</Stack>
);
};
export default CreateStatusPage;
@@ -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<StatusPageResponse>(isCreate ? null : `/status-page/${url}?type=uptime`);
const { data: monitorsResponse } = useGet<Monitor[]>(
"/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<StatusPageFormData>({
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 <BasePage>Loading...</BasePage>;
}
return (
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
<ConfigBox
title={t("pages.statusPages.form.access.title")}
subtitle={t("pages.statusPages.form.access.description")}
rightContent={
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
>
<Controller
name="isPublished"
control={control}
render={({ field }) => (
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
<Typography>
{t("pages.statusPages.form.access.option.published.name")}
</Typography>
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(6)}>
<Controller
name="companyName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
render={({ field, fieldState }) => {
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 (
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={monitors}
getOptionLabel={(option: Monitor) => 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) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="monitors-list">
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedMonitors.map((monitor, index) => (
<Draggable
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(4)}
padding={theme.spacing(4)}
marginTop={theme.spacing(2)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
<Typography flexGrow={1}>{monitor.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter(
(id: string) => id !== monitor.id
)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)}
</Stack>
);
}}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<Controller
name="timezone"
control={control}
render={({ field }) => (
<Autocomplete
options={timezones}
getOptionLabel={(option: TimezoneOption) => 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) => (
<TextField
{...params}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)}
/>
)}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
control={control}
render={({ field }) => (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
if (file) {
field.onChange({
data: file.src,
contentType: file.file.type,
});
} else {
field.onChange(null);
}
}}
/>
)}
/>
</Stack>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(2)}>
<Controller
name="showCharts"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showUptimePercentage.label"
)}
/>
)}
/>
<Controller
name="showAdminLoginLink"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showAdminLoginLink.label"
)}
/>
)}
/> */}
</Stack>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
</BasePage>
);
};
export default CreateStatusPage;
@@ -50,7 +50,7 @@ export const HeaderStatusPageControls = ({
<Typography
onClick={() => {
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={<Icon icon={Settings} />}
onClick={() => navigate(`/status/uptime/configure/${statusPage.url}`)}
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
>
{t("common.buttons.configure")}
</Button>
@@ -96,6 +96,8 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(0.5), md: theme.spacing(5) }}
checks={monitor?.checks?.slice().reverse() ?? []}
/>
) : (
@@ -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 = <CircleCheck size={24} />;
}
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 {
@@ -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<boolean>} - 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 };
@@ -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 };
+54 -5
View File
@@ -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 (
<BasePage
loading={isLoading}
error={error}
breadcrumbOverride={isPublic ? [] : undefined}
>
<Stack alignItems={"center"}>
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.text.secondary}
>
{t("pages.statusPages.details.empty.title")}
</Typography>
{isAdmin && (
<Link to={`/status/configure/${url}`}>
{t("pages.statusPages.details.empty.addMonitor")}
</Link>
)}
</BaseFallback>
</Stack>
</BasePage>
);
}
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 (
<BasePage
loading={isLoading}
@@ -47,6 +82,20 @@ const StatusPageView = () => {
statusPage={statusPage}
isPublic={isPublic}
/>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alignSelf={"flex-start"}
alt={statusPage.companyName}
sx={{
maxHeight: 120,
maxWidth: "100%",
objectFit: "contain",
mb: 2,
}}
/>
)}
<Typography variant="h2">{t("statusPageStatusServiceStatus")}</Typography>
<StatusBar monitors={monitors} />
<MonitorsList
@@ -2,6 +2,7 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Table, type Header, ValueLabel } from "@/Components/v2/design-elements";
import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu";
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -12,17 +13,44 @@ import type { StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
setSelectedStatusPage: (statusPage: StatusPage | null) => 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: (
<Typography color={theme.palette.error.main}>
{t("common.buttons.delete")}
</Typography>
),
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 <ActionsMenu items={getActions(row)} />;
},
},
];
};
const handleRowClick = (statusPage: StatusPage) => {
navigate(`/status/uptime/${statusPage.url}`);
navigate(`/status/${statusPage.url}`);
};
return (
@@ -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<StatusPage[]>("/status-page/team");
const { deleteFn, loading: isDeleting } = useDelete();
const [selectedStatusPage, setSelectedStatusPage] = useState<StatusPage | null>(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 (
<BasePageWithStates
page={t("pages.statusPages.title")}
@@ -27,13 +45,24 @@ const StatusPages = () => {
error={!!error}
items={statusPages ?? []}
actionButtonText={t("pages.statusPages.fallback.actionButton")}
actionLink="/status/uptime/create"
actionLink="/status/create"
>
<HeaderCreate
path="/status/uptime/create"
path="/status/create"
isAdmin={isAdmin}
/>
<StatusPagesTable data={statusPages ?? []} />
<StatusPagesTable
data={statusPages ?? []}
setSelectedStatusPage={setSelectedStatusPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
+19 -7
View File
@@ -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 = () => {
/>
<Route
path="status/uptime/:url"
path="status/:url"
element={
<>
<ThemeProvider theme={v2theme}>
@@ -248,13 +248,25 @@ const Routes = () => {
/>
<Route
path="status/uptime/create"
element={<CreateStatus />}
path="status/create"
element={
<>
<ThemeProvider theme={v2theme}>
<CreateStatus />
</ThemeProvider>
</>
}
/>
<Route
path="status/uptime/configure/:url"
element={<CreateStatus />}
path="status/configure/:url"
element={
<>
<ThemeProvider theme={v2theme}>
<CreateStatus />
</ThemeProvider>
</>
}
/>
<Route
@@ -365,7 +377,7 @@ const Routes = () => {
element={<AuthNewPasswordConfirmed />}
/>
<Route
path="/status/uptime/public/:url"
path="/status/public/:url"
element={
<>
<ThemeProvider theme={v2theme}>
+3 -1
View File
@@ -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;
+1 -2
View File
@@ -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 = [
+33
View File
@@ -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<typeof statusPageSchema>;
+94 -26
View File
@@ -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",
@@ -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;
+4 -4
View File
@@ -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<StatusPageLogo & { data: Buffer }>(
const logoSchema = new Schema<StatusPageLogoDocument>(
{
data: { type: Buffer },
contentType: { type: String },
@@ -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<Omit<StatusPage, "id" | "userId" | "teamId" | "logo" | "createdAt" | "updatedAt">> & {
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<StatusPage>): Promise<StatusPage> => {
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<StatusPage>): Promise<StatusPage> => {
updateById = async (
id: string,
teamId: string,
image: Express.Multer.File | undefined,
patch: Partial<StatusPage> & { removeLogo?: string }
): Promise<StatusPage> => {
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,
});
+1 -1
View File
@@ -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() {
+5
View File
@@ -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;
}
+1
View File
@@ -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