mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-05-20 00:18:47 -05:00
Merge pull request #3238 from bluewave-labs/feat/v2-create-config-status-page
feat: v2 create config status page
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,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 = [
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user