infra refactor

This commit is contained in:
Alex Holliday committed 2025-12-28 18:35:14 +00:00
1 parent df707ab194
commit 4dfeb71a9d
14 files changed
+195 -187

No files matched your search

+55 -28
View File
@@ -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<z.infer<typeof monitorSchemaInfra>> | 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<typeof monitorSchemaInfra>;
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]);
};
@@ -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 = ({
@@ -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<typeof monitorSchemaInfra>;
type SubmitValues = Omit<FormValues, "interval"> & {
interval: number | undefined;
};
const { id } = useParams();
const navigate = useNavigate();
const { response } = useGet<ApiResponse<INotificationChannel[]>>(
@@ -29,20 +23,12 @@ const InfraConfigurePage = () => {
const { patch, loading, error } = usePatch<Partial<SubmitValues>, 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 {
@@ -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<typeof monitorSchemaInfra>;
type SubmitValues = Omit<FormValues, "interval"> & {
interval: number | undefined;
};
const { response } = useGet<ApiResponse<INotificationChannel[]>>(
"/notification-channels"
);
const { post, loading, error } = usePost<SubmitValues, any>();
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 {
+16 -8
View File
@@ -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<typeof monitorSchemaInfra>;
export type FormValues = z.infer<typeof monitorSchemaInfra>;
export type SubmitValues = Omit<FormValues, "interval"> & {
interval: number | undefined;
};
export const InfraForm = ({
mode = "create",
@@ -35,14 +39,14 @@ export const InfraForm = ({
loading,
}: {
mode?: "create" | "configure";
initialData?: Partial<FormValues>;
onSubmit: SubmitHandler<FormValues>;
initialData?: IMonitor;
onSubmit: SubmitHandler<SubmitValues>;
notificationOptions: any[];
loading: boolean;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const { defaults } = useInitForm({ initialData: initialData });
const { defaults, formToApi } = useInitForm({ initialData: initialData });
const urlInputRef = useRef<HTMLInputElement>(null);
const [selectedProtocol, setSelectedProtocol] = useState<string>("https");
@@ -97,8 +101,12 @@ export const InfraForm = ({
name: "notificationChannels",
});
const submitForm = (formData: FormValues) => {
onSubmit(formToApi(formData));
};
return (
<BasePage component={"form"} onSubmit={handleSubmit(onSubmit)}>
<BasePage component={"form"} onSubmit={handleSubmit(submitForm)}>
<ConfigBox
title={t("monitors.common.form.general.title")}
subtitle={t(`monitors.infrastructure.form.general.description`)}
@@ -303,7 +311,7 @@ export const InfraForm = ({
)}
/>
<Stack gap={theme.spacing(2)} mt={theme.spacing(2)}>
{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);
}}
@@ -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;
-1
View File
@@ -104,7 +104,6 @@ export const UptimeForm = ({
} else {
setValue("url", `${selectedType}://${url}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedType, setValue]);
useEffect(() => {
+7
View File
@@ -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;
}
+2
View File
@@ -3,3 +3,5 @@ export * from "./login";
export * from "./register";
export * from "./monitor";
export * from "./pagespeed";
export * from "./infra";
export * from "./utils";
+74
View File
@@ -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",
})
),
}),
});
+1 -21
View File
@@ -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({
+1 -1
View File
@@ -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"),
+23
View File
@@ -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 })}`,
});
}
});
+3 -92
View File
@@ -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 = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess(
@@ -38,78 +20,7 @@ const optionalString = <T extends z.ZodTypeAny>(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"),