Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
182ff390e9 | ||
|
|
fc711e8e24 | ||
|
|
0f662e1a65 | ||
|
|
4b4fae91c2 | ||
|
|
172d36ba1d | ||
|
|
0c36c8e625 | ||
|
|
2a7bde9574 | ||
|
|
cb9cc101c0 | ||
|
|
06ef850577 | ||
|
|
0797008ac9 | ||
|
|
fda9d9edfe | ||
|
|
c6d86d78b8 | ||
|
|
0d72071019 | ||
|
|
84533119c9 | ||
|
|
60cded78a6 | ||
|
|
c45bcfaa2f | ||
|
|
747f31477d | ||
|
|
713c3ea34f | ||
|
|
1d90b8d8d9 | ||
|
|
0827d4f528 | ||
|
|
7c14d81a24 | ||
|
|
7906f2f615 | ||
|
|
b462ba7562 | ||
|
|
bd059da94a | ||
|
|
4db95cfa44 | ||
|
|
602769b3e7 | ||
|
|
a69f8a5ab5 |
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatStatusCode,
|
||||
getStatusCodeTooltip,
|
||||
getStatusCodeValueType,
|
||||
} from "@/Utils/statusCode";
|
||||
import { ValueLabel } from "./StatusLabel";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export type StatusCodeLabelProps = {
|
||||
statusCode: number | null | undefined;
|
||||
message?: string | null;
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
export const StatusCodeLabel = ({
|
||||
statusCode,
|
||||
message,
|
||||
fallback = "N/A",
|
||||
}: StatusCodeLabelProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!statusCode) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
const text = formatStatusCode(statusCode, t);
|
||||
const tooltip = getStatusCodeTooltip(statusCode, message, t);
|
||||
const body = (
|
||||
<ValueLabel
|
||||
value={getStatusCodeValueType(statusCode)}
|
||||
text={text}
|
||||
/>
|
||||
);
|
||||
if (!tooltip) return body;
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>{body}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -25,3 +25,4 @@ export * from "./TextLink";
|
||||
export * from "./NoticeBanner";
|
||||
export * from "./OfflineBanner";
|
||||
export * from "./Avatar";
|
||||
export * from "./StatusCodeLabel";
|
||||
@@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
|
||||
import Map, { Marker, Popup } from "react-map-gl/maplibre";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatStatusCode } from "@/Utils/statusCode";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
interface GeoChecksMapProps {
|
||||
@@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
|
||||
const theme = useTheme();
|
||||
const isDarkMode = theme.palette.mode === "dark";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mapPopupStyles = (
|
||||
<GlobalStyles
|
||||
@@ -174,7 +177,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
>
|
||||
Status Code:
|
||||
</Typography>{" "}
|
||||
{selectedCheck.statusCode}
|
||||
{formatStatusCode(selectedCheck.statusCode, t)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Typography
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { Check } from "@/Types/Check";
|
||||
@@ -29,7 +33,7 @@ export const ChecksTable = ({
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const getHeaders = () => {
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
id: "monitorName",
|
||||
@@ -63,13 +67,10 @@ export const ChecksTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
|
||||
return (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -85,7 +86,7 @@ export const ChecksTable = ({
|
||||
return headers;
|
||||
};
|
||||
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
const headers = getHeaders();
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseBox, ValueLabel } from "@/Components/design-elements";
|
||||
import { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -46,6 +46,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
if (!incident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
@@ -123,7 +124,12 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Cell>{incident.statusCode ?? "N/A"}</Cell>
|
||||
<Cell>
|
||||
<StatusCodeLabel
|
||||
statusCode={incident.statusCode}
|
||||
message={incident.message}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
|
||||
import { Pagination } from "@/Components/design-elements/Table";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { ValueLabel } from "@/Components/design-elements";
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
import { ActionsMenu } from "@/Components/actions-menu";
|
||||
import type { Incident } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
color:
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main,
|
||||
}}
|
||||
color={
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
textTransform={"capitalize"}
|
||||
>
|
||||
{row.resolutionType}
|
||||
</Typography>
|
||||
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.incidents.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
let value: ValueType = "neutral";
|
||||
if (code < 300) value = "positive";
|
||||
else if (code >= 400) value = "negative";
|
||||
return (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => {
|
||||
repeat,
|
||||
};
|
||||
|
||||
if (repeat === 0) {
|
||||
payload.expiry = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { Check } from "@/Types/Check";
|
||||
@@ -9,7 +14,25 @@ import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
export const ChecksTable = ({
|
||||
checks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
checks: Check[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
id: "status",
|
||||
@@ -36,32 +59,15 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
return row.statusCode || "N/A";
|
||||
return (
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const ChecksTable = ({
|
||||
checks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
checks: Check[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
@@ -8,7 +13,24 @@ import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
export const GeoChecksTable = ({
|
||||
geoChecks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
geoChecks: FlatGeoCheck[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const headers: Header<FlatGeoCheck>[] = [
|
||||
{
|
||||
id: "status",
|
||||
@@ -29,7 +51,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
return row.statusCode || "N/A";
|
||||
return <StatusCodeLabel statusCode={row.statusCode} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -51,27 +73,6 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const GeoChecksTable = ({
|
||||
geoChecks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
geoChecks: FlatGeoCheck[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
|
||||
export const NETWORK_ERROR_CODE = 5000;
|
||||
|
||||
export const NETWORK_ERROR_SUBTYPES = [
|
||||
"timeout",
|
||||
"dns",
|
||||
"refused",
|
||||
"tls",
|
||||
"reset",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number];
|
||||
|
||||
type TranslateFn = (key: string) => string;
|
||||
|
||||
export const isNetworkError = (code?: number | null): boolean =>
|
||||
code === NETWORK_ERROR_CODE;
|
||||
|
||||
const ERROR_CODE_PATTERN =
|
||||
/\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/;
|
||||
|
||||
export const extractErrorCode = (message?: string | null): string | null => {
|
||||
if (!message) return null;
|
||||
const match = message.match(ERROR_CODE_PATTERN);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => {
|
||||
if (!message) return "unknown";
|
||||
const m = message.toUpperCase();
|
||||
if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT"))
|
||||
return "timeout";
|
||||
if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS"))
|
||||
return "dns";
|
||||
if (m.includes("ECONNREFUSED")) return "refused";
|
||||
if (
|
||||
m.includes("CERT_") ||
|
||||
m.includes("UNABLE_TO_VERIFY") ||
|
||||
m.includes("DEPTH_ZERO_SELF_SIGNED") ||
|
||||
m.includes("SELF_SIGNED") ||
|
||||
m.includes("TLS") ||
|
||||
m.includes("SSL")
|
||||
)
|
||||
return "tls";
|
||||
if (m.includes("ECONNRESET")) return "reset";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export const formatStatusCode = (
|
||||
code: number | null | undefined,
|
||||
t: TranslateFn
|
||||
): string => {
|
||||
if (isNetworkError(code)) return t("common.statusCode.down");
|
||||
return String(code);
|
||||
};
|
||||
|
||||
export const getStatusCodeTooltip = (
|
||||
code: number | null | undefined,
|
||||
message: string | null | undefined,
|
||||
t: TranslateFn
|
||||
): string | null => {
|
||||
if (!isNetworkError(code)) return null;
|
||||
const description = t(
|
||||
`common.statusCode.networkError.${getNetworkErrorSubtype(message)}`
|
||||
);
|
||||
const errorCode = extractErrorCode(message);
|
||||
return errorCode ? `${description} (${errorCode})` : description;
|
||||
};
|
||||
|
||||
export const getStatusCodeValueType = (code: number): ValueType => {
|
||||
if (isNetworkError(code)) return "negative";
|
||||
if (code < 300) return "positive";
|
||||
if (code < 400) return "neutral";
|
||||
return "negative";
|
||||
};
|
||||
@@ -14,6 +14,17 @@
|
||||
}
|
||||
},
|
||||
"appName": "Checkmate",
|
||||
"statusCode": {
|
||||
"down": "Down",
|
||||
"networkError": {
|
||||
"timeout": "The server didn't respond in time (request timed out).",
|
||||
"dns": "Couldn't resolve the hostname (DNS lookup failed).",
|
||||
"refused": "The server refused the connection (port closed or service not listening).",
|
||||
"tls": "TLS or SSL handshake failed (certificate or encryption issue).",
|
||||
"reset": "The connection was reset before a response was received.",
|
||||
"unknown": "No HTTP response was received from the target."
|
||||
}
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"details": "Details",
|
||||
"home": "Home"
|
||||
|
||||
@@ -13,6 +13,7 @@ const config: Config = {
|
||||
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
||||
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
},
|
||||
testMatch: ["<rootDir>/test/**/*.test.ts"],
|
||||
setupFilesAfterEnv: [],
|
||||
|
||||
@@ -64,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
|
||||
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
|
||||
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
|
||||
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
|
||||
import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js";
|
||||
|
||||
// Third-party
|
||||
import { Resolver } from "dns/promises";
|
||||
import axios from "axios";
|
||||
import got from "got";
|
||||
import ping from "ping";
|
||||
@@ -226,6 +228,7 @@ export const initializeServices = async ({
|
||||
const gameProvider = new GameProvider(logger, GameDig);
|
||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||
const dnsProvider = new DNSProvider(() => new Resolver());
|
||||
|
||||
const networkService = new NetworkService(axios, logger, [
|
||||
pingProvider,
|
||||
@@ -237,6 +240,7 @@ export const initializeServices = async ({
|
||||
gameProvider,
|
||||
grpcProvider,
|
||||
webSocketProvider,
|
||||
dnsProvider,
|
||||
]);
|
||||
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema, model, type Types } from "mongoose";
|
||||
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
|
||||
import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js";
|
||||
|
||||
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
|
||||
monitorId: Types.ObjectId;
|
||||
@@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
|
||||
},
|
||||
durationUnit: {
|
||||
type: String,
|
||||
enum: ["seconds", "minutes", "hours", "days"],
|
||||
enum: DurationUnits,
|
||||
},
|
||||
repeat: {
|
||||
type: Number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema, model, Types } from "mongoose";
|
||||
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
|
||||
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
|
||||
import { DnsRecordTypes, MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
|
||||
import type {
|
||||
CheckAudits,
|
||||
CheckCaptureInfo,
|
||||
@@ -351,6 +351,13 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
type: Number,
|
||||
default: 300000,
|
||||
},
|
||||
dnsServer: {
|
||||
type: String,
|
||||
},
|
||||
dnsRecordType: {
|
||||
type: String,
|
||||
enum: DnsRecordTypes,
|
||||
},
|
||||
recentChecks: {
|
||||
type: [checkSnapshotSchema],
|
||||
default: [],
|
||||
|
||||
@@ -446,6 +446,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||
dnsServer: doc.dnsServer ?? undefined,
|
||||
dnsRecordType: doc.dnsRecordType ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
@@ -505,6 +507,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||
dnsServer: doc.dnsServer ?? undefined,
|
||||
dnsRecordType: doc.dnsRecordType ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
||||
import type { Resolver } from "dns/promises";
|
||||
import { Monitor, MonitorType } from "@/types/monitor.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
|
||||
const SERVICE_NAME = "DNSProvider";
|
||||
|
||||
export class DNSProvider implements IStatusProvider<DNSStatusPayload> {
|
||||
readonly type = "dns";
|
||||
constructor(private createResolver: () => Resolver) {}
|
||||
supports(type: MonitorType) {
|
||||
return type === "dns";
|
||||
}
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DNSStatusPayload>> {
|
||||
try {
|
||||
const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor;
|
||||
if (!dnsServer) {
|
||||
throw new Error("DNS server is required for DNS monitoring");
|
||||
}
|
||||
|
||||
const resolver = this.createResolver();
|
||||
resolver.setServers([dnsServer]);
|
||||
|
||||
const {
|
||||
response: results,
|
||||
responseTime,
|
||||
error,
|
||||
} = await timeRequest(() => {
|
||||
switch (dnsRecordType.toUpperCase()) {
|
||||
case "A":
|
||||
return resolver.resolve4(hostname);
|
||||
case "AAAA":
|
||||
return resolver.resolve6(hostname);
|
||||
case "CNAME":
|
||||
return resolver.resolveCname(hostname);
|
||||
case "MX":
|
||||
return resolver.resolveMx(hostname);
|
||||
case "TXT":
|
||||
return resolver.resolveTxt(hostname);
|
||||
case "NS":
|
||||
return resolver.resolveNs(hostname);
|
||||
default:
|
||||
return resolver.resolve(hostname, dnsRecordType);
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
responseTime,
|
||||
payload: {
|
||||
hostname,
|
||||
dnsServer,
|
||||
recordType: dnsRecordType,
|
||||
resolved: false,
|
||||
results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime,
|
||||
payload: {
|
||||
hostname,
|
||||
dnsServer,
|
||||
recordType: dnsRecordType,
|
||||
resolved: true,
|
||||
results,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const originalMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new AppError({
|
||||
message: originalMessage || "Error performing DNS check",
|
||||
status: 500,
|
||||
service: SERVICE_NAME,
|
||||
method: "handle",
|
||||
details: { url: monitor.url },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
|
||||
export const DurationUnits = ["seconds", "minutes", "hours", "days"] as const;
|
||||
export type DurationUnit = (typeof DurationUnits)[number];
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ export type { CheckSnapshot } from "@/types/check.js";
|
||||
import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
|
||||
export type { GeoContinent } from "@/types/geoCheck.js";
|
||||
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
@@ -14,6 +14,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
||||
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
|
||||
|
||||
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
||||
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
||||
|
||||
export const MAX_RECENT_CHECKS = 50;
|
||||
|
||||
export interface Monitor {
|
||||
@@ -54,6 +58,8 @@ export interface Monitor {
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CheckNetworkInterfaceInfo,
|
||||
GotTimings,
|
||||
ILighthouseAudit,
|
||||
DnsRecordType,
|
||||
Monitor,
|
||||
MonitorMatchMethod,
|
||||
MonitorType,
|
||||
@@ -116,6 +117,14 @@ export interface WebSocketStatusPayload {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface DNSStatusPayload {
|
||||
hostname: string;
|
||||
dnsServer: string;
|
||||
recordType: DnsRecordType;
|
||||
resolved: boolean;
|
||||
results: unknown;
|
||||
}
|
||||
|
||||
export interface MonitorPayloadMap {
|
||||
ping: PingStatusPayload;
|
||||
http: HttpStatusPayload;
|
||||
@@ -126,6 +135,7 @@ export interface MonitorPayloadMap {
|
||||
game: GameStatusPayload;
|
||||
grpc: GrpcStatusPayload;
|
||||
websocket: WebSocketStatusPayload;
|
||||
dns: DNSStatusPayload;
|
||||
unknown: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { DurationUnits } from "@/types/maintenanceWindow.js";
|
||||
|
||||
const dateToString = z.coerce.date().transform((d) => d.toISOString());
|
||||
|
||||
@@ -7,17 +8,37 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString());
|
||||
// Maintenance Window Validations
|
||||
//****************************************
|
||||
|
||||
export const createMaintenanceWindowBodyValidation = z.object({
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
active: z.boolean().optional(),
|
||||
duration: z.number().min(1, "Duration is required"),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
|
||||
start: dateToString,
|
||||
end: dateToString,
|
||||
repeat: z.number().min(0, "Repeat must be a non-negative number"),
|
||||
expiry: dateToString.optional(),
|
||||
});
|
||||
export const createMaintenanceWindowBodyValidation = z
|
||||
.object({
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
active: z.boolean().optional(),
|
||||
duration: z.number().min(1, "Duration is required"),
|
||||
durationUnit: z.enum(DurationUnits),
|
||||
start: dateToString,
|
||||
end: dateToString,
|
||||
repeat: z.number().min(0, "Repeat must be a non-negative number"),
|
||||
expiry: dateToString.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
const start = new Date(data.start).getTime();
|
||||
const end = new Date(data.end).getTime();
|
||||
if (end <= start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End must be after start",
|
||||
path: ["end"],
|
||||
});
|
||||
}
|
||||
if (data.repeat === 0 && end <= Date.now()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End must be in the future for one-time maintenance windows",
|
||||
path: ["end"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const getMaintenanceWindowByIdParamValidation = z.object({
|
||||
id: z.string().min(1, "ID is required"),
|
||||
@@ -43,14 +64,29 @@ export const editMaintenanceWindowByIdParamValidation = z.object({
|
||||
id: z.string().min(1, "ID is required"),
|
||||
});
|
||||
|
||||
export const editMaintenanceByIdWindowBodyValidation = z.object({
|
||||
active: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
repeat: z.number().optional(),
|
||||
start: dateToString.optional(),
|
||||
end: dateToString.optional(),
|
||||
expiry: dateToString.optional(),
|
||||
monitors: z.array(z.string()).optional(),
|
||||
duration: z.number().optional(),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
|
||||
});
|
||||
export const editMaintenanceByIdWindowBodyValidation = z
|
||||
.object({
|
||||
active: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
repeat: z.number().optional(),
|
||||
start: dateToString.optional(),
|
||||
end: dateToString.optional(),
|
||||
expiry: dateToString.optional(),
|
||||
monitors: z.array(z.string()).optional(),
|
||||
duration: z.number().optional(),
|
||||
durationUnit: z.enum(DurationUnits).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.start && data.end) {
|
||||
const start = new Date(data.start).getTime();
|
||||
const end = new Date(data.end).getTime();
|
||||
if (end <= start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End must be after start",
|
||||
path: ["end"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { GeoContinents } from "@/types/geoCheck.js";
|
||||
import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
|
||||
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
|
||||
|
||||
export const getMonitorByIdParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
@@ -20,12 +20,7 @@ export const getMonitorByIdQueryValidation = z.object({
|
||||
export const getMonitorsByTeamIdParamValidation = z.object({});
|
||||
|
||||
export const getMonitorsByTeamIdQueryValidation = z.object({
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
|
||||
])
|
||||
.optional(),
|
||||
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
|
||||
filter: z.union([z.string(), z.literal("")]).optional(),
|
||||
});
|
||||
|
||||
@@ -36,12 +31,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({
|
||||
filter: z.union([z.string(), z.literal("")]).optional(),
|
||||
field: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
|
||||
])
|
||||
.optional(),
|
||||
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
|
||||
explain: booleanCoercion.optional(),
|
||||
});
|
||||
|
||||
@@ -78,6 +68,8 @@ export const createMonitorBodyValidation = z.object({
|
||||
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 editMonitorBodyValidation = z.object({
|
||||
@@ -107,6 +99,8 @@ export const editMonitorBodyValidation = z.object({
|
||||
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 pauseMonitorParamValidation = z.object({
|
||||
@@ -168,6 +162,8 @@ const importedMonitorSchema = z.object({
|
||||
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(),
|
||||
});
|
||||
@@ -219,6 +215,8 @@ export const monitorResponseSchema = z
|
||||
geoCheckEnabled: z.boolean(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
||||
geoCheckInterval: z.number(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
teamId: z.string(),
|
||||
userId: z.string(),
|
||||
createdAt: z.string(),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResolverInstance = {
|
||||
setServers: jest.fn(),
|
||||
resolve4: jest.fn<() => Promise<string[]>>(),
|
||||
resolve6: jest.fn<() => Promise<string[]>>(),
|
||||
resolveCname: jest.fn<() => Promise<string[]>>(),
|
||||
resolveMx: jest.fn<() => Promise<unknown[]>>(),
|
||||
resolveTxt: jest.fn<() => Promise<string[][]>>(),
|
||||
resolveNs: jest.fn<() => Promise<string[]>>(),
|
||||
resolve: jest.fn<() => Promise<unknown>>(),
|
||||
};
|
||||
|
||||
const createResolver = () => mockResolverInstance as any;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "dns",
|
||||
url: "example.com",
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "A",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolverInstance.setServers.mockClear();
|
||||
mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]);
|
||||
mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]);
|
||||
mockResolverInstance.resolveCname.mockReset().mockResolvedValue(["alias.example.com"]);
|
||||
mockResolverInstance.resolveMx.mockReset().mockResolvedValue([{ exchange: "mx.example.com", priority: 10 }]);
|
||||
mockResolverInstance.resolveTxt.mockReset().mockResolvedValue([["v=spf1"]]);
|
||||
mockResolverInstance.resolveNs.mockReset().mockResolvedValue(["ns.example.com"]);
|
||||
mockResolverInstance.resolve.mockReset().mockResolvedValue(["something"]);
|
||||
});
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("DNSProvider", {
|
||||
create: () => new DNSProvider(createResolver),
|
||||
supportedType: "dns",
|
||||
unsupportedType: "ping",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DNSProvider", () => {
|
||||
it("returns success with results and payload on resolve", async () => {
|
||||
mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]);
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "dns",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
payload: {
|
||||
hostname: "example.com",
|
||||
dnsServer: "8.8.8.8",
|
||||
recordType: "A",
|
||||
resolved: true,
|
||||
results: ["1.2.3.4"],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(typeof result.responseTime).toBe("number");
|
||||
});
|
||||
|
||||
it("calls setServers with the configured dnsServer", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" }));
|
||||
expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("defaults to A record when dnsRecordType is not provided", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
const result = await provider.handle(makeMonitor({ dnsRecordType: undefined }));
|
||||
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
|
||||
expect(result.payload?.recordType).toBe("A");
|
||||
});
|
||||
|
||||
it("dispatches AAAA records to resolve6", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "AAAA" }));
|
||||
expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches CNAME records to resolveCname", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "CNAME" }));
|
||||
expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches MX records to resolveMx", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "MX" }));
|
||||
expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches TXT records to resolveTxt", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "TXT" }));
|
||||
expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches NS records to resolveNs", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "NS" }));
|
||||
expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("falls back to generic resolve for unrecognized record types", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "SOA" }));
|
||||
expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA");
|
||||
});
|
||||
|
||||
it("treats record type case-insensitively", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "mx" }));
|
||||
expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("returns failure response with NETWORK_ERROR when resolver rejects", async () => {
|
||||
mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND"));
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "dns",
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "ENOTFOUND",
|
||||
payload: expect.objectContaining({
|
||||
hostname: "example.com",
|
||||
dnsServer: "8.8.8.8",
|
||||
recordType: "A",
|
||||
resolved: false,
|
||||
results: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(typeof result.responseTime).toBe("number");
|
||||
});
|
||||
|
||||
it("stringifies non-Error rejections in failure response", async () => {
|
||||
mockResolverInstance.resolve4.mockRejectedValue("DNS bombed");
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("DNS bombed");
|
||||
});
|
||||
|
||||
it("throws AppError when dnsServer is missing", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring");
|
||||
});
|
||||
|
||||
it("falls back to default message when underlying error has empty message", async () => {
|
||||
mockResolverInstance.setServers.mockImplementationOnce(() => {
|
||||
throw new Error("");
|
||||
});
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check");
|
||||
});
|
||||
|
||||
it("stringifies non-Error throws from outside timeRequest", async () => {
|
||||
mockResolverInstance.setServers.mockImplementationOnce(() => {
|
||||
throw 42;
|
||||
});
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, jest, afterEach } from "@jest/globals";
|
||||
import {
|
||||
createMaintenanceWindowBodyValidation,
|
||||
editMaintenanceByIdWindowBodyValidation,
|
||||
} from "../../../src/validation/maintenanceWindowValidation.ts";
|
||||
|
||||
const FROZEN_NOW = new Date("2026-05-07T12:00:00Z").getTime();
|
||||
|
||||
const baseCreateBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
monitors: ["mon-1"],
|
||||
name: "Scheduled Maintenance",
|
||||
duration: 60,
|
||||
durationUnit: "minutes",
|
||||
start: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(),
|
||||
repeat: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createMaintenanceWindowBodyValidation", () => {
|
||||
it("accepts a valid one-time window with end in the future", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a recurring window even when end is in the past", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
repeat: 1,
|
||||
start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when end is before start", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
start: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when end equals start", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const sameInstant = new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString();
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ start: sameInstant, end: sameInstant }));
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a one-time window with end in the past", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "End must be in the future for one-time maintenance windows",
|
||||
path: ["end"],
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a one-time window with end exactly equal to now", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
start: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "End must be in the future for one-time maintenance windows",
|
||||
path: ["end"],
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown fields under .strict()", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ teamId: "team-1" }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an invalid durationUnit value", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ durationUnit: "weeks" }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing required field", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const { name: _name, ...withoutName } = baseCreateBody();
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(withoutName);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMaintenanceByIdWindowBodyValidation", () => {
|
||||
it("accepts an empty body (all fields optional)", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a partial update with only active toggled", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ active: false });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown fields under .strict()", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
|
||||
active: false,
|
||||
id: "mw-1",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when start and end are both present and end <= start", () => {
|
||||
const start = new Date("2026-05-08T10:00:00Z").toISOString();
|
||||
const end = new Date("2026-05-08T09:00:00Z").toISOString();
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ start, end });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply the start/end ordering rule when only end is provided", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
|
||||
end: new Date("2020-01-01T00:00:00Z").toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("does not apply the start/end ordering rule when only start is provided", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
|
||||
start: new Date("2099-01-01T00:00:00Z").toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts";
|
||||
|
||||
const baseDnsBody = {
|
||||
name: "DNS check",
|
||||
type: "dns" as const,
|
||||
url: "example.com",
|
||||
};
|
||||
|
||||
describe("monitorValidation — DNS fields", () => {
|
||||
describe("createMonitorBodyValidation", () => {
|
||||
it("retains dnsServer and dnsRecordType on a DNS monitor", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "A",
|
||||
});
|
||||
|
||||
expect(parsed.dnsServer).toBe("8.8.8.8");
|
||||
expect(parsed.dnsRecordType).toBe("A");
|
||||
});
|
||||
|
||||
it("accepts every supported DNS record type", () => {
|
||||
for (const recordType of ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const) {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
dnsServer: "1.1.1.1",
|
||||
dnsRecordType: recordType,
|
||||
});
|
||||
expect(parsed.dnsRecordType).toBe(recordType);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown DNS record types", () => {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "BOGUS",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("treats DNS fields as optional (other monitor types still validate)", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
name: "HTTP check",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(parsed.dnsServer).toBeUndefined();
|
||||
expect(parsed.dnsRecordType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMonitorBodyValidation", () => {
|
||||
it("retains dnsServer and dnsRecordType on edits", () => {
|
||||
const parsed = editMonitorBodyValidation.parse({
|
||||
dnsServer: "1.1.1.1",
|
||||
dnsRecordType: "MX",
|
||||
});
|
||||
|
||||
expect(parsed.dnsServer).toBe("1.1.1.1");
|
||||
expect(parsed.dnsRecordType).toBe("MX");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importMonitorsBodyValidation", () => {
|
||||
it("retains dnsServer and dnsRecordType on imported DNS monitors", () => {
|
||||
const parsed = importMonitorsBodyValidation.parse({
|
||||
monitors: [
|
||||
{
|
||||
name: "Imported DNS",
|
||||
type: "dns",
|
||||
url: "example.com",
|
||||
dnsServer: "8.8.4.4",
|
||||
dnsRecordType: "TXT",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.monitors[0].dnsServer).toBe("8.8.4.4");
|
||||
expect(parsed.monitors[0].dnsRecordType).toBe("TXT");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user