diff --git a/client/src/hooks/forms/UseInitInfraForm.tsx b/client/src/hooks/forms/UseInitInfraForm.tsx index 033468447..fa95e920f 100644 --- a/client/src/hooks/forms/UseInitInfraForm.tsx +++ b/client/src/hooks/forms/UseInitInfraForm.tsx @@ -17,7 +17,8 @@ export const useInitForm = ({ } } - const defaults: z.infer = { + type InfraValues = z.infer; + const defaults: InfraValues = { type: "infrastructure", url: initialData?.url || "", secret: initialData?.secret || "", @@ -26,6 +27,12 @@ export const useInitForm = ({ name: initialData?.name || "", interval: humanInterval, rejectUnauthorized: true, + thresholds: initialData?.thresholds ?? { + cpu: 100, + memory: 100, + disk: 100, + temperature: 100, + }, }; return { defaults }; }, [initialData]); diff --git a/client/src/pages/billing/Billing.tsx b/client/src/pages/billing/Billing.tsx index db272e91d..2d543ca00 100644 --- a/client/src/pages/billing/Billing.tsx +++ b/client/src/pages/billing/Billing.tsx @@ -1,7 +1,6 @@ import { BasePage, InfoBox, PlanCard } from "@/components/design-elements"; import Grid from "@mui/material/Grid"; import Box from "@mui/material/Box"; -import { useTheme } from "@mui/material/styles"; import { Button } from "@/components/inputs"; import { useAppDispatch, useAppSelector } from "@/hooks/AppHooks"; @@ -27,7 +26,6 @@ const BillingPage = () => { const { post, loading: isPosting } = usePost(); const plans = response?.data ?? []; - const theme = useTheme(); const selectedPlan = user?.entitlements?.plan || null; const dispatch = useAppDispatch(); const selectedTeamId = useAppSelector((s) => s.auth.selectedTeamId); diff --git a/client/src/pages/infrastructure/InfraConfig.tsx b/client/src/pages/infrastructure/InfraConfig.tsx index 0e8fc8904..7eb873387 100644 --- a/client/src/pages/infrastructure/InfraConfig.tsx +++ b/client/src/pages/infrastructure/InfraConfig.tsx @@ -37,8 +37,12 @@ const InfraConfigurePage = () => { secret: data.secret, name: data.name, notificationChannels: data.notificationChannels, + thresholds: data.thresholds, interval, }; + + console.log(JSON.stringify(submitData, null, 2)); + const result = await patch(`/monitors/${id}`, submitData); if (result) { navigate(`/infrastructure/${id}`); diff --git a/client/src/pages/infrastructure/InfraCreate.tsx b/client/src/pages/infrastructure/InfraCreate.tsx index 90c13eabe..633f13752 100644 --- a/client/src/pages/infrastructure/InfraCreate.tsx +++ b/client/src/pages/infrastructure/InfraCreate.tsx @@ -1,22 +1,21 @@ 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 "@/hooks/UseApi"; -import { useNavigate } from "react-router"; import type { INotificationChannel } from "@/types/notification-channel"; const InfraCreatePage = () => { type FormValues = z.infer; type SubmitValues = Omit & { interval: number | undefined; }; - const navigate = useNavigate(); const { response } = useGet>( "/notification-channels" ); const { post, loading, error } = usePost(); + const navigate = useNavigate(); const onSubmit = async (data: FormValues) => { let interval = humanInterval(data.interval); diff --git a/client/src/pages/infrastructure/InfraForm.tsx b/client/src/pages/infrastructure/InfraForm.tsx index c888ec3f9..3216460b4 100644 --- a/client/src/pages/infrastructure/InfraForm.tsx +++ b/client/src/pages/infrastructure/InfraForm.tsx @@ -190,7 +190,9 @@ export const InfraForm = ({ } /> + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + } + /> >; + export default ReactComponent; +} + +declare module "*.svg" { + const src: string; + export default src; +} + diff --git a/client/src/validation/zod.ts b/client/src/validation/zod.ts index 9b1056fd9..07ab8bb93 100644 --- a/client/src/validation/zod.ts +++ b/client/src/validation/zod.ts @@ -158,6 +158,65 @@ export const monitorSchemaInfra = z.object({ 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", + }) + ), + }), }); export const teamSchema = z.object({ diff --git a/server/src/services/infrastructure/StatusService.ts b/server/src/services/infrastructure/StatusService.ts index bb023cf47..bb7f354b4 100644 --- a/server/src/services/infrastructure/StatusService.ts +++ b/server/src/services/infrastructure/StatusService.ts @@ -191,12 +191,17 @@ class StatusService implements IStatusService { const breaches: string[] = []; const metrics: ThresholdEvaluationResult["metrics"] = emptyMetrics(); - const cpu = check.system.cpu?.usage_percent; + // usage values in check.system are 0..1 decimals; thresholds are 0..100 + const cpuRaw = check.system.cpu?.usage_percent; + const cpuPct = typeof cpuRaw === "number" ? cpuRaw * 100 : undefined; + const cpuThreshold = typeof t.cpu === "number" ? t.cpu : undefined; metrics.cpu = { - value: typeof cpu === "number" ? cpu : undefined, - threshold: typeof t.cpu === "number" ? t.cpu : undefined, + value: cpuPct, + threshold: cpuThreshold, breached: - typeof t.cpu === "number" && typeof cpu === "number" && cpu > t.cpu, + cpuPct !== undefined && + cpuThreshold !== undefined && + cpuPct > cpuThreshold, }; if ( metrics.cpu.breached && @@ -206,14 +211,16 @@ class StatusService implements IStatusService { breaches.push(`cpu ${metrics.cpu.value}% > ${metrics.cpu.threshold}%`); } - const mem = check.system.memory?.usage_percent; + const memRaw = check.system.memory?.usage_percent; + const memPct = typeof memRaw === "number" ? memRaw * 100 : undefined; + const memThreshold = typeof t.memory === "number" ? t.memory : undefined; metrics.memory = { - value: typeof mem === "number" ? mem : undefined, - threshold: typeof t.memory === "number" ? t.memory : undefined, + value: memPct, + threshold: memThreshold, breached: - typeof t.memory === "number" && - typeof mem === "number" && - mem > t.memory, + memPct !== undefined && + memThreshold !== undefined && + memPct > memThreshold, }; if ( metrics.memory.breached && @@ -225,18 +232,32 @@ class StatusService implements IStatusService { ); } - const diskUsages: number[] = Array.isArray(check.system.disk) + const diskUsagesRaw: number[] = Array.isArray(check.system.disk) ? check.system.disk .map((d) => d?.usage_percent) .filter((n): n is number => typeof n === "number") : []; - const maxDisk = diskUsages.length ? Math.max(...diskUsages) : undefined; + const diskUsagesPct = diskUsagesRaw.map((n) => n * 100); + const maxDiskPct = diskUsagesPct.length + ? Math.max(...diskUsagesPct) + : undefined; + const diskThreshold = typeof t.disk === "number" ? t.disk : undefined; + metrics.disk = { + value: maxDiskPct, + threshold: diskThreshold, + breached: + maxDiskPct !== undefined && + diskThreshold !== undefined && + maxDiskPct > diskThreshold, + }; if ( - typeof t.disk === "number" && - typeof maxDisk === "number" && - maxDisk > t.disk + metrics.disk.breached && + metrics.disk.value !== undefined && + metrics.disk.threshold !== undefined ) { - breaches.push(`disk ${maxDisk}% > ${t.disk}%`); + breaches.push( + `disk ${metrics.disk.value}% > ${metrics.disk.threshold}%` + ); } const temps: number[] = Array.isArray(check.system.cpu?.temperature) @@ -244,6 +265,7 @@ class StatusService implements IStatusService { (n): n is number => typeof n === "number" ) : []; + const maxTemp = temps.length ? Math.max(...temps) : undefined; if ( typeof t.temperature === "number" && @@ -254,9 +276,9 @@ class StatusService implements IStatusService { } const hasBreach = breaches.length > 0; + return { hasBreach, notes: breaches, metrics }; } catch { - // best-effort; do not throw from evaluator return { hasBreach: false, notes: [], metrics: emptyMetrics() }; } }; diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index 1c736cf23..5686505bb 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -67,7 +67,7 @@ export const monitorSchema = z cpu: z.number().min(0).max(100).optional(), memory: z.number().min(0).max(100).optional(), disk: z.number().min(0).max(100).optional(), - temperature: z.number().min(0).max(150).optional(), + temperature: z.number().min(-50).max(150).optional(), }) .optional(), })