diff --git a/client/src/Components/v2/inputs/Radio.tsx b/client/src/Components/v2/inputs/Radio.tsx new file mode 100644 index 000000000..ad8dc3374 --- /dev/null +++ b/client/src/Components/v2/inputs/Radio.tsx @@ -0,0 +1,88 @@ +import Radio from "@mui/material/Radio"; +import type { RadioProps } from "@mui/material/Radio"; +import { useTheme } from "@mui/material/styles"; +import { Circle, CircleDot } from "lucide-react"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Typography from "@mui/material/Typography"; + +interface RadioInputProps extends RadioProps {} + +export const RadioInput = ({ ...props }: RadioInputProps) => { + const theme = useTheme(); + return ( + + } + checkedIcon={ + + } + sx={{ + padding: 0, + mt: theme.spacing(0.5), + color: theme.palette.text.secondary, + "&.Mui-checked": { + color: theme.palette.primary.main, + "& svg circle": { + fill: theme.palette.primary.main, + }, + }, + "& .MuiSvgIcon-root": { + fontSize: 16, + }, + "& svg": { + stroke: "currentColor", + }, + "& svg path, & svg line, & svg polyline, & svg rect, & svg circle": { + stroke: "currentColor", + fill: "none", + }, + }} + /> + ); +}; + +export const RadioWithDescription = ({ + label, + description, + ...props +}: RadioInputProps & { label: string; description: string }) => { + const theme = useTheme(); + return ( + } + label={ + <> + {label} + + {description} + + + } + sx={{ + alignItems: "flex-start", + p: theme.spacing(2.5), + m: theme.spacing(-2.5), + borderRadius: theme.shape.borderRadius, + + "&:hover": { + backgroundColor: theme.palette.background.paper, + }, + "& .MuiButtonBase-root": { + p: 0, + mr: theme.spacing(6), + }, + }} + /> + ); +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 70e740dbd..a0646d394 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -9,3 +9,4 @@ export { ToggleButtonGroupInput as ToggleButtonGroup, } from "./ToggleButton"; export { DialogInput as Dialog } from "./Dialog"; +export * from "./Radio"; diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts new file mode 100644 index 000000000..2d7df97ab --- /dev/null +++ b/client/src/Hooks/useMonitorForm.ts @@ -0,0 +1,105 @@ +import { useMemo } from "react"; +import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; +import type { Monitor, MonitorType } from "@/Types/Monitor"; + +interface UseMonitorFormOptions { + data?: Monitor | null; + defaultType?: MonitorType; +} + +const getBaseDefaults = (data?: Monitor | null) => ({ + name: data?.name || "", + interval: data?.interval || 60000, + notifications: data?.notifications || [], + statusWindowSize: data?.statusWindowSize || 5, + statusWindowThreshold: data?.statusWindowThreshold || 60, +}); + +export const useMonitorForm = ({ + data = null, + defaultType = "http", +}: UseMonitorFormOptions = {}) => { + return useMemo(() => { + const type = data?.type || defaultType; + const base = getBaseDefaults(data); + + let defaults: MonitorFormData; + + switch (type) { + case "http": + defaults = { + ...base, + type: "http", + url: data?.url || "", + ignoreTlsErrors: data?.ignoreTlsErrors || false, + matchMethod: data?.matchMethod || "", + expectedValue: data?.expectedValue || "", + jsonPath: data?.jsonPath || "", + }; + break; + case "ping": + defaults = { + ...base, + type: "ping", + url: data?.url || "", + }; + break; + case "port": + defaults = { + ...base, + type: "port", + url: data?.url || "", + port: data?.port || 80, + }; + break; + case "docker": + defaults = { + ...base, + type: "docker", + url: data?.url || "", + }; + break; + case "game": + defaults = { + ...base, + type: "game", + url: data?.url || "", + port: data?.port || 27015, + gameId: data?.gameId || "", + }; + break; + case "pagespeed": + defaults = { + ...base, + type: "pagespeed", + url: data?.url || "", + }; + break; + case "hardware": + defaults = { + ...base, + type: "hardware", + url: data?.url || "", + secret: data?.secret || "", + cpuAlertThreshold: data?.cpuAlertThreshold ?? 80, + memoryAlertThreshold: data?.memoryAlertThreshold ?? 80, + diskAlertThreshold: data?.diskAlertThreshold ?? 80, + tempAlertThreshold: data?.tempAlertThreshold ?? 80, + selectedDisks: data?.selectedDisks || [], + }; + break; + default: + defaults = { + ...base, + type: "http", + url: "", + ignoreTlsErrors: false, + matchMethod: "", + expectedValue: "", + jsonPath: "", + }; + } + + return { schema: monitorSchema, defaults }; + }, [data, defaultType]); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx new file mode 100644 index 000000000..80f818407 --- /dev/null +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -0,0 +1,266 @@ +import { useMemo } from "react"; +import { useEffect } from "react"; +import { useParams } from "react-router"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTheme } from "@mui/material"; +import Stack from "@mui/material/Stack"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControl from "@mui/material/FormControl"; +import { useTranslation } from "react-i18next"; + +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { RadioWithDescription, Button, TextField, Select } from "@/Components/v2/inputs"; +import { useGet } from "@/Hooks/UseApi"; +import { useMonitorForm } from "@/Hooks/useMonitorForm"; +import type { Monitor, MonitorType } from "@/Types/Monitor"; +import type { MonitorFormData } from "@/Validation/monitor"; +import MenuItem from "@mui/material/MenuItem"; + +interface GeneralSettingsConfig { + urlLabel: string; + urlPlaceholder: string; + namePlaceholder: string; + showPort: boolean; + showGameSelect: boolean; +} + +const getGeneralSettingsConfig = ( + type: MonitorType, + t: (key: string) => string +): GeneralSettingsConfig => { + const configs: Record = { + http: { + urlLabel: t("monitorType.http.label"), + urlPlaceholder: t("monitorType.http.placeholder"), + namePlaceholder: t("monitorType.http.namePlaceholder"), + showPort: false, + showGameSelect: false, + }, + ping: { + urlLabel: t("monitorType.ping.label"), + urlPlaceholder: t("monitorType.ping.placeholder"), + namePlaceholder: t("monitorType.ping.namePlaceholder"), + showPort: false, + showGameSelect: false, + }, + docker: { + urlLabel: t("monitorType.docker.label"), + urlPlaceholder: t("monitorType.docker.placeholder"), + namePlaceholder: t("monitorType.docker.namePlaceholder"), + showPort: false, + showGameSelect: false, + }, + port: { + urlLabel: t("monitorType.port.label"), + urlPlaceholder: t("monitorType.port.placeholder"), + namePlaceholder: t("monitorType.port.namePlaceholder"), + showPort: true, + showGameSelect: false, + }, + game: { + urlLabel: t("monitorType.game.label"), + urlPlaceholder: t("monitorType.game.placeholder"), + namePlaceholder: t("monitorType.game.namePlaceholder"), + showPort: true, + showGameSelect: true, + }, + }; + return configs[type] || configs.http; +}; + +const CreateMonitorPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { monitorId } = useParams(); + const isEditMode = Boolean(monitorId); + + const { data: existingMonitor } = useGet( + isEditMode ? `/monitors/${monitorId}` : null + ); + + const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + const { control, watch, handleSubmit, clearErrors } = form; + + useEffect(() => { + form.reset(defaults); + }, [defaults, form]); + + const watchedType = watch("type") as MonitorType; + + useEffect(() => { + clearErrors(); + }, [watchedType, clearErrors]); + + const generalSettingsConfig = useMemo( + () => getGeneralSettingsConfig(watchedType, t), + [watchedType, t] + ); + + const onSubmit = async (data: MonitorFormData) => { + console.log(data); + }; + + const onError = (errors: unknown) => { + console.log(errors); + }; + + return ( + + {/* Monitor Type Selection */} + ( + + + + + + + + + + )} + /> + } + /> + + {/* General Settings - Dynamic based on type */} + + {/* URL/Host/Container field */} + ( + + )} + /> + + {/* Port field - only for port and game types */} + {generalSettingsConfig.showPort && ( + ( + field.onChange(Number(e.target.value) || 0)} + type="number" + fieldLabel={t("portToMonitor")} + placeholder="5173" + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + )} + + {/* Game select - only for game type */} + {generalSettingsConfig.showGameSelect && ( + ( + + )} + /> + )} + + {/* Display name field - common to all types */} + ( + + )} + /> + + } + /> + + {/* Submit Button */} + + + + + ); +}; + +export default CreateMonitorPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 498be4ba7..c2613a16c 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -60,6 +60,8 @@ import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; import BulkImport from "../Pages/Uptime/BulkImport/index.jsx"; import Logs from "../Pages/Logs/index.jsx"; +import CreateMonitor from "@/Pages/CreateMonitor"; + const Routes = () => { const mode = useSelector((state) => state.ui.mode); const AdminCheckedRegister = withAdminCheck(AuthRegister); @@ -78,6 +80,16 @@ const Routes = () => { path="/" element={} /> + + + + + + } + /> ; + +// Type-specific schemas exported for individual use +export { + httpSchema, + pingSchema, + portSchema, + dockerSchema, + gameSchema, + pagespeedSchema, + hardwareSchema, +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 80da1835c..d9f2e7ac6 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -220,6 +220,37 @@ } } }, + "createMonitor": { + "form": { + "type": { + "description": "Select the type of check to perform", + "optionType": "Type", + "title": "Type", + "optionHttp": "HTTP(S)", + "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", + "optionPing": "Ping", + "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", + "optionDocker": "Docker", + "optionDockerDescription": "Use Docker to monitor if a container is running.", + "optionPort": "Port", + "optionPortDescription": "Monitor if a specific port on a server is open.", + "optionGame": "Game", + "optionGameDescription": "Monitor if a specific game server is online." + }, + "general": { + "title": "General settings", + "description": { + "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.", + "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.", + "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": "Here you can select the URL of the host, together with the type of monitor.", + "infrastructure": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent." + } + } + } + }, "uptime": { "table": { "headers": {