initial commit

This commit is contained in:
Alex Holliday
2026-01-29 22:17:56 +00:00
parent 5eb1d54d65
commit 4b92dbd19b
7 changed files with 625 additions and 0 deletions
+88
View File
@@ -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 (
<Radio
{...props}
icon={
<Circle
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<CircleDot
size={14}
strokeWidth={1.5}
/>
}
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 (
<FormControlLabel
control={<RadioInput {...props} />}
label={
<>
<Typography component="p">{label}</Typography>
<Typography
component="h6"
color={theme.palette.text.secondary}
>
{description}
</Typography>
</>
}
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),
},
}}
/>
);
};
@@ -9,3 +9,4 @@ export {
ToggleButtonGroupInput as ToggleButtonGroup,
} from "./ToggleButton";
export { DialogInput as Dialog } from "./Dialog";
export * from "./Radio";
+105
View File
@@ -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]);
};
+266
View File
@@ -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<string, GeneralSettingsConfig> = {
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<Monitor>(
isEditMode ? `/monitors/${monitorId}` : null
);
const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null });
const form = useForm<MonitorFormData>({
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 (
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
{/* Monitor Type Selection */}
<ConfigBox
title={t("pages.createMonitor.form.type.title")}
subtitle={t("pages.createMonitor.form.type.description")}
rightContent={
<Controller
name="type"
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
<RadioGroup
{...field}
sx={{ gap: theme.spacing(6) }}
>
<RadioWithDescription
value="http"
label={t("pages.createMonitor.form.type.optionHttp")}
description={t("pages.createMonitor.form.type.optionHttpDescription")}
/>
<RadioWithDescription
value="ping"
label={t("pages.createMonitor.form.type.optionPing")}
description={t("pages.createMonitor.form.type.optionPingDescription")}
/>
<RadioWithDescription
value="docker"
label={t("pages.createMonitor.form.type.optionDocker")}
description={t(
"pages.createMonitor.form.type.optionDockerDescription"
)}
/>
<RadioWithDescription
value="port"
label={t("pages.createMonitor.form.type.optionPort")}
description={t("pages.createMonitor.form.type.optionPortDescription")}
/>
<RadioWithDescription
value="game"
label={t("pages.createMonitor.form.type.optionGame")}
description={t("pages.createMonitor.form.type.optionGameDescription")}
/>
</RadioGroup>
</FormControl>
)}
/>
}
/>
{/* General Settings - Dynamic based on type */}
<ConfigBox
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
rightContent={
<Stack spacing={theme.spacing(8)}>
{/* URL/Host/Container field */}
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={generalSettingsConfig.urlLabel}
placeholder={generalSettingsConfig.urlPlaceholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
{/* Port field - only for port and game types */}
{generalSettingsConfig.showPort && (
<Controller
name="port"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
onChange={(e) => 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 && (
<Controller
name="gameId"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? ""}
fieldLabel={t("chooseGame")}
error={!!fieldState.error}
>
<MenuItem value="">Select a game</MenuItem>
</Select>
)}
/>
)}
{/* Display name field - common to all types */}
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("displayName")}
placeholder={generalSettingsConfig.namePlaceholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
{/* Submit Button */}
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
loading={false}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
);
};
export default CreateMonitorPage;
+12
View File
@@ -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={<Navigate to="/uptime" />}
/>
<Route
path="/testing"
element={
<>
<ThemeProvider theme={v2theme}>
<CreateMonitor />
</ThemeProvider>
</>
}
/>
<Route
path="/uptime"
element={
+122
View File
@@ -0,0 +1,122 @@
import { z } from "zod";
// Common base schema for all monitor types
const baseSchema = z.object({
name: z
.string()
.min(1, "Monitor name is required")
.max(50, "Monitor name must be at most 50 characters"),
interval: z.number().min(15000, "Interval must be at least 15 seconds"),
notifications: z.array(z.string()),
statusWindowSize: z
.number()
.min(1, "Status window size must be at least 1")
.max(20, "Status window size must be at most 20"),
statusWindowThreshold: z
.number()
.min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100"),
});
// HTTP monitor schema
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: z
.string()
.min(1, "URL is required")
.url("Please enter a valid URL"),
ignoreTlsErrors: z.boolean(),
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
expectedValue: z.string().optional(),
jsonPath: z.string().optional(),
});
// Ping monitor schema
const pingSchema = baseSchema.extend({
type: z.literal("ping"),
url: z.string().min(1, "Host is required"),
});
// Port monitor schema
const portSchema = baseSchema.extend({
type: z.literal("port"),
url: z.string().min(1, "Host is required"),
port: z
.number()
.min(1, "Port must be at least 1")
.max(65535, "Port must be at most 65535"),
});
// Docker monitor schema
const dockerSchema = baseSchema.extend({
type: z.literal("docker"),
url: z.string().min(1, "Container ID is required"),
});
// Game server monitor schema
const gameSchema = baseSchema.extend({
type: z.literal("game"),
url: z.string().min(1, "Host is required"),
port: z
.number()
.min(1, "Port must be at least 1")
.max(65535, "Port must be at most 65535"),
gameId: z.string().min(1, "Game type is required"),
});
// PageSpeed monitor schema
const pagespeedSchema = baseSchema.extend({
type: z.literal("pagespeed"),
url: z
.string()
.min(1, "URL is required")
.url("Please enter a valid URL"),
});
// Hardware/Infrastructure monitor schema
const hardwareSchema = baseSchema.extend({
type: z.literal("hardware"),
url: z.string().optional(),
secret: z.string().optional(),
cpuAlertThreshold: z
.number()
.min(0, "CPU threshold must be at least 0")
.max(100, "CPU threshold must be at most 100"),
memoryAlertThreshold: z
.number()
.min(0, "Memory threshold must be at least 0")
.max(100, "Memory threshold must be at most 100"),
diskAlertThreshold: z
.number()
.min(0, "Disk threshold must be at least 0")
.max(100, "Disk threshold must be at most 100"),
tempAlertThreshold: z
.number()
.min(0, "Temperature threshold must be at least 0")
.max(150, "Temperature threshold must be at most 150"),
selectedDisks: z.array(z.string()),
});
// Discriminated union of all monitor types
export const monitorSchema = z.discriminatedUnion("type", [
httpSchema,
pingSchema,
portSchema,
dockerSchema,
gameSchema,
pagespeedSchema,
hardwareSchema,
]);
export type MonitorFormData = z.infer<typeof monitorSchema>;
// Type-specific schemas exported for individual use
export {
httpSchema,
pingSchema,
portSchema,
dockerSchema,
gameSchema,
pagespeedSchema,
hardwareSchema,
};
+31
View File
@@ -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": {