Merge pull request #3644 from bluewave-labs/feat/fe/dns-monitors
feat: fe/dns monitors
This commit is contained in:
15 files changed
+401
-122
No files matched your search
@@ -111,6 +111,15 @@ export const useMonitorForm = ({
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
};
|
||||
break;
|
||||
case "dns":
|
||||
defaults = {
|
||||
...base,
|
||||
type: "dns",
|
||||
url: data?.url || "",
|
||||
dnsServer: data?.dnsServer || "",
|
||||
dnsRecordType: data?.dnsRecordType || "A",
|
||||
};
|
||||
break;
|
||||
default:
|
||||
defaults = {
|
||||
...base,
|
||||
|
||||
@@ -51,6 +51,8 @@ interface GeneralSettingsConfig {
|
||||
showSecret: boolean;
|
||||
showGrpcServiceName: boolean;
|
||||
showIgnoreTls: boolean;
|
||||
showDnsServer: boolean;
|
||||
showDnsRecordType: boolean;
|
||||
}
|
||||
|
||||
const getGeneralSettingsConfig = (
|
||||
@@ -68,6 +70,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
ping: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||
@@ -79,6 +83,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
docker: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||
@@ -90,6 +96,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
port: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -101,6 +109,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
game: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -112,6 +122,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
grpc: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||
@@ -123,6 +135,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: true,
|
||||
showIgnoreTls: true,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
pagespeed: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -134,6 +148,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
hardware: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
|
||||
@@ -145,6 +161,8 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: true,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
websocket: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
||||
@@ -156,6 +174,21 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: true,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
dns: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showDnsServer: true,
|
||||
showDnsRecordType: true,
|
||||
},
|
||||
};
|
||||
return configs[type] || configs.http;
|
||||
@@ -349,6 +382,13 @@ const CreateMonitorPage = () => {
|
||||
"pages.createMonitor.form.type.optionWebSocketDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="dns"
|
||||
label={t("pages.common.monitors.monitorTypes.optionDns")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionDnsDescription"
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -495,6 +535,74 @@ const CreateMonitorPage = () => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{generalSettingsConfig.showDnsServer && (
|
||||
<Controller
|
||||
name="dnsServer"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="text"
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.dnsServer.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.createMonitor.form.general.option.dnsServer.placeholder"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{generalSettingsConfig.showDnsRecordType && (
|
||||
<Controller
|
||||
name="dnsRecordType"
|
||||
control={control}
|
||||
defaultValue="A"
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value ?? "A"}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.label"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
>
|
||||
<MenuItem value={"A"}>
|
||||
{t("pages.createMonitor.form.general.option.dnsRecordType.value.A")}
|
||||
</MenuItem>
|
||||
<MenuItem value={"AAAA"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.AAAA"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"CNAME"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.CNAME"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"MX"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.MX"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"NS"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.NS"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"TXT"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.TXT"
|
||||
)}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="name"
|
||||
@@ -900,7 +1008,7 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color="text.secondary"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
<Trans
|
||||
|
||||
@@ -77,7 +77,7 @@ const UptimeMonitorsPage = () => {
|
||||
const effectiveTypes =
|
||||
selectedTypes.length > 0
|
||||
? selectedTypes
|
||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
|
||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
|
||||
|
||||
// Build URL for monitors with checks
|
||||
const monitorsWithChecksUrl = useMemo(() => {
|
||||
|
||||
@@ -15,10 +15,14 @@ export const MonitorTypes = [
|
||||
"game",
|
||||
"grpc",
|
||||
"websocket",
|
||||
"dns",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
||||
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
|
||||
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
|
||||
@@ -78,6 +82,8 @@ export interface Monitor {
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ export const getMonitorPath = (type: MonitorType): string => {
|
||||
game: "uptime",
|
||||
grpc: "uptime",
|
||||
websocket: "uptime",
|
||||
dns: "uptime",
|
||||
unknown: "uptime",
|
||||
docker: "uptime",
|
||||
hardware: "infrastructure",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { GeoContinents } from "@/Types/GeoCheck";
|
||||
import { DnsRecordTypes } from "@/Types/Monitor";
|
||||
|
||||
// URL schema with custom error message
|
||||
const urlSchema = z.url({ message: "Please enter a valid URL" });
|
||||
@@ -122,6 +123,29 @@ const websocketSchema = baseSchema.extend({
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
});
|
||||
|
||||
// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots,
|
||||
// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp).
|
||||
// No scheme, port, path, or whitespace. Total length ≤ 253.
|
||||
const hostnameRegex =
|
||||
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
|
||||
|
||||
// DNS monitor schema
|
||||
const dnsSchema = baseSchema.extend({
|
||||
type: z.literal("dns"),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "Domain is required")
|
||||
.regex(hostnameRegex, "Enter a valid domain (e.g. www.example.com)"),
|
||||
dnsServer: z
|
||||
.string()
|
||||
.min(1, "DNS server is required")
|
||||
.refine(
|
||||
(v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success,
|
||||
"Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"
|
||||
),
|
||||
dnsRecordType: z.enum(DnsRecordTypes),
|
||||
});
|
||||
|
||||
// Discriminated union of all monitor types
|
||||
export const monitorSchema = z.discriminatedUnion("type", [
|
||||
httpSchema,
|
||||
@@ -133,6 +157,7 @@ export const monitorSchema = z.discriminatedUnion("type", [
|
||||
pagespeedSchema,
|
||||
hardwareSchema,
|
||||
websocketSchema,
|
||||
dnsSchema,
|
||||
]);
|
||||
|
||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||
|
||||
@@ -475,7 +475,8 @@
|
||||
"optionPagespeed": "PageSpeed",
|
||||
"optionHardware": "Infrastructure",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
"optionWebSocket": "WebSocket",
|
||||
"optionDns": "DNS"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -565,6 +566,21 @@
|
||||
"captureUrl": {
|
||||
"label": "Capture endpoint",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
},
|
||||
"dnsServer": {
|
||||
"label": "DNS server",
|
||||
"placeholder": "8.8.8.8"
|
||||
},
|
||||
"dnsRecordType": {
|
||||
"label": "DNS record type",
|
||||
"value": {
|
||||
"A": "A",
|
||||
"AAAA": "AAAA",
|
||||
"CNAME": "CNAME",
|
||||
"MX": "MX",
|
||||
"NS": "NS",
|
||||
"TXT": "TXT"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "General settings",
|
||||
@@ -577,7 +593,8 @@
|
||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'."
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'.",
|
||||
"dns": "Monitor if a specific DNS server is responding."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -614,6 +631,7 @@
|
||||
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
|
||||
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
|
||||
"optionPortDescription": "Monitor if a specific port on a server is open.",
|
||||
"optionDnsDescription": "Monitor if a specific DNS server is responding.",
|
||||
"title": "Type"
|
||||
},
|
||||
"thresholds": {
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
GamesMap,
|
||||
GroupedGeoCheckResult,
|
||||
} from "@/types/monitor.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js";
|
||||
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js";
|
||||
import type { GeoContinent } from "@/types/geoCheck.js";
|
||||
import type {
|
||||
IChecksRepository,
|
||||
@@ -28,6 +29,9 @@ import { ILogger } from "@/utils/logger.js";
|
||||
const SERVICE_NAME = "MonitorService";
|
||||
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
|
||||
|
||||
const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
|
||||
supportsUptimeDetails(result.monitorType);
|
||||
|
||||
export interface IMonitorService {
|
||||
readonly serviceName: string;
|
||||
|
||||
@@ -216,15 +220,7 @@ export class MonitorService implements IMonitorService {
|
||||
});
|
||||
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
|
||||
|
||||
if (
|
||||
checksData.monitorType !== "http" &&
|
||||
checksData.monitorType !== "ping" &&
|
||||
checksData.monitorType !== "docker" &&
|
||||
checksData.monitorType !== "port" &&
|
||||
checksData.monitorType !== "game" &&
|
||||
checksData.monitorType !== "grpc" &&
|
||||
checksData.monitorType !== "websocket"
|
||||
) {
|
||||
if (!isUptimeChecksResult(checksData)) {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,19 @@ export class DNSProvider implements IStatusProvider<DNSStatusPayload> {
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DNSStatusPayload>> {
|
||||
try {
|
||||
const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor;
|
||||
const { url, dnsServer, dnsRecordType = "A" } = monitor;
|
||||
if (!dnsServer) {
|
||||
throw new Error("DNS server is required for DNS monitoring");
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
const withScheme = /^[a-z]+:\/\//i.test(url) ? url : `http://${url}`; // Ensure URL has a scheme for proper parsing
|
||||
hostname = new URL(withScheme).hostname;
|
||||
} catch {
|
||||
hostname = url;
|
||||
}
|
||||
|
||||
const resolver = this.createResolver();
|
||||
resolver.setServers([dnsServer]);
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ export type MonitorType = (typeof MonitorTypes)[number];
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
|
||||
|
||||
export const UptimeDetailsSupportedTypes = [
|
||||
"http",
|
||||
"ping",
|
||||
"docker",
|
||||
"port",
|
||||
"game",
|
||||
"grpc",
|
||||
"websocket",
|
||||
"dns",
|
||||
] as const satisfies readonly MonitorType[];
|
||||
export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
|
||||
export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
|
||||
|
||||
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
|
||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { GeoContinents } from "@/types/geoCheck.js";
|
||||
import { MonitorTypes } from "@/types/monitor.js";
|
||||
|
||||
//****************************************
|
||||
// Check Validations
|
||||
@@ -11,7 +12,7 @@ export const getChecksParamValidation = z.object({
|
||||
});
|
||||
|
||||
export const getChecksQueryValidation = z.object({
|
||||
type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(),
|
||||
type: z.enum(MonitorTypes).optional(),
|
||||
sortOrder: z.enum(["asc", "desc"]),
|
||||
limit: z.coerce.number().optional(),
|
||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js";
|
||||
import { GeoContinents } from "@/types/geoCheck.js";
|
||||
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
|
||||
|
||||
@@ -39,69 +39,83 @@ export const getCertificateParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
|
||||
export const createMonitorBodyValidation = z.object({
|
||||
_id: z.string().optional(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.union([z.string(), z.literal("")]).optional(),
|
||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||
url: z.string().min(1, "URL is required"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
interval: z.number().optional(),
|
||||
cpuAlertThreshold: z.number().optional(),
|
||||
memoryAlertThreshold: z.number().optional(),
|
||||
diskAlertThreshold: z.number().optional(),
|
||||
tempAlertThreshold: z.number().optional(),
|
||||
notifications: z.array(z.string()).optional(),
|
||||
secret: z.string().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||
selectedDisks: z.array(z.string()).optional(),
|
||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
});
|
||||
const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
|
||||
if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["url"],
|
||||
message: "Enter a valid domain (e.g. www.example.com)",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const editMonitorBodyValidation = z.object({
|
||||
name: z.string().optional(),
|
||||
type: z.enum(MonitorTypes).optional(),
|
||||
url: z.string().optional(),
|
||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||
description: z.union([z.string(), z.literal("")]).optional(),
|
||||
interval: z.number().optional(),
|
||||
notifications: z.array(z.string()).optional(),
|
||||
secret: z.string().optional(),
|
||||
ignoreTlsErrors: z.boolean().optional(),
|
||||
useAdvancedMatching: z.boolean().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
port: z.number().min(1).max(65535).optional(),
|
||||
cpuAlertThreshold: z.number().optional(),
|
||||
memoryAlertThreshold: z.number().optional(),
|
||||
diskAlertThreshold: z.number().optional(),
|
||||
tempAlertThreshold: z.number().optional(),
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
|
||||
selectedDisks: z.array(z.string()).optional(),
|
||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
});
|
||||
export const createMonitorBodyValidation = z
|
||||
.object({
|
||||
_id: z.string().optional(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.union([z.string(), z.literal("")]).optional(),
|
||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||
url: z.string().min(1, "URL is required"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
interval: z.number().optional(),
|
||||
cpuAlertThreshold: z.number().optional(),
|
||||
memoryAlertThreshold: z.number().optional(),
|
||||
diskAlertThreshold: z.number().optional(),
|
||||
tempAlertThreshold: z.number().optional(),
|
||||
notifications: z.array(z.string()).optional(),
|
||||
secret: z.string().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||
selectedDisks: z.array(z.string()).optional(),
|
||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
.superRefine(refineDnsHostname);
|
||||
|
||||
export const editMonitorBodyValidation = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
type: z.enum(MonitorTypes).optional(),
|
||||
url: z.string().optional(),
|
||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||
description: z.union([z.string(), z.literal("")]).optional(),
|
||||
interval: z.number().optional(),
|
||||
notifications: z.array(z.string()).optional(),
|
||||
secret: z.string().optional(),
|
||||
ignoreTlsErrors: z.boolean().optional(),
|
||||
useAdvancedMatching: z.boolean().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
port: z.number().min(1).max(65535).optional(),
|
||||
cpuAlertThreshold: z.number().optional(),
|
||||
memoryAlertThreshold: z.number().optional(),
|
||||
diskAlertThreshold: z.number().optional(),
|
||||
tempAlertThreshold: z.number().optional(),
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
|
||||
selectedDisks: z.array(z.string()).optional(),
|
||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
.superRefine(refineDnsHostname);
|
||||
|
||||
export const pauseMonitorParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
@@ -124,49 +138,51 @@ export const getUptimeDetailsByIdQueryValidation = z.object({
|
||||
normalize: booleanCoercion.optional(),
|
||||
});
|
||||
|
||||
const importedMonitorSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
teamId: z.string().optional(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.union([z.string(), z.literal("")]).optional(),
|
||||
status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
|
||||
statusWindow: z.array(z.boolean()).default([]),
|
||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
url: z.string().min(1, "URL is required"),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
interval: z.number().default(60000),
|
||||
uptimePercentage: z.number().optional(),
|
||||
notifications: z.array(z.string()).default([]),
|
||||
secret: z.string().optional(),
|
||||
cpuAlertThreshold: z.number().default(100),
|
||||
cpuAlertCounter: z.number().default(5),
|
||||
memoryAlertThreshold: z.number().default(100),
|
||||
memoryAlertCounter: z.number().default(5),
|
||||
diskAlertThreshold: z.number().default(100),
|
||||
diskAlertCounter: z.number().default(5),
|
||||
tempAlertThreshold: z.number().default(100),
|
||||
tempAlertCounter: z.number().default(5),
|
||||
selectedDisks: z.array(z.string()).default([]),
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
|
||||
geoCheckEnabled: z.boolean().default(false),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||
geoCheckInterval: z.number().min(300000).default(300000),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
createdAt: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
const importedMonitorSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
teamId: z.string().optional(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.union([z.string(), z.literal("")]).optional(),
|
||||
status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
|
||||
statusWindow: z.array(z.boolean()).default([]),
|
||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
url: z.string().min(1, "URL is required"),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
interval: z.number().default(60000),
|
||||
uptimePercentage: z.number().optional(),
|
||||
notifications: z.array(z.string()).default([]),
|
||||
secret: z.string().optional(),
|
||||
cpuAlertThreshold: z.number().default(100),
|
||||
cpuAlertCounter: z.number().default(5),
|
||||
memoryAlertThreshold: z.number().default(100),
|
||||
memoryAlertCounter: z.number().default(5),
|
||||
diskAlertThreshold: z.number().default(100),
|
||||
diskAlertCounter: z.number().default(5),
|
||||
tempAlertThreshold: z.number().default(100),
|
||||
tempAlertCounter: z.number().default(5),
|
||||
selectedDisks: z.array(z.string()).default([]),
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
|
||||
geoCheckEnabled: z.boolean().default(false),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||
geoCheckInterval: z.number().min(300000).default(300000),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
createdAt: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
.superRefine(refineDnsHostname);
|
||||
|
||||
export const importMonitorsBodyValidation = z.object({
|
||||
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
|
||||
|
||||
@@ -15,6 +15,17 @@ export const nameValidation = z
|
||||
|
||||
export const lowercaseEmailValidation = z.email().transform((val) => val.toLowerCase());
|
||||
|
||||
export const dnsServerValidation = z
|
||||
.string()
|
||||
.min(1, "DNS server is required")
|
||||
.refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)");
|
||||
|
||||
// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots,
|
||||
// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp).
|
||||
// No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with
|
||||
// the client-side regex in client/src/Validation/monitor.ts.
|
||||
export const dnsHostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
|
||||
|
||||
export const booleanCoercion = z.preprocess((val) => {
|
||||
if (val === "true" || val === true) return true;
|
||||
if (val === "false" || val === false) return false;
|
||||
|
||||
@@ -187,6 +187,28 @@ describe("DNSProvider", () => {
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check");
|
||||
});
|
||||
|
||||
it("strips scheme from url before querying the resolver", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
const result = await provider.handle(makeMonitor({ url: "https://www.google.ca" }));
|
||||
|
||||
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("www.google.ca");
|
||||
expect(result.payload?.hostname).toBe("www.google.ca");
|
||||
});
|
||||
|
||||
it("strips port and path from url before querying the resolver", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ url: "example.com:53/some/path" }));
|
||||
|
||||
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("passes a bare hostname through unchanged", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ url: "example.com" }));
|
||||
|
||||
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("stringifies non-Error throws from outside timeRequest", async () => {
|
||||
mockResolverInstance.setServers.mockImplementationOnce(() => {
|
||||
throw 42;
|
||||
|
||||
@@ -51,6 +51,51 @@ describe("monitorValidation — DNS fields", () => {
|
||||
expect(parsed.dnsServer).toBeUndefined();
|
||||
expect(parsed.dnsRecordType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a DNS monitor whose url has a scheme or path", () => {
|
||||
for (const badUrl of ["https://example.com", "example.com/path", "example.com:53", " example.com"]) {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
url: badUrl,
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "A",
|
||||
})
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a DNS monitor with an invalid dnsServer", () => {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
dnsServer: "not-an-ip",
|
||||
dnsRecordType: "A",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts service labels with leading underscore (DMARC, SRV, ACME)", () => {
|
||||
for (const url of ["_dmarc.example.com", "_imaps._tcp.example.com", "_acme-challenge.example.com"]) {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
url,
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "TXT",
|
||||
});
|
||||
expect(parsed.url).toBe(url);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not enforce the hostname rule for non-DNS monitors", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
name: "HTTP check",
|
||||
type: "http",
|
||||
url: "https://example.com/some/path",
|
||||
});
|
||||
|
||||
expect(parsed.url).toBe("https://example.com/some/path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMonitorBodyValidation", () => {
|
||||
|
||||
Reference in new issue
Block a user