feat/v2/uptime/create

This commit is contained in:
Alex Holliday
2025-10-03 12:45:21 -07:00
parent 5dbfb3b8c5
commit 938d9b6642
18 changed files with 590 additions and 7 deletions

View File

@@ -22,6 +22,7 @@
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
"i18next": "25.4.2",
"joi": "17.13.3",
"mui-color-input": "^6.0.0",
@@ -4118,6 +4119,15 @@
"node": ">=8.0.0"
}
},
"node_modules/human-interval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/human-interval/-/human-interval-2.0.1.tgz",
"integrity": "sha512-r4Aotzf+OtKIGQCB3odUowy4GfUDTy3aTWTfLd7ZF2gBCy3XW3v/dJLRefZnOFFnjqs5B1TypvS8WarpBkYUNQ==",
"license": "MIT",
"dependencies": {
"numbered": "^1.1.0"
}
},
"node_modules/i18next": {
"version": "25.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.4.2.tgz",
@@ -4952,6 +4962,12 @@
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"license": "MIT"
},
"node_modules/numbered": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/numbered/-/numbered-1.1.0.tgz",
"integrity": "sha512-pv/ue2Odr7IfYOO0byC1KgBI10wo5YDauLhxY6/saNzAdAs0r1SotGCPzzCLNPL0xtrAwWRialLu23AAu9xO1g==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@@ -27,6 +27,7 @@
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
"i18next": "25.4.2",
"joi": "17.13.3",
"mui-color-input": "^6.0.0",

View File

@@ -3,7 +3,6 @@ import { FormControlLabel, Checkbox as MuiCheckbox } from "@mui/material";
import { useTheme } from "@emotion/react";
import CheckboxOutline from "../../../../assets/icons/checkbox-outline.svg?react";
import CheckboxFilled from "../../../../assets/icons/checkbox-filled.svg?react";
import "./index.css";
/**
* Checkbox Component

View File

@@ -0,0 +1,24 @@
import Stack from "@mui/material/Stack";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
interface BasePageProps extends StackProps {
children: React.ReactNode;
}
export const BasePage: React.FC<BasePageProps> = ({
children,
...props
}: {
children: React.ReactNode;
}) => {
const theme = useTheme();
return (
<Stack
spacing={theme.spacing(10)}
{...props}
>
{children}
</Stack>
);
};

View File

@@ -0,0 +1,68 @@
import { Fragment } from "react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useMediaQuery } from "@mui/material";
export const SplitBox = ({
left,
right,
}: {
left: React.ReactNode;
right: React.ReactNode;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
direction={isSmall ? "column" : "row"}
bgcolor={theme.palette.primary.main}
border={1}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.spacing(2)}
>
<Box
padding={theme.spacing(15)}
borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.primary.lowContrast}
flex={0.7}
>
{left}
</Box>
<Box
flex={1}
padding={theme.spacing(15)}
>
{right}
</Box>
</Stack>
);
};
export const ConfigBox = ({
title,
subtitle,
rightContent,
}: {
title: string;
subtitle: string;
rightContent: React.ReactNode;
}) => {
return (
<SplitBox
left={
<Fragment>
<Typography
component="h2"
variant="h2"
>
{title}
</Typography>
<Typography component="p">{subtitle}</Typography>
</Fragment>
}
right={rightContent}
/>
);
};

View File

@@ -0,0 +1,2 @@
export { SplitBox as HorizontalSplitBox, ConfigBox } from "./SplitBox";
export { BasePage } from "./BasePage";

View File

@@ -0,0 +1,48 @@
import Autocomplete from "@mui/material/Autocomplete";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
import { TextInput } from "@/Components/v2/Inputs/TextInput";
import { CheckboxInput } from "@/Components/v2/Inputs/Checkbox";
import ListItem from "@mui/material/ListItem";
import { useTheme } from "@mui/material/styles";
type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>,
"renderInput"
> & {
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
};
export const AutoCompleteInput: React.FC<AutoCompleteInputProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Autocomplete
{...props}
disableCloseOnSelect
renderInput={(params) => (
<TextInput
{...params}
placeholder="Type to search"
/>
)}
getOptionKey={(option) => option._id}
renderTags={() => null}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
>
<CheckboxInput checked={selected} />
{option.name}
</ListItem>
);
}}
sx={{
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
padding: `0 ${theme.spacing(5)}`,
},
}}
/>
);
};

View File

@@ -0,0 +1,23 @@
import Checkbox from "@mui/material/Checkbox";
import type { CheckboxProps } from "@mui/material/Checkbox";
import CheckboxOutline from "@/assets/icons/checkbox-outline.svg?react";
import CheckboxFilled from "@/assets/icons/checkbox-filled.svg?react";
import { useTheme } from "@mui/material/styles";
type CheckboxInputProps = CheckboxProps & {
label?: string;
};
export const CheckboxInput: React.FC<CheckboxInputProps> = ({ label, ...props }) => {
const theme = useTheme();
return (
<Checkbox
{...props}
icon={<CheckboxOutline />}
checkedIcon={<CheckboxFilled />}
sx={{
"&:hover": { backgroundColor: "transparent" },
"& svg": { width: theme.spacing(8), height: theme.spacing(8) },
}}
/>
);
};

View File

@@ -0,0 +1,62 @@
import Radio from "@mui/material/Radio";
import type { RadioProps } from "@mui/material/Radio";
import { useTheme } from "@mui/material/styles";
import RadioChecked from "@/assets/icons/radio-checked.svg?react";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
interface RadioInputProps extends RadioProps {}
export const RadioInput: React.FC<RadioInputProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Radio
{...props}
checkedIcon={<RadioChecked />}
sx={{
color: "transparent",
boxShadow: `inset 0 0 0 1px ${theme.palette.secondary.main}`,
"&:not(.Mui-checked)": {
boxShadow: `inset 0 0 0 1px ${theme.palette.primary.contrastText}70`, // Use theme text color for the outline
},
mt: theme.spacing(0.5),
}}
/>
);
};
export const RadioWithDescription: React.FC<
RadioInputProps & { label: string; description: string }
> = ({ label, description, ...props }) => {
const theme = useTheme();
return (
<FormControlLabel
control={<RadioInput {...props} />}
label={
<>
<Typography component="p">{label}</Typography>
<Typography
component="h6"
color={theme.palette.primary.contrastTextSecondary}
>
{description}
</Typography>
</>
}
sx={{
alignItems: "flex-start",
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.tertiary.main,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
/>
);
};

View File

@@ -0,0 +1,6 @@
import Select from "@mui/material/Select";
import type { SelectProps } from "@mui/material/Select";
export const SelectInput: React.FC<SelectProps> = ({ ...props }) => {
return <Select {...props} />;
};

View File

@@ -1,5 +1,15 @@
import { forwardRef } from "react";
import TextField from "@mui/material/TextField";
import type { TextFieldProps } from "@mui/material";
export const TextInput = (props: TextFieldProps) => {
return <TextField {...props} />;
};
export const TextInput = forwardRef<HTMLInputElement, TextFieldProps>(
function TextInput(props, ref) {
return (
<TextField
{...props}
inputRef={ref}
/>
);
}
);
TextInput.displayName = "TextInput";

View File

@@ -1,15 +1,21 @@
import { Outlet } from "react-router";
import Stack from "@mui/material/Stack";
import { SideBar } from "@/Components/v2/Layouts/Sidebar";
import { useTheme } from "@mui/material/styles";
const RootLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
minHeight="100vh"
>
<SideBar />
<Outlet />
<Stack
flex={1}
padding={theme.spacing(12)}
>
<Outlet />
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,262 @@
import Stack from "@mui/material/Stack";
import { TextInput } from "@/Components/v2/Inputs/TextInput";
import { AutoCompleteInput } from "@/Components/v2/Inputs/AutoComplete";
import { ConfigBox, BasePage } from "@/Components/v2/DesignElements";
import RadioGroup from "@mui/material/RadioGroup";
import FormControl from "@mui/material/FormControl";
import { RadioWithDescription } from "@/Components/v2/Inputs/RadioInput";
import Button from "@mui/material/Button";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import { Typography } from "@mui/material";
import humanInterval from "human-interval";
import { useTranslation } from "react-i18next";
import { useForm, Controller, useWatch } from "react-hook-form";
import { int, z } from "zod";
import { monitorSchema } from "@/Validation/v2/zod";
import { useTheme } from "@mui/material/styles";
import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost } from "@/Hooks/v2/UseApi";
import type { ApiResponse } from "@/Hooks/v2/UseApi";
const CreateUptimePage = () => {
const { t } = useTranslation();
const theme = useTheme();
type FormValues = z.infer<typeof monitorSchema>;
type SubmitValues = Omit<FormValues, "interval"> & { interval: number | undefined };
const {
handleSubmit,
control,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(monitorSchema) as any,
defaultValues: {
type: "http",
url: "",
n: 3,
notificationChannels: [],
name: "",
interval: "1 minute",
},
mode: "onChange",
});
const { response } = useGet<ApiResponse>("/notification-channels");
const { post, loading, error } = usePost<SubmitValues, ApiResponse>("/monitors");
const selectedType = useWatch({
control,
name: "type",
});
const notificationChannels = useWatch({
control,
name: "notificationChannels",
});
const onSubmit = async (data: FormValues) => {
let interval = humanInterval(data.interval);
if (!interval) interval = 60000;
const submitData = { ...data, interval };
const result = await post(submitData);
if (result) {
console.log(result);
} else {
console.error(error);
}
};
const notificationOptions = response?.data ?? [];
return (
<form onSubmit={handleSubmit(onSubmit)}>
<BasePage>
<ConfigBox
title={t("distributedUptimeCreateChecks")}
subtitle={t("distributedUptimeCreateChecksDescription")}
rightContent={
<Controller
name="type"
control={control}
render={({ field }) => (
<FormControl error={!!errors.type}>
<RadioGroup
{...field}
sx={{ gap: theme.spacing(6) }}
>
<RadioWithDescription
value="http"
label={"HTTP"}
description={"Use HTTP to monitor your website or API endpoint."}
/>
<RadioWithDescription
value="https"
label="HTTPS"
description="Use HTTPS to monitor your website or API endpoint.
"
/>
<RadioWithDescription
value="ping"
label={t("pingMonitoring")}
description={t("pingMonitoringDescription")}
/>
</RadioGroup>
</FormControl>
)}
/>
}
/>
<ConfigBox
title={t("settingsGeneralSettings")}
subtitle={t(`uptimeGeneralInstructions.${selectedType}`)}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="url"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
label={t("url")}
fullWidth
error={!!errors.url}
helperText={errors.url ? errors.url.message : ""}
/>
)}
/>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
label={t("displayName")}
fullWidth
error={!!errors.name}
helperText={errors.name ? errors.name.message : ""}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("createMonitorPage.incidentConfigTitle")}
subtitle={t("createMonitorPage.incidentConfigDescriptionV2")}
rightContent={
<Controller
name="n"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
label={t("createMonitorPage.incidentConfigStatusCheckNumber")}
fullWidth
error={!!errors.n}
helperText={errors.n ? errors.n.message : ""}
onChange={(e) => {
const target = e.target as HTMLInputElement;
field.onChange(target.valueAsNumber);
}}
/>
)}
/>
}
/>
<ConfigBox
title={t("notificationConfig.title")}
subtitle={t("notificationConfig.description")}
rightContent={
<Stack>
<Controller
name="notificationChannels"
control={control}
defaultValue={[]} // important!
render={({ field }) => (
<AutoCompleteInput
multiple
options={notificationOptions}
getOptionLabel={(option) => option.name}
value={notificationOptions.filter((o: any) =>
(field.value || []).includes(o._id)
)}
onChange={(_, newValue) => {
field.onChange(newValue.map((o: any) => o._id));
}}
/>
)}
/>
<Stack
gap={theme.spacing(2)}
mt={theme.spacing(2)}
>
{notificationChannels.map((notificationId) => {
const option = notificationOptions.find(
(o: any) => o._id === notificationId
);
if (!option) return null;
return (
<Stack
width={"100%"}
justifyContent={"space-between"}
direction="row"
key={notificationId}
>
<Typography>{option.name}</Typography>
<DeleteOutlineRoundedIcon
onClick={() => {
const updated = notificationChannels.filter(
(id) => id !== notificationId
);
setValue("notificationChannels", updated);
}}
sx={{ cursor: "pointer" }}
/>
</Stack>
);
})}
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("createMonitorPage.intervalTitle")}
subtitle="How often to check the URL"
rightContent={
<Controller
name="interval"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="text"
label={t("createMonitorPage.intervalDescription")}
fullWidth
error={!!errors.interval}
helperText={errors.interval ? errors.interval.message : ""}
/>
)}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
loading={loading}
type="submit"
variant="contained"
color="accent"
>
{t("settingsSave")}
</Button>
</Stack>
</BasePage>
</form>
);
};
export default CreateUptimePage;

View File

@@ -4,6 +4,7 @@ import { lightTheme, darkTheme } from "@/Utils/Theme/v2/theme";
import AuthLoginV2 from "@/Pages/v2/Auth/Login";
import AuthRegisterV2 from "@/Pages/v2/Auth/Register";
import CreateUptimePage from "@/Pages/v2/Uptime/Create";
import RootLayout from "@/Components/v2/Layouts/RootLayout";
const V2Routes = ({ mode = "light" }) => {
@@ -32,6 +33,10 @@ const V2Routes = ({ mode = "light" }) => {
path="uptime"
element={<h1>Test Page</h1>}
/>
<Route
path="uptime/create"
element={<CreateUptimePage />}
/>
</Route>
</Routes>
</ThemeProvider>

View File

@@ -66,6 +66,16 @@ export const theme = (mode: string, palette: any) =>
}),
},
},
MuiRadio: {
styleOverrides: {
root: {
padding: 0,
"& .MuiSvgIcon-root": {
fontSize: 16,
},
},
},
},
},
shape: {
borderRadius: 2,

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
import humanInterval from "human-interval";
const urlRegex =
/^(?:https?:\/\/)?([a-zA-Z0-9.-]+|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-fA-F:]+\])(:\d{1,5})?$/;
const durationSchema = z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val || val.trim() === "") return;
const ms = humanInterval(val);
if (!ms || isNaN(ms)) {
ctx.addIssue({
code: "custom",
message: "Invalid duration format",
});
} else if (ms < 10000) {
ctx.addIssue({
code: "custom",
message: "Minimum duration is 10 seconds",
});
}
});
export const monitorSchema = z.object({
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
n: z.coerce
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
.max(25, "Maximum value is 25"),
notificationChannels: z.array(z.string()).optional().default([]),
name: z.string().min(1, "Display name is required"),
interval: durationSchema,
});

View File

@@ -484,6 +484,7 @@
},
"uptimeGeneralInstructions": {
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.",
"https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.",
"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.",
"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).",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
@@ -1094,9 +1095,13 @@
"chooseGame": "Choose game",
"createMonitorPage": {
"incidentConfigDescription": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.",
"incidentConfigDescriptionV2": "Number of consecutive checks needed to change monitor status. Maximum 25",
"incidentConfigStatusWindowLabel": "How many checks should be in the sliding window?",
"incidentConfigStatusCheckNumber": "How many checks before status change?",
"incidentConfigStatusWindowThresholdLabel": "What percentage of checks in the sliding window fail/succeed before monitor status changes?",
"incidentConfigTitle": "Incidents"
"incidentConfigTitle": "Incidents",
"intervalTitle": "Check interval",
"intervalDescription": "How often the monitor should be checked."
},
"dataRate": "Data Rate",
"dataReceived": "Data Received",