infra thresholds FE

This commit is contained in:
Alex Holliday committed 2025-12-07 03:34:33 +00:00
1 parent 7631e65cd2
commit 0453b790af
9 files changed
+191 -25

No files matched your search

+8 -1
View File
@@ -17,7 +17,8 @@ export const useInitForm = ({
}
}
const defaults: z.infer<typeof monitorSchemaInfra> = {
type InfraValues = z.infer<typeof monitorSchemaInfra>;
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]);
-2
View File
@@ -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);
@@ -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}`);
@@ -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<typeof monitorSchemaInfra>;
type SubmitValues = Omit<FormValues, "interval"> & {
interval: number | undefined;
};
const navigate = useNavigate();
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);
+67 -1
View File
@@ -190,7 +190,9 @@ export const InfraForm = ({
<TextInput
{...field}
type="number"
fieldLabel={t("createMonitorPage.incidentConfigStatusCheckNumber")}
fieldLabel={t(
"createMonitorPage.incidentConfigStatusCheckNumber"
)}
fullWidth
error={!!errors.n}
helperText={errors.n ? errors.n.message : ""}
@@ -203,6 +205,70 @@ export const InfraForm = ({
/>
}
/>
<ConfigBox
title={t("Threshold Alerts")}
subtitle={t("Set percentage thresholds to trigger incidents.")}
rightContent={
<Stack gap={theme.spacing(4)}>
<Controller
name="thresholds.cpu"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
fieldLabel={t("CPU %")}
fullWidth
error={!!errors.thresholds?.cpu}
helperText={errors.thresholds?.cpu?.message ?? ""}
/>
)}
/>
<Controller
name="thresholds.memory"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
fieldLabel={t("Memory %")}
fullWidth
error={!!errors.thresholds?.memory}
helperText={errors.thresholds?.memory?.message ?? ""}
/>
)}
/>
<Controller
name="thresholds.disk"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
fieldLabel={t("Disk %")}
fullWidth
error={!!errors.thresholds?.disk}
helperText={errors.thresholds?.disk?.message ?? ""}
/>
)}
/>
<Controller
name="thresholds.temperature"
control={control}
render={({ field }) => (
<TextInput
{...field}
type="number"
fieldLabel={t("Temperature °C")}
fullWidth
error={!!errors.thresholds?.temperature}
helperText={errors.thresholds?.temperature?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("notificationConfig.title")}
subtitle={t("notificationConfig.description")}
+11
View File
@@ -0,0 +1,11 @@
declare module "*.svg?react" {
import * as React from "react";
const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
export default ReactComponent;
}
declare module "*.svg" {
const src: string;
export default src;
}
+59
View File
@@ -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({
@@ -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() };
}
};
+1 -1
View File
@@ -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(),
})