mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-05-21 00:48:45 -05:00
initial commit
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user