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