Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85903adc9 | ||
|
|
a7895cce32 | ||
|
|
8e4e9dd87c | ||
|
|
0d25973efb | ||
|
|
e69f40fdd0 | ||
|
|
9aa2594df7 | ||
|
|
9027707c92 | ||
|
|
9e69d863df | ||
|
|
c740fb1aca | ||
|
|
3c05b9df98 | ||
|
|
d5d79a1eda | ||
|
|
2097bc477a | ||
|
|
744d8c8dcf | ||
|
|
7a5ee6ba05 | ||
|
|
eeee6db94e | ||
|
|
656c04d0e4 | ||
|
|
010ef9661f | ||
|
|
ff0ef928a6 | ||
|
|
eb29a725c2 | ||
|
|
72f23ca025 | ||
|
|
ae9416641c | ||
|
|
b7844f8b10 | ||
|
|
ffb563f36d | ||
|
|
ae5115a38f | ||
|
|
b925434078 | ||
|
|
f713698572 | ||
|
|
47d2b62e92 | ||
|
|
780189e4a1 | ||
|
|
f86ceae0c6 | ||
|
|
e78524d1c9 | ||
|
|
7a2dab6cc4 | ||
|
|
c90442b49f | ||
|
|
bdecc1a5e2 | ||
|
|
c1fc537479 | ||
|
|
6f0540fb33 | ||
|
|
3876aae484 | ||
|
|
2f646ee154 | ||
|
|
01da5847f0 | ||
|
|
4b888c1ed1 | ||
|
|
6b63091d89 | ||
|
|
78fdc548bb | ||
|
|
bbdbe75255 | ||
|
|
74bc5accb6 | ||
|
|
60770a4544 | ||
|
|
766d847561 | ||
|
|
08043a5ea0 | ||
|
|
272e4b5014 | ||
|
|
98d2bbfbb9 | ||
|
|
427f8c632f | ||
|
|
9a004ab1fb | ||
|
|
c9b9c4a53f | ||
|
|
db746b0ff2 | ||
|
|
80fca45ce8 | ||
|
|
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 | ||
|
|
53c58eb945 | ||
|
|
49fcab2ee3 | ||
|
|
d41d9a3dab | ||
|
|
1a54a24fd5 | ||
|
|
ac51dfeab7 | ||
|
|
99a5aa1722 | ||
|
|
5791bdd5e7 | ||
|
|
f7514bc938 | ||
|
|
2bbaf7d1eb |
No files matched your search
Regular → Executable
+21
-2
@@ -1,4 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Skip during merge/rebase/cherry-pick/revert. lint-staged's internal
|
||||||
|
# git-stash mechanism is incompatible with these states and will silently
|
||||||
|
# wipe MERGE_HEAD and your conflict resolutions on commit failure.
|
||||||
|
git_dir=$(git rev-parse --git-dir)
|
||||||
|
if [ -e "$git_dir/MERGE_HEAD" ] \
|
||||||
|
|| [ -e "$git_dir/CHERRY_PICK_HEAD" ] \
|
||||||
|
|| [ -e "$git_dir/REVERT_HEAD" ] \
|
||||||
|
|| [ -d "$git_dir/rebase-merge" ] \
|
||||||
|
|| [ -d "$git_dir/rebase-apply" ]; then
|
||||||
|
echo "Skipping lint-staged (mid merge/rebase/cherry-pick/revert)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Anchor to repo root so commits made from a subdirectory still work, and
|
||||||
|
# run each package's lint-staged in a subshell so cwd is scoped (a failure
|
||||||
|
# inside one package can't poison the cwd for the next).
|
||||||
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||||
|
|
||||||
echo "Running lint-staged..."
|
echo "Running lint-staged..."
|
||||||
cd client && npx lint-staged && cd ..
|
(cd client && npx lint-staged) || exit 1
|
||||||
cd server && npx lint-staged && cd ..
|
(cd server && npx lint-staged) || exit 1
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { PulseDot, Dot } from "@/Components/design-elements";
|
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
||||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import prettyMilliseconds from "pretty-ms";
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
@@ -28,7 +29,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
>
|
>
|
||||||
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
||||||
<Typography
|
<Typography
|
||||||
@@ -41,6 +42,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
>
|
>
|
||||||
{formatUrl(monitor?.url)}
|
{formatUrl(monitor?.url)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{monitor.type === "pagespeed" && monitor.strategy && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<StrategyBadge strategy={monitor.strategy} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!isSmall && (
|
{!isSmall && (
|
||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { PageSpeedStrategy } from "@/Types/Monitor";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
|
interface StrategyBadgeProps {
|
||||||
|
strategy: PageSpeedStrategy | undefined;
|
||||||
|
variant?: "caption" | "body2";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!strategy) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
variant={variant}
|
||||||
|
px={SPACING.XXL}
|
||||||
|
py={SPACING.SM}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.secondary.light}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{t(`pages.pageSpeed.strategy.${strategy}`)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -142,7 +142,7 @@ export function DataTable<
|
|||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
component="div"
|
component="div"
|
||||||
color={theme.palette.text.primary}
|
color={theme.palette.text.secondary}
|
||||||
>
|
>
|
||||||
{header.mobileLabel ?? header.content}
|
{header.mobileLabel ?? header.content}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ export * from "./TextLink";
|
|||||||
export * from "./NoticeBanner";
|
export * from "./NoticeBanner";
|
||||||
export * from "./OfflineBanner";
|
export * from "./OfflineBanner";
|
||||||
export * from "./Avatar";
|
export * from "./Avatar";
|
||||||
|
export * from "./StatusCodeLabel";
|
||||||
|
export * from "./StrategyBadge";
|
||||||
@@ -10,6 +10,7 @@ import type { RootState } from "@/Types/state";
|
|||||||
export const languageNames: Record<string, string> = {
|
export const languageNames: Record<string, string> = {
|
||||||
en: "English",
|
en: "English",
|
||||||
de: "Deutsch",
|
de: "Deutsch",
|
||||||
|
ca: "Català",
|
||||||
es: "Español",
|
es: "Español",
|
||||||
fr: "Français",
|
fr: "Français",
|
||||||
"pt-BR": "Português (BR)",
|
"pt-BR": "Português (BR)",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { Dialog, Select, RadioWithDescription } from "@/Components/inputs";
|
||||||
|
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import RadioGroup from "@mui/material/RadioGroup";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import { useGet, usePatch } from "@/Hooks/UseApi";
|
||||||
|
import type { Notification } from "@/Types/Notification";
|
||||||
|
|
||||||
|
interface BulkEditNotificationsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedMonitors: string[];
|
||||||
|
onComplete: (success: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BulkEditNotificationsModal: React.FC<BulkEditNotificationsModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
selectedMonitors,
|
||||||
|
onComplete,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const [action, setAction] = useState<"add" | "remove" | "set">("add");
|
||||||
|
const [notificationIds, setNotificationIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch available notifications
|
||||||
|
const { data: notifications } = useGet<Notification[]>(
|
||||||
|
"/notifications/team",
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { patch, loading: isPatching, error } = usePatch();
|
||||||
|
|
||||||
|
// Reset state when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setAction("add");
|
||||||
|
setNotificationIds([]);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
const res = await patch("/monitors/notifications", {
|
||||||
|
monitorIds: selectedMonitors,
|
||||||
|
notificationIds,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
onComplete(!!res);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent submitting empty arrays unless the action is 'set'
|
||||||
|
const isMissingSelection = action !== "set" && notificationIds.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={t("pages.common.monitors.bulkEdit.title")}
|
||||||
|
onCancel={onClose}
|
||||||
|
onConfirm={isMissingSelection ? undefined : handleConfirm}
|
||||||
|
loading={isPatching}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
spacing={theme.spacing(LAYOUT.XS)}
|
||||||
|
mt={theme.spacing(SPACING.LG)}
|
||||||
|
>
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
<Typography variant="body1">
|
||||||
|
{t("pages.common.monitors.bulkEdit.selectedText", {
|
||||||
|
count: selectedMonitors.length,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={action}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === "add" || val === "remove" || val === "set") {
|
||||||
|
setAction(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RadioWithDescription
|
||||||
|
value="add"
|
||||||
|
label={t("pages.common.monitors.bulkEdit.actionAdd")}
|
||||||
|
description={t("pages.common.monitors.bulkEdit.actionAddDesc")}
|
||||||
|
/>
|
||||||
|
<RadioWithDescription
|
||||||
|
value="remove"
|
||||||
|
label={t("pages.common.monitors.bulkEdit.actionRemove")}
|
||||||
|
description={t("pages.common.monitors.bulkEdit.actionRemoveDesc")}
|
||||||
|
/>
|
||||||
|
<RadioWithDescription
|
||||||
|
value="set"
|
||||||
|
label={t("pages.common.monitors.bulkEdit.actionSet")}
|
||||||
|
description={t("pages.common.monitors.bulkEdit.actionSetDesc")}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
value={notificationIds}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (typeof val === "string") {
|
||||||
|
setNotificationIds(val.split(","));
|
||||||
|
} else if (Array.isArray(val)) {
|
||||||
|
setNotificationIds(val.filter((v) => typeof v === "string"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fieldLabel={t("pages.common.monitors.bulkEdit.selectLabel")}
|
||||||
|
placeholder={t("pages.common.monitors.bulkEdit.selectPlaceholder")}
|
||||||
|
>
|
||||||
|
{notifications?.map((n) => (
|
||||||
|
<MenuItem
|
||||||
|
key={n.id}
|
||||||
|
value={n.id}
|
||||||
|
>
|
||||||
|
{n.notificationName} ({n.type.toUpperCase()})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
|
|||||||
import Map, { Marker, Popup } from "react-map-gl/maplibre";
|
import Map, { Marker, Popup } from "react-map-gl/maplibre";
|
||||||
import type { MapRef } from "react-map-gl/maplibre";
|
import type { MapRef } from "react-map-gl/maplibre";
|
||||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatStatusCode } from "@/Utils/statusCode";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
interface GeoChecksMapProps {
|
interface GeoChecksMapProps {
|
||||||
@@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
|
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDarkMode = theme.palette.mode === "dark";
|
const isDarkMode = theme.palette.mode === "dark";
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const mapPopupStyles = (
|
const mapPopupStyles = (
|
||||||
<GlobalStyles
|
<GlobalStyles
|
||||||
@@ -174,7 +177,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
>
|
>
|
||||||
Status Code:
|
Status Code:
|
||||||
</Typography>{" "}
|
</Typography>{" "}
|
||||||
{selectedCheck.statusCode}
|
{formatStatusCode(selectedCheck.statusCode, t)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Text,
|
Text,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import type { TooltipProps } from "recharts";
|
||||||
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
@@ -18,13 +20,14 @@ import {
|
|||||||
tooltipDateFormatLookup,
|
tooltipDateFormatLookup,
|
||||||
} from "@/Utils/TimeUtils";
|
} from "@/Utils/TimeUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
import type { GroupedCheck } from "@/Types/Check";
|
import type { GroupedCheck } from "@/Types/Check";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
|
|
||||||
type XTickProps = {
|
type XTickProps = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
payload: { value: any };
|
payload: { value: string };
|
||||||
range: string;
|
range: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,12 +49,9 @@ export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResponseTimeToolTipProps = {
|
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
active?: boolean | undefined;
|
|
||||||
payload?: any[];
|
|
||||||
label?: string | number;
|
|
||||||
range: string;
|
range: string;
|
||||||
theme: any;
|
theme: Theme;
|
||||||
uiTimezone: string;
|
uiTimezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,7 +68,8 @@ const ResponseTimeToolTip = ({
|
|||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
const format = tooltipDateFormatLookup(range);
|
||||||
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
|
console.log(payload);
|
||||||
|
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
|
||||||
return (
|
return (
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
|||||||
export * from "./charts/HistogramInfrastructure";
|
export * from "./charts/HistogramInfrastructure";
|
||||||
export * from "./HeaderMonitorsSummary";
|
export * from "./HeaderMonitorsSummary";
|
||||||
export * from "./BulkActionsBar";
|
export * from "./BulkActionsBar";
|
||||||
|
export * from "./BulkEditNotificationsModal";
|
||||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import {
|
import {
|
||||||
maintenanceWindowSchema,
|
maintenanceWindowSchema,
|
||||||
|
maintenanceWindowEditSchema,
|
||||||
repeatOptions,
|
repeatOptions,
|
||||||
type MaintenanceWindowFormData,
|
type MaintenanceWindowFormData,
|
||||||
} from "@/Validation/maintenanceWindow";
|
} from "@/Validation/maintenanceWindow";
|
||||||
@@ -32,7 +33,7 @@ export const useMaintenanceWindowForm = ({
|
|||||||
startTime: startDate.format("HH:mm"),
|
startTime: startDate.format("HH:mm"),
|
||||||
duration: data.duration ?? 0,
|
duration: data.duration ?? 0,
|
||||||
durationUnit: data.durationUnit ?? "minutes",
|
durationUnit: data.durationUnit ?? "minutes",
|
||||||
monitors: [data.monitorId],
|
monitors: data.monitorIds,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
@@ -47,6 +48,9 @@ export const useMaintenanceWindowForm = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { schema: maintenanceWindowSchema, defaults };
|
return {
|
||||||
|
schema: data ? maintenanceWindowEditSchema : maintenanceWindowSchema,
|
||||||
|
defaults,
|
||||||
|
};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
||||||
|
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
|
||||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||||
|
|
||||||
interface UseMonitorFormOptions {
|
interface UseMonitorFormOptions {
|
||||||
@@ -88,6 +89,7 @@ export const useMonitorForm = ({
|
|||||||
...base,
|
...base,
|
||||||
type: "pagespeed",
|
type: "pagespeed",
|
||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
|
strategy: data?.strategy ?? DefaultPageSpeedStrategy,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "hardware":
|
case "hardware":
|
||||||
@@ -111,6 +113,15 @@ export const useMonitorForm = ({
|
|||||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case "dns":
|
||||||
|
defaults = {
|
||||||
|
...base,
|
||||||
|
type: "dns",
|
||||||
|
url: data?.url || "",
|
||||||
|
dnsServer: data?.dnsServer || "",
|
||||||
|
dnsRecordType: data?.dnsRecordType || "A",
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
defaults = {
|
defaults = {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -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 Box from "@mui/material/Box";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
import type { Check } from "@/Types/Check";
|
import type { Check } from "@/Types/Check";
|
||||||
@@ -29,7 +33,7 @@ export const ChecksTable = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
|
||||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
const getHeaders = () => {
|
||||||
const headers: Header<Check>[] = [
|
const headers: Header<Check>[] = [
|
||||||
{
|
{
|
||||||
id: "monitorName",
|
id: "monitorName",
|
||||||
@@ -63,13 +67,10 @@ export const ChecksTable = ({
|
|||||||
id: "statusCode",
|
id: "statusCode",
|
||||||
content: t("pages.checks.table.headers.statusCode"),
|
content: t("pages.checks.table.headers.statusCode"),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const code = row.statusCode;
|
|
||||||
if (!code) return "N/A";
|
|
||||||
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
|
|
||||||
return (
|
return (
|
||||||
<ValueLabel
|
<StatusCodeLabel
|
||||||
value={value}
|
statusCode={row.statusCode}
|
||||||
text={String(code)}
|
message={row.message}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -85,7 +86,7 @@ export const ChecksTable = ({
|
|||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers = getHeaders(t, uiTimezone);
|
const headers = getHeaders();
|
||||||
|
|
||||||
const handlePageChange = (
|
const handlePageChange = (
|
||||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type MonitorType,
|
type MonitorType,
|
||||||
type GamesMap,
|
type GamesMap,
|
||||||
supportsGeoCheck,
|
supportsGeoCheck,
|
||||||
|
DefaultPageSpeedStrategy,
|
||||||
} from "@/Types/Monitor";
|
} from "@/Types/Monitor";
|
||||||
import type { Notification } from "@/Types/Notification";
|
import type { Notification } from "@/Types/Notification";
|
||||||
import type { MonitorFormData } from "@/Validation/monitor";
|
import type { MonitorFormData } from "@/Validation/monitor";
|
||||||
@@ -51,6 +52,9 @@ interface GeneralSettingsConfig {
|
|||||||
showSecret: boolean;
|
showSecret: boolean;
|
||||||
showGrpcServiceName: boolean;
|
showGrpcServiceName: boolean;
|
||||||
showIgnoreTls: boolean;
|
showIgnoreTls: boolean;
|
||||||
|
showStrategy: boolean;
|
||||||
|
showDnsServer: boolean;
|
||||||
|
showDnsRecordType: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGeneralSettingsConfig = (
|
const getGeneralSettingsConfig = (
|
||||||
@@ -68,6 +72,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
ping: {
|
ping: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||||
@@ -79,6 +86,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
docker: {
|
docker: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||||
@@ -90,6 +100,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
port: {
|
port: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||||
@@ -101,6 +114,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||||
@@ -112,6 +128,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
grpc: {
|
grpc: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||||
@@ -123,6 +142,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: true,
|
showGrpcServiceName: true,
|
||||||
showIgnoreTls: true,
|
showIgnoreTls: true,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
pagespeed: {
|
pagespeed: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||||
@@ -134,6 +156,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: true,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
hardware: {
|
hardware: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
|
||||||
@@ -145,6 +170,9 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: true,
|
showSecret: true,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: false,
|
||||||
|
showDnsRecordType: false,
|
||||||
},
|
},
|
||||||
websocket: {
|
websocket: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
||||||
@@ -156,6 +184,23 @@ const getGeneralSettingsConfig = (
|
|||||||
showSecret: false,
|
showSecret: false,
|
||||||
showGrpcServiceName: false,
|
showGrpcServiceName: false,
|
||||||
showIgnoreTls: true,
|
showIgnoreTls: true,
|
||||||
|
showStrategy: false,
|
||||||
|
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,
|
||||||
|
showStrategy: false,
|
||||||
|
showDnsServer: true,
|
||||||
|
showDnsRecordType: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return configs[type] || configs.http;
|
return configs[type] || configs.http;
|
||||||
@@ -349,6 +394,13 @@ const CreateMonitorPage = () => {
|
|||||||
"pages.createMonitor.form.type.optionWebSocketDescription"
|
"pages.createMonitor.form.type.optionWebSocketDescription"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<RadioWithDescription
|
||||||
|
value="dns"
|
||||||
|
label={t("pages.common.monitors.monitorTypes.optionDns")}
|
||||||
|
description={t(
|
||||||
|
"pages.createMonitor.form.type.optionDnsDescription"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
@@ -395,6 +447,31 @@ const CreateMonitorPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Strategy field - only for pagespeed type */}
|
||||||
|
{generalSettingsConfig.showStrategy && (
|
||||||
|
<Controller
|
||||||
|
name="strategy"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? DefaultPageSpeedStrategy}
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.createMonitor.form.general.option.strategy.label"
|
||||||
|
)}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
>
|
||||||
|
<MenuItem value="desktop">
|
||||||
|
{t("pages.createMonitor.form.general.option.strategy.desktop")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="mobile">
|
||||||
|
{t("pages.createMonitor.form.general.option.strategy.mobile")}
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Port field - only for port and game types */}
|
{/* Port field - only for port and game types */}
|
||||||
{generalSettingsConfig.showPort && (
|
{generalSettingsConfig.showPort && (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -495,6 +572,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
|
<Controller
|
||||||
name="name"
|
name="name"
|
||||||
@@ -900,7 +1045,7 @@ const CreateMonitorPage = () => {
|
|||||||
/>
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
component="span"
|
component="span"
|
||||||
color="text.secondary"
|
color={theme.palette.text.secondary}
|
||||||
sx={{ opacity: 0.8 }}
|
sx={{ opacity: 0.8 }}
|
||||||
>
|
>
|
||||||
<Trans
|
<Trans
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
|||||||
import Grid from "@mui/material/Grid";
|
import Grid from "@mui/material/Grid";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import Typography from "@mui/material/Typography";
|
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 { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -46,6 +46,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
|||||||
if (!incident) {
|
if (!incident) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
@@ -123,7 +124,12 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
|||||||
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
|
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={4}>
|
<Grid size={4}>
|
||||||
<Cell>{incident.statusCode ?? "N/A"}</Cell>
|
<Cell>
|
||||||
|
<StatusCodeLabel
|
||||||
|
statusCode={incident.statusCode}
|
||||||
|
message={incident.message}
|
||||||
|
/>
|
||||||
|
</Cell>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={2}>
|
<Grid size={2}>
|
||||||
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
|
<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 { Pagination } from "@/Components/design-elements/Table";
|
||||||
import type { Header } 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 { ActionsMenu } from "@/Components/actions-menu";
|
||||||
import type { Incident } from "@/Types/Incident";
|
import type { Incident } from "@/Types/Incident";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
|
|||||||
return (
|
return (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
color={
|
||||||
textTransform: "capitalize",
|
row.resolutionType === "manual"
|
||||||
color:
|
? theme.palette.warning.main
|
||||||
row.resolutionType === "manual"
|
: theme.palette.success.main
|
||||||
? theme.palette.warning.main
|
}
|
||||||
: theme.palette.success.main,
|
textTransform={"capitalize"}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{row.resolutionType}
|
{row.resolutionType}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
|
|||||||
id: "statusCode",
|
id: "statusCode",
|
||||||
content: t("pages.incidents.table.headers.statusCode"),
|
content: t("pages.incidents.table.headers.statusCode"),
|
||||||
render: (row) => {
|
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 (
|
return (
|
||||||
<ValueLabel
|
<StatusCodeLabel
|
||||||
value={value}
|
statusCode={row.statusCode}
|
||||||
text={String(code)}
|
message={row.message}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow";
|
|||||||
import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow";
|
import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow";
|
||||||
import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm";
|
import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm";
|
||||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||||
|
import { mutate } from "swr";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
@@ -85,10 +86,6 @@ const CreateMaintenanceWindowPage = () => {
|
|||||||
repeat,
|
repeat,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (repeat === 0) {
|
|
||||||
payload.expiry = endDateTime.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,6 +100,9 @@ const CreateMaintenanceWindowPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
|
await mutate(
|
||||||
|
(key) => typeof key === "string" && key.startsWith("/maintenance-window")
|
||||||
|
);
|
||||||
navigate("/maintenance");
|
navigate("/maintenance");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
import {
|
||||||
|
Table,
|
||||||
|
Pagination,
|
||||||
|
StatusLabel,
|
||||||
|
StrategyBadge,
|
||||||
|
} from "@/Components/design-elements";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
@@ -179,7 +185,19 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
),
|
),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
return row.name;
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={LAYOUT.XS}
|
||||||
|
>
|
||||||
|
<Typography>{row.name}</Typography>
|
||||||
|
<StrategyBadge
|
||||||
|
strategy={row.strategy}
|
||||||
|
variant="caption"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 Box from "@mui/material/Box";
|
||||||
import type { Header } from "@/Components/design-elements";
|
import type { Header } from "@/Components/design-elements";
|
||||||
import type { Check } from "@/Types/Check";
|
import type { Check } from "@/Types/Check";
|
||||||
@@ -9,7 +14,25 @@ import { formatDateWithTz } from "@/Utils/TimeUtils";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { useSelector } from "react-redux";
|
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>[] = [
|
const headers: Header<Check>[] = [
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
@@ -36,32 +59,15 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
|||||||
id: "statusCode",
|
id: "statusCode",
|
||||||
content: t("pages.checks.table.headers.statusCode"),
|
content: t("pages.checks.table.headers.statusCode"),
|
||||||
render: (row) => {
|
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 = (
|
const handlePageChange = (
|
||||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
_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 Box from "@mui/material/Box";
|
||||||
import type { Header } from "@/Components/design-elements";
|
import type { Header } from "@/Components/design-elements";
|
||||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||||
@@ -8,7 +13,24 @@ import type { RootState } from "@/Types/state";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import prettyMilliseconds from "pretty-ms";
|
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>[] = [
|
const headers: Header<FlatGeoCheck>[] = [
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
@@ -29,7 +51,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
|||||||
id: "statusCode",
|
id: "statusCode",
|
||||||
content: t("pages.checks.table.headers.statusCode"),
|
content: t("pages.checks.table.headers.statusCode"),
|
||||||
render: (row) => {
|
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 = (
|
const handlePageChange = (
|
||||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ArrowDown, ArrowUp } from "lucide-react";
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
@@ -21,6 +22,7 @@ import type { Monitor } from "@/Types/Monitor";
|
|||||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface MonitorTableProps {
|
interface MonitorTableProps {
|
||||||
monitors: Monitor[];
|
monitors: Monitor[];
|
||||||
@@ -57,6 +59,7 @@ export const MonitorTable = ({
|
|||||||
}: MonitorTableProps) => {
|
}: MonitorTableProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
||||||
const { post } = usePost<Record<string, never>, Monitor>();
|
const { post } = usePost<Record<string, never>, Monitor>();
|
||||||
@@ -205,7 +208,7 @@ export const MonitorTable = ({
|
|||||||
mobileLabel: t("common.table.headers.name"),
|
mobileLabel: t("common.table.headers.name"),
|
||||||
content: (
|
content: (
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
direction={"row"}
|
direction={"row"}
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
onClick={(e) => handleSort(e, "name")}
|
onClick={(e) => handleSort(e, "name")}
|
||||||
@@ -225,14 +228,14 @@ export const MonitorTable = ({
|
|||||||
mobileLabel: t("common.table.headers.status"),
|
mobileLabel: t("common.table.headers.status"),
|
||||||
content: (
|
content: (
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
direction={"row"}
|
direction={"row"}
|
||||||
justifyContent={"center"}
|
justifyContent={"center"}
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
onClick={(e) => handleSort(e, "status")}
|
onClick={(e) => handleSort(e, "status")}
|
||||||
sx={{ cursor: "pointer" }}
|
sx={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<Box width={theme.spacing(8)} />
|
<Box width={theme.spacing(LAYOUT.MD)} />
|
||||||
{t("common.table.headers.status")}
|
{t("common.table.headers.status")}
|
||||||
{renderSortIcon(sortField === "status")}
|
{renderSortIcon(sortField === "status")}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -257,14 +260,14 @@ export const MonitorTable = ({
|
|||||||
mobileLabel: t("common.table.headers.type"),
|
mobileLabel: t("common.table.headers.type"),
|
||||||
content: (
|
content: (
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
direction={"row"}
|
direction={"row"}
|
||||||
justifyContent={"center"}
|
justifyContent={"center"}
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
onClick={(e) => handleSort(e, "type")}
|
onClick={(e) => handleSort(e, "type")}
|
||||||
sx={{ cursor: "pointer" }}
|
sx={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<Box width={theme.spacing(8)} />
|
<Box width={theme.spacing(LAYOUT.MD)} />
|
||||||
{t("common.table.headers.type")}
|
{t("common.table.headers.type")}
|
||||||
{renderSortIcon(sortField === "type")}
|
{renderSortIcon(sortField === "type")}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -288,6 +291,30 @@ export const MonitorTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
{isSmall && (
|
||||||
|
<Box
|
||||||
|
px={SPACING.SM}
|
||||||
|
pb={SPACING.LG}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={SPACING.SM}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
indeterminate={isSomeSelected}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
|
{t("pages.common.monitors.bulkEdit.selectAll", "Select All")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Table
|
<Table
|
||||||
headers={headers}
|
headers={headers}
|
||||||
data={monitors}
|
data={monitors}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Stack from "@mui/material/Stack";
|
|||||||
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
||||||
import { HeaderCreate } from "@/Components/common";
|
import { HeaderCreate } from "@/Components/common";
|
||||||
import { Play, Pause } from "lucide-react";
|
import { Play, Pause } from "lucide-react";
|
||||||
|
import { BulkEditNotificationsModal } from "@/Components/monitors";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
@@ -22,6 +23,7 @@ import type { RootState } from "@/Types/state";
|
|||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
import useDebounce from "@/Hooks/useDebounce";
|
||||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
const UptimeMonitorsPage = () => {
|
const UptimeMonitorsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -43,7 +45,7 @@ const UptimeMonitorsPage = () => {
|
|||||||
const [sortField, setSortField] = useState<string>("");
|
const [sortField, setSortField] = useState<string>("");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||||
|
const [isBulkEditModalOpen, setIsBulkEditModalOpen] = useState(false);
|
||||||
const isDialogOpen = Boolean(selectedMonitor);
|
const isDialogOpen = Boolean(selectedMonitor);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
const debouncedSearch = useDebounce<string>(search, 300);
|
||||||
@@ -77,7 +79,7 @@ const UptimeMonitorsPage = () => {
|
|||||||
const effectiveTypes =
|
const effectiveTypes =
|
||||||
selectedTypes.length > 0
|
selectedTypes.length > 0
|
||||||
? selectedTypes
|
? selectedTypes
|
||||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
|
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
|
||||||
|
|
||||||
// Build URL for monitors with checks
|
// Build URL for monitors with checks
|
||||||
const monitorsWithChecksUrl = useMemo(() => {
|
const monitorsWithChecksUrl = useMemo(() => {
|
||||||
@@ -136,6 +138,14 @@ const UptimeMonitorsPage = () => {
|
|||||||
refetch();
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBulkEditComplete = (success: boolean) => {
|
||||||
|
setIsBulkEditModalOpen(false);
|
||||||
|
if (success) {
|
||||||
|
setSelectedRows([]);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setSelectedMonitor(null);
|
setSelectedMonitor(null);
|
||||||
};
|
};
|
||||||
@@ -174,7 +184,7 @@ const UptimeMonitorsPage = () => {
|
|||||||
<Stack
|
<Stack
|
||||||
direction={isSmall ? "column" : "row"}
|
direction={isSmall ? "column" : "row"}
|
||||||
justifyContent={isSmall ? "flex-start" : "space-between"}
|
justifyContent={isSmall ? "flex-start" : "space-between"}
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
>
|
>
|
||||||
<ControlsFilter
|
<ControlsFilter
|
||||||
selectedTypes={selectedTypes}
|
selectedTypes={selectedTypes}
|
||||||
@@ -213,16 +223,27 @@ const UptimeMonitorsPage = () => {
|
|||||||
>
|
>
|
||||||
{t("common.buttons.pause")}
|
{t("common.buttons.pause")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsBulkEditModalOpen(true)}
|
||||||
|
>
|
||||||
|
{t("pages.common.monitors.bulkEdit.editButton", {
|
||||||
|
defaultValue: "Edit Notifications",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
</BulkActionsBar>
|
</BulkActionsBar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MonitorTable
|
<MonitorTable
|
||||||
|
page={page}
|
||||||
monitors={monitorsWithChecks || []}
|
monitors={monitorsWithChecks || []}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
setSelectedMonitor={setSelectedMonitor}
|
setSelectedMonitor={setSelectedMonitor}
|
||||||
count={count || 0}
|
count={count || 0}
|
||||||
page={page}
|
setPage={(newPage) => {
|
||||||
setPage={setPage}
|
setPage(newPage);
|
||||||
|
setSelectedRows([]);
|
||||||
|
}}
|
||||||
rowsPerPage={rowsPerPage}
|
rowsPerPage={rowsPerPage}
|
||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
setSortField={setSortField}
|
setSortField={setSortField}
|
||||||
@@ -236,6 +257,7 @@ const UptimeMonitorsPage = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
|
setSelectedRows([]);
|
||||||
}}
|
}}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onSelectionChange={setSelectedRows}
|
onSelectionChange={setSelectedRows}
|
||||||
@@ -248,6 +270,12 @@ const UptimeMonitorsPage = () => {
|
|||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
loading={isDeleting}
|
loading={isDeleting}
|
||||||
/>
|
/>
|
||||||
|
<BulkEditNotificationsModal
|
||||||
|
open={isBulkEditModalOpen}
|
||||||
|
onClose={() => setIsBulkEditModalOpen(false)}
|
||||||
|
selectedMonitors={selectedRows}
|
||||||
|
onComplete={handleBulkEditComplete}
|
||||||
|
/>
|
||||||
</MonitorBasePageWithStates>
|
</MonitorBasePageWithStates>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
|
|||||||
|
|
||||||
export interface MaintenanceWindow {
|
export interface MaintenanceWindow {
|
||||||
id: string;
|
id: string;
|
||||||
monitorId: string;
|
monitorIds: string[];
|
||||||
teamId: string;
|
teamId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ export const MonitorTypes = [
|
|||||||
"game",
|
"game",
|
||||||
"grpc",
|
"grpc",
|
||||||
"websocket",
|
"websocket",
|
||||||
|
"dns",
|
||||||
"unknown",
|
"unknown",
|
||||||
] as const;
|
] as const;
|
||||||
export type MonitorType = (typeof MonitorTypes)[number];
|
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 GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||||
|
|
||||||
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
|
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
|
||||||
@@ -40,6 +44,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
|
|||||||
|
|
||||||
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
||||||
|
|
||||||
|
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||||
|
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||||
|
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
|
||||||
|
|
||||||
export interface Monitor {
|
export interface Monitor {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -74,10 +82,13 @@ export interface Monitor {
|
|||||||
selectedDisks: string[];
|
selectedDisks: string[];
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
grpcServiceName?: string;
|
grpcServiceName?: string;
|
||||||
|
strategy?: PageSpeedStrategy;
|
||||||
group: string | null;
|
group: string | null;
|
||||||
geoCheckEnabled?: boolean;
|
geoCheckEnabled?: boolean;
|
||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
geoCheckInterval?: number;
|
geoCheckInterval?: number;
|
||||||
|
dnsServer?: string;
|
||||||
|
dnsRecordType?: DnsRecordType;
|
||||||
recentChecks: CheckSnapshot[];
|
recentChecks: CheckSnapshot[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const getMonitorPath = (type: MonitorType): string => {
|
|||||||
game: "uptime",
|
game: "uptime",
|
||||||
grpc: "uptime",
|
grpc: "uptime",
|
||||||
websocket: "uptime",
|
websocket: "uptime",
|
||||||
|
dns: "uptime",
|
||||||
unknown: "uptime",
|
unknown: "uptime",
|
||||||
docker: "uptime",
|
docker: "uptime",
|
||||||
hardware: "infrastructure",
|
hardware: "infrastructure",
|
||||||
|
|||||||
@@ -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,30 +14,32 @@ export const durationUnitOptions = [
|
|||||||
{ id: "days", name: "days", multiplier: 86400000 },
|
{ id: "days", name: "days", multiplier: 86400000 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const maintenanceWindowSchema = z
|
const maintenanceWindowBaseSchema = z.object({
|
||||||
.object({
|
name: z
|
||||||
name: z
|
.string()
|
||||||
.string()
|
.min(1, "Name is required")
|
||||||
.min(1, "Name is required")
|
.max(100, "Name must be at most 100 characters"),
|
||||||
.max(100, "Name must be at most 100 characters"),
|
repeat: z.string(),
|
||||||
repeat: z.string(),
|
startDate: z.string().min(1, "Start date is required"),
|
||||||
startDate: z.string().min(1, "Start date is required"),
|
startTime: z.string().min(1, "Start time is required"),
|
||||||
startTime: z.string().min(1, "Start time is required"),
|
duration: z.number().int().min(1, "Duration must be at least 1"),
|
||||||
duration: z.number().int().min(1, "Duration must be at least 1"),
|
durationUnit: z.string(),
|
||||||
durationUnit: z.string(),
|
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
});
|
||||||
})
|
|
||||||
.refine(
|
export const maintenanceWindowSchema = maintenanceWindowBaseSchema.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
const startDateTime = dayjs(data.startDate)
|
const startDateTime = dayjs(data.startDate)
|
||||||
.set("hour", parseInt(data.startTime.split(":")[0], 10))
|
.set("hour", parseInt(data.startTime.split(":")[0], 10))
|
||||||
.set("minute", parseInt(data.startTime.split(":")[1], 10));
|
.set("minute", parseInt(data.startTime.split(":")[1], 10));
|
||||||
return startDateTime.isAfter(dayjs());
|
return startDateTime.isAfter(dayjs());
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: "Start date and time must be in the future",
|
message: "Start date and time must be in the future",
|
||||||
path: ["startDate"],
|
path: ["startDate"],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const maintenanceWindowEditSchema = maintenanceWindowBaseSchema;
|
||||||
|
|
||||||
export type MaintenanceWindowFormData = z.infer<typeof maintenanceWindowSchema>;
|
export type MaintenanceWindowFormData = z.infer<typeof maintenanceWindowSchema>;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { GeoContinents } from "@/Types/GeoCheck";
|
import { GeoContinents } from "@/Types/GeoCheck";
|
||||||
|
import { DnsRecordTypes, PageSpeedStrategies } from "@/Types/Monitor";
|
||||||
|
|
||||||
// URL schema with custom error message
|
// URL schema with custom error message
|
||||||
const urlSchema = z.url({ message: "Please enter a valid URL" });
|
const urlSchema = z.url({ message: "Please enter a valid URL" });
|
||||||
@@ -89,6 +90,7 @@ const grpcSchema = baseSchema.extend({
|
|||||||
const pagespeedSchema = baseSchema.extend({
|
const pagespeedSchema = baseSchema.extend({
|
||||||
type: z.literal("pagespeed"),
|
type: z.literal("pagespeed"),
|
||||||
url: urlSchema,
|
url: urlSchema,
|
||||||
|
strategy: z.enum(PageSpeedStrategies),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hardware/Infrastructure monitor schema
|
// Hardware/Infrastructure monitor schema
|
||||||
@@ -122,6 +124,29 @@ const websocketSchema = baseSchema.extend({
|
|||||||
ignoreTlsErrors: z.boolean(),
|
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
|
// Discriminated union of all monitor types
|
||||||
export const monitorSchema = z.discriminatedUnion("type", [
|
export const monitorSchema = z.discriminatedUnion("type", [
|
||||||
httpSchema,
|
httpSchema,
|
||||||
@@ -133,6 +158,7 @@ export const monitorSchema = z.discriminatedUnion("type", [
|
|||||||
pagespeedSchema,
|
pagespeedSchema,
|
||||||
hardwareSchema,
|
hardwareSchema,
|
||||||
websocketSchema,
|
websocketSchema,
|
||||||
|
dnsSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -14,11 +14,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appName": "Checkmate",
|
"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": {
|
"breadcrumbs": {
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"home": "Home"
|
"home": "Home"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
|
"selected": "selected",
|
||||||
"addMember": "Add member",
|
"addMember": "Add member",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
@@ -414,6 +426,20 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"monitors": {
|
"monitors": {
|
||||||
|
"bulkEdit": {
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"editButton": "Edit Notifications",
|
||||||
|
"title": "Edit Notifications",
|
||||||
|
"selectedText": "You are applying changes to {{count}} monitor(s).",
|
||||||
|
"actionAdd": "Add",
|
||||||
|
"actionAddDesc": "Adds the selected notifications to these monitors without removing existing ones.",
|
||||||
|
"actionRemove": "Remove",
|
||||||
|
"actionRemoveDesc": "Removes exclusively the selected notifications from these monitors.",
|
||||||
|
"actionSet": "Replace all",
|
||||||
|
"actionSetDesc": "Replaces all existing notifications. Leave empty to clear all notifications.",
|
||||||
|
"selectLabel": "Notification Channels",
|
||||||
|
"selectPlaceholder": "Select channels..."
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"configure": "Configure",
|
"configure": "Configure",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -464,7 +490,8 @@
|
|||||||
"optionPagespeed": "PageSpeed",
|
"optionPagespeed": "PageSpeed",
|
||||||
"optionHardware": "Infrastructure",
|
"optionHardware": "Infrastructure",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket",
|
||||||
|
"optionDns": "DNS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -551,9 +578,29 @@
|
|||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
},
|
},
|
||||||
|
"strategy": {
|
||||||
|
"label": "Strategy",
|
||||||
|
"desktop": "Desktop",
|
||||||
|
"mobile": "Mobile"
|
||||||
|
},
|
||||||
"captureUrl": {
|
"captureUrl": {
|
||||||
"label": "Capture endpoint",
|
"label": "Capture endpoint",
|
||||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
"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",
|
"title": "General settings",
|
||||||
@@ -566,7 +613,8 @@
|
|||||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
"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.",
|
"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.",
|
"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": {
|
"ignoreTls": {
|
||||||
@@ -603,6 +651,7 @@
|
|||||||
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
|
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
|
||||||
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
|
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
|
||||||
"optionPortDescription": "Monitor if a specific port on a server is open.",
|
"optionPortDescription": "Monitor if a specific port on a server is open.",
|
||||||
|
"optionDnsDescription": "Monitor if a specific DNS server is responding.",
|
||||||
"title": "Type"
|
"title": "Type"
|
||||||
},
|
},
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
@@ -1073,6 +1122,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pageSpeed": {
|
"pageSpeed": {
|
||||||
|
"strategy": {
|
||||||
|
"desktop": "Desktop",
|
||||||
|
"mobile": "Mobile"
|
||||||
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"common": {
|
"common": {
|
||||||
"cls": "Cumulative Layout Shift (CLS)",
|
"cls": "Cumulative Layout Shift (CLS)",
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ RUN npm run build
|
|||||||
|
|
||||||
COPY --from=frontend-build /app/client/dist ./public
|
COPY --from=frontend-build /app/client/dist ./public
|
||||||
|
|
||||||
|
RUN chown -R node:node ./public \
|
||||||
|
&& chown -R node:node ./dist \
|
||||||
|
&& chown -R node:node ./scripts
|
||||||
|
|
||||||
|
|
||||||
EXPOSE 52345
|
EXPOSE 52345
|
||||||
|
|
||||||
CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"]
|
CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"]
|
||||||
@@ -26,8 +26,12 @@ RUN cp -r src/templates dist/templates
|
|||||||
|
|
||||||
COPY --from=frontend-build /app/client/dist ./public
|
COPY --from=frontend-build /app/client/dist ./public
|
||||||
|
|
||||||
RUN chmod +x ./scripts/inject-vars.sh
|
RUN chmod +x ./scripts/inject-vars.sh \
|
||||||
|
&& chown -R node:node ./public \
|
||||||
|
&& chown -R node:node ./dist \
|
||||||
|
&& chown -R node:node ./scripts
|
||||||
|
|
||||||
|
|
||||||
EXPOSE 52345
|
EXPOSE 52345
|
||||||
|
|
||||||
CMD ./scripts/inject-vars.sh && node ./dist/index.js
|
CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Checkmate API documentation
|
||||||
|
|
||||||
|
Public-facing API documentation that powers **https://checkmate.so/docs**.
|
||||||
|
|
||||||
|
The site is built with [Astro Starlight](https://starlight.astro.build/) — a static-site generator that produces a fast, modern docs site with zero JavaScript by default. Starlight is open source and self-hostable; there is no third-party account required to publish.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
├── api-content/ ← markdown prose (this folder — edit here)
|
||||||
|
│ ├── introduction.md
|
||||||
|
│ ├── quickstart.md
|
||||||
|
│ ├── authentication.md
|
||||||
|
│ ├── conventions.md
|
||||||
|
│ ├── errors.md
|
||||||
|
│ ├── rate-limiting.md
|
||||||
|
│ └── api-reference/
|
||||||
|
│ └── overview.md
|
||||||
|
└── api/ ← Astro Starlight project (build tool — leave alone unless changing the site)
|
||||||
|
├── package.json
|
||||||
|
├── astro.config.mjs
|
||||||
|
├── src/content.config.ts
|
||||||
|
└── src/styles/checkmate.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contributors editing prose only need to touch `docs/api-content/`**. Astro's content loader picks up the markdown automatically.
|
||||||
|
|
||||||
|
The endpoint reference (every URL + schema) is **auto-generated from [`server/openapi.json`](../../server/openapi.json)** via the `starlight-openapi` plugin. Adding or changing an endpoint means updating that JSON; the public docs pick it up on the next Vercel build.
|
||||||
|
|
||||||
|
## Local preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docs/api
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit http://localhost:4321/docs. Hot-reload works for markdown changes in `docs/api-content/`.
|
||||||
|
|
||||||
|
To produce the static site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # outputs docs/api/dist/
|
||||||
|
npm run preview # serve the built output locally
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying to checkmate.so/docs
|
||||||
|
|
||||||
|
Astro Starlight builds a static site. Deploy on Vercel and reverse-proxy `checkmate.so/docs/*` to it.
|
||||||
|
|
||||||
|
### One-time Vercel setup
|
||||||
|
|
||||||
|
1. Go to https://vercel.com → New Project → import the `bluewave-labs/Checkmate` GitHub repo.
|
||||||
|
2. **Root Directory**: set to `docs/api`.
|
||||||
|
3. **Framework Preset**: Vercel auto-detects Astro from `vercel.json`.
|
||||||
|
4. **Build & Output**: leave defaults (`npm run build` → `dist/`).
|
||||||
|
5. **Production Branch**: `develop`.
|
||||||
|
6. Deploy. Vercel returns a URL like `checkmate-docs.vercel.app`.
|
||||||
|
7. Verify the site renders correctly.
|
||||||
|
|
||||||
|
The deployment auto-rebuilds on every push to `develop` and on every PR (as a preview deploy).
|
||||||
|
|
||||||
|
### Reverse-proxy /docs to Vercel
|
||||||
|
|
||||||
|
In whichever edge proxy fronts `checkmate.so`, route `/docs/*` to the Vercel deployment.
|
||||||
|
|
||||||
|
**Cloudflare Workers:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) {
|
||||||
|
const target = new URL(url.pathname, "https://checkmate-docs.vercel.app");
|
||||||
|
target.search = url.search;
|
||||||
|
return event.respondWith(fetch(target.toString(), event.request));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx:**
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /docs/ {
|
||||||
|
proxy_pass https://checkmate-docs.vercel.app/docs/;
|
||||||
|
proxy_set_header Host checkmate-docs.vercel.app;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `base: "/docs"` setting in `astro.config.mjs` ensures every internal link and asset URL is prefixed correctly so the site works at `checkmate.so/docs/...` rather than the root.
|
||||||
|
|
||||||
|
## Updating the docs
|
||||||
|
|
||||||
|
| Task | What to edit |
|
||||||
|
|---|---|
|
||||||
|
| Fix a typo, clarify wording | `docs/api-content/*.md` |
|
||||||
|
| Add a new narrative page | Add the file to `docs/api-content/`, then add a `slug` entry to `astro.config.mjs` → `sidebar` |
|
||||||
|
| Fix something in the API reference | Edit `server/openapi.json` (or the route spec it's built from) — never edit the generated reference pages |
|
||||||
|
| Change branding / colors | `docs/api/src/styles/checkmate.css` |
|
||||||
|
| Change site config (title, logo, social) | `docs/api/astro.config.mjs` |
|
||||||
|
|
||||||
|
Two rules:
|
||||||
|
|
||||||
|
1. **Never hand-edit the API reference.** It's regenerated on every build. Fix the spec.
|
||||||
|
2. **Test code samples against a real instance before merging.** Wrong code in docs is worse than missing code.
|
||||||
|
|
||||||
|
## Why Astro Starlight (and not Mintlify, Docusaurus, etc.)
|
||||||
|
|
||||||
|
- **No third-party account required.** Self-hostable on any static host.
|
||||||
|
- **Best looking** of the modern docs frameworks per multiple 2026 reviews — Distr, among others, migrated away from Docusaurus specifically for Starlight's design.
|
||||||
|
- **Zero JavaScript by default.** Pages are pure HTML — fast load, perfect Lighthouse scores.
|
||||||
|
- **OpenAPI integration is first-class** via `starlight-openapi`.
|
||||||
|
- **Markdown is the source of truth.** No proprietary formats. Easy to migrate later if needed.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: API reference overview
|
||||||
|
description: All endpoints, grouped by resource. Auto-generated from the OpenAPI spec.
|
||||||
|
---
|
||||||
|
|
||||||
|
# API reference
|
||||||
|
|
||||||
|
The full machine-readable specification is at [`server/openapi.json`](https://github.com/bluewave-labs/Checkmate/blob/develop/server/openapi.json). Mintlify renders an interactive reference from it — each endpoint below is fully documented with request/response schemas, examples, and a built-in "Try it" form.
|
||||||
|
|
||||||
|
## Endpoint groups
|
||||||
|
|
||||||
|
| Group | Description |
|
||||||
|
|---|---|
|
||||||
|
| **auth** | Register, log in, password recovery, manage users |
|
||||||
|
| **invite** | Send and redeem team invites |
|
||||||
|
| **monitors** | Create, update, pause, and delete monitors |
|
||||||
|
| **checks** | Read individual check results and team-wide summaries |
|
||||||
|
| **incidents** | List, filter, and resolve downtime incidents |
|
||||||
|
| **maintenance-window** | Schedule maintenance windows to silence checks |
|
||||||
|
| **notifications** | Configure email, Slack, Discord, and webhook channels |
|
||||||
|
| **status-page** | Manage public status pages |
|
||||||
|
| **geo-checks** | Multi-region check results |
|
||||||
|
| **logs** | Audit and operational logs |
|
||||||
|
| **settings** | Global Checkmate settings |
|
||||||
|
| **queue** | Background-job queue health (operational) |
|
||||||
|
| **diagnostic** | System health and version info |
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Every endpoint except the unauthenticated auth/recovery routes requires:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Authentication](/api/authentication) for how to obtain a token.
|
||||||
|
|
||||||
|
## Conventions recap
|
||||||
|
|
||||||
|
- Base URL: `https://your-checkmate-host/api/v1`
|
||||||
|
- Response envelope: `{ success, msg, data }`
|
||||||
|
- IDs: 24-character MongoDB ObjectIds
|
||||||
|
- Timestamps: ISO 8601 UTC
|
||||||
|
- Durations: integers in milliseconds
|
||||||
|
- Most resources are team-scoped — pass `teamId` where the path includes `/team/...`
|
||||||
|
|
||||||
|
See [Conventions](/api/conventions) for full details.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
---
|
||||||
|
title: Authentication
|
||||||
|
description: Obtain a JWT from /auth/login and send it as a Bearer token on every subsequent request.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
|
||||||
|
Checkmate uses **JWT bearer tokens**. There are no API keys, no OAuth, and no client secrets. Every API user is a regular Checkmate user — the credentials you log in to the web UI with are the credentials you use to obtain a token.
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
1. **Get a token** by calling `POST /auth/login` with your email and password.
|
||||||
|
2. **Send the token** as `Authorization: Bearer <token>` on every subsequent request.
|
||||||
|
3. **Refresh** by calling `POST /auth/login` again before the token expires.
|
||||||
|
|
||||||
|
## Obtaining a token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-checkmate-host/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "you@example.com",
|
||||||
|
"password": "YourPass1!"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
A successful response returns the token plus the authenticated user record:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "OK",
|
||||||
|
"data": {
|
||||||
|
"user": {
|
||||||
|
"_id": "65f1c2a4d8b9e0123456789a",
|
||||||
|
"firstName": "Ada",
|
||||||
|
"lastName": "Lovelace",
|
||||||
|
"email": "you@example.com",
|
||||||
|
"role": ["admin"],
|
||||||
|
"teamId": "65f1c2a4d8b9e01234567890",
|
||||||
|
"createdAt": "2026-04-01T10:00:00.000Z",
|
||||||
|
"updatedAt": "2026-04-15T14:30:00.000Z"
|
||||||
|
},
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store both `token` (for the `Authorization` header) and `user.teamId` (most resource endpoints are scoped by team).
|
||||||
|
|
||||||
|
## Using a token
|
||||||
|
|
||||||
|
Every authenticated request must include:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://your-checkmate-host/api/v1/monitors/team/<teamId> \
|
||||||
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const res = await fetch("https://your-checkmate-host/api/v1/monitors/team/<teamId>", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token lifetime
|
||||||
|
|
||||||
|
Tokens expire after the duration configured by the operator via the `TOKEN_TTL` environment variable. The default is **99 days**.
|
||||||
|
|
||||||
|
There is no separate refresh-token endpoint. When a token is close to expiry — or after a 401 response — call `POST /auth/login` again to obtain a new one.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
The `user.role` claim is an array. Available roles:
|
||||||
|
|
||||||
|
| Role | Capability |
|
||||||
|
|---|---|
|
||||||
|
| `superadmin` | Full access to all teams and global settings. Can promote other users. |
|
||||||
|
| `admin` | Full access within their team. |
|
||||||
|
| `user` | Read access within their team. |
|
||||||
|
| `demo` | Limited demo-mode access. |
|
||||||
|
|
||||||
|
A user can hold multiple roles simultaneously.
|
||||||
|
|
||||||
|
## Registering the first user
|
||||||
|
|
||||||
|
A fresh Checkmate instance has no users. The first call to `POST /auth/register` creates the first **superadmin** and provisions a team for them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-checkmate-host/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"firstName": "Ada",
|
||||||
|
"lastName": "Lovelace",
|
||||||
|
"email": "ada@example.com",
|
||||||
|
"password": "Hunter2!",
|
||||||
|
"role": ["superadmin"],
|
||||||
|
"teamId": ""
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
After the first user exists, additional users are usually created via the invite flow rather than `POST /auth/register`:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /invite/send # superadmin/admin sends an invite
|
||||||
|
POST /auth/register # invitee redeems the invite token
|
||||||
|
```
|
||||||
|
|
||||||
|
You can check whether a superadmin already exists with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://your-checkmate-host/api/v1/auth/users/superadmin
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Password recovery
|
||||||
|
|
||||||
|
The password-recovery flow is three calls, all unauthenticated:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/recovery/request # user requests a recovery email
|
||||||
|
POST /auth/recovery/validate # client validates the recovery token
|
||||||
|
POST /auth/recovery/reset # user sets a new password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging out
|
||||||
|
|
||||||
|
JWTs are stateless — there is no server-side logout. Drop the token from your client storage. If you need to invalidate a token before its expiry (e.g. a compromised device), the operator must rotate the `JWT_SECRET` environment variable, which invalidates every outstanding token.
|
||||||
|
|
||||||
|
Next: [Conventions](/api/conventions).
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
title: Conventions
|
||||||
|
description: Response envelope, request format, identifiers, pagination, and team scoping.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Conventions
|
||||||
|
|
||||||
|
A small number of conventions apply to every endpoint. Once you've internalized them, the per-resource reference is mostly self-explanatory.
|
||||||
|
|
||||||
|
## Request format
|
||||||
|
|
||||||
|
- **Content-Type**: `application/json` for all POST/PUT/PATCH bodies, except multipart uploads (logos, profile images), which use `multipart/form-data`.
|
||||||
|
- **Charset**: UTF-8.
|
||||||
|
- **Method semantics**:
|
||||||
|
- `GET` — read, never mutates state.
|
||||||
|
- `POST` — create, or action that doesn't fit GET/PATCH/DELETE.
|
||||||
|
- `PATCH` — partial update.
|
||||||
|
- `PUT` — full replace.
|
||||||
|
- `DELETE` — delete.
|
||||||
|
|
||||||
|
## Response envelope
|
||||||
|
|
||||||
|
Every JSON response uses the same shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "OK",
|
||||||
|
"data": { /* endpoint-specific payload */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | boolean | `true` on 2xx, `false` on 4xx/5xx |
|
||||||
|
| `msg` | string | Human-readable summary — safe to surface in UIs |
|
||||||
|
| `data` | object \| array \| null | Endpoint-specific payload. Absent or `null` for `204 No Content`. |
|
||||||
|
|
||||||
|
Error responses use the same envelope with `success: false` and `data` typically omitted. See [Errors](/api/errors).
|
||||||
|
|
||||||
|
## Identifiers
|
||||||
|
|
||||||
|
- All resource IDs are **MongoDB ObjectIds** (24-character hex strings):
|
||||||
|
```
|
||||||
|
65f1c2a4d8b9e0123456789a
|
||||||
|
```
|
||||||
|
- Treat IDs as opaque strings. Do not attempt to parse the timestamp or counter components.
|
||||||
|
|
||||||
|
## Team scoping
|
||||||
|
|
||||||
|
Most resources belong to a **team**. After login, the user record contains a `teamId`; pass it as a path parameter wherever the endpoint name includes `/team/...`:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /monitors/team/65f1c2a4d8b9e01234567890
|
||||||
|
GET /incidents/team
|
||||||
|
GET /checks/team
|
||||||
|
```
|
||||||
|
|
||||||
|
Users only see resources belonging to their own team, except for `superadmin`, who can read across teams.
|
||||||
|
|
||||||
|
## Date and time
|
||||||
|
|
||||||
|
- All timestamps in JSON are **ISO 8601** strings in UTC:
|
||||||
|
```
|
||||||
|
"createdAt": "2026-04-01T10:00:00.000Z"
|
||||||
|
```
|
||||||
|
- Durations are typically integers in **milliseconds** (intervals, response times, time windows).
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
Endpoints that return collections accept these optional query parameters:
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `limit` | integer | endpoint-dependent | Maximum rows to return. |
|
||||||
|
| `page` | integer | `0` | Zero-indexed page number. |
|
||||||
|
| `sortBy` | string | endpoint-dependent | Field to sort by. |
|
||||||
|
| `sortOrder` | `asc` \| `desc` | `desc` | Sort direction. |
|
||||||
|
| `filter` | string | — | Endpoint-specific filter (e.g. status). |
|
||||||
|
|
||||||
|
When a collection supports pagination, the response `data` includes the rows plus the total count:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "OK",
|
||||||
|
"data": {
|
||||||
|
"monitors": [ /* … */ ],
|
||||||
|
"filteredMonitors": [ /* … */ ],
|
||||||
|
"summary": { /* counters */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact shapes vary per endpoint. See the [API reference](/api/reference) for each endpoint's response schema.
|
||||||
|
|
||||||
|
## File uploads
|
||||||
|
|
||||||
|
Two endpoints accept files:
|
||||||
|
|
||||||
|
- Status page logo: `POST /status-page` (multipart, field `logo`)
|
||||||
|
- User profile picture: `POST /auth/register`, `PATCH /auth/user/:id` (multipart, field `profileImage`)
|
||||||
|
|
||||||
|
Use `multipart/form-data` and include the JSON fields as separate form parts.
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
|
||||||
|
The server's CORS policy is configured via the `CLIENT_HOST` environment variable. Only requests whose `Origin` matches `CLIENT_HOST` are accepted, and credentials (cookies) are allowed.
|
||||||
|
|
||||||
|
If you're calling the API from a browser on a different origin, the operator must add that origin to `CLIENT_HOST`. Server-to-server calls (curl, your backend) are not subject to CORS.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
The base path includes the API version:
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/v1/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Within `v1`, additive changes (new endpoints, new optional fields) ship without notice. Breaking changes (removed endpoints, changed field types) only ship under a new major version.
|
||||||
|
|
||||||
|
Next: [Errors](/api/errors).
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
title: Errors
|
||||||
|
description: HTTP status codes Checkmate returns, what they mean, and how to handle them.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
|
||||||
|
Failure responses share the same envelope as successful ones, with `success: false` and `data` omitted (or `null`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "Validation failed: email must be a valid email"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always treat the **HTTP status code** as the source of truth for handling logic, and surface `msg` to humans.
|
||||||
|
|
||||||
|
## Status codes
|
||||||
|
|
||||||
|
| Status | Meaning | When you'll see it |
|
||||||
|
|---|---|---|
|
||||||
|
| `200 OK` | Request succeeded with a body | Most GETs, successful mutations that return the new resource |
|
||||||
|
| `204 No Content` | Request succeeded with no body | DELETEs, successful actions like `/status-page/:url/unlock` |
|
||||||
|
| `400 Bad Request` | Malformed request | Missing required path param, malformed JSON, invalid file upload |
|
||||||
|
| `401 Unauthorized` | Missing or invalid JWT | Token expired, token missing, token signed with a different secret |
|
||||||
|
| `403 Forbidden` | Authenticated, but not allowed | Accessing a resource that belongs to a different team, or insufficient role |
|
||||||
|
| `404 Not Found` | Resource doesn't exist | Wrong ID, deleted resource, mistyped path |
|
||||||
|
| `409 Conflict` | Operation would violate a constraint | Duplicate email on register, slug already taken on status page |
|
||||||
|
| `413 Payload Too Large` | File upload exceeded limit | Logo or profile image too big |
|
||||||
|
| `422 Unprocessable Entity` | Request was well-formed but failed validation | Zod / Joi schema rejected the body |
|
||||||
|
| `429 Too Many Requests` | Rate limit exceeded | See [Rate limiting](/api/rate-limiting) |
|
||||||
|
| `500 Internal Server Error` | Unexpected server error | A bug — report it with the request ID |
|
||||||
|
|
||||||
|
## Common shapes
|
||||||
|
|
||||||
|
### 401 — missing or invalid token
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "No auth token provided"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-authenticate by calling `POST /auth/login`.
|
||||||
|
|
||||||
|
### 422 — validation error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "\"email\" must be a valid email"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `msg` describes the first failing field. Re-send the request with the fix.
|
||||||
|
|
||||||
|
### 403 — forbidden
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "Forbidden"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You're authenticated but the resource isn't visible to you. Common cause: passing a `teamId` that isn't your team's.
|
||||||
|
|
||||||
|
### 429 — rate limited
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "Too many requests"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect `RateLimit-*` response headers and back off. See [Rate limiting](/api/rate-limiting).
|
||||||
|
|
||||||
|
## Error handling pattern
|
||||||
|
|
||||||
|
Client code should branch on status code, not on `msg`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
switch (res.status) {
|
||||||
|
case 200:
|
||||||
|
case 204:
|
||||||
|
return body.data;
|
||||||
|
|
||||||
|
case 401:
|
||||||
|
// Token expired or missing — re-authenticate, then retry once.
|
||||||
|
await reauthenticate();
|
||||||
|
return retry();
|
||||||
|
|
||||||
|
case 422:
|
||||||
|
// Surface the validation error to the user.
|
||||||
|
throw new ValidationError(body.msg);
|
||||||
|
|
||||||
|
case 429:
|
||||||
|
// Back off using Retry-After if present.
|
||||||
|
const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
|
||||||
|
await sleep(retryAfter * 1000);
|
||||||
|
return retry();
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Checkmate API ${res.status}: ${body.msg ?? res.statusText}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reporting bugs
|
||||||
|
|
||||||
|
If you see a `500`, capture:
|
||||||
|
|
||||||
|
- The exact request (method, path, body — **redact secrets**)
|
||||||
|
- The full response body and headers
|
||||||
|
- A timestamp in UTC
|
||||||
|
|
||||||
|
Open an issue at https://github.com/bluewave-labs/Checkmate/issues with that information.
|
||||||
|
|
||||||
|
Next: [Rate limiting](/api/rate-limiting).
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
title: Introduction
|
||||||
|
description: Use the Checkmate REST API to manage monitors, incidents, status pages, notifications, and users programmatically.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Checkmate API
|
||||||
|
|
||||||
|
Checkmate exposes a REST API that mirrors everything the web UI can do. Use it to provision monitors from infrastructure-as-code, build internal dashboards, automate incident workflows, or integrate Checkmate into your existing alerting stack.
|
||||||
|
|
||||||
|
The web UI you log in to is itself just a client of this same API.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<your-checkmate-host>/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<your-checkmate-host>` with the domain where you deployed Checkmate. For local development:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:52345/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
All endpoints are relative to this base. Every path on this page is shown without the `/api/v1` prefix — prepend it when making requests.
|
||||||
|
|
||||||
|
## API stability
|
||||||
|
|
||||||
|
The API is at version **`v1`** and is considered stable. Breaking changes will only ship under a new major version (`/api/v2`).
|
||||||
|
|
||||||
|
## What you can do
|
||||||
|
|
||||||
|
| Resource | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| `monitors` | Create, update, pause, and delete monitors of any type (HTTP, Ping, Port, DNS, Docker, PageSpeed, gRPC, WebSocket, Game, Hardware) |
|
||||||
|
| `checks` | Read individual check results and aggregated stats |
|
||||||
|
| `incidents` | List, filter, and resolve downtime incidents |
|
||||||
|
| `notifications` | Manage notification channels (email, Slack, Discord, webhooks) |
|
||||||
|
| `status-page` | Configure public status pages |
|
||||||
|
| `maintenance-window` | Schedule maintenance windows so checks don't fire incidents |
|
||||||
|
| `geo-checks` | Multi-region check results |
|
||||||
|
| `auth` | Register, log in, manage users |
|
||||||
|
| `invite` | Invite teammates |
|
||||||
|
| `logs` | Audit and operational logs |
|
||||||
|
| `settings` | Tune global Checkmate settings |
|
||||||
|
| `queue` | Inspect the background-job queue (operational) |
|
||||||
|
| `diagnostic` | Health and system info |
|
||||||
|
|
||||||
|
## What you'll need
|
||||||
|
|
||||||
|
1. A running Checkmate instance you administer.
|
||||||
|
2. A user account on that instance.
|
||||||
|
3. A JWT obtained by calling `POST /auth/login` — see [Authentication](/api/authentication).
|
||||||
|
|
||||||
|
There is no separate "API key" concept in Checkmate. The same JWT that authenticates the web UI authenticates every API call.
|
||||||
|
|
||||||
|
## Response shape
|
||||||
|
|
||||||
|
Every endpoint returns the same envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "OK",
|
||||||
|
"data": { /* endpoint-specific payload */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Conventions](/api/conventions) for the full contract and [Errors](/api/errors) for failure shapes.
|
||||||
|
|
||||||
|
## Quick example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Log in to get a token
|
||||||
|
TOKEN=$(curl -s -X POST https://your-checkmate-host/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"you@example.com","password":"YourPass1!"}' \
|
||||||
|
| jq -r '.data.token')
|
||||||
|
|
||||||
|
# 2. List your team's monitors
|
||||||
|
curl https://your-checkmate-host/api/v1/monitors/team/<teamId> \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Next: [Authentication](/api/authentication).
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
title: Quickstart
|
||||||
|
description: From zero to a working monitor in under five minutes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quickstart
|
||||||
|
|
||||||
|
This walkthrough creates a Checkmate user, logs in, provisions an HTTP monitor, and reads its checks — all via the API.
|
||||||
|
|
||||||
|
It assumes you have a Checkmate instance running and reachable. Substitute `https://your-checkmate-host` with your actual host throughout.
|
||||||
|
|
||||||
|
## 1. Register the first user
|
||||||
|
|
||||||
|
If your instance is fresh, the first user becomes a superadmin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-checkmate-host/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"firstName": "Ada",
|
||||||
|
"lastName": "Lovelace",
|
||||||
|
"email": "ada@example.com",
|
||||||
|
"password": "Hunter2!Hunter2!",
|
||||||
|
"role": ["superadmin"],
|
||||||
|
"teamId": ""
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The response includes a JWT and a freshly-created `teamId`. Save both.
|
||||||
|
|
||||||
|
If a superadmin already exists, skip to step 2 and use existing credentials.
|
||||||
|
|
||||||
|
## 2. Log in
|
||||||
|
|
||||||
|
If you already have an account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s -X POST https://your-checkmate-host/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"ada@example.com","password":"Hunter2!Hunter2!"}' \
|
||||||
|
| jq -r '.data.token')
|
||||||
|
|
||||||
|
TEAM_ID=$(curl -s -X POST https://your-checkmate-host/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"ada@example.com","password":"Hunter2!Hunter2!"}' \
|
||||||
|
| jq -r '.data.user.teamId')
|
||||||
|
|
||||||
|
echo "Token: $TOKEN"
|
||||||
|
echo "Team: $TEAM_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Create a monitor
|
||||||
|
|
||||||
|
This creates an HTTP monitor that probes example.com every 60 seconds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-checkmate-host/api/v1/monitors \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"name\": \"example.com\",
|
||||||
|
\"type\": \"http\",
|
||||||
|
\"url\": \"https://example.com\",
|
||||||
|
\"interval\": 60000,
|
||||||
|
\"teamId\": \"$TEAM_ID\",
|
||||||
|
\"isActive\": true
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
A successful response returns the new monitor including its `_id`.
|
||||||
|
|
||||||
|
## 4. List your team's monitors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://your-checkmate-host/api/v1/monitors/team/$TEAM_ID \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the monitor you just created. Within a minute, the background worker will have run a few checks against it.
|
||||||
|
|
||||||
|
## 5. Read recent checks
|
||||||
|
|
||||||
|
Replace `<MONITOR_ID>` with the `_id` from step 3:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://your-checkmate-host/api/v1/checks/<MONITOR_ID>?limit=10" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see an array of check results — status code, response time, timestamp, region (if applicable).
|
||||||
|
|
||||||
|
## 6. Pause and delete the monitor
|
||||||
|
|
||||||
|
Pause:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://your-checkmate-host/api/v1/monitors/<MONITOR_ID> \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"isActive": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE https://your-checkmate-host/api/v1/monitors/<MONITOR_ID> \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
- **Set up notifications** — `POST /notifications` to wire email / Slack / Discord / webhook channels, then attach them to monitors.
|
||||||
|
- **Build a status page** — `POST /status-page` to publish your monitors publicly.
|
||||||
|
- **Schedule maintenance** — `POST /maintenance-window` to silence checks during planned downtime.
|
||||||
|
- **Browse the full API reference** — see every endpoint with request/response schemas.
|
||||||
|
|
||||||
|
See the [reference](/api/reference) for the complete list of endpoints.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
title: Rate limiting
|
||||||
|
description: Per-IP request limits, headers, and how to back off.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
|
||||||
|
Checkmate applies two per-IP rate limiters, both with a **60-second window**:
|
||||||
|
|
||||||
|
| Limiter | Applies to | Limit |
|
||||||
|
|---|---|---|
|
||||||
|
| General | All requests under `/api/v1/*` | **600 requests / 60 seconds** |
|
||||||
|
| Auth-sensitive | `/auth/login`, `/auth/register`, `/auth/recovery/*`, `/status-page/:url/unlock` | **15 requests / 60 seconds** |
|
||||||
|
|
||||||
|
The two limiters are **independent**. Auth-sensitive endpoints count against both buckets — you can hammer `/auth/login` 15 times in a minute before being rate-limited there, but a single client can never exceed 600 total requests per minute regardless of which endpoints are called.
|
||||||
|
|
||||||
|
## Headers
|
||||||
|
|
||||||
|
Every response includes the standard `RateLimit-*` headers so you can pace yourself proactively:
|
||||||
|
|
||||||
|
```
|
||||||
|
RateLimit-Limit: 600
|
||||||
|
RateLimit-Remaining: 587
|
||||||
|
RateLimit-Reset: 42
|
||||||
|
```
|
||||||
|
|
||||||
|
| Header | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `RateLimit-Limit` | The cap for the current window. |
|
||||||
|
| `RateLimit-Remaining` | Requests left before you hit `429`. |
|
||||||
|
| `RateLimit-Reset` | Seconds until the window resets. |
|
||||||
|
|
||||||
|
## When you exceed the limit
|
||||||
|
|
||||||
|
You receive `HTTP 429 Too Many Requests`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "Too many requests"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The response also includes `Retry-After`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Retry-After: 42
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait that many seconds before retrying. If `Retry-After` is missing, fall back to the `RateLimit-Reset` header.
|
||||||
|
|
||||||
|
## Recommended backoff
|
||||||
|
|
||||||
|
For one-off scripts, simple sleep-then-retry is fine:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function callWithRetry(url, opts, attempts = 3) {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (res.status !== 429) return res;
|
||||||
|
const retry = Number(res.headers.get("Retry-After") ?? 60);
|
||||||
|
await new Promise(r => setTimeout(r, retry * 1000));
|
||||||
|
}
|
||||||
|
throw new Error("Rate limit retry exhausted");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For higher-throughput integrations, watch `RateLimit-Remaining` and pace your sender to stay below the limit rather than waiting for `429`.
|
||||||
|
|
||||||
|
## Limits and proxies
|
||||||
|
|
||||||
|
Rate-limiting is per **client IP**, derived from the connection — or, when Checkmate is behind a reverse proxy with `trust proxy` configured, from the leftmost public IP in `X-Forwarded-For`.
|
||||||
|
|
||||||
|
If many of your clients share an egress IP (NAT, corporate proxy), they share the bucket. In that case, an operator should either:
|
||||||
|
|
||||||
|
- Increase the limits (server code change required), or
|
||||||
|
- Make sure each client connects from a distinct IP.
|
||||||
|
|
||||||
|
## Limits are not contractual
|
||||||
|
|
||||||
|
The numbers above reflect the current defaults. An operator can change them at any time (they're hardcoded in `server/src/middleware/rateLimiter.ts`). If you're building an integration that depends on a higher limit, coordinate with the operator first.
|
||||||
|
|
||||||
|
Next: [API reference](/api/reference) — auto-generated from the OpenAPI spec.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Astro build
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# Sources copied in at build time by scripts/sync-sources.mjs.
|
||||||
|
# Edit /docs/api-content/ and /server/openapi.json instead.
|
||||||
|
src/vendored/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
.vercel
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import starlight from "@astrojs/starlight";
|
||||||
|
import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi";
|
||||||
|
|
||||||
|
// The OpenAPI spec is vendored from server/openapi.json by
|
||||||
|
// scripts/sync-sources.mjs (runs as predev/prebuild). We point at the
|
||||||
|
// vendored copy so the build is self-contained — Vercel only uploads
|
||||||
|
// the docs/api/ subtree, so paths outside this directory are not
|
||||||
|
// available at build time.
|
||||||
|
const OPENAPI_PATH = "./src/vendored/openapi.json";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// The deployed site serves at the root of its Vercel domain. The
|
||||||
|
// reverse proxy that maps checkmate.so/docs/* → Vercel must strip
|
||||||
|
// the /docs/ prefix before forwarding (the Cloudflare Worker snippet
|
||||||
|
// in docs/api-content/README.md does this).
|
||||||
|
site: "https://checkmate.so",
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
starlight({
|
||||||
|
title: "Checkmate API",
|
||||||
|
description: "Use the Checkmate REST API to manage monitors, incidents, status pages, notifications, and users programmatically.",
|
||||||
|
|
||||||
|
// Prose markdown is loaded from /docs/api-content via the custom
|
||||||
|
// docsLoader in src/content.config.ts. Keeping content outside the
|
||||||
|
// Astro project tree means contributors can edit it without dealing
|
||||||
|
// with Astro / Starlight tooling.
|
||||||
|
|
||||||
|
logo: {
|
||||||
|
src: "./src/assets/logo.svg",
|
||||||
|
replacesTitle: true,
|
||||||
|
},
|
||||||
|
favicon: "/favicon.svg",
|
||||||
|
customCss: ["./src/styles/checkmate.css"],
|
||||||
|
|
||||||
|
social: {
|
||||||
|
github: "https://github.com/bluewave-labs/Checkmate",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inject a "Releases" link into the site header.
|
||||||
|
components: {
|
||||||
|
SocialIcons: "./src/components/HeaderLinks.astro",
|
||||||
|
},
|
||||||
|
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
label: "Getting started",
|
||||||
|
items: [
|
||||||
|
{ label: "Introduction", slug: "" },
|
||||||
|
{ label: "Quickstart", slug: "quickstart" },
|
||||||
|
{ label: "Authentication", slug: "authentication" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Core concepts",
|
||||||
|
items: [
|
||||||
|
{ label: "Conventions", slug: "conventions" },
|
||||||
|
{ label: "Errors", slug: "errors" },
|
||||||
|
{ label: "Rate limiting", slug: "rate-limiting" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "API reference",
|
||||||
|
items: [
|
||||||
|
{ label: "Overview", slug: "api-reference/overview" },
|
||||||
|
// The OpenAPI plugin appends every endpoint under this group.
|
||||||
|
...openAPISidebarGroups,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
starlightOpenAPI([
|
||||||
|
{
|
||||||
|
base: "api-reference",
|
||||||
|
label: "REST API",
|
||||||
|
schema: OPENAPI_PATH,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+7983
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "checkmate-docs-api",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Public API documentation for Checkmate. Built with Astro Starlight.",
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "node ./scripts/sync-sources.mjs",
|
||||||
|
"predev": "node ./scripts/sync-sources.mjs",
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/starlight": "^0.32.0",
|
||||||
|
"astro": "^5.0.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"starlight-openapi": "^0.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Copies external sources into the Astro project tree so the build is
|
||||||
|
// self-contained. Required because Vercel's "Root Directory" setting
|
||||||
|
// only uploads the docs/api/ subtree, leaving sibling/parent paths
|
||||||
|
// (server/openapi.json, docs/api-content/) outside the build context.
|
||||||
|
//
|
||||||
|
// Run automatically by predev / prebuild via package.json. If the
|
||||||
|
// external paths exist, they win; otherwise we assume the files were
|
||||||
|
// already vendored (e.g. by a previous run) and continue.
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, cpSync, copyFileSync, statSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PROJECT_ROOT = join(__dirname, "..");
|
||||||
|
const REPO_ROOT = join(PROJECT_ROOT, "..", "..");
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
{
|
||||||
|
// Markdown prose (the public docs content).
|
||||||
|
from: join(REPO_ROOT, "docs", "api-content"),
|
||||||
|
to: join(PROJECT_ROOT, "src", "vendored", "api-content"),
|
||||||
|
kind: "dir",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OpenAPI spec — read at build time by starlight-openapi.
|
||||||
|
from: join(REPO_ROOT, "server", "openapi.json"),
|
||||||
|
to: join(PROJECT_ROOT, "src", "vendored", "openapi.json"),
|
||||||
|
kind: "file",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
for (const src of SOURCES) {
|
||||||
|
if (!existsSync(src.from)) {
|
||||||
|
if (existsSync(src.to)) {
|
||||||
|
console.log(`[sync-sources] ${src.from} not present; using existing vendored copy.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.error(`[sync-sources] ERROR: source missing and no vendored copy: ${src.from}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(src.to), { recursive: true });
|
||||||
|
if (src.kind === "dir") {
|
||||||
|
cpSync(src.from, src.to, { recursive: true });
|
||||||
|
} else {
|
||||||
|
copyFileSync(src.from, src.to);
|
||||||
|
}
|
||||||
|
const stat = statSync(src.from);
|
||||||
|
console.log(`[sync-sources] ${src.from} → ${src.to} (${stat.isDirectory() ? "dir" : `${stat.size} bytes`})`);
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[sync-sources] OK (${synced} sources copied)`);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 32" fill="none">
|
||||||
|
<rect x="2" y="6" width="20" height="20" rx="4" fill="#13715B"/>
|
||||||
|
<path d="M7 16l3.5 3.5L17 12" stroke="white" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="30" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Inter', sans-serif" font-size="18" font-weight="600" fill="currentColor">Checkmate</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
// Renders next to the search/theme controls in Starlight's header.
|
||||||
|
// Adds a "Releases" link plus delegates to the default SocialIcons.
|
||||||
|
import Default from "@astrojs/starlight/components/SocialIcons.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<a class="header-link" href="/releases">Releases</a>
|
||||||
|
<Default><slot /></Default>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header-link {
|
||||||
|
color: var(--sl-color-white);
|
||||||
|
font-size: var(--sl-text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.header-link:hover {
|
||||||
|
background-color: var(--sl-color-gray-5);
|
||||||
|
}
|
||||||
|
:global([data-theme="light"]) .header-link {
|
||||||
|
color: var(--sl-color-gray-6);
|
||||||
|
}
|
||||||
|
:global([data-theme="light"]) .header-link:hover {
|
||||||
|
background-color: var(--sl-color-gray-1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
headline: string;
|
||||||
|
highlights: string[];
|
||||||
|
isLatest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version, date, headline, highlights, isLatest = false } = Astro.props;
|
||||||
|
|
||||||
|
const formattedDate = new Date(date).toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="release-card">
|
||||||
|
<aside class="release-meta">
|
||||||
|
<time datetime={date}>{formattedDate}</time>
|
||||||
|
<span class="release-version">
|
||||||
|
{version}
|
||||||
|
{isLatest && <span class="latest-badge">Latest</span>}
|
||||||
|
</span>
|
||||||
|
</aside>
|
||||||
|
<div class="release-content">
|
||||||
|
<h2 class="release-headline">{headline}</h2>
|
||||||
|
<ul class="release-highlights">
|
||||||
|
{highlights.map((item) => <li>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.release-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 2.5rem;
|
||||||
|
padding: 2rem 0;
|
||||||
|
border-bottom: 1px solid var(--sl-color-gray-5);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-meta time {
|
||||||
|
color: var(--sl-color-gray-3);
|
||||||
|
font-size: var(--sl-text-sm);
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-version {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--sl-color-white);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--sl-text-base);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-badge {
|
||||||
|
background: var(--sl-color-accent);
|
||||||
|
color: var(--sl-color-white);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-headline {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: var(--sl-text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sl-color-white);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-highlights {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-highlights li {
|
||||||
|
color: var(--sl-color-gray-2);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: var(--sl-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-highlights li::marker {
|
||||||
|
color: var(--sl-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .release-card {
|
||||||
|
border-bottom-color: var(--sl-color-gray-2);
|
||||||
|
}
|
||||||
|
:global([data-theme="light"]) .release-version,
|
||||||
|
:global([data-theme="light"]) .release-headline {
|
||||||
|
color: var(--sl-color-gray-6);
|
||||||
|
}
|
||||||
|
:global([data-theme="light"]) .release-highlights li {
|
||||||
|
color: var(--sl-color-gray-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.release-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.release-meta {
|
||||||
|
position: static;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineCollection } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
import { docsSchema } from "@astrojs/starlight/schema";
|
||||||
|
|
||||||
|
// Load markdown from a sibling folder so prose lives outside the Astro
|
||||||
|
// project tree. Contributors edit /docs/api-content/*.md without touching
|
||||||
|
// the build tool.
|
||||||
|
//
|
||||||
|
// Starlight's built-in docsLoader is hardcoded to src/content/docs/, so we
|
||||||
|
// use Astro's plain glob loader with a custom base and apply Starlight's
|
||||||
|
// schema on top. The collection name must remain "docs" — Starlight looks
|
||||||
|
// it up by that exact name.
|
||||||
|
export const collections = {
|
||||||
|
docs: defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
// Vendored from /docs/api-content/ by scripts/sync-sources.mjs.
|
||||||
|
// This indirection keeps the build self-contained for Vercel
|
||||||
|
// (whose Root Directory setting limits the upload to docs/api/).
|
||||||
|
base: "./src/vendored/api-content",
|
||||||
|
// README is contributor-facing docs (how to build the site),
|
||||||
|
// not part of the published content.
|
||||||
|
pattern: ["**/[^_]*.{md,mdx}", "!README.md"],
|
||||||
|
}),
|
||||||
|
schema: docsSchema(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Source of truth for the Releases timeline. Newest first.
|
||||||
|
// Each entry should focus on user-facing features — leave routine
|
||||||
|
// bumps, lint fixes, and dependency updates out.
|
||||||
|
|
||||||
|
export interface Release {
|
||||||
|
version: string;
|
||||||
|
date: string; // ISO 8601 yyyy-mm-dd
|
||||||
|
headline: string;
|
||||||
|
highlights: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const releases: Release[] = [
|
||||||
|
{
|
||||||
|
version: "v3.7.1",
|
||||||
|
date: "2026-04-29",
|
||||||
|
headline: "Status page themes and a polished incidents page",
|
||||||
|
highlights: [
|
||||||
|
"Pick a theme for your public status page: Refined, Modern, Bold, or Editorial",
|
||||||
|
"Choose between light, dark, or auto theme modes",
|
||||||
|
"Polished incidents page with better filtering and grouping",
|
||||||
|
"New empty state for the infrastructure network view",
|
||||||
|
"Remove-monitors confirmation modal in settings",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v3.6.1",
|
||||||
|
date: "2026-04-23",
|
||||||
|
headline: "Twilio SMS notifications and incident comments",
|
||||||
|
highlights: [
|
||||||
|
"Send incident alerts via SMS using Twilio",
|
||||||
|
"Resolve incidents with a comment so context is preserved",
|
||||||
|
"Login page no longer shows the register tip when registration is closed",
|
||||||
|
"Removed TimescaleDB dependency for a leaner deployment",
|
||||||
|
"New monitor-lifecycle documentation",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v3.5.1",
|
||||||
|
date: "2026-03-06",
|
||||||
|
headline: "Uptime over WebSockets, Globalping ping, and team member removal",
|
||||||
|
highlights: [
|
||||||
|
"Real-time uptime updates over WebSockets — no more polling delays",
|
||||||
|
"Use Globalping as the engine for distributed ping monitors",
|
||||||
|
"Admins can remove team members from the team settings page",
|
||||||
|
"Fixed dark-mode background and several theme contrast issues",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v3.5.0",
|
||||||
|
date: "2026-03-04",
|
||||||
|
headline: "SWR data layer and tightened validation",
|
||||||
|
highlights: [
|
||||||
|
"Global SWR-based data fetching for snappier navigation and automatic revalidation",
|
||||||
|
"Stricter server-side validation across all monitor types",
|
||||||
|
"Env-var validation surfaces misconfiguration at startup, not at first failure",
|
||||||
|
"Logs page improvements for easier debugging",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v3.4.0",
|
||||||
|
date: "2026-02-20",
|
||||||
|
headline: "Settings refactor and status page access fixes",
|
||||||
|
highlights: [
|
||||||
|
"Settings UI rebuilt for clarity and consistency",
|
||||||
|
"Fixed access rules so unpublished status pages remain admin-only",
|
||||||
|
"Several caching fixes for fresher data after configuration changes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v3.3",
|
||||||
|
date: "2026-01-27",
|
||||||
|
headline: "PageSpeed v2 details and a faster uptime details page",
|
||||||
|
highlights: [
|
||||||
|
"Rebuilt PageSpeed details view with deeper Core Web Vitals breakdown",
|
||||||
|
"v2 uptime details page with denser charts and faster loading",
|
||||||
|
"Recent-checks endpoint redesigned for lower latency",
|
||||||
|
"PageSpeed tooltips clarify what each metric means",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v3.2.0",
|
||||||
|
date: "2025-09-18",
|
||||||
|
headline: "Notification bodies and uptime % charts",
|
||||||
|
highlights: [
|
||||||
|
"Customise the body of email, Slack, Discord, and webhook notifications",
|
||||||
|
"Toggle between absolute uptime and uptime % on monitor charts",
|
||||||
|
"Sliding-window threshold evaluation for more stable status decisions",
|
||||||
|
"Skip-maintenance notifications no longer fire during maintenance windows",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v3.1",
|
||||||
|
date: "2025-08-20",
|
||||||
|
headline: "N-of-M alert thresholds and PagerDuty",
|
||||||
|
highlights: [
|
||||||
|
"Alert only after N consecutive failures out of M checks — fewer flaky alerts",
|
||||||
|
"PagerDuty notification channel",
|
||||||
|
"Streamlined maintenance window creation flow",
|
||||||
|
"Network service refactor for more reliable cross-region checks",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v2.3",
|
||||||
|
date: "2025-07-08",
|
||||||
|
headline: "Resolvable incidents and a cleaner incidents UI",
|
||||||
|
highlights: [
|
||||||
|
"Mark incidents as resolved from the UI with a single click",
|
||||||
|
"Redesigned incidents page with better grouping",
|
||||||
|
"Move the status-page delete action out of harm's way",
|
||||||
|
"Configurable behavior when no notifications are attached to a monitor",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v2.2",
|
||||||
|
date: "2025-07-07",
|
||||||
|
headline: "Notification management and i18n polish",
|
||||||
|
highlights: [
|
||||||
|
"Delete notification channels with confirmation",
|
||||||
|
"Sorted translation keys for easier maintenance",
|
||||||
|
"Many small i18n fixes across pages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v2.1",
|
||||||
|
date: "2025-05-13",
|
||||||
|
headline: "Maintenance window improvements and form fixes",
|
||||||
|
highlights: [
|
||||||
|
"Settings update flow rebuilt",
|
||||||
|
"Fixed monitor resume / pause behavior",
|
||||||
|
"Better PageSpeed reliability",
|
||||||
|
"Infrastructure monitor filtering improvements",
|
||||||
|
"Conditional alert rendering during maintenance windows",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v2.0",
|
||||||
|
date: "2024-12-04",
|
||||||
|
headline: "Major UI refresh and password security",
|
||||||
|
highlights: [
|
||||||
|
"Major frontend redesign with a refreshed theme",
|
||||||
|
"Improved password validation and error messages",
|
||||||
|
"Many fixes to incident detail views, colors, and responsiveness",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.1.0",
|
||||||
|
date: "2024-10-11",
|
||||||
|
headline: "Maintenance windows and JWT-expiry handling",
|
||||||
|
highlights: [
|
||||||
|
"Schedule maintenance windows so checks don't fire false incidents",
|
||||||
|
"Auto-logout users when their JWT expires",
|
||||||
|
"Centralised backend error handling",
|
||||||
|
"Initial maintenance-page feature",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.0",
|
||||||
|
date: "2024-09-23",
|
||||||
|
headline: "First public release",
|
||||||
|
highlights: [
|
||||||
|
"HTTP, ping, port, and PageSpeed monitors",
|
||||||
|
"Incident detection with email and Discord notifications",
|
||||||
|
"Public status pages",
|
||||||
|
"Team management with role-based access",
|
||||||
|
"Open source under the AGPL — self-host the entire stack",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||||
|
import ReleaseCard from "../components/ReleaseCard.astro";
|
||||||
|
import { releases } from "../data/releases";
|
||||||
|
---
|
||||||
|
|
||||||
|
<StarlightPage
|
||||||
|
frontmatter={{
|
||||||
|
title: "Releases",
|
||||||
|
description: "What's new in Checkmate — every release with the most important features.",
|
||||||
|
template: "splash",
|
||||||
|
pagefind: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="releases-intro">
|
||||||
|
<p>
|
||||||
|
Every meaningful Checkmate release, newest first. For the
|
||||||
|
complete commit history, see the
|
||||||
|
<a href="https://github.com/bluewave-labs/Checkmate/releases" rel="noopener">GitHub releases page</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="releases-timeline">
|
||||||
|
{releases.map((release, index) => (
|
||||||
|
<ReleaseCard
|
||||||
|
version={release.version}
|
||||||
|
date={release.date}
|
||||||
|
headline={release.headline}
|
||||||
|
highlights={release.highlights}
|
||||||
|
isLatest={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</StarlightPage>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.releases-intro {
|
||||||
|
max-width: 60ch;
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
color: var(--sl-color-gray-3);
|
||||||
|
font-size: var(--sl-text-base);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.releases-intro a {
|
||||||
|
color: var(--sl-color-accent-high);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.releases-timeline {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="light"]) .releases-intro {
|
||||||
|
color: var(--sl-color-gray-5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/* Checkmate brand colors layered on top of Starlight defaults. */
|
||||||
|
:root {
|
||||||
|
--sl-color-accent-low: #0e5a48;
|
||||||
|
--sl-color-accent: #13715b;
|
||||||
|
--sl-color-accent-high: #1a8a6f;
|
||||||
|
|
||||||
|
--sl-color-white: #ffffff;
|
||||||
|
--sl-color-gray-1: #eef2f0;
|
||||||
|
--sl-color-gray-2: #c9d3cf;
|
||||||
|
--sl-color-gray-3: #95a39d;
|
||||||
|
--sl-color-gray-4: #5c6d66;
|
||||||
|
--sl-color-gray-5: #3a4844;
|
||||||
|
--sl-color-gray-6: #2a3531;
|
||||||
|
--sl-color-black: #1a201e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode tweaks so the green accent reads against white. */
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--sl-color-accent-low: #d0e4dc;
|
||||||
|
--sl-color-accent: #13715b;
|
||||||
|
--sl-color-accent-high: #0e5a48;
|
||||||
|
--sl-color-text-accent: #0e5a48;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"outputDirectory": "dist",
|
||||||
|
"framework": "astro",
|
||||||
|
"installCommand": "npm install"
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const config: Config = {
|
|||||||
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
||||||
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
||||||
"^@/(.*)$": "<rootDir>/src/$1",
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
|
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||||
},
|
},
|
||||||
testMatch: ["<rootDir>/test/**/*.test.ts"],
|
testMatch: ["<rootDir>/test/**/*.test.ts"],
|
||||||
setupFilesAfterEnv: [],
|
setupFilesAfterEnv: [],
|
||||||
|
|||||||
+57
-12
@@ -346,7 +346,7 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -436,6 +436,13 @@
|
|||||||
"geoCheckInterval": {
|
"geoCheckInterval": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"dnsServer": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dnsRecordType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
|
||||||
|
},
|
||||||
"teamId": {
|
"teamId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2981,7 +2988,7 @@
|
|||||||
{
|
{
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
"required": false,
|
"required": false,
|
||||||
"name": "type",
|
"name": "type",
|
||||||
@@ -3416,7 +3423,7 @@
|
|||||||
{
|
{
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
"required": false,
|
"required": false,
|
||||||
"name": "type",
|
"name": "type",
|
||||||
@@ -4664,7 +4671,8 @@
|
|||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"]
|
"required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5208,7 +5216,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["seconds", "minutes", "hours", "days"]
|
"enum": ["seconds", "minutes", "hours", "days"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5439,13 +5448,13 @@
|
|||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -5648,13 +5657,13 @@
|
|||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -6862,7 +6871,7 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
"statusWindowSize": {
|
"statusWindowSize": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -6975,6 +6984,10 @@
|
|||||||
],
|
],
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
|
"strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desktop", "mobile"]
|
||||||
|
},
|
||||||
"selectedDisks": {
|
"selectedDisks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -7009,6 +7022,14 @@
|
|||||||
"geoCheckInterval": {
|
"geoCheckInterval": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 300000
|
"minimum": 300000
|
||||||
|
},
|
||||||
|
"dnsServer": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"dnsRecordType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["name", "type", "url"]
|
"required": ["name", "type", "url"]
|
||||||
@@ -7496,7 +7517,7 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
"ignoreTlsErrors": {
|
"ignoreTlsErrors": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@@ -7630,6 +7651,10 @@
|
|||||||
],
|
],
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
|
"strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desktop", "mobile"]
|
||||||
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -7659,6 +7684,14 @@
|
|||||||
"minimum": 300000,
|
"minimum": 300000,
|
||||||
"default": 300000
|
"default": 300000
|
||||||
},
|
},
|
||||||
|
"dnsServer": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"dnsRecordType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -8034,7 +8067,7 @@
|
|||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
|
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -8153,6 +8186,10 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desktop", "mobile"]
|
||||||
|
},
|
||||||
"selectedDisks": {
|
"selectedDisks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -8187,6 +8224,14 @@
|
|||||||
"geoCheckInterval": {
|
"geoCheckInterval": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 300000
|
"minimum": 300000
|
||||||
|
},
|
||||||
|
"dnsServer": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"dnsRecordType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+67
-52
@@ -21,7 +21,7 @@
|
|||||||
"dockerode": "4.0.6",
|
"dockerode": "4.0.6",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "8.3.2",
|
"express-rate-limit": "^8.5.1",
|
||||||
"gamedig": "5.2.0",
|
"gamedig": "5.2.0",
|
||||||
"got": "14.4.7",
|
"got": "14.4.7",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
@@ -811,14 +811,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/xml-builder": {
|
"node_modules/@aws-sdk/xml-builder": {
|
||||||
"version": "3.972.16",
|
"version": "3.972.22",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz",
|
||||||
"integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
|
"integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.13.1",
|
"@nodable/entities": "2.1.0",
|
||||||
"fast-xml-parser": "5.5.8",
|
"@smithy/types": "^4.14.1",
|
||||||
|
"fast-xml-parser": "5.7.2",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -865,7 +866,6 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -1485,7 +1485,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -1530,7 +1529,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3193,6 +3191,19 @@
|
|||||||
"@tybys/wasm-util": "^0.10.0"
|
"@tybys/wasm-util": "^0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -3757,9 +3768,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/types": {
|
"node_modules/@smithy/types": {
|
||||||
"version": "4.13.1",
|
"version": "4.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
|
||||||
"integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
|
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4244,7 +4255,6 @@
|
|||||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -4405,7 +4415,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -4643,7 +4652,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -5257,7 +5265,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5836,7 +5843,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@@ -6730,7 +6736,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
|
||||||
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
|
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssnano-preset-default": "^7.0.9",
|
"cssnano-preset-default": "^7.0.9",
|
||||||
"lilconfig": "^3.1.3"
|
"lilconfig": "^3.1.3"
|
||||||
@@ -7495,7 +7500,6 @@
|
|||||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -7857,7 +7861,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -7900,12 +7903,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.3.2",
|
"version": "8.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "10.1.0"
|
"ip-address": "^10.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -7990,9 +7993,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.4",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8002,13 +8005,14 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-expression-matcher": "^1.1.3"
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"xml-naming": "^0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "5.5.8",
|
"version": "5.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz",
|
||||||
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
|
"integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8018,9 +8022,10 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-xml-builder": "^1.1.4",
|
"@nodable/entities": "^2.1.0",
|
||||||
"path-expression-matcher": "^1.2.0",
|
"fast-xml-builder": "^1.1.5",
|
||||||
"strnum": "^2.2.0"
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"strnum": "^2.2.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
@@ -9097,9 +9102,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -9526,7 +9531,6 @@
|
|||||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "30.2.0",
|
"@jest/core": "30.2.0",
|
||||||
"@jest/types": "30.2.0",
|
"@jest/types": "30.2.0",
|
||||||
@@ -11543,9 +11547,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mongoose": {
|
"node_modules/mongoose": {
|
||||||
"version": "8.19.2",
|
"version": "8.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz",
|
||||||
"integrity": "sha512-ww2T4dBV+suCbOfG5YPwj9pLCfUVyj8FEA1D3Ux1HHqutpLxGyOYEPU06iPRBW4cKr3PJfOSYsIpHWPTkz5zig==",
|
"integrity": "sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bson": "^6.10.4",
|
"bson": "^6.10.4",
|
||||||
@@ -12154,9 +12158,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-expression-matcher": {
|
"node_modules/path-expression-matcher": {
|
||||||
"version": "1.2.1",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||||
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
|
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12371,7 +12375,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -14210,9 +14213,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.2.2",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
||||||
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -14394,7 +14397,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -14563,7 +14565,6 @@
|
|||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
@@ -14727,7 +14728,6 @@
|
|||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -15390,6 +15390,22 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",
|
||||||
@@ -15562,7 +15578,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@
|
|||||||
"dockerode": "4.0.6",
|
"dockerode": "4.0.6",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "8.3.2",
|
"express-rate-limit": "^8.5.1",
|
||||||
"gamedig": "5.2.0",
|
"gamedig": "5.2.0",
|
||||||
"got": "14.4.7",
|
"got": "14.4.7",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
|
|||||||
@@ -64,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
|
|||||||
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
|
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
|
||||||
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
|
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
|
||||||
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
|
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
|
||||||
|
import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js";
|
||||||
|
|
||||||
// Third-party
|
// Third-party
|
||||||
|
import { Resolver } from "dns/promises";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
import ping from "ping";
|
import ping from "ping";
|
||||||
@@ -226,6 +228,7 @@ export const initializeServices = async ({
|
|||||||
const gameProvider = new GameProvider(logger, GameDig);
|
const gameProvider = new GameProvider(logger, GameDig);
|
||||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||||
|
const dnsProvider = new DNSProvider(() => new Resolver());
|
||||||
|
|
||||||
const networkService = new NetworkService(axios, logger, [
|
const networkService = new NetworkService(axios, logger, [
|
||||||
pingProvider,
|
pingProvider,
|
||||||
@@ -237,6 +240,7 @@ export const initializeServices = async ({
|
|||||||
gameProvider,
|
gameProvider,
|
||||||
grpcProvider,
|
grpcProvider,
|
||||||
webSocketProvider,
|
webSocketProvider,
|
||||||
|
dnsProvider,
|
||||||
]);
|
]);
|
||||||
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
|
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import mongoose from "mongoose";
|
||||||
|
import type { AnyBulkWriteOperation, Document } from "mongodb";
|
||||||
|
import { logger } from "@/utils/logger.js";
|
||||||
|
import MaintenanceWindowModel from "../models/MaintenanceWindow.js";
|
||||||
|
|
||||||
|
type GroupResult = {
|
||||||
|
_id: {
|
||||||
|
teamId: mongoose.Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
repeat: number;
|
||||||
|
durationUnit: string;
|
||||||
|
duration: number;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
docIds: mongoose.Types.ObjectId[];
|
||||||
|
monitorIds: mongoose.Types.ObjectId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function migrateMaintenanceWindowMonitorIdToArray(): Promise<void> {
|
||||||
|
const SERVICE_NAME = "Migration:MigrateMaintenanceWindowMonitorIdToArray";
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info({
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
message: "Starting migration of MaintenanceWindow.monitorId to monitorIds array (eager merge)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
if (!db) {
|
||||||
|
throw new Error("Database connection is not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group old-style docs by their scheduling key.
|
||||||
|
// Skip rows that already have a monitorIds array — those are either already migrated
|
||||||
|
// or in a partially-migrated state that needs manual review.
|
||||||
|
const groups: GroupResult[] = await MaintenanceWindowModel.aggregate<GroupResult>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
monitorId: { $exists: true, $ne: null },
|
||||||
|
monitorIds: { $exists: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
teamId: "$teamId",
|
||||||
|
name: "$name",
|
||||||
|
start: "$start",
|
||||||
|
end: "$end",
|
||||||
|
repeat: "$repeat",
|
||||||
|
durationUnit: "$durationUnit",
|
||||||
|
duration: "$duration",
|
||||||
|
active: "$active",
|
||||||
|
},
|
||||||
|
docIds: { $push: "$_id" },
|
||||||
|
monitorIds: { $addToSet: "$monitorId" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
logger.info({ service: SERVICE_NAME, message: "No MaintenanceWindow documents needed migration" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bulkOps: AnyBulkWriteOperation<Document>[] = [];
|
||||||
|
let originalDocCount = 0;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const { docIds, monitorIds } = group;
|
||||||
|
originalDocCount += docIds.length;
|
||||||
|
const [survivorId, ...duplicateIds] = docIds;
|
||||||
|
|
||||||
|
bulkOps.push({
|
||||||
|
updateOne: {
|
||||||
|
filter: { _id: survivorId },
|
||||||
|
update: {
|
||||||
|
$set: { monitorIds },
|
||||||
|
$unset: { monitorId: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateIds.length > 0) {
|
||||||
|
bulkOps.push({
|
||||||
|
deleteMany: {
|
||||||
|
filter: { _id: { $in: duplicateIds } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await MaintenanceWindowModel.collection.bulkWrite(bulkOps, { ordered: true });
|
||||||
|
const deletedCount = originalDocCount - groups.length;
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
message: `Migration complete. Consolidated ${originalDocCount} old document(s) into ${groups.length} group window(s); deleted ${deletedCount} duplicate(s).`,
|
||||||
|
});
|
||||||
|
logger.debug({
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
message: `bulkWrite result: modified=${result.modifiedCount}, deleted=${result.deletedCount}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error({ service: SERVICE_NAME, message: `Error during MaintenanceWindow migration: ${errorMessage}` });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.
|
|||||||
import MigrationModel from "../models/Migration.js";
|
import MigrationModel from "../models/Migration.js";
|
||||||
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
|
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
|
||||||
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
|
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
|
||||||
|
import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
|
|
||||||
type MigrationEntry = {
|
type MigrationEntry = {
|
||||||
@@ -19,6 +20,7 @@ const migrations: MigrationEntry[] = [
|
|||||||
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
|
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
|
||||||
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
|
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
|
||||||
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
|
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
|
||||||
|
{ name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray },
|
||||||
];
|
];
|
||||||
|
|
||||||
const runMigrations = async (logger?: ILogger) => {
|
const runMigrations = async (logger?: ILogger) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Schema, model, type Types } from "mongoose";
|
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"> & {
|
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorIds" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
|
||||||
monitorId: Types.ObjectId;
|
monitorIds: Types.ObjectId[];
|
||||||
teamId: Types.ObjectId;
|
teamId: Types.ObjectId;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
@@ -17,10 +17,10 @@ interface MaintenanceWindowDocument extends MaintenanceWindowDocumentBase {
|
|||||||
|
|
||||||
const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
|
const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
|
||||||
{
|
{
|
||||||
monitorId: {
|
monitorIds: {
|
||||||
type: Schema.Types.ObjectId,
|
type: [Schema.Types.ObjectId],
|
||||||
ref: "Monitor",
|
ref: "Monitor",
|
||||||
immutable: true,
|
index: true,
|
||||||
},
|
},
|
||||||
teamId: {
|
teamId: {
|
||||||
type: Schema.Types.ObjectId,
|
type: Schema.Types.ObjectId,
|
||||||
@@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
|
|||||||
},
|
},
|
||||||
durationUnit: {
|
durationUnit: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ["seconds", "minutes", "hours", "days"],
|
enum: DurationUnits,
|
||||||
},
|
},
|
||||||
repeat: {
|
repeat: {
|
||||||
type: Number,
|
type: Number,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Schema, model, Types } from "mongoose";
|
import { Schema, model, Types } from "mongoose";
|
||||||
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
|
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
|
||||||
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
|
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
|
||||||
import type {
|
import type {
|
||||||
CheckAudits,
|
CheckAudits,
|
||||||
CheckCaptureInfo,
|
CheckCaptureInfo,
|
||||||
@@ -330,6 +330,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
strategy: {
|
||||||
|
type: String,
|
||||||
|
enum: PageSpeedStrategies,
|
||||||
|
},
|
||||||
group: {
|
group: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
@@ -351,6 +355,13 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 300000,
|
default: 300000,
|
||||||
},
|
},
|
||||||
|
dnsServer: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
dnsRecordType: {
|
||||||
|
type: String,
|
||||||
|
enum: DnsRecordTypes,
|
||||||
|
},
|
||||||
recentChecks: {
|
recentChecks: {
|
||||||
type: [checkSnapshotSchema],
|
type: [checkSnapshotSchema],
|
||||||
default: [],
|
default: [],
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
|
|||||||
private toEntity = (doc: MaintenanceWindowDocument): MaintenanceWindow => {
|
private toEntity = (doc: MaintenanceWindowDocument): MaintenanceWindow => {
|
||||||
return {
|
return {
|
||||||
id: this.toStringId(doc._id),
|
id: this.toStringId(doc._id),
|
||||||
monitorId: this.toStringId(doc.monitorId),
|
monitorIds: doc.monitorIds.map(this.toStringId),
|
||||||
teamId: this.toStringId(doc.teamId),
|
teamId: this.toStringId(doc.teamId),
|
||||||
active: doc.active,
|
active: doc.active,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
@@ -67,7 +67,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
|
|||||||
|
|
||||||
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
|
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
|
||||||
const maintenanceWindows = await MaintenanceWindowModel.find({
|
const maintenanceWindows = await MaintenanceWindowModel.find({
|
||||||
monitorId: monitorId,
|
monitorIds: monitorId,
|
||||||
teamId: teamId,
|
teamId: teamId,
|
||||||
});
|
});
|
||||||
return this.mapDocuments(maintenanceWindows);
|
return this.mapDocuments(maintenanceWindows);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
$lookup: {
|
$lookup: {
|
||||||
from: "maintenancewindows",
|
from: "maintenancewindows",
|
||||||
let: { monitorId: "$_id" },
|
let: { monitorId: "$_id" },
|
||||||
pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }],
|
pipeline: [{ $match: { $expr: { $in: ["$$monitorId", { $ifNull: ["$monitorIds", []] }] } } }],
|
||||||
as: "maintenanceWindows",
|
as: "maintenanceWindows",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -441,11 +441,14 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
selectedDisks: doc.selectedDisks ?? [],
|
selectedDisks: doc.selectedDisks ?? [],
|
||||||
gameId: doc.gameId ?? undefined,
|
gameId: doc.gameId ?? undefined,
|
||||||
grpcServiceName: doc.grpcServiceName ?? undefined,
|
grpcServiceName: doc.grpcServiceName ?? undefined,
|
||||||
|
strategy: doc.strategy ?? undefined,
|
||||||
group: doc.group ?? null,
|
group: doc.group ?? null,
|
||||||
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
||||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||||
|
dnsServer: doc.dnsServer ?? undefined,
|
||||||
|
dnsRecordType: doc.dnsRecordType ?? undefined,
|
||||||
createdAt: toDateString(doc.createdAt),
|
createdAt: toDateString(doc.createdAt),
|
||||||
updatedAt: toDateString(doc.updatedAt),
|
updatedAt: toDateString(doc.updatedAt),
|
||||||
};
|
};
|
||||||
@@ -500,11 +503,14 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
selectedDisks: doc.selectedDisks ?? [],
|
selectedDisks: doc.selectedDisks ?? [],
|
||||||
gameId: doc.gameId ?? undefined,
|
gameId: doc.gameId ?? undefined,
|
||||||
grpcServiceName: doc.grpcServiceName ?? undefined,
|
grpcServiceName: doc.grpcServiceName ?? undefined,
|
||||||
|
strategy: doc.strategy ?? undefined,
|
||||||
group: doc.group ?? null,
|
group: doc.group ?? null,
|
||||||
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
||||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||||
|
dnsServer: doc.dnsServer ?? undefined,
|
||||||
|
dnsRecordType: doc.dnsRecordType ?? undefined,
|
||||||
createdAt: toDateString(doc.createdAt),
|
createdAt: toDateString(doc.createdAt),
|
||||||
updatedAt: toDateString(doc.updatedAt),
|
updatedAt: toDateString(doc.updatedAt),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ export interface IMaintenanceWindowService {
|
|||||||
}): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>;
|
}): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>;
|
||||||
getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise<MaintenanceWindow[]>;
|
getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise<MaintenanceWindow[]>;
|
||||||
deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
|
deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
|
||||||
editMaintenanceWindow(params: { id: string; teamId: string; body: Partial<MaintenanceWindow> }): Promise<MaintenanceWindow>;
|
editMaintenanceWindow(params: {
|
||||||
|
id: string;
|
||||||
|
teamId: string;
|
||||||
|
body: Partial<Omit<MaintenanceWindow, "monitorIds">> & { monitors?: string[] };
|
||||||
|
}): Promise<MaintenanceWindow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MaintenanceWindowService implements IMaintenanceWindowService {
|
export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||||
@@ -84,20 +88,17 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbTransactions = monitorIDs.map((monitorId: string) => {
|
await this.maintenanceWindowsRepository.create({
|
||||||
return this.maintenanceWindowsRepository.create({
|
teamId,
|
||||||
teamId,
|
monitorIds: monitorIDs,
|
||||||
monitorId,
|
name: name,
|
||||||
name: name,
|
active: active,
|
||||||
active: active,
|
duration: duration,
|
||||||
duration: duration,
|
durationUnit: durationUnit,
|
||||||
durationUnit: durationUnit,
|
repeat: repeat,
|
||||||
repeat: repeat,
|
start: start,
|
||||||
start: start,
|
end: end,
|
||||||
end: end,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
await Promise.all(dbTransactions);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getMaintenanceWindowById = async ({ id, teamId }: { id: string; teamId: string }) => {
|
getMaintenanceWindowById = async ({ id, teamId }: { id: string; teamId: string }) => {
|
||||||
@@ -135,7 +136,32 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
|||||||
return await this.maintenanceWindowsRepository.deleteById(id, teamId);
|
return await this.maintenanceWindowsRepository.deleteById(id, teamId);
|
||||||
};
|
};
|
||||||
|
|
||||||
editMaintenanceWindow = async ({ id, teamId, body }: { id: string; teamId: string; body: Partial<MaintenanceWindow> }) => {
|
editMaintenanceWindow = async ({
|
||||||
return await this.maintenanceWindowsRepository.updateById(id, teamId, body);
|
id,
|
||||||
|
teamId,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
teamId: string;
|
||||||
|
body: Partial<Omit<MaintenanceWindow, "monitorIds">> & { monitors?: string[] };
|
||||||
|
}) => {
|
||||||
|
const { monitors, ...rest } = body;
|
||||||
|
const update: Partial<MaintenanceWindow> = rest;
|
||||||
|
|
||||||
|
if (monitors !== undefined) {
|
||||||
|
const monitorDocs = await this.monitorsRepository.findByIds(monitors);
|
||||||
|
const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId);
|
||||||
|
if (unauthorizedMonitors.length > 0) {
|
||||||
|
throw new AppError({
|
||||||
|
message: "Unauthorized to edit maintenance window for one or more monitors",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "editMaintenanceWindow",
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
update.monitorIds = monitors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.maintenanceWindowsRepository.updateById(id, teamId, update);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,8 @@ import type {
|
|||||||
GamesMap,
|
GamesMap,
|
||||||
GroupedGeoCheckResult,
|
GroupedGeoCheckResult,
|
||||||
} from "@/types/monitor.js";
|
} 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 { GeoContinent } from "@/types/geoCheck.js";
|
||||||
import type {
|
import type {
|
||||||
IChecksRepository,
|
IChecksRepository,
|
||||||
@@ -28,6 +29,9 @@ import { ILogger } from "@/utils/logger.js";
|
|||||||
const SERVICE_NAME = "MonitorService";
|
const SERVICE_NAME = "MonitorService";
|
||||||
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
|
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
|
||||||
|
|
||||||
|
const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
|
||||||
|
supportsUptimeDetails(result.monitorType);
|
||||||
|
|
||||||
export interface IMonitorService {
|
export interface IMonitorService {
|
||||||
readonly serviceName: string;
|
readonly serviceName: string;
|
||||||
|
|
||||||
@@ -216,15 +220,7 @@ export class MonitorService implements IMonitorService {
|
|||||||
});
|
});
|
||||||
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
|
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
|
||||||
|
|
||||||
if (
|
if (!isUptimeChecksResult(checksData)) {
|
||||||
checksData.monitorType !== "http" &&
|
|
||||||
checksData.monitorType !== "ping" &&
|
|
||||||
checksData.monitorType !== "docker" &&
|
|
||||||
checksData.monitorType !== "port" &&
|
|
||||||
checksData.monitorType !== "game" &&
|
|
||||||
checksData.monitorType !== "grpc" &&
|
|
||||||
checksData.monitorType !== "websocket"
|
|
||||||
) {
|
|
||||||
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
|
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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, 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]);
|
||||||
|
|
||||||
|
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,6 +1,6 @@
|
|||||||
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
||||||
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
|
import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js";
|
||||||
import { Monitor, MonitorType } from "@/types/monitor.js";
|
import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/types/monitor.js";
|
||||||
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
|
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
|
||||||
import { ISettingsService } from "@/service/system/settingsService.js";
|
import { ISettingsService } from "@/service/system/settingsService.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
@@ -19,14 +19,15 @@ export class PageSpeedProvider implements IStatusProvider<PageSpeedStatusPayload
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<PageSpeedStatusPayload>> {
|
async handle(monitor: Monitor): Promise<MonitorStatusResponse<PageSpeedStatusPayload>> {
|
||||||
const { url } = monitor;
|
const { url, strategy } = monitor;
|
||||||
try {
|
try {
|
||||||
if (!url) throw new Error("URL is required for PageSpeed monitor");
|
if (!url) throw new Error("URL is required for PageSpeed monitor");
|
||||||
const dbSettings = await this.settingsService.getDBSettings();
|
const dbSettings = await this.settingsService.getDBSettings();
|
||||||
const apiKey = dbSettings?.pagespeedApiKey;
|
const apiKey = dbSettings?.pagespeedApiKey;
|
||||||
|
const resolvedStrategy = strategy ?? DefaultPageSpeedStrategy;
|
||||||
let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(
|
let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(
|
||||||
url
|
url
|
||||||
)}&category=seo&category=accessibility&category=best-practices&category=performance`;
|
)}&strategy=${encodeURIComponent(resolvedStrategy)}&category=seo&category=accessibility&category=best-practices&category=performance`;
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
pageSpeedUrl += `&key=${apiKey}`;
|
pageSpeedUrl += `&key=${apiKey}`;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 {
|
export interface MaintenanceWindow {
|
||||||
id: string;
|
id: string;
|
||||||
monitorId: string;
|
monitorIds: string[];
|
||||||
teamId: string;
|
teamId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,17 +3,38 @@ export type { CheckSnapshot } from "@/types/check.js";
|
|||||||
import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
|
import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
|
||||||
export type { GeoContinent } 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 type MonitorType = (typeof MonitorTypes)[number];
|
||||||
|
|
||||||
|
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||||
|
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||||
|
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
|
||||||
|
|
||||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||||
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
|
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 const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
|
||||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||||
|
|
||||||
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
||||||
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
|
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 const MAX_RECENT_CHECKS = 50;
|
||||||
|
|
||||||
export interface Monitor {
|
export interface Monitor {
|
||||||
@@ -50,10 +71,13 @@ export interface Monitor {
|
|||||||
selectedDisks: string[];
|
selectedDisks: string[];
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
grpcServiceName?: string;
|
grpcServiceName?: string;
|
||||||
|
strategy?: PageSpeedStrategy;
|
||||||
group: string | null;
|
group: string | null;
|
||||||
geoCheckEnabled?: boolean;
|
geoCheckEnabled?: boolean;
|
||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
geoCheckInterval?: number;
|
geoCheckInterval?: number;
|
||||||
|
dnsServer?: string;
|
||||||
|
dnsRecordType?: DnsRecordType;
|
||||||
recentChecks: CheckSnapshot[];
|
recentChecks: CheckSnapshot[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
CheckNetworkInterfaceInfo,
|
CheckNetworkInterfaceInfo,
|
||||||
GotTimings,
|
GotTimings,
|
||||||
ILighthouseAudit,
|
ILighthouseAudit,
|
||||||
|
DnsRecordType,
|
||||||
Monitor,
|
Monitor,
|
||||||
MonitorMatchMethod,
|
MonitorMatchMethod,
|
||||||
MonitorType,
|
MonitorType,
|
||||||
@@ -116,6 +117,14 @@ export interface WebSocketStatusPayload {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DNSStatusPayload {
|
||||||
|
hostname: string;
|
||||||
|
dnsServer: string;
|
||||||
|
recordType: DnsRecordType;
|
||||||
|
resolved: boolean;
|
||||||
|
results: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonitorPayloadMap {
|
export interface MonitorPayloadMap {
|
||||||
ping: PingStatusPayload;
|
ping: PingStatusPayload;
|
||||||
http: HttpStatusPayload;
|
http: HttpStatusPayload;
|
||||||
@@ -126,6 +135,7 @@ export interface MonitorPayloadMap {
|
|||||||
game: GameStatusPayload;
|
game: GameStatusPayload;
|
||||||
grpc: GrpcStatusPayload;
|
grpc: GrpcStatusPayload;
|
||||||
websocket: WebSocketStatusPayload;
|
websocket: WebSocketStatusPayload;
|
||||||
|
dns: DNSStatusPayload;
|
||||||
unknown: unknown;
|
unknown: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { booleanCoercion } from "./shared.js";
|
import { booleanCoercion } from "./shared.js";
|
||||||
import { GeoContinents } from "@/types/geoCheck.js";
|
import { GeoContinents } from "@/types/geoCheck.js";
|
||||||
|
import { MonitorTypes } from "@/types/monitor.js";
|
||||||
|
|
||||||
//****************************************
|
//****************************************
|
||||||
// Check Validations
|
// Check Validations
|
||||||
@@ -11,7 +12,7 @@ export const getChecksParamValidation = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getChecksQueryValidation = 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"]),
|
sortOrder: z.enum(["asc", "desc"]),
|
||||||
limit: z.coerce.number().optional(),
|
limit: z.coerce.number().optional(),
|
||||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
|
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { booleanCoercion } from "./shared.js";
|
import { booleanCoercion } from "./shared.js";
|
||||||
|
import { DurationUnits } from "@/types/maintenanceWindow.js";
|
||||||
|
|
||||||
const dateToString = z.coerce.date().transform((d) => d.toISOString());
|
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
|
// Maintenance Window Validations
|
||||||
//****************************************
|
//****************************************
|
||||||
|
|
||||||
export const createMaintenanceWindowBodyValidation = z.object({
|
export const createMaintenanceWindowBodyValidation = z
|
||||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||||
active: z.boolean().optional(),
|
name: z.string().min(1, "Name is required"),
|
||||||
duration: z.number().min(1, "Duration is required"),
|
active: z.boolean().optional(),
|
||||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
|
duration: z.number().min(1, "Duration is required"),
|
||||||
start: dateToString,
|
durationUnit: z.enum(DurationUnits),
|
||||||
end: dateToString,
|
start: dateToString,
|
||||||
repeat: z.number().min(0, "Repeat must be a non-negative number"),
|
end: dateToString,
|
||||||
expiry: dateToString.optional(),
|
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({
|
export const getMaintenanceWindowByIdParamValidation = z.object({
|
||||||
id: z.string().min(1, "ID is required"),
|
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"),
|
id: z.string().min(1, "ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const editMaintenanceByIdWindowBodyValidation = z.object({
|
export const editMaintenanceByIdWindowBodyValidation = z
|
||||||
active: z.boolean().optional(),
|
.object({
|
||||||
name: z.string().optional(),
|
active: z.boolean().optional(),
|
||||||
repeat: z.number().optional(),
|
name: z.string().optional(),
|
||||||
start: dateToString.optional(),
|
repeat: z.number().optional(),
|
||||||
end: dateToString.optional(),
|
start: dateToString.optional(),
|
||||||
expiry: dateToString.optional(),
|
end: dateToString.optional(),
|
||||||
monitors: z.array(z.string()).optional(),
|
expiry: dateToString.optional(),
|
||||||
duration: z.number().optional(),
|
monitors: z.array(z.string()).min(1, "At least one monitor is required").optional(),
|
||||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).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 { z } from "zod";
|
||||||
import { booleanCoercion } from "./shared.js";
|
import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js";
|
||||||
import { GeoContinents } from "@/types/geoCheck.js";
|
import { GeoContinents } from "@/types/geoCheck.js";
|
||||||
import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
|
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js";
|
||||||
|
|
||||||
export const getMonitorByIdParamValidation = z.object({
|
export const getMonitorByIdParamValidation = z.object({
|
||||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
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 getMonitorsByTeamIdParamValidation = z.object({});
|
||||||
|
|
||||||
export const getMonitorsByTeamIdQueryValidation = z.object({
|
export const getMonitorsByTeamIdQueryValidation = z.object({
|
||||||
type: z
|
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
|
||||||
.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(),
|
|
||||||
filter: z.union([z.string(), z.literal("")]).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(),
|
filter: z.union([z.string(), z.literal("")]).optional(),
|
||||||
field: z.string().optional(),
|
field: z.string().optional(),
|
||||||
order: z.enum(["asc", "desc"]).optional(),
|
order: z.enum(["asc", "desc"]).optional(),
|
||||||
type: z
|
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
|
||||||
.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(),
|
|
||||||
explain: booleanCoercion.optional(),
|
explain: booleanCoercion.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,65 +39,97 @@ export const getCertificateParamValidation = z.object({
|
|||||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createMonitorBodyValidation = z.object({
|
const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
|
||||||
_id: z.string().optional(),
|
if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) {
|
||||||
name: z.string().min(1, "Name is required"),
|
ctx.addIssue({
|
||||||
description: z.union([z.string(), z.literal("")]).optional(),
|
code: "custom",
|
||||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
path: ["url"],
|
||||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
message: "Enter a valid domain (e.g. www.example.com)",
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const editMonitorBodyValidation = z.object({
|
const refineStrategyType = (body: { type?: string; strategy?: string }, ctx: z.RefinementCtx) => {
|
||||||
name: z.string().optional(),
|
if (body.strategy !== undefined && body.type !== undefined && body.type !== "pagespeed") {
|
||||||
type: z.enum(MonitorTypes).optional(),
|
ctx.addIssue({
|
||||||
url: z.string().optional(),
|
code: "custom",
|
||||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
path: ["strategy"],
|
||||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
message: "Strategy is only valid for pagespeed monitors",
|
||||||
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(),
|
export const createMonitorBodyValidation = z
|
||||||
useAdvancedMatching: z.boolean().optional(),
|
.object({
|
||||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
_id: z.string().optional(),
|
||||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
name: z.string().min(1, "Name is required"),
|
||||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
description: z.union([z.string(), z.literal("")]).optional(),
|
||||||
port: z.number().min(1).max(65535).optional(),
|
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||||
cpuAlertThreshold: z.number().optional(),
|
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||||
memoryAlertThreshold: z.number().optional(),
|
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||||
diskAlertThreshold: z.number().optional(),
|
url: z.string().min(1, "URL is required"),
|
||||||
tempAlertThreshold: z.number().optional(),
|
ignoreTlsErrors: z.boolean().default(false),
|
||||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
useAdvancedMatching: z.boolean().default(false),
|
||||||
grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
|
port: z.number().optional(),
|
||||||
selectedDisks: z.array(z.string()).optional(),
|
isActive: z.boolean().optional(),
|
||||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
interval: z.number().optional(),
|
||||||
geoCheckEnabled: z.boolean().optional(),
|
cpuAlertThreshold: z.number().optional(),
|
||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
memoryAlertThreshold: z.number().optional(),
|
||||||
geoCheckInterval: z.number().min(300000).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(""),
|
||||||
|
strategy: z.enum(PageSpeedStrategies).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)
|
||||||
|
.superRefine(refineStrategyType);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
strategy: z.enum(PageSpeedStrategies).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)
|
||||||
|
.superRefine(refineStrategyType);
|
||||||
|
|
||||||
export const pauseMonitorParamValidation = z.object({
|
export const pauseMonitorParamValidation = z.object({
|
||||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||||
@@ -130,47 +152,53 @@ export const getUptimeDetailsByIdQueryValidation = z.object({
|
|||||||
normalize: booleanCoercion.optional(),
|
normalize: booleanCoercion.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const importedMonitorSchema = z.object({
|
const importedMonitorSchema = z
|
||||||
id: z.string().optional(),
|
.object({
|
||||||
userId: z.string().optional(),
|
id: z.string().optional(),
|
||||||
teamId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
name: z.string().min(1, "Name is required"),
|
teamId: z.string().optional(),
|
||||||
description: z.union([z.string(), z.literal("")]).optional(),
|
name: z.string().min(1, "Name is required"),
|
||||||
status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
|
description: z.union([z.string(), z.literal("")]).optional(),
|
||||||
statusWindow: z.array(z.boolean()).default([]),
|
status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"),
|
||||||
statusWindowSize: z.number().min(1).max(20).default(5),
|
statusWindow: z.array(z.boolean()).default([]),
|
||||||
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
statusWindowSize: z.number().min(1).max(20).default(5),
|
||||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
statusWindowThreshold: z.number().min(1).max(100).default(60),
|
||||||
ignoreTlsErrors: z.boolean().default(false),
|
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||||
useAdvancedMatching: z.boolean().default(false),
|
ignoreTlsErrors: z.boolean().default(false),
|
||||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
useAdvancedMatching: z.boolean().default(false),
|
||||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||||
url: z.string().min(1, "URL is required"),
|
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||||
port: z.number().optional(),
|
url: z.string().min(1, "URL is required"),
|
||||||
isActive: z.boolean().default(true),
|
port: z.number().optional(),
|
||||||
interval: z.number().default(60000),
|
isActive: z.boolean().default(true),
|
||||||
uptimePercentage: z.number().optional(),
|
interval: z.number().default(60000),
|
||||||
notifications: z.array(z.string()).default([]),
|
uptimePercentage: z.number().optional(),
|
||||||
secret: z.string().optional(),
|
notifications: z.array(z.string()).default([]),
|
||||||
cpuAlertThreshold: z.number().default(100),
|
secret: z.string().optional(),
|
||||||
cpuAlertCounter: z.number().default(5),
|
cpuAlertThreshold: z.number().default(100),
|
||||||
memoryAlertThreshold: z.number().default(100),
|
cpuAlertCounter: z.number().default(5),
|
||||||
memoryAlertCounter: z.number().default(5),
|
memoryAlertThreshold: z.number().default(100),
|
||||||
diskAlertThreshold: z.number().default(100),
|
memoryAlertCounter: z.number().default(5),
|
||||||
diskAlertCounter: z.number().default(5),
|
diskAlertThreshold: z.number().default(100),
|
||||||
tempAlertThreshold: z.number().default(100),
|
diskAlertCounter: z.number().default(5),
|
||||||
tempAlertCounter: z.number().default(5),
|
tempAlertThreshold: z.number().default(100),
|
||||||
selectedDisks: z.array(z.string()).default([]),
|
tempAlertCounter: z.number().default(5),
|
||||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
selectedDisks: z.array(z.string()).default([]),
|
||||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||||
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
|
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||||
geoCheckEnabled: z.boolean().default(false),
|
strategy: z.enum(PageSpeedStrategies).optional(),
|
||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
|
||||||
geoCheckInterval: z.number().min(300000).default(300000),
|
geoCheckEnabled: z.boolean().default(false),
|
||||||
createdAt: z.string().optional(),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||||
updatedAt: z.string().optional(),
|
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)
|
||||||
|
.superRefine(refineStrategyType);
|
||||||
|
|
||||||
export const importMonitorsBodyValidation = z.object({
|
export const importMonitorsBodyValidation = z.object({
|
||||||
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
|
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
|
||||||
@@ -219,6 +247,8 @@ export const monitorResponseSchema = z
|
|||||||
geoCheckEnabled: z.boolean(),
|
geoCheckEnabled: z.boolean(),
|
||||||
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
||||||
geoCheckInterval: z.number(),
|
geoCheckInterval: z.number(),
|
||||||
|
dnsServer: z.string().optional(),
|
||||||
|
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||||
teamId: z.string(),
|
teamId: z.string(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ export const nameValidation = z
|
|||||||
|
|
||||||
export const lowercaseEmailValidation = z.email().transform((val) => val.toLowerCase());
|
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) => {
|
export const booleanCoercion = z.preprocess((val) => {
|
||||||
if (val === "true" || val === true) return true;
|
if (val === "true" || val === true) return true;
|
||||||
if (val === "false" || val === false) return false;
|
if (val === "false" || val === false) return false;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const makeMaintenanceWindow = (overrides?: Partial<MaintenanceWindow>): Maintena
|
|||||||
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||||
return {
|
return {
|
||||||
id: "mw-1",
|
id: "mw-1",
|
||||||
monitorId: "mon-1",
|
monitorIds: ["mon-1"],
|
||||||
teamId: "team-1",
|
teamId: "team-1",
|
||||||
active: true,
|
active: true,
|
||||||
name: "Test Maintenance",
|
name: "Test Maintenance",
|
||||||
@@ -109,6 +109,23 @@ describe("Heartbeat job: maintenance windows", () => {
|
|||||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips checks for every monitor covered by a group window", async () => {
|
||||||
|
const monitorA = makeMonitor({ id: "mon-a", name: "Monitor A" });
|
||||||
|
const monitorB = makeMonitor({ id: "mon-b", name: "Monitor B" });
|
||||||
|
h.monitorsRepo.seed(monitorA);
|
||||||
|
h.monitorsRepo.seed(monitorB);
|
||||||
|
|
||||||
|
// A single group window covers both monitors
|
||||||
|
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ monitorIds: ["mon-a", "mon-b"] })]);
|
||||||
|
|
||||||
|
await h.heartbeatJob(monitorA);
|
||||||
|
await h.heartbeatJob(monitorB);
|
||||||
|
|
||||||
|
expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance");
|
||||||
|
expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance");
|
||||||
|
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("skips checks when window is in the past (expired)", async () => {
|
it("skips checks when window is in the past (expired)", async () => {
|
||||||
const monitor = makeMonitor();
|
const monitor = makeMonitor();
|
||||||
h.monitorsRepo.seed(monitor);
|
h.monitorsRepo.seed(monitor);
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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("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;
|
||||||
|
});
|
||||||
|
const provider = new DNSProvider(createResolver);
|
||||||
|
|
||||||
|
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ const createService = (overrides?: {
|
|||||||
|
|
||||||
const makeWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => ({
|
const makeWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => ({
|
||||||
id: "mw-1",
|
id: "mw-1",
|
||||||
monitorId: "mon-1",
|
monitorIds: ["mon-1"],
|
||||||
teamId: "team-1",
|
teamId: "team-1",
|
||||||
active: true,
|
active: true,
|
||||||
name: "Scheduled Maintenance",
|
name: "Scheduled Maintenance",
|
||||||
@@ -80,16 +80,16 @@ describe("MaintenanceWindowService", () => {
|
|||||||
// ── createMaintenanceWindow ──────────────────────────────────────────────
|
// ── createMaintenanceWindow ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe("createMaintenanceWindow", () => {
|
describe("createMaintenanceWindow", () => {
|
||||||
it("creates a maintenance window for each monitor", async () => {
|
it("creates a single maintenance window covering all monitor IDs", async () => {
|
||||||
const { service, maintenanceWindowsRepository } = createService();
|
const { service, maintenanceWindowsRepository } = createService();
|
||||||
|
|
||||||
await service.createMaintenanceWindow(defaultCreateParams);
|
await service.createMaintenanceWindow(defaultCreateParams);
|
||||||
|
|
||||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(2);
|
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1);
|
||||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(
|
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
teamId: "team-1",
|
teamId: "team-1",
|
||||||
monitorId: "mon-1",
|
monitorIds: ["mon-1", "mon-2"],
|
||||||
name: "Scheduled Maintenance",
|
name: "Scheduled Maintenance",
|
||||||
active: true,
|
active: true,
|
||||||
duration: 60,
|
duration: 60,
|
||||||
@@ -99,7 +99,6 @@ describe("MaintenanceWindowService", () => {
|
|||||||
end: "2026-04-10T03:00:00Z",
|
end: "2026-04-10T03:00:00Z",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorId: "mon-2" }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("verifies monitor ownership via findByIds", async () => {
|
it("verifies monitor ownership via findByIds", async () => {
|
||||||
@@ -130,12 +129,13 @@ describe("MaintenanceWindowService", () => {
|
|||||||
await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined();
|
await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a single window when monitorIDs has one entry", async () => {
|
it("passes a single-element monitorIds array when only one monitor is provided", async () => {
|
||||||
const { service, maintenanceWindowsRepository } = createService();
|
const { service, maintenanceWindowsRepository } = createService();
|
||||||
|
|
||||||
await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] });
|
await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] });
|
||||||
|
|
||||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1);
|
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorIds: ["mon-1"] }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,5 +248,59 @@ describe("MaintenanceWindowService", () => {
|
|||||||
|
|
||||||
expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 });
|
expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps the monitors body field to monitorIds when calling the repository", async () => {
|
||||||
|
const { service, maintenanceWindowsRepository } = createService();
|
||||||
|
|
||||||
|
await service.editMaintenanceWindow({
|
||||||
|
id: "mw-1",
|
||||||
|
teamId: "team-1",
|
||||||
|
body: { monitors: ["mon-1", "mon-2"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { monitorIds: ["mon-1", "mon-2"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies monitor ownership when monitors is in the body", async () => {
|
||||||
|
const { service, monitorsRepository } = createService();
|
||||||
|
|
||||||
|
await service.editMaintenanceWindow({
|
||||||
|
id: "mw-1",
|
||||||
|
teamId: "team-1",
|
||||||
|
body: { monitors: ["mon-1", "mon-2"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws 403 when a new monitor belongs to a different team", async () => {
|
||||||
|
const monitorsRepository = createMonitorsRepo();
|
||||||
|
(monitorsRepository.findByIds as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: "mon-1", teamId: "team-1" },
|
||||||
|
{ id: "mon-2", teamId: "team-other" },
|
||||||
|
]);
|
||||||
|
const { service, maintenanceWindowsRepository } = createService({ monitorsRepository });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.editMaintenanceWindow({
|
||||||
|
id: "mw-1",
|
||||||
|
teamId: "team-1",
|
||||||
|
body: { monitors: ["mon-1", "mon-2"] },
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Unauthorized to edit maintenance window for one or more monitors");
|
||||||
|
expect(maintenanceWindowsRepository.updateById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call findByIds when monitors is not in the body", async () => {
|
||||||
|
const { service, monitorsRepository } = createService();
|
||||||
|
|
||||||
|
await service.editMaintenanceWindow({
|
||||||
|
id: "mw-1",
|
||||||
|
teamId: "team-1",
|
||||||
|
body: { name: "Updated Name" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(monitorsRepository.findByIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a body without a monitors field (monitors is optional on edit)", () => {
|
||||||
|
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ name: "Updated Name" });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a body with one or more monitors", () => {
|
||||||
|
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: ["mon-1"] });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty monitors array", () => {
|
||||||
|
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: [] });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ message: "At least one monitor is required", path: ["monitors"] })])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("monitorValidation — strategy gating", () => {
|
||||||
|
describe("createMonitorBodyValidation", () => {
|
||||||
|
it("accepts strategy on pagespeed monitors", () => {
|
||||||
|
const parsed = createMonitorBodyValidation.parse({
|
||||||
|
name: "PS check",
|
||||||
|
type: "pagespeed",
|
||||||
|
url: "https://example.com",
|
||||||
|
strategy: "mobile",
|
||||||
|
});
|
||||||
|
expect(parsed.strategy).toBe("mobile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts pagespeed monitors without a strategy", () => {
|
||||||
|
const parsed = createMonitorBodyValidation.parse({
|
||||||
|
name: "PS check",
|
||||||
|
type: "pagespeed",
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
expect(parsed.strategy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects strategy on non-pagespeed monitor types", () => {
|
||||||
|
for (const type of ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns", "hardware"] as const) {
|
||||||
|
expect(() =>
|
||||||
|
createMonitorBodyValidation.parse({
|
||||||
|
name: "wrong-type check",
|
||||||
|
type,
|
||||||
|
url: type === "dns" ? "example.com" : "https://example.com",
|
||||||
|
strategy: "desktop",
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows non-pagespeed monitors without a strategy", () => {
|
||||||
|
const parsed = createMonitorBodyValidation.parse({
|
||||||
|
name: "HTTP check",
|
||||||
|
type: "http",
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
expect(parsed.strategy).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editMonitorBodyValidation", () => {
|
||||||
|
it("rejects strategy when type is explicitly non-pagespeed", () => {
|
||||||
|
expect(() =>
|
||||||
|
editMonitorBodyValidation.parse({
|
||||||
|
type: "http",
|
||||||
|
strategy: "mobile",
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows strategy when type is omitted (partial edit)", () => {
|
||||||
|
const parsed = editMonitorBodyValidation.parse({
|
||||||
|
strategy: "mobile",
|
||||||
|
});
|
||||||
|
expect(parsed.strategy).toBe("mobile");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importMonitorsBodyValidation", () => {
|
||||||
|
it("does not inject a default strategy on imported HTTP monitors", () => {
|
||||||
|
const parsed = importMonitorsBodyValidation.parse({
|
||||||
|
monitors: [
|
||||||
|
{
|
||||||
|
name: "Imported HTTP",
|
||||||
|
type: "http",
|
||||||
|
url: "https://example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(parsed.monitors[0].strategy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects strategy on imported non-pagespeed monitors", () => {
|
||||||
|
expect(() =>
|
||||||
|
importMonitorsBodyValidation.parse({
|
||||||
|
monitors: [
|
||||||
|
{
|
||||||
|
name: "Imported HTTP",
|
||||||
|
type: "http",
|
||||||
|
url: "https://example.com",
|
||||||
|
strategy: "desktop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user