diff --git a/client/src/hooks/forms/UseInitInfraForm.tsx b/client/src/hooks/forms/UseInitInfraForm.tsx index fa95e920f..9a688b382 100644 --- a/client/src/hooks/forms/UseInitInfraForm.tsx +++ b/client/src/hooks/forms/UseInitInfraForm.tsx @@ -1,39 +1,66 @@ -import { monitorSchemaInfra } from "@/validation/zod"; import ms from "ms"; import { useMemo } from "react"; +import type { + FormValues, + SubmitValues, +} from "@/pages/infrastructure/InfraForm"; +import type { IMonitor } from "@/types/monitor"; +import humanInterval from "human-interval"; -import { z } from "zod"; export const useInitForm = ({ initialData, }: { - initialData: Partial> | undefined; + initialData: IMonitor | undefined; }) => { return useMemo(() => { - let humanInterval = "1 minute"; - if (initialData?.interval) { - const parsed = Number(initialData.interval); - if (!isNaN(parsed)) { - humanInterval = ms(parsed, { long: true }); - } - } - - type InfraValues = z.infer; - const defaults: InfraValues = { - type: "infrastructure", - url: initialData?.url || "", - secret: initialData?.secret || "", - n: initialData?.n || 3, - notificationChannels: initialData?.notificationChannels || [], - name: initialData?.name || "", - interval: humanInterval, - rejectUnauthorized: true, - thresholds: initialData?.thresholds ?? { - cpu: 100, - memory: 100, - disk: 100, - temperature: 100, - }, + const apiToForm = (apiData: IMonitor): FormValues => { + const formData: FormValues = { + type: apiData.type, + url: apiData.url, + secret: apiData.secret, + n: apiData.n, + notificationChannels: apiData.notificationChannels, + name: apiData.name, + interval: ms(apiData.interval, { long: true }), + rejectUnauthorized: apiData.rejectUnauthorized, + thresholds: apiData.thresholds, + }; + return formData; }; - return { defaults }; + + const formToApi = (formData: FormValues): SubmitValues => { + const submitData: SubmitValues = { + type: formData.type, + url: formData.url, + secret: formData.secret, + n: formData.n, + notificationChannels: formData.notificationChannels, + name: formData.name, + interval: humanInterval(formData.interval), + rejectUnauthorized: formData.rejectUnauthorized, + thresholds: formData.thresholds, + }; + return submitData; + }; + + const defaults: FormValues = initialData + ? apiToForm(initialData) + : { + type: "infrastructure", + url: "", + secret: "", + n: 3, + notificationChannels: [], + name: "", + interval: "1 minute", + rejectUnauthorized: true, + thresholds: { + cpu: 100, + memory: 100, + disk: 100, + temperature: 100, + }, + }; + return { defaults, apiToForm, formToApi }; }, [initialData]); }; diff --git a/client/src/hooks/forms/UseInitMonitorFrom.tsx b/client/src/hooks/forms/UseInitMonitorFrom.tsx index e7b12da65..8535ed659 100644 --- a/client/src/hooks/forms/UseInitMonitorFrom.tsx +++ b/client/src/hooks/forms/UseInitMonitorFrom.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import humanInterval from "human-interval"; import type { FormValues, SubmitValues } from "@/pages/uptime/UptimeForm"; import type { IMonitor } from "@/types/monitor"; - import ms from "ms"; export const useInitForm = ({ diff --git a/client/src/pages/infrastructure/InfraConfig.tsx b/client/src/pages/infrastructure/InfraConfig.tsx index 7dede1076..0d640e416 100644 --- a/client/src/pages/infrastructure/InfraConfig.tsx +++ b/client/src/pages/infrastructure/InfraConfig.tsx @@ -1,21 +1,15 @@ -import { monitorSchemaInfra } from "@/validation/zod"; +import { monitorSchemaInfra } from "@/validation"; import { useGet, usePatch } from "@/hooks/UseApi"; import { InfraForm } from "@/pages/infrastructure/InfraForm"; import { useParams } from "react-router"; import type { ApiResponse } from "@/types/api"; -import humanInterval from "human-interval"; -import { z } from "zod"; import { useNavigate } from "react-router"; import type { INotificationChannel } from "@/types/notification-channel"; import type { IMonitor } from "@/types/monitor"; +import type { SubmitValues } from "@/pages/infrastructure/InfraForm"; const InfraConfigurePage = () => { - type FormValues = z.infer; - type SubmitValues = Omit & { - interval: number | undefined; - }; - const { id } = useParams(); const navigate = useNavigate(); const { response } = useGet>( @@ -29,20 +23,12 @@ const InfraConfigurePage = () => { const { patch, loading, error } = usePatch, IMonitor>(); - const onSubmit = async (data: FormValues) => { - let interval = humanInterval(data.interval); - if (!interval) interval = 180000; - const submitData = { - type: data.type, - secret: data.secret, - name: data.name, - notificationChannels: data.notificationChannels, - thresholds: data.thresholds, - interval, - }; + if (!monitor) { + return null; + } - - const result = await patch(`/monitors/${id}`, submitData); + const onSubmit = async (data: SubmitValues) => { + const result = await patch(`/monitors/${id}`, data); if (result) { navigate(`/infrastructure/${id}`); } else { diff --git a/client/src/pages/infrastructure/InfraCreate.tsx b/client/src/pages/infrastructure/InfraCreate.tsx index 21b571183..042f065a3 100644 --- a/client/src/pages/infrastructure/InfraCreate.tsx +++ b/client/src/pages/infrastructure/InfraCreate.tsx @@ -1,27 +1,19 @@ import { InfraForm } from "@/pages/infrastructure/InfraForm"; -import humanInterval from "human-interval"; import { useNavigate } from "react-router"; -import { z } from "zod"; -import { monitorSchemaInfra } from "@/validation/zod"; import { useGet, usePost } from "@/hooks/UseApi"; import type { ApiResponse } from "@/types/api"; import type { INotificationChannel } from "@/types/notification-channel"; +import type { SubmitValues } from "@/pages/infrastructure/InfraForm"; + const InfraCreatePage = () => { - type FormValues = z.infer; - type SubmitValues = Omit & { - interval: number | undefined; - }; const { response } = useGet>( "/notification-channels" ); const { post, loading, error } = usePost(); const navigate = useNavigate(); - const onSubmit = async (data: FormValues) => { - let interval = humanInterval(data.interval); - if (!interval) interval = 60000; - const submitData = { ...data, interval }; - const result = await post("/monitors", submitData); + const onSubmit = async (data: SubmitValues) => { + const result = await post("/monitors", data); if (result) { navigate("/infrastructure"); } else { diff --git a/client/src/pages/infrastructure/InfraForm.tsx b/client/src/pages/infrastructure/InfraForm.tsx index aa50ae177..12d30c8b0 100644 --- a/client/src/pages/infrastructure/InfraForm.tsx +++ b/client/src/pages/infrastructure/InfraForm.tsx @@ -13,7 +13,7 @@ import { Typography } from "@mui/material"; import { useEffect, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { monitorSchemaInfra } from "@/validation/zod"; +import { monitorSchemaInfra } from "@/validation"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { @@ -24,8 +24,12 @@ import { } from "react-hook-form"; import { useTheme } from "@mui/material/styles"; import { useInitForm } from "@/hooks/forms/UseInitInfraForm"; +import type { IMonitor } from "@/types/monitor"; -type FormValues = z.infer; +export type FormValues = z.infer; +export type SubmitValues = Omit & { + interval: number | undefined; +}; export const InfraForm = ({ mode = "create", @@ -35,14 +39,14 @@ export const InfraForm = ({ loading, }: { mode?: "create" | "configure"; - initialData?: Partial; - onSubmit: SubmitHandler; + initialData?: IMonitor; + onSubmit: SubmitHandler; notificationOptions: any[]; loading: boolean; }) => { const { t } = useTranslation(); const theme = useTheme(); - const { defaults } = useInitForm({ initialData: initialData }); + const { defaults, formToApi } = useInitForm({ initialData: initialData }); const urlInputRef = useRef(null); const [selectedProtocol, setSelectedProtocol] = useState("https"); @@ -97,8 +101,12 @@ export const InfraForm = ({ name: "notificationChannels", }); + const submitForm = (formData: FormValues) => { + onSubmit(formToApi(formData)); + }; + return ( - + - {notificationChannels.map((notificationId) => { + {notificationChannels.map((notificationId: string) => { const option = notificationOptions.find( (o: any) => o._id === notificationId ); @@ -321,7 +329,7 @@ export const InfraForm = ({ strokeWidth={1.5} onClick={() => { const updated = notificationChannels.filter( - (id) => id !== notificationId + (id: string) => id !== notificationId ); setValue("notificationChannels", updated); }} diff --git a/client/src/pages/infrastructure/InfraMonitors.tsx b/client/src/pages/infrastructure/InfraMonitors.tsx index 80bfb1472..a82d38fb7 100644 --- a/client/src/pages/infrastructure/InfraMonitors.tsx +++ b/client/src/pages/infrastructure/InfraMonitors.tsx @@ -24,7 +24,7 @@ import { useLimitReached } from "@/hooks/UsePlanEntitlements"; const GLOBAL_REFRESH = config.GLOBAL_REFRESH; -const PageSpeedMonitorsPage = () => { +const InfraMonitorsPage = () => { const theme = useTheme(); const { t } = useTranslation(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -131,4 +131,4 @@ const PageSpeedMonitorsPage = () => { ); }; -export default PageSpeedMonitorsPage; +export default InfraMonitorsPage; diff --git a/client/src/pages/uptime/UptimeForm.tsx b/client/src/pages/uptime/UptimeForm.tsx index 0904b68c2..e4127ecc9 100644 --- a/client/src/pages/uptime/UptimeForm.tsx +++ b/client/src/pages/uptime/UptimeForm.tsx @@ -104,7 +104,6 @@ export const UptimeForm = ({ } else { setValue("url", `${selectedType}://${url}`); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedType, setValue]); useEffect(() => { diff --git a/client/src/types/monitor.ts b/client/src/types/monitor.ts index b59fa4819..489cef221 100644 --- a/client/src/types/monitor.ts +++ b/client/src/types/monitor.ts @@ -39,6 +39,13 @@ export interface IMonitor { updatedAt: string; updatedBy: string; url: string; + secret: string; + thresholds: { + cpu: number; + memory: number; + disk: number; + temperature: number; + }; __v: number; _id: string; } diff --git a/client/src/validation/index.ts b/client/src/validation/index.ts index 5db3f3e4c..3d3737a96 100644 --- a/client/src/validation/index.ts +++ b/client/src/validation/index.ts @@ -3,3 +3,5 @@ export * from "./login"; export * from "./register"; export * from "./monitor"; export * from "./pagespeed"; +export * from "./infra"; +export * from "./utils"; diff --git a/client/src/validation/infra.ts b/client/src/validation/infra.ts new file mode 100644 index 000000000..4d5bca693 --- /dev/null +++ b/client/src/validation/infra.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { urlRegex, durationSchema } from "@/validation/utils"; + +export const monitorSchemaInfra = z.object({ + type: z.string().min(1, "You must select an option"), + url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"), + secret: z.string().min(1, "Secret is required"), + 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(60000), + rejectUnauthorized: z.boolean().default(true), + thresholds: z.object({ + cpu: z.preprocess( + (val) => (typeof val === "number" ? String(val) : val), + z + .string() + .trim() + .min(1, { message: "CPU threshold is required" }) + .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { + message: "CPU must be a number", + }) + .transform((v) => Number(v)) + .refine((v) => v >= 0 && v <= 100, { + message: "CPU must be between 0 and 100", + }) + ), + memory: z.preprocess( + (val) => (typeof val === "number" ? String(val) : val), + z + .string() + .trim() + .min(1, { message: "Memory threshold is required" }) + .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { + message: "Memory must be a number", + }) + .transform((v) => Number(v)) + .refine((v) => v >= 0 && v <= 100, { + message: "Memory must be between 0 and 100", + }) + ), + disk: z.preprocess( + (val) => (typeof val === "number" ? String(val) : val), + z + .string() + .trim() + .min(1, { message: "Disk threshold is required" }) + .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { + message: "Disk must be a number", + }) + .transform((v) => Number(v)) + .refine((v) => v >= 0 && v <= 100, { + message: "Disk must be between 0 and 100", + }) + ), + temperature: z.preprocess( + (val) => (typeof val === "number" ? String(val) : val), + z + .string() + .trim() + .min(1, { message: "Temperature threshold is required" }) + .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { + message: "Temperature must be a number", + }) + .transform((v) => Number(v)) + .refine((v) => v >= -50 && v <= 150, { + message: "Temperature must be between -50 and 150", + }) + ), + }), +}); diff --git a/client/src/validation/monitor.ts b/client/src/validation/monitor.ts index 4235cfc92..21278dc7b 100644 --- a/client/src/validation/monitor.ts +++ b/client/src/validation/monitor.ts @@ -1,25 +1,5 @@ import { z } from "zod"; -import humanInterval from "human-interval"; -import ms from "ms"; -export const urlRegex = - /^(https?:\/\/)?(localhost|([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|[a-zA-Z0-9-]+|(\d{1,3}\.){3}\d{1,3})(:\d{1,5})?(\/.*)?$/; - -export const durationSchema = (minInterval: number) => - z.string().superRefine((val, ctx) => { - const milis = humanInterval(val); - - if (!milis || isNaN(milis)) { - ctx.addIssue({ - code: "custom", - message: "Invalid duration format", - }); - } else if (milis < minInterval) { - ctx.addIssue({ - code: "custom", - message: `Minimum duration is ${ms(minInterval, { long: true })}`, - }); - } - }); +import { urlRegex, durationSchema } from "@/validation/utils"; export const monitorSchema = z .object({ diff --git a/client/src/validation/pagespeed.ts b/client/src/validation/pagespeed.ts index bfb3e6df2..496b69c55 100644 --- a/client/src/validation/pagespeed.ts +++ b/client/src/validation/pagespeed.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { urlRegex, durationSchema } from "@/validation"; +import { urlRegex, durationSchema } from "@/validation/utils"; export const monitorSchemaPageSpeed = z.object({ type: z.string().min(1, "You must select an option"), diff --git a/client/src/validation/utils.ts b/client/src/validation/utils.ts new file mode 100644 index 000000000..c4dc597c2 --- /dev/null +++ b/client/src/validation/utils.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import humanInterval from "human-interval"; +import ms from "ms"; + +export const urlRegex = + /^(https?:\/\/)?(localhost|([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|[a-zA-Z0-9-]+|(\d{1,3}\.){3}\d{1,3})(:\d{1,5})?(\/.*)?$/; + +export const durationSchema = (minInterval: number) => + z.string().superRefine((val, ctx) => { + const milis = humanInterval(val); + + if (!milis || isNaN(milis)) { + ctx.addIssue({ + code: "custom", + message: "Invalid duration format", + }); + } else if (milis < minInterval) { + ctx.addIssue({ + code: "custom", + message: `Minimum duration is ${ms(minInterval, { long: true })}`, + }); + } + }); diff --git a/client/src/validation/zod.ts b/client/src/validation/zod.ts index 8f8976990..c7378636e 100644 --- a/client/src/validation/zod.ts +++ b/client/src/validation/zod.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import humanInterval from "human-interval"; +// humanInterval moved to split schema files import { ChannelTypes } from "@/types/notification-channel"; import { MaintenanceRepeats } from "@/types/maintenance"; @@ -8,25 +8,7 @@ const urlRegex = // monitor duration schema moved to client/src/validation/monitor.ts // pagespeed duration schema moved to client/src/validation/pagespeed.ts -const durationSchemaInfra = 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 < 60000) { - ctx.addIssue({ - code: "custom", - message: "Minimum duration is 1 minute", - }); - } - }); +// infra duration schema moved to client/src/validation/infra.ts const optionalString = (schema: T) => z.preprocess( @@ -38,78 +20,7 @@ const optionalString = (schema: T) => // moved to client/src/validation/monitor.ts // moved to client/src/validation/pagespeed.ts -export const monitorSchemaInfra = z.object({ - type: z.string().min(1, "You must select an option"), - url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"), - secret: z.string().min(1, "Secret is required"), - 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: durationSchemaInfra, - rejectUnauthorized: z.boolean().default(true), - thresholds: z.object({ - // Accept both strings and numbers from the form. Enforce required and ranges with clear messages. - cpu: z.preprocess( - (val) => (typeof val === "number" ? String(val) : val), - z - .string() - .trim() - .min(1, { message: "CPU threshold is required" }) - .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { - message: "CPU must be a number", - }) - .transform((v) => Number(v)) - .refine((v) => v >= 0 && v <= 100, { - message: "CPU must be between 0 and 100", - }) - ), - memory: z.preprocess( - (val) => (typeof val === "number" ? String(val) : val), - z - .string() - .trim() - .min(1, { message: "Memory threshold is required" }) - .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { - message: "Memory must be a number", - }) - .transform((v) => Number(v)) - .refine((v) => v >= 0 && v <= 100, { - message: "Memory must be between 0 and 100", - }) - ), - disk: z.preprocess( - (val) => (typeof val === "number" ? String(val) : val), - z - .string() - .trim() - .min(1, { message: "Disk threshold is required" }) - .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { - message: "Disk must be a number", - }) - .transform((v) => Number(v)) - .refine((v) => v >= 0 && v <= 100, { - message: "Disk must be between 0 and 100", - }) - ), - temperature: z.preprocess( - (val) => (typeof val === "number" ? String(val) : val), - z - .string() - .trim() - .min(1, { message: "Temperature threshold is required" }) - .refine((v) => /^-?\d+(\.\d+)?$/.test(v), { - message: "Temperature must be a number", - }) - .transform((v) => Number(v)) - .refine((v) => v >= -50 && v <= 150, { - message: "Temperature must be between -50 and 150", - }) - ), - }), -}); +// moved to client/src/validation/infra.ts export const teamSchema = z.object({ name: z.string().min(1, "Team name is required"),