Merge branch 'develop' into feat/bulk-edit-notifications
This commit is contained in:
commit
f9a4659cb2
67 files changed
+1388
-513
No files matched your search
Generated
+3
-1
@@ -44,7 +44,7 @@
|
||||
"redux-persist": "6.0.0",
|
||||
"swr": "^2.3.6",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.5.2",
|
||||
@@ -7340,6 +7340,8 @@
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@
|
||||
"format-check": "prettier --check ."
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "npm run format"
|
||||
"**/*": "prettier --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
@@ -52,7 +52,7 @@
|
||||
"redux-persist": "6.0.0",
|
||||
"swr": "^2.3.6",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.5.2",
|
||||
|
||||
@@ -11,20 +11,26 @@ import type { ResponsiveStyleValue } from "@mui/system";
|
||||
interface SliderInputProps extends SliderProps {
|
||||
sx?: SliderProps["sx"];
|
||||
showValue?: boolean;
|
||||
formatDisplayValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
|
||||
({ sx, showValue = false, ...props }, ref) => {
|
||||
({ sx, showValue = false, formatDisplayValue, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
|
||||
|
||||
const displayValue =
|
||||
showValue && formatDisplayValue && typeof props.value === "number"
|
||||
? formatDisplayValue(props.value)
|
||||
: props.value;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(8)}
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
>
|
||||
{showValue && <Typography>{props.value}</Typography>}
|
||||
{showValue && <Typography>{displayValue}</Typography>}
|
||||
<Slider
|
||||
{...props}
|
||||
ref={ref}
|
||||
@@ -63,12 +69,21 @@ interface SliderWithLabelProps extends SliderProps {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
showValue?: boolean;
|
||||
formatDisplayValue?: (value: number) => string;
|
||||
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
|
||||
}
|
||||
|
||||
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
|
||||
(
|
||||
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
|
||||
{
|
||||
fieldLabel,
|
||||
required,
|
||||
showValue = true,
|
||||
formatDisplayValue,
|
||||
value,
|
||||
sliderMaxWidth = "100%",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
@@ -82,6 +97,7 @@ export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>
|
||||
<SliderInput
|
||||
{...props}
|
||||
showValue={showValue}
|
||||
formatDisplayValue={formatDisplayValue}
|
||||
value={value}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import BackgroundSVG from "@/assets/Images/background.svg";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { OfflineBanner } from "@/Components/design-elements";
|
||||
import { setServerUnreachableCallback, get } from "@/Utils/ApiClient";
|
||||
|
||||
@@ -13,7 +10,6 @@ interface AppLayoutProps {
|
||||
|
||||
const AppLayout = ({ children }: AppLayoutProps) => {
|
||||
const theme = useTheme();
|
||||
const mode = useSelector((state: RootState) => state.ui.mode);
|
||||
const [serverUnreachable, setServerUnreachable] = useState(false);
|
||||
const retryIntervalRef = useRef<number | null>(null);
|
||||
|
||||
@@ -47,10 +43,6 @@ const AppLayout = ({ children }: AppLayoutProps) => {
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
backgroundImage: mode === "dark" ? `url("${BackgroundSVG}")` : "none",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<OfflineBanner visible={serverUnreachable} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MonitorType } from "@/Types/Monitor";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const types = ["http", "ping", "port", "docker", "game", "grpc"];
|
||||
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
|
||||
const typeDisplayNames: Record<string, string> = {
|
||||
http: "HTTP",
|
||||
ping: "Ping",
|
||||
@@ -15,6 +15,7 @@ const typeDisplayNames: Record<string, string> = {
|
||||
docker: "Docker",
|
||||
game: "Game",
|
||||
grpc: "gRPC",
|
||||
websocket: "WebSocket",
|
||||
};
|
||||
const statuses = ["up", "down"];
|
||||
const states = ["active", "paused"];
|
||||
|
||||
@@ -103,6 +103,14 @@ export const useMonitorForm = ({
|
||||
selectedDisks: data?.selectedDisks || [],
|
||||
};
|
||||
break;
|
||||
case "websocket":
|
||||
defaults = {
|
||||
...base,
|
||||
type: "websocket",
|
||||
url: data?.url || "",
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
defaults = {
|
||||
...base,
|
||||
|
||||
@@ -11,12 +11,17 @@ import { ConfigBox, BasePage } from "@/Components/design-elements";
|
||||
import { UserRoles } from "@/Types/User";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UserRole, User } from "@/Types/User";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useEditUserForm } from "@/Hooks/useEditUserForm";
|
||||
import { useGet, usePatch } from "@/Hooks/UseApi";
|
||||
import { useGet, usePatch, useDelete } from "@/Hooks/UseApi";
|
||||
import type { EditUserFormData } from "@/Validation/editUser";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DialogInput } from "@/Components/inputs/Dialog";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
||||
|
||||
interface RoleOption {
|
||||
id: UserRole;
|
||||
@@ -29,6 +34,12 @@ const EditUserPage = () => {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const { resolver, defaults } = useEditUserForm();
|
||||
const { patch, loading: isSaving } = usePatch();
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
const navigate = useNavigate();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const currentUser = useSelector((state: RootState) => state.auth.user);
|
||||
const isSuperAdmin = useIsSuperAdmin();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
|
||||
|
||||
@@ -60,6 +71,20 @@ const EditUserPage = () => {
|
||||
setValue("role", newRoles, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const canDeleteUser =
|
||||
isAdmin &&
|
||||
userId !== currentUser?.id &&
|
||||
!user?.role?.includes("demo") &&
|
||||
(isSuperAdmin || user?.role?.every((r) => r !== "admin" && r !== "superadmin"));
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const result = await deleteFn(`/auth/users/${userId}`);
|
||||
setShowDeleteDialog(false);
|
||||
if (result) {
|
||||
navigate("/account", { state: { tab: "team" } });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: EditUserFormData) => {
|
||||
await patch(`/auth/users/${userId}`, data);
|
||||
};
|
||||
@@ -165,17 +190,45 @@ const EditUserPage = () => {
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
||||
<Stack
|
||||
gap={LAYOUT.XS}
|
||||
direction="row"
|
||||
justifyContent={"flex-end"}
|
||||
width="100%"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
{canDeleteUser && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{t("common.buttons.removeUser")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
<DialogInput
|
||||
open={showDeleteDialog}
|
||||
title={t("pages.editUser.dialog.removeUser.title")}
|
||||
content={t("pages.editUser.dialog.removeUser.content", {
|
||||
name: `${user?.firstName} ${user?.lastName}`,
|
||||
})}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
onConfirm={handleDeleteUser}
|
||||
confirmText={t("common.buttons.removeUser")}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { User } from "@/Types/User";
|
||||
|
||||
interface TeamTableProps {
|
||||
@@ -13,7 +13,7 @@ interface TeamTableProps {
|
||||
export const TeamTable = ({ users }: TeamTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isSuperAdmin = useIsSuperAdmin();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const headers: Header<User>[] = [
|
||||
{
|
||||
@@ -45,7 +45,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
|
||||
];
|
||||
|
||||
const handleRowClick = (row: User) => {
|
||||
if (isSuperAdmin) {
|
||||
if (isAdmin) {
|
||||
navigate(`/account/team/${row.id}`);
|
||||
}
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
|
||||
<Table
|
||||
headers={headers}
|
||||
data={users}
|
||||
onRowClick={isSuperAdmin ? handleRowClick : undefined}
|
||||
onRowClick={isAdmin ? handleRowClick : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -32,7 +32,12 @@ import {
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
|
||||
import { useMonitorForm } from "@/Hooks/useMonitorForm";
|
||||
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
|
||||
import {
|
||||
type Monitor,
|
||||
type MonitorType,
|
||||
type GamesMap,
|
||||
supportsGeoCheck,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MonitorFormData } from "@/Validation/monitor";
|
||||
|
||||
@@ -141,6 +146,17 @@ const getGeneralSettingsConfig = (
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
},
|
||||
websocket: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: true,
|
||||
},
|
||||
};
|
||||
return configs[type] || configs.http;
|
||||
};
|
||||
@@ -326,6 +342,13 @@ const CreateMonitorPage = () => {
|
||||
"pages.createMonitor.form.type.optionGrpcDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="websocket"
|
||||
label={t("pages.createMonitor.form.type.optionWebSocket")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionWebSocketDescription"
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -742,7 +765,9 @@ const CreateMonitorPage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{(watchedType === "http" || watchedType === "grpc") && (
|
||||
{(watchedType === "http" ||
|
||||
watchedType === "grpc" ||
|
||||
watchedType === "websocket") && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.ignoreTls.title")}
|
||||
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
|
||||
@@ -885,7 +910,7 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{watchedType === "http" && (
|
||||
{supportsGeoCheck(watchedType) && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.geoChecks.title")}
|
||||
subtitle={t("pages.createMonitor.form.geoChecks.description")}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/Features/UI/uiSlice.js";
|
||||
import timezones from "@/Utils/timezones.json";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
|
||||
|
||||
interface Timezone {
|
||||
id: string;
|
||||
@@ -462,6 +463,43 @@ export const SettingsPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Check Retention */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.retention.title")}
|
||||
subtitle={t("pages.settings.form.retention.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="checkTTL"
|
||||
control={form.control}
|
||||
defaultValue={defaults.checkTTL}
|
||||
render={({ field }) => (
|
||||
<SliderWithLabel
|
||||
{...field}
|
||||
fieldLabel={t("pages.settings.form.retention.option.days.label")}
|
||||
min={1}
|
||||
max={CHECK_TTL_SENTINEL}
|
||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
||||
value={field.value || 30}
|
||||
onChange={(_, value) => field.onChange(value)}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value: number) =>
|
||||
value >= CHECK_TTL_SENTINEL
|
||||
? t("pages.settings.form.retention.option.days.unlimited")
|
||||
: `${value}`
|
||||
}
|
||||
formatDisplayValue={(value: number) =>
|
||||
value >= CHECK_TTL_SENTINEL
|
||||
? t("pages.settings.form.retention.option.days.unlimited")
|
||||
: `${value}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Global Thresholds */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useState, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { MonitorDetailsResponse } from "@/Types/Monitor";
|
||||
import { type MonitorDetailsResponse, supportsGeoCheck } from "@/Types/Monitor";
|
||||
import type { ChecksResponse } from "@/Types/Check";
|
||||
import type {
|
||||
GeoChecksResult,
|
||||
@@ -122,7 +122,7 @@ const UptimeDetailsPage = () => {
|
||||
);
|
||||
|
||||
const geoChecksUrl = useMemo(() => {
|
||||
if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) {
|
||||
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
@@ -141,7 +141,7 @@ const UptimeDetailsPage = () => {
|
||||
|
||||
// Fetch paginated geo checks for the table
|
||||
const geoChecksTableUrl = useMemo(() => {
|
||||
if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) {
|
||||
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -70,7 +70,7 @@ const UptimeMonitorsPage = () => {
|
||||
const effectiveTypes =
|
||||
selectedTypes.length > 0
|
||||
? selectedTypes
|
||||
: ["http", "ping", "docker", "port", "game", "grpc"];
|
||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
|
||||
|
||||
// Build URL for monitors with checks
|
||||
const monitorsWithChecksUrl = useMemo(() => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const CHECK_TTL_SENTINEL = 366;
|
||||
|
||||
export interface CheckMetadata {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
@@ -125,9 +127,6 @@ export interface Check {
|
||||
timings?: CheckTimings;
|
||||
statusCode: number;
|
||||
message: string;
|
||||
ack: boolean;
|
||||
ackAt?: string | null;
|
||||
expiry: string;
|
||||
cpu?: CheckCpuInfo;
|
||||
memory?: CheckMemoryInfo;
|
||||
disk?: CheckDiskInfo[];
|
||||
@@ -140,7 +139,6 @@ export interface Check {
|
||||
seo?: number;
|
||||
performance?: number;
|
||||
audits?: CheckAudits;
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -243,10 +241,7 @@ export interface ChecksSummary {
|
||||
downChecks: number;
|
||||
}
|
||||
|
||||
export type CheckSnapshot = Omit<
|
||||
Check,
|
||||
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
|
||||
> & {
|
||||
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
|
||||
originalResponseTime: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,10 +12,20 @@ export const MonitorTypes = [
|
||||
"port",
|
||||
"game",
|
||||
"grpc",
|
||||
"websocket",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
|
||||
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
return GeoCheckSupportedTypes.includes(type);
|
||||
};
|
||||
|
||||
export const MonitorStatuses = [
|
||||
"up",
|
||||
"down",
|
||||
|
||||
@@ -5,6 +5,7 @@ export const NotificationChannels = [
|
||||
"webhook",
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const getMonitorPath = (type: MonitorType): string => {
|
||||
ping: "uptime",
|
||||
game: "uptime",
|
||||
grpc: "uptime",
|
||||
websocket: "uptime",
|
||||
unknown: "uptime",
|
||||
docker: "uptime",
|
||||
hardware: "infrastructure",
|
||||
|
||||
@@ -12,6 +12,8 @@ export const typographyLevels = {
|
||||
export const colors = {
|
||||
gray200: "#EFEFEF",
|
||||
gray700: "#313131",
|
||||
gray900: "#151518",
|
||||
gray850: "#1c1c21",
|
||||
blueBlueWave: "#1570EF",
|
||||
};
|
||||
|
||||
@@ -31,4 +33,8 @@ export const darkPalette = {
|
||||
secondary: {
|
||||
main: colors.gray700,
|
||||
},
|
||||
background: {
|
||||
default: colors.gray900,
|
||||
paper: colors.gray850,
|
||||
},
|
||||
};
|
||||
@@ -115,6 +115,13 @@ const hardwareSchema = baseSchema.extend({
|
||||
selectedDisks: z.array(z.string()),
|
||||
});
|
||||
|
||||
// WebSocket monitor schema
|
||||
const websocketSchema = baseSchema.extend({
|
||||
type: z.literal("websocket"),
|
||||
url: z.string().min(1, "WebSocket URL is required"),
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
});
|
||||
|
||||
// Discriminated union of all monitor types
|
||||
export const monitorSchema = z.discriminatedUnion("type", [
|
||||
httpSchema,
|
||||
@@ -125,6 +132,7 @@ export const monitorSchema = z.discriminatedUnion("type", [
|
||||
grpcSchema,
|
||||
pagespeedSchema,
|
||||
hardwareSchema,
|
||||
websocketSchema,
|
||||
]);
|
||||
|
||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||
@@ -139,4 +147,5 @@ export {
|
||||
grpcSchema,
|
||||
pagespeedSchema,
|
||||
hardwareSchema,
|
||||
websocketSchema,
|
||||
};
|
||||
@@ -45,6 +45,11 @@ const matrixSchema = baseSchema.extend({
|
||||
accessToken: z.string().min(1, "Access token is required"),
|
||||
});
|
||||
|
||||
const teamsSchema = baseSchema.extend({
|
||||
type: z.literal("teams"),
|
||||
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
emailSchema,
|
||||
slackSchema,
|
||||
@@ -52,6 +57,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
webhookSchema,
|
||||
pagerDutySchema,
|
||||
matrixSchema,
|
||||
teamsSchema,
|
||||
]);
|
||||
|
||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
|
||||
import { z } from "zod";
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
@@ -11,7 +12,11 @@ export const settingsSchema = z.object({
|
||||
systemEmailSecure: z.boolean().optional(),
|
||||
systemEmailPool: z.boolean().optional(),
|
||||
showURL: z.boolean().optional(),
|
||||
checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"),
|
||||
checkTTL: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Please enter a value")
|
||||
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
|
||||
pagespeedApiKey: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"sendTestEmail": "Send test email",
|
||||
"exportToJSON": "Export to JSON",
|
||||
"importFromJSON": "Import from JSON",
|
||||
"clearFilters": "Clear filters"
|
||||
"clearFilters": "Clear filters",
|
||||
"removeUser": "Remove user"
|
||||
},
|
||||
"charts": {
|
||||
"labels": {
|
||||
@@ -487,6 +488,10 @@
|
||||
"grpcServiceName": {
|
||||
"label": "Service name",
|
||||
"placeholder": "e.g. my.service.v1 (leave empty for overall health)"
|
||||
},
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
}
|
||||
},
|
||||
"title": "General settings",
|
||||
@@ -498,6 +503,7 @@
|
||||
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
|
||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
|
||||
}
|
||||
},
|
||||
@@ -534,6 +540,8 @@
|
||||
"optionGameDescription": "Monitor if a specific game server is online.",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
|
||||
"optionWebSocket": "WebSocket",
|
||||
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
|
||||
"optionHttp": "HTTP(S)",
|
||||
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
|
||||
"optionPing": "Ping",
|
||||
@@ -619,6 +627,12 @@
|
||||
"title": "Roles",
|
||||
"description": "Assign roles to the user. Multiple roles can be selected."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"removeUser": {
|
||||
"title": "Remove user",
|
||||
"content": "Are you sure you want to remove {{name}} from your team? This action cannot be undone."
|
||||
}
|
||||
}
|
||||
},
|
||||
"incidents": {
|
||||
@@ -1027,7 +1041,7 @@
|
||||
},
|
||||
"stats": {
|
||||
"title": "Monitor history",
|
||||
"description": "Define how long you want to retain historical data. You can also clear all existing data.",
|
||||
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
||||
"option": {
|
||||
"clear": {
|
||||
"label": "Clear all stats. This is irreversible."
|
||||
@@ -1038,6 +1052,16 @@
|
||||
"description": "This cannot be undone"
|
||||
}
|
||||
},
|
||||
"retention": {
|
||||
"title": "Check retention",
|
||||
"description": "Set how long check data is retained before being automatically cleaned up.",
|
||||
"option": {
|
||||
"days": {
|
||||
"label": "Retention period (days)",
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Global Thresholds",
|
||||
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
|
||||
|
||||
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default defineConfig(({}) => {
|
||||
let version = "3.5.0";
|
||||
let version = "3.5.1";
|
||||
|
||||
return {
|
||||
base: "/",
|
||||
|
||||
Generated
+15
-61
@@ -43,6 +43,7 @@
|
||||
"super-simple-scheduler": "1.4.5",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -63,6 +64,7 @@
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/ping": "0.4.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"c8": "10.1.3",
|
||||
@@ -4660,6 +4662,16 @@
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@@ -4722,7 +4734,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -14431,63 +14442,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz",
|
||||
"integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"commander": "^7.2.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^2.3.1",
|
||||
"css-what": "^6.1.0",
|
||||
"csso": "^5.0.5",
|
||||
"picocolors": "^1.0.0",
|
||||
"sax": "^1.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"svgo": "bin/svgo"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/svgo"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/css-tree": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.30",
|
||||
"source-map-js": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/mdn-data": {
|
||||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
||||
"license": "CC0-1.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.30.0",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.0.tgz",
|
||||
@@ -15613,9 +15567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+3
-1
@@ -15,7 +15,7 @@
|
||||
"format-check": "prettier --check ."
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "npm run format"
|
||||
"**/*": "prettier --write"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -58,6 +58,7 @@
|
||||
"super-simple-scheduler": "1.4.5",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -78,6 +79,7 @@
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/ping": "0.4.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"c8": "10.1.3",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DiscordProvider,
|
||||
PagerDutyProvider,
|
||||
MatrixProvider,
|
||||
TeamsProvider,
|
||||
// Interfaces
|
||||
INetworkService,
|
||||
IEmailService,
|
||||
@@ -58,6 +59,7 @@ import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.
|
||||
import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
|
||||
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
|
||||
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
|
||||
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
|
||||
|
||||
// Third-party
|
||||
import axios from "axios";
|
||||
@@ -77,6 +79,7 @@ import { games, GameDig } from "gamedig";
|
||||
import jmespath from "jmespath";
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
import WebSocket from "ws";
|
||||
|
||||
// Repositories
|
||||
import {
|
||||
@@ -185,6 +188,7 @@ export const initializeServices = async ({
|
||||
const portProvider = new PortProvider(net);
|
||||
const gameProvider = new GameProvider(logger, GameDig);
|
||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||
|
||||
const networkService = new NetworkService(axios, logger, [
|
||||
pingProvider,
|
||||
@@ -195,6 +199,7 @@ export const initializeServices = async ({
|
||||
portProvider,
|
||||
gameProvider,
|
||||
grpcProvider,
|
||||
webSocketProvider,
|
||||
]);
|
||||
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
|
||||
|
||||
@@ -224,6 +229,7 @@ export const initializeServices = async ({
|
||||
const discordProvider = new DiscordProvider(logger);
|
||||
const pagerDutyProvider = new PagerDutyProvider(logger);
|
||||
const matrixProvider = new MatrixProvider(logger);
|
||||
const teamsProvider = new TeamsProvider(logger);
|
||||
|
||||
const notificationsService = new NotificationsService(
|
||||
notificationsRepository,
|
||||
@@ -234,6 +240,7 @@ export const initializeServices = async ({
|
||||
discordProvider,
|
||||
pagerDutyProvider,
|
||||
matrixProvider,
|
||||
teamsProvider,
|
||||
settingsService,
|
||||
logger,
|
||||
notificationMessageBuilder
|
||||
@@ -245,6 +252,7 @@ export const initializeServices = async ({
|
||||
statusService,
|
||||
notificationsService,
|
||||
checkService,
|
||||
settingsService,
|
||||
bufferService,
|
||||
incidentService,
|
||||
maintenanceWindowsRepository,
|
||||
|
||||
@@ -100,8 +100,8 @@ class AuthController {
|
||||
|
||||
editUser = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
editUserBodyValidation.parse(req.body);
|
||||
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
|
||||
const validatedBody = editUserBodyValidation.parse(req.body);
|
||||
const updatedUser = await this.userService.editUser(validatedBody, req.file, req.user);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
@@ -180,6 +180,20 @@ class AuthController {
|
||||
}
|
||||
};
|
||||
|
||||
deleteUserById = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
getUserByIdParamValidation.parse(req.params);
|
||||
const targetUserId = req.params.userId;
|
||||
await this.userService.deleteUserById(req.user, targetUserId);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "User removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getAllUsers = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const allUsers = await this.userService.getAllUsers();
|
||||
|
||||
@@ -4,18 +4,19 @@ import {
|
||||
getChecksParamValidation,
|
||||
getChecksQueryValidation,
|
||||
getTeamChecksQueryValidation,
|
||||
getChecksSummaryByTeamIdQueryValidation,
|
||||
deleteChecksParamValidation,
|
||||
deleteChecksByTeamIdParamValidation,
|
||||
updateChecksTTLBodyValidation,
|
||||
} from "@/validation/checkValidation.js";
|
||||
import { ICheckService } from "@/service/index.js";
|
||||
import { requireTeamId } from "@/controllers/controllerUtils.js";
|
||||
|
||||
const SERVICE_NAME = "checkController";
|
||||
|
||||
class CheckController {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private checkService: any;
|
||||
constructor(checkService: any) {
|
||||
private checkService: ICheckService;
|
||||
constructor(checkService: ICheckService) {
|
||||
this.checkService = checkService;
|
||||
}
|
||||
|
||||
@@ -25,13 +26,20 @@ class CheckController {
|
||||
|
||||
getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
getChecksParamValidation.parse(req.params);
|
||||
getChecksQueryValidation.parse(req.query);
|
||||
const validatedParams = getChecksParamValidation.parse(req.params);
|
||||
const validatedQuery = getChecksQueryValidation.parse(req.query);
|
||||
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const result = await this.checkService.getChecksByMonitor({
|
||||
monitorId: req?.params?.monitorId,
|
||||
query: req?.query,
|
||||
teamId: req?.user?.teamId,
|
||||
monitorId: validatedParams.monitorId,
|
||||
teamId,
|
||||
sortOrder: validatedQuery.sortOrder,
|
||||
dateRange: validatedQuery.dateRange,
|
||||
filter: validatedQuery.filter,
|
||||
page: validatedQuery.page,
|
||||
rowsPerPage: validatedQuery.rowsPerPage,
|
||||
status: validatedQuery.status,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -46,10 +54,16 @@ class CheckController {
|
||||
|
||||
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
getTeamChecksQueryValidation.parse(req.query);
|
||||
const validatedQuery = getTeamChecksQueryValidation.parse(req.query);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const checkData = await this.checkService.getChecksByTeam({
|
||||
teamId: req?.user?.teamId,
|
||||
query: req?.query,
|
||||
teamId,
|
||||
sortOrder: validatedQuery.sortOrder,
|
||||
dateRange: validatedQuery.dateRange,
|
||||
page: validatedQuery.page,
|
||||
rowsPerPage: validatedQuery.rowsPerPage,
|
||||
filter: validatedQuery.filter,
|
||||
});
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -63,8 +77,11 @@ class CheckController {
|
||||
|
||||
getChecksSummaryByTeamId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const dateRange = req.query?.dateRange || "hour";
|
||||
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId, dateRange });
|
||||
const validatedQuery = getChecksSummaryByTeamIdQueryValidation.parse(req.query);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
const dateRange = validatedQuery.dateRange ?? "hour";
|
||||
|
||||
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId, dateRange });
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Checks summary retrieved successfully",
|
||||
@@ -77,11 +94,12 @@ class CheckController {
|
||||
|
||||
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
deleteChecksParamValidation.parse(req.params);
|
||||
const validatedParams = deleteChecksParamValidation.parse(req.params);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const deletedCount = await this.checkService.deleteChecks({
|
||||
monitorId: req.params.monitorId as string,
|
||||
teamId: req?.user?.teamId,
|
||||
monitorId: validatedParams.monitorId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -96,9 +114,9 @@ class CheckController {
|
||||
|
||||
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
deleteChecksByTeamIdParamValidation.parse(req.params);
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId });
|
||||
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -109,24 +127,6 @@ class CheckController {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
updateChecksTTLBodyValidation.parse(req.body);
|
||||
|
||||
await this.checkService.updateChecksTTL({
|
||||
teamId: req?.user?.teamId,
|
||||
ttl: req?.body?.ttl,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Checks TTL updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default CheckController;
|
||||
@@ -21,13 +21,10 @@ type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
|
||||
type: MonitorType;
|
||||
};
|
||||
|
||||
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
|
||||
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "createdAt" | "updatedAt"> & {
|
||||
metadata: CheckMetadataDocument;
|
||||
expiry: Date;
|
||||
ackAt?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
__v: number;
|
||||
};
|
||||
|
||||
interface CheckDocument extends CheckDocumentBase {
|
||||
@@ -225,18 +222,7 @@ const CheckSchema = new Schema<CheckDocument>(
|
||||
message: {
|
||||
type: String,
|
||||
},
|
||||
expiry: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
ack: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
ackAt: {
|
||||
type: Date,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
cpu: {
|
||||
type: cpuSchema,
|
||||
default: () => ({}),
|
||||
|
||||
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as NotificationChannel[],
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
|
||||
required: true,
|
||||
},
|
||||
notificationName: {
|
||||
|
||||
+2
-3
@@ -50,9 +50,8 @@ const startApp = async () => {
|
||||
openApiSpec,
|
||||
});
|
||||
|
||||
const port = 52345;
|
||||
const server = app.listen(port, () => {
|
||||
logger.info({ message: `Server started on port:${port}` });
|
||||
const server = app.listen(env.PORT, () => {
|
||||
logger.info({ message: `Server started on port:${env.PORT}` });
|
||||
});
|
||||
|
||||
initShutdownListener(server, services);
|
||||
|
||||
@@ -47,4 +47,5 @@ export interface IChecksRepository {
|
||||
deleteByMonitorId(monitorId: string): Promise<number>;
|
||||
deleteByTeamId(teamId: string): Promise<number>;
|
||||
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
|
||||
deleteOlderThan(date: Date): Promise<number>;
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import type {
|
||||
GotTimings,
|
||||
MonitorType,
|
||||
} from "@/types/index.js";
|
||||
import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js";
|
||||
import { CheckModel, type CheckDocument } from "@/db/models/index.js";
|
||||
import mongoose from "mongoose";
|
||||
import { getDateForRange } from "@/utils/dataUtils.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "StatusService";
|
||||
|
||||
@@ -26,8 +27,8 @@ type HardwareUpChecks = { totalChecks: number };
|
||||
class MongoChecksRepository implements IChecksRepository {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: any;
|
||||
constructor(logger: any) {
|
||||
private logger: ILogger;
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,13 +47,6 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
};
|
||||
|
||||
const toOptionalDateString = (value?: Date | string | null): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return toDateString(value);
|
||||
};
|
||||
|
||||
const mapTimings = (timings?: GotTimings): GotTimings => {
|
||||
const phases = timings?.phases ?? {
|
||||
wait: 0,
|
||||
@@ -171,9 +165,6 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
timings: mapTimings(doc.timings),
|
||||
statusCode: doc.statusCode ?? 0,
|
||||
message: doc.message ?? "",
|
||||
ack: doc.ack ?? false,
|
||||
ackAt: toOptionalDateString(doc.ackAt),
|
||||
expiry: toDateString(doc.expiry),
|
||||
cpu: mapCpu(doc.cpu),
|
||||
memory: mapMemory(doc.memory),
|
||||
disk: mapDisks(doc.disk),
|
||||
@@ -186,7 +177,6 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
seo: doc.seo,
|
||||
performance: doc.performance,
|
||||
audits: mapAudits(doc.audits),
|
||||
__v: doc.__v ?? 0,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
@@ -237,7 +227,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
status: boolean | undefined
|
||||
) => {
|
||||
// Match
|
||||
const matchStage: Record<string, any> = {
|
||||
const matchStage: Record<string, unknown> = {
|
||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||
...(typeof status !== "undefined" && { status }),
|
||||
...(getDateForRange(dateRange) && {
|
||||
@@ -289,7 +279,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
};
|
||||
|
||||
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
|
||||
const matchStage: Record<string, any> = {
|
||||
const matchStage: Record<string, unknown> = {
|
||||
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
|
||||
...(getDateForRange(dateRange) && {
|
||||
createdAt: {
|
||||
@@ -358,7 +348,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
);
|
||||
|
||||
const mapped = results.reduce<LatestChecksMap>((acc, { monitorId, docs }) => {
|
||||
acc[monitorId] = docs.map((doc: any) => this.toEntity(doc as CheckDocument));
|
||||
acc[monitorId] = docs.map((doc: CheckDocument) => this.toEntity(doc));
|
||||
return acc;
|
||||
}, {});
|
||||
return mapped;
|
||||
@@ -412,6 +402,31 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
return result.deletedCount ?? 0;
|
||||
};
|
||||
|
||||
deleteOlderThan = async (cutoffDate: Date, batchDays: number = 30): Promise<number> => {
|
||||
// Find the oldest check that is older than the cutoff
|
||||
const oldest = await CheckModel.findOne({ createdAt: { $lt: cutoffDate } })
|
||||
.sort({ createdAt: 1 })
|
||||
.select({ createdAt: 1 });
|
||||
if (!oldest?.createdAt) return 0;
|
||||
|
||||
let totalDeleted = 0;
|
||||
let batchStart = new Date(oldest.createdAt);
|
||||
const batchMs = batchDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Delete in 30 day chunks until we reach the cutoff
|
||||
while (batchStart < cutoffDate) {
|
||||
// Advance startTime by batchMs to get to the end of last batch
|
||||
const nextStart = batchStart.getTime() + batchMs;
|
||||
const batchEnd = new Date(Math.min(nextStart, cutoffDate.getTime()));
|
||||
const result = await CheckModel.deleteMany({
|
||||
createdAt: { $gte: batchStart, $lt: batchEnd },
|
||||
});
|
||||
totalDeleted += result.deletedCount ?? 0;
|
||||
batchStart = batchEnd;
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
};
|
||||
private findUptimeDateRangeChecks = async (
|
||||
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
|
||||
monitorObjectId: mongoose.Types.ObjectId,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { IGeoChecksRepository } from "./IGeoChecksRepository.js";
|
||||
import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js";
|
||||
import type { GeoChecksQueryResult, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
|
||||
import type { FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
|
||||
import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js";
|
||||
import mongoose from "mongoose";
|
||||
import { getDateForRange } from "@/utils/dataUtils.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "GeoChecksRepository";
|
||||
|
||||
class MongoGeoChecksRepository implements IGeoChecksRepository {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: any;
|
||||
constructor(logger: any) {
|
||||
private logger: ILogger;
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,10 @@ class AuthRoutes {
|
||||
|
||||
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
|
||||
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
|
||||
this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById);
|
||||
this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById);
|
||||
this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
|
||||
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
|
||||
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
|
||||
|
||||
this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
|
||||
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
|
||||
|
||||
@@ -15,7 +15,6 @@ class CheckRoutes {
|
||||
initRoutes() {
|
||||
this.router.get("/team/summary", this.checkController.getChecksSummaryByTeamId);
|
||||
this.router.get("/team", this.checkController.getChecksByTeam);
|
||||
this.router.put("/team/ttl", isAllowed(["admin", "superadmin"]), this.checkController.updateChecksTTL);
|
||||
this.router.delete("/team", isAllowed(["admin", "superadmin"]), this.checkController.deleteChecksByTeamId);
|
||||
this.router.get("/:monitorId", this.checkController.getChecksByMonitor);
|
||||
this.router.delete("/:monitorId", this.checkController.deleteChecks);
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
import { Types } from "mongoose";
|
||||
import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js";
|
||||
import type { MonitorStatusResponse, CheckErrorInfo, Check, ILighthouseAudit, ChecksQueryResult, ChecksSummary } from "@/types/index.js";
|
||||
import type {
|
||||
MonitorStatusResponse,
|
||||
CheckErrorInfo,
|
||||
Check,
|
||||
ILighthouseAudit,
|
||||
ChecksQueryResult,
|
||||
ChecksSummary,
|
||||
MonitorPayloadMap,
|
||||
} from "@/types/index.js";
|
||||
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ParseBoolean } from "@/utils/utils.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "checkService";
|
||||
|
||||
export interface ICheckService {
|
||||
createChecks(checks: Check[]): Promise<Check[]>;
|
||||
buildCheck(statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>): Partial<Check> | undefined;
|
||||
getChecksByMonitor(params: { monitorId: string; query: Record<string, string>; teamId: string }): Promise<ChecksQueryResult>;
|
||||
getChecksByTeam(params: { teamId: string; query: Record<string, string> }): Promise<ChecksQueryResult>;
|
||||
buildCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
|
||||
getChecksByMonitor(params: {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
sortOrder: string;
|
||||
dateRange: string;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
filter?: string;
|
||||
status?: boolean;
|
||||
}): Promise<ChecksQueryResult>;
|
||||
getChecksByTeam(params: {
|
||||
teamId: string;
|
||||
sortOrder: string;
|
||||
dateRange: string;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
filter?: string;
|
||||
}): Promise<ChecksQueryResult>;
|
||||
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
|
||||
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
|
||||
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
|
||||
updateChecksTTL(params: { teamId: string; ttl: string }): Promise<void>;
|
||||
deleteOlderThan(date: Date): Promise<number>;
|
||||
}
|
||||
|
||||
export class CheckService implements ICheckService {
|
||||
@@ -39,12 +62,14 @@ export class CheckService implements ICheckService {
|
||||
return this.checksRepository.createChecks(checks);
|
||||
};
|
||||
|
||||
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
|
||||
buildCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
|
||||
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
|
||||
|
||||
const check: Partial<Check> = {
|
||||
const now = new Date().toISOString();
|
||||
const check: Check = {
|
||||
id: new Types.ObjectId().toString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: {
|
||||
monitorId,
|
||||
teamId,
|
||||
@@ -112,7 +137,25 @@ export class CheckService implements ICheckService {
|
||||
return check;
|
||||
};
|
||||
|
||||
getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: Record<string, string>; teamId: string }) => {
|
||||
getChecksByMonitor = async ({
|
||||
monitorId,
|
||||
teamId,
|
||||
sortOrder,
|
||||
dateRange,
|
||||
filter,
|
||||
page,
|
||||
rowsPerPage,
|
||||
status,
|
||||
}: {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
sortOrder: string;
|
||||
dateRange: string;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
status?: boolean;
|
||||
filter?: string;
|
||||
}) => {
|
||||
if (!monitorId) {
|
||||
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
|
||||
}
|
||||
@@ -123,75 +166,54 @@ export class CheckService implements ICheckService {
|
||||
// For verification, throws an error if monitor doesn't belong to team
|
||||
await this.monitorsRepository.findById(monitorId, teamId);
|
||||
|
||||
const { sortOrder, dateRange, filter, page, rowsPerPage, status } = query;
|
||||
if (!sortOrder || !dateRange || !page || !rowsPerPage) {
|
||||
throw new AppError({
|
||||
message: `Missing required query parameters sortOrder: ${sortOrder}, dateRange: ${dateRange}, filter: ${filter}, page: ${page}, rowsPerPage: ${rowsPerPage}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getChecksByMonitor",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const parsedStatus = typeof status === "undefined" ? status : ParseBoolean(status);
|
||||
const parsedPage = page ? parseInt(page) : 0;
|
||||
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
|
||||
const parsedPage = page ?? 0;
|
||||
const parsedRowsPerPage = rowsPerPage ?? 5;
|
||||
|
||||
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, parsedStatus);
|
||||
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, status);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
getChecksByTeam = async ({ teamId, query }: { teamId: string; query: Record<string, string> }) => {
|
||||
const { sortOrder, dateRange, filter, page, rowsPerPage } = query;
|
||||
if (!sortOrder || !dateRange || !page || !rowsPerPage) {
|
||||
throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
|
||||
}
|
||||
|
||||
const parsedPage = page ? parseInt(page) : 0;
|
||||
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
|
||||
getChecksByTeam = async ({
|
||||
teamId,
|
||||
sortOrder,
|
||||
dateRange,
|
||||
filter,
|
||||
page,
|
||||
rowsPerPage,
|
||||
}: {
|
||||
teamId: string;
|
||||
sortOrder: string;
|
||||
dateRange: string;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
filter?: string;
|
||||
}) => {
|
||||
const parsedPage = page ?? 0;
|
||||
const parsedRowsPerPage = rowsPerPage ?? 5;
|
||||
|
||||
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
|
||||
return checkData;
|
||||
};
|
||||
|
||||
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => {
|
||||
if (!teamId) {
|
||||
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksSummaryByTeamId", status: 400 });
|
||||
}
|
||||
const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange);
|
||||
return summary;
|
||||
};
|
||||
|
||||
deleteChecks = async ({ monitorId, teamId }: { monitorId: string; teamId: string }) => {
|
||||
if (!monitorId) {
|
||||
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 });
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 });
|
||||
}
|
||||
|
||||
// For verification, throws an error if monitor doesn't belong to team
|
||||
await this.monitorsRepository.findById(monitorId, teamId);
|
||||
|
||||
const deletedCount = await this.checksRepository.deleteByMonitorId(monitorId);
|
||||
return deletedCount;
|
||||
};
|
||||
deleteChecksByTeamId = async ({ teamId }: { teamId: string }) => {
|
||||
if (!teamId) {
|
||||
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecksByTeamId", status: 400 });
|
||||
}
|
||||
|
||||
const deletedCount = await this.checksRepository.deleteByTeamId(teamId);
|
||||
return deletedCount;
|
||||
};
|
||||
|
||||
updateChecksTTL = async ({ teamId, ttl }: { teamId: string; ttl: string }) => {
|
||||
throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500, details: { teamId, ttl } });
|
||||
deleteOlderThan = async (date: Date) => {
|
||||
const deletedCount = await this.checksRepository.deleteOlderThan(date);
|
||||
return deletedCount;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,4 +107,3 @@ export class DiagnosticService implements IDiagnosticService {
|
||||
return diagnostics;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
}
|
||||
|
||||
// Step 1: Create measurement request
|
||||
const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations);
|
||||
const measurementId = await this.globalPingService.createMeasurement(monitor.type, monitor.url, monitor.geoCheckLocations);
|
||||
|
||||
if (!measurementId) {
|
||||
// GlobalPing API is down, skip this check
|
||||
@@ -76,7 +76,6 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
message: "Skipping geo check due to API unavailability",
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -90,7 +89,6 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
message: "No successful geo check results",
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id, measurementId },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -102,17 +100,16 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
message: `Geo check completed for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id, resultsCount: results.length },
|
||||
});
|
||||
|
||||
return geoCheck;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: "Error executing geo check",
|
||||
service: SERVICE_NAME,
|
||||
method: "buildGeoCheck",
|
||||
details: { monitorId: monitor.id, error: error.message },
|
||||
stack: error.stack,
|
||||
details: { monitorId: monitor.id, error: error instanceof Error ? error.message : "Unknown error" },
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -172,20 +169,9 @@ export class GeoChecksService implements IGeoChecksService {
|
||||
|
||||
const { sortOrder, dateRange, page, rowsPerPage, continent } = query;
|
||||
const continents = continent ? (Array.isArray(continent) ? continent : [continent]) : undefined;
|
||||
|
||||
this.logger.debug({
|
||||
message: "getGeoChecksByMonitor query params",
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoChecksByMonitor",
|
||||
details: { continent, continents, query },
|
||||
});
|
||||
|
||||
const parsedPage = page ? parseInt(page) : page;
|
||||
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
|
||||
|
||||
const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continents);
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,4 +266,3 @@ export class IncidentService implements IIncidentService {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,4 +94,3 @@ export class InviteService implements IInviteService {
|
||||
return await this.invitesRepository.findByToken(inviteToken);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,4 +96,3 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
|
||||
return await this.maintenanceWindowsRepository.updateById(id, teamId, body);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PageSpeedDetailsResult,
|
||||
GamesMap,
|
||||
} from "@/types/monitor.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
import type { GeoContinent } from "@/types/geoCheck.js";
|
||||
import type {
|
||||
IChecksRepository,
|
||||
@@ -17,9 +18,7 @@ import type {
|
||||
IMonitorStatsRepository,
|
||||
IStatusPagesRepository,
|
||||
} from "@/repositories/index.js";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
|
||||
@@ -179,23 +178,17 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<any[]> => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const demoMonitorsPath = path.resolve(__dirname, "../../utils/demoMonitors.json");
|
||||
|
||||
const demoData = JSON.parse(fs.readFileSync(demoMonitorsPath, "utf8"));
|
||||
const monitors: Monitor[] = demoData.map((monitor: Monitor) => {
|
||||
return {
|
||||
userId,
|
||||
teamId,
|
||||
name: monitor.name,
|
||||
description: monitor.name,
|
||||
type: "http",
|
||||
url: monitor.url,
|
||||
interval: 60000,
|
||||
};
|
||||
});
|
||||
const demoMonitors = await this.monitorsRepository.createMonitors(monitors);
|
||||
const demoData = demoMonitorsData;
|
||||
const monitors = demoData.map((monitor) => ({
|
||||
userId,
|
||||
teamId,
|
||||
name: monitor.name,
|
||||
description: monitor.name,
|
||||
type: "http" as const,
|
||||
url: monitor.url,
|
||||
interval: 60000,
|
||||
}));
|
||||
const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]);
|
||||
|
||||
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
|
||||
return demoMonitors;
|
||||
@@ -229,7 +222,8 @@ export class MonitorService implements IMonitorService {
|
||||
checksData.monitorType !== "docker" &&
|
||||
checksData.monitorType !== "port" &&
|
||||
checksData.monitorType !== "game" &&
|
||||
checksData.monitorType !== "grpc"
|
||||
checksData.monitorType !== "grpc" &&
|
||||
checksData.monitorType !== "websocket"
|
||||
) {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
|
||||
}
|
||||
@@ -343,7 +337,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
|
||||
}
|
||||
|
||||
if (monitor.type !== "http" || !monitor.geoCheckEnabled) {
|
||||
if (!supportsGeoCheck(monitor.type) || !monitor.geoCheckEnabled) {
|
||||
return { groupedGeoChecks: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface IUserService {
|
||||
validateRecovery(recoveryToken: string): Promise<void>;
|
||||
resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>;
|
||||
deleteUser(user: User): Promise<void>;
|
||||
deleteUserById(actor: User, targetUserId: string): Promise<void>;
|
||||
getAllUsers(): Promise<User[]>;
|
||||
getUserById(roles: UserRole[], userId: string): Promise<User>;
|
||||
editUserById(userId: string, patch: Partial<User>): Promise<void>;
|
||||
@@ -141,7 +142,7 @@ export class UserService implements IUserService {
|
||||
message: "New user created",
|
||||
service: SERVICE_NAME,
|
||||
method: "registerUser",
|
||||
details: { id: newUser.id },
|
||||
details: { userId: newUser.id },
|
||||
});
|
||||
|
||||
delete newUser.profileImage;
|
||||
@@ -205,7 +206,7 @@ export class UserService implements IUserService {
|
||||
message: "New user created by superadmin",
|
||||
service: SERVICE_NAME,
|
||||
method: "createUser",
|
||||
details: { id: newUser.id },
|
||||
details: { userId: newUser.id },
|
||||
});
|
||||
|
||||
newUser.profileImage = undefined;
|
||||
@@ -272,8 +273,8 @@ export class UserService implements IUserService {
|
||||
await this.recoveryTokensRepository.deleteManyByEmail(email);
|
||||
const recoveryToken = await this.recoveryTokensRepository.create(email);
|
||||
const name = user.firstName;
|
||||
const { clientHost } = this.settingsService.getSettings();
|
||||
const url = `${clientHost}/set-new-password/${recoveryToken.token}`;
|
||||
const settings = this.settingsService.getSettings();
|
||||
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
|
||||
|
||||
const html = await this.emailService.buildEmail("passwordResetTemplate", {
|
||||
name,
|
||||
@@ -281,7 +282,12 @@ export class UserService implements IUserService {
|
||||
url,
|
||||
});
|
||||
if (!html) {
|
||||
throw new AppError({ message: "Failed to generate password reset email", status: 500 });
|
||||
throw new AppError({
|
||||
message: "Failed to build password reset email HTML",
|
||||
service: SERVICE_NAME,
|
||||
method: "requestRecovery",
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html);
|
||||
return msgId;
|
||||
@@ -343,7 +349,7 @@ export class UserService implements IUserService {
|
||||
if (res && res.length > 0) {
|
||||
await Promise.all(
|
||||
res.map(async (monitor) => {
|
||||
await this.jobQueue.deleteJob(monitor.id);
|
||||
await this.jobQueue.deleteJob(monitor);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -352,13 +358,53 @@ export class UserService implements IUserService {
|
||||
await this.usersRepository.deleteById(userId);
|
||||
};
|
||||
|
||||
deleteUserById = async (actor: User, targetUserId: string) => {
|
||||
if (actor.id === targetUserId) {
|
||||
throw new AppError({ message: "Cannot delete your own account from here", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
|
||||
}
|
||||
|
||||
const targetUser = await this.usersRepository.findById(targetUserId);
|
||||
|
||||
if (targetUser.teamId !== actor.teamId) {
|
||||
throw new AppError({ message: "User is not on your team", service: SERVICE_NAME, method: "deleteUserById", status: 403 });
|
||||
}
|
||||
|
||||
if (targetUser.role.includes("demo")) {
|
||||
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
|
||||
}
|
||||
|
||||
const actorRoles = actor.role;
|
||||
const targetRoles = targetUser.role;
|
||||
|
||||
// Check actor can manage all of target's roles
|
||||
for (const targetRole of targetRoles) {
|
||||
const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
|
||||
if (!canManage) {
|
||||
throw new AppError({
|
||||
message: "You do not have permission to remove this user",
|
||||
service: SERVICE_NAME,
|
||||
method: "deleteUserById",
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.usersRepository.deleteById(targetUserId);
|
||||
|
||||
this.logger.info({
|
||||
message: `User ${targetUserId} deleted by ${actor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "deleteUserById",
|
||||
});
|
||||
};
|
||||
|
||||
getAllUsers = async () => {
|
||||
return await this.usersRepository.findAll();
|
||||
};
|
||||
|
||||
getUserById = async (roles: any, userId: any) => {
|
||||
if (!roles.includes("superadmin")) {
|
||||
throw new AppError({ message: "User is not a superadmin", service: SERVICE_NAME, status: 403 });
|
||||
if (!roles.includes("superadmin") && !roles.includes("admin")) {
|
||||
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
|
||||
}
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export * from "@/service/infrastructure/notificationProviders/INotificationProvi
|
||||
export * from "@/service/infrastructure/notificationProviders/matrix.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/slack.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/teams.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
||||
|
||||
// System services
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IMonitorsRepository } from "@/repositories/index.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import Scheduler from "super-simple-scheduler";
|
||||
import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
|
||||
import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js";
|
||||
const SERVICE_NAME = "JobQueue";
|
||||
|
||||
type QueueJobFailure = {
|
||||
@@ -41,11 +42,11 @@ type QueueJobSummary = {
|
||||
export interface ISuperSimpleQueue {
|
||||
readonly serviceName: string;
|
||||
init(): Promise<boolean>;
|
||||
addJob(monitorId: string, monitor: any): Promise<void>;
|
||||
deleteJob(monitor: any): Promise<void>;
|
||||
pauseJob(monitor: any): Promise<void>;
|
||||
resumeJob(monitor: any): Promise<void>;
|
||||
updateJob(monitor: any): Promise<void>;
|
||||
addJob(monitorId: string, monitor: Monitor): Promise<void>;
|
||||
deleteJob(monitor: Monitor): Promise<void>;
|
||||
pauseJob(monitor: Monitor): Promise<void>;
|
||||
resumeJob(monitor: Monitor): Promise<void>;
|
||||
updateJob(monitor: Monitor): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
getMetrics(): Promise<QueueMetrics>;
|
||||
getJobs(): Promise<QueueJobSummary[]>;
|
||||
@@ -88,9 +89,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
try {
|
||||
this.scheduler.start();
|
||||
|
||||
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
|
||||
this.scheduler.addTemplate("geo-check-job", this.helper.getGeoCheckJob());
|
||||
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
|
||||
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
|
||||
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
|
||||
this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob());
|
||||
const monitors = await this.monitorsRepository.findAll();
|
||||
if (!monitors) {
|
||||
return true;
|
||||
@@ -103,20 +105,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
}
|
||||
|
||||
this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true });
|
||||
this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 });
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: "Failed to initialize SuperSimpleQueue",
|
||||
message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "init",
|
||||
details: error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
addJob = async (monitorId: string, monitor: any) => {
|
||||
addJob = async (monitorId: string, monitor: Monitor) => {
|
||||
this.scheduler.addJob({
|
||||
id: monitorId,
|
||||
template: "monitor-job",
|
||||
@@ -125,8 +127,13 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
data: monitor,
|
||||
});
|
||||
|
||||
// Return early if we don't need geo checks
|
||||
if (!supportsGeoCheck(monitor.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add geo check job if enabled for HTTP monitors
|
||||
if (monitor.geoCheckEnabled && monitor.type === "http") {
|
||||
if (monitor.geoCheckEnabled) {
|
||||
this.scheduler.addJob({
|
||||
id: `${monitorId}-geo`,
|
||||
template: "geo-check-job",
|
||||
@@ -137,12 +144,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
}
|
||||
};
|
||||
|
||||
deleteJob = async (monitor: any) => {
|
||||
deleteJob = async (monitor: Monitor) => {
|
||||
this.scheduler.removeJob(monitor.id);
|
||||
this.scheduler.removeJob(`${monitor.id}-geo`);
|
||||
};
|
||||
|
||||
pauseJob = async (monitor: any) => {
|
||||
pauseJob = async (monitor: Monitor) => {
|
||||
const result = await this.scheduler.pauseJob(monitor.id);
|
||||
if (result === false) {
|
||||
throw new Error("Failed to pause monitor");
|
||||
@@ -155,7 +162,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
});
|
||||
};
|
||||
|
||||
resumeJob = async (monitor: any) => {
|
||||
resumeJob = async (monitor: Monitor) => {
|
||||
const result = await this.scheduler.resumeJob(monitor.id);
|
||||
if (result === false) {
|
||||
throw new Error("Failed to resume monitor");
|
||||
@@ -170,12 +177,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
});
|
||||
};
|
||||
|
||||
updateJob = async (monitor: any) => {
|
||||
updateJob = async (monitor: Monitor) => {
|
||||
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
|
||||
|
||||
// Handle geo check job lifecycle
|
||||
const geoJobId = `${monitor.id}-geo`;
|
||||
if (monitor.geoCheckEnabled && monitor.type === "http") {
|
||||
if (monitor.geoCheckEnabled && supportsGeoCheck(monitor.type)) {
|
||||
// Check if geo job exists
|
||||
const existingGeoJob = await this.scheduler.getJob(geoJobId);
|
||||
if (existingGeoJob) {
|
||||
@@ -238,8 +245,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
failingJobs: 0,
|
||||
jobsWithFailures: [] as Array<{
|
||||
monitorId: string | number;
|
||||
monitorUrl: any;
|
||||
monitorType: any;
|
||||
monitorUrl: string;
|
||||
monitorType: MonitorType;
|
||||
failedAt: number | null;
|
||||
failCount: number;
|
||||
failReason: string | null;
|
||||
@@ -289,4 +296,3 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
const SERVICE_NAME = "JobQueueHelper";
|
||||
import type { Monitor } from "@/types/monitor.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { INetworkService, INotificationsService, IStatusService, IncidentService, type IGeoChecksService } from "@/service/index.js";
|
||||
import type { StatusChangeResult } from "@/types/index.js";
|
||||
import {
|
||||
ICheckService,
|
||||
INetworkService,
|
||||
INotificationsService,
|
||||
ISettingsService,
|
||||
IStatusService,
|
||||
IncidentService,
|
||||
type IGeoChecksService,
|
||||
} from "@/service/index.js";
|
||||
import { CHECK_TTL_SENTINEL, type MaintenanceWindow, type StatusChangeResult } from "@/types/index.js";
|
||||
import {
|
||||
IMaintenanceWindowsRepository,
|
||||
IMonitorsRepository,
|
||||
@@ -17,9 +26,10 @@ import { IBufferService } from "@/service/index.js";
|
||||
|
||||
export interface ISuperSimpleQueueHelper {
|
||||
readonly serviceName: string;
|
||||
getMonitorJob(): (monitor: Monitor) => Promise<void>;
|
||||
getHeartbeatJob(): (monitor: Monitor) => Promise<void>;
|
||||
getHeartbeatGeoJob(): (monitor: Monitor) => Promise<void>;
|
||||
getCleanupOrphanedJob(): () => Promise<void>;
|
||||
getGeoCheckJob(): (monitor: Monitor) => Promise<void>;
|
||||
getCleanupRetentionJob(): () => Promise<void>;
|
||||
isInMaintenanceWindow(monitorId: string, teamId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -40,11 +50,12 @@ export interface MonitorActionDecision {
|
||||
export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: any;
|
||||
private logger: ILogger;
|
||||
private networkService: INetworkService;
|
||||
private statusService: IStatusService;
|
||||
private notificationsService: INotificationsService;
|
||||
private checkService: any;
|
||||
private checkService: ICheckService;
|
||||
private settingsService: ISettingsService;
|
||||
private buffer: IBufferService;
|
||||
private incidentService: IncidentService;
|
||||
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
|
||||
@@ -61,7 +72,8 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
networkService: INetworkService,
|
||||
statusService: IStatusService,
|
||||
notificationsService: INotificationsService,
|
||||
checkService: any,
|
||||
checkService: ICheckService,
|
||||
settingsService: ISettingsService,
|
||||
buffer: IBufferService,
|
||||
incidentService: IncidentService,
|
||||
maintenanceWindowsRepository: IMaintenanceWindowsRepository,
|
||||
@@ -77,6 +89,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
this.networkService = networkService;
|
||||
this.statusService = statusService;
|
||||
this.checkService = checkService;
|
||||
this.settingsService = settingsService;
|
||||
this.buffer = buffer;
|
||||
this.notificationsService = notificationsService;
|
||||
this.incidentService = incidentService;
|
||||
@@ -94,7 +107,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
return SuperSimpleQueueHelper.SERVICE_NAME;
|
||||
}
|
||||
|
||||
getMonitorJob = () => {
|
||||
getHeartbeatJob = () => {
|
||||
return async (monitor: Monitor) => {
|
||||
try {
|
||||
const monitorId = monitor.id;
|
||||
@@ -125,7 +138,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
}
|
||||
|
||||
// Step 3. Build check
|
||||
const check = await this.checkService.buildCheck(status);
|
||||
const check = this.checkService.buildCheck(status);
|
||||
if (!check) {
|
||||
this.logger.warn({
|
||||
message: `No check could be built for monitor ${monitorId}`,
|
||||
@@ -145,33 +158,31 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
|
||||
// Step 6. Handle notifications (best effort, continue even in event of failure, don't wait)
|
||||
if (decision.shouldSendNotification) {
|
||||
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => {
|
||||
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => {
|
||||
this.logger.error({
|
||||
message: error.message,
|
||||
message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`,
|
||||
stack: error.stack,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Step 7. Handle incidents (best effort, don't wait)
|
||||
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => {
|
||||
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
|
||||
this.logger.warn({
|
||||
message: error.message,
|
||||
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
details: `Error handling incident for job ${monitor.id}: ${error.message}`,
|
||||
stack: error.stack,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error.message,
|
||||
service: error.service || SERVICE_NAME,
|
||||
method: error.method || "getMonitorJob",
|
||||
stack: error.stack,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -257,19 +268,19 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error.message,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
stack: error.stack,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getGeoCheckJob = () => {
|
||||
getHeartbeatGeoJob = () => {
|
||||
return async (monitor: Monitor) => {
|
||||
try {
|
||||
const monitorId = monitor.id;
|
||||
@@ -277,27 +288,26 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
|
||||
// Step 1: Validate monitor eligibility
|
||||
if (!monitorId) {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" });
|
||||
}
|
||||
|
||||
if (monitor.type !== "http") {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitorId} is not HTTP type, skipping geo check`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
});
|
||||
return;
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" });
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!supportsGeoCheck(monitor.type)) {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitorId} type does not support geo checks, skipping`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
|
||||
this.logger.warn({
|
||||
message: `No geo check locations configured for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -308,7 +318,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
this.logger.debug({
|
||||
message: `Monitor ${monitorId} is in maintenance window, skipping geo check`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -319,7 +329,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
this.logger.warn({
|
||||
message: `No geo check could be built for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -330,14 +340,14 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
this.logger.debug({
|
||||
message: `Geo check job executed for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
method: "getHeartbeatGeoJob",
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error.message,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
stack: error.stack,
|
||||
method: "getHeartbeatGeoJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
// Don't throw - geo check failures shouldn't crash the job scheduler
|
||||
}
|
||||
@@ -347,7 +357,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
async isInMaintenanceWindow(monitorId: string, teamId: string) {
|
||||
const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId);
|
||||
// Check for active maintenance window:
|
||||
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: any, window: any) => {
|
||||
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: boolean, window: MaintenanceWindow) => {
|
||||
if (window.active) {
|
||||
const start = new Date(window.start);
|
||||
const end = new Date(window.end);
|
||||
@@ -374,6 +384,40 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
return maintenanceWindowIsActive;
|
||||
}
|
||||
|
||||
getCleanupRetentionJob = () => {
|
||||
return async () => {
|
||||
try {
|
||||
const settings = await this.settingsService.getDBSettings();
|
||||
|
||||
const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS
|
||||
|
||||
if (checkTTL === CHECK_TTL_SENTINEL) {
|
||||
this.logger.info({
|
||||
message: `Check TTL is set to unlimited, skipping cleanup`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupRetentionJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000;
|
||||
const cutoffDate = new Date(Date.now() - checkTTLInMs);
|
||||
const deleteCount = await this.checkService.deleteOlderThan(cutoffDate);
|
||||
this.logger.info({
|
||||
message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupRetentionJob",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupRetentionJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision {
|
||||
const { monitor, statusChanged, prevStatus } = statusChangeResult;
|
||||
|
||||
@@ -412,4 +456,3 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,4 +177,3 @@ export class BufferService implements IBufferService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,4 +178,3 @@ export class EmailService implements IEmailService {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js";
|
||||
import { supportsGeoCheck } from "@/types/monitor.js";
|
||||
import { MonitorType } from "@/types/index.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
import got from "got";
|
||||
|
||||
@@ -8,7 +10,7 @@ const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_TIMEOUT_MS = 30000;
|
||||
|
||||
interface GlobalPingMeasurementRequest {
|
||||
type: "http";
|
||||
type: MonitorType;
|
||||
target: string;
|
||||
locations: Array<{ continent: GeoContinent }>;
|
||||
limit: number;
|
||||
@@ -44,13 +46,22 @@ interface GlobalPingProbeResult {
|
||||
firstByte: number;
|
||||
download: number;
|
||||
};
|
||||
stats?: {
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
total: number;
|
||||
loss: number;
|
||||
rcv: number;
|
||||
drop: number;
|
||||
};
|
||||
rawOutput?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IGlobalPingService {
|
||||
readonly serviceName: string;
|
||||
createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null>;
|
||||
createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null>;
|
||||
pollForResults(measurementId: string, timeoutMs?: number): Promise<GeoCheckResult[]>;
|
||||
}
|
||||
|
||||
@@ -67,13 +78,16 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
return GlobalPingService.SERVICE_NAME;
|
||||
}
|
||||
|
||||
async createMeasurement(url: string, locations: GeoContinent[]): Promise<string | null> {
|
||||
async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise<string | null> {
|
||||
try {
|
||||
if (!supportsGeoCheck(monitorType)) {
|
||||
throw new Error(`Unsupported monitor type for GlobalPing: ${monitorType}`);
|
||||
}
|
||||
// GlobalPing API expects target without protocol (http:// or https://)
|
||||
const cleanTarget = url.replace(/^https?:\/\//, "");
|
||||
|
||||
const requestBody: GlobalPingMeasurementRequest = {
|
||||
type: "http",
|
||||
type: monitorType,
|
||||
target: cleanTarget,
|
||||
locations: locations.map((continent) => ({ continent })),
|
||||
limit: locations.length,
|
||||
@@ -88,10 +102,9 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
const measurementId = response.body.id;
|
||||
|
||||
this.logger.debug({
|
||||
message: `Created GlobalPing measurement: ${measurementId}`,
|
||||
message: `Created GlobalPing measurement: ${measurementId} for target: ${cleanTarget}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "createMeasurement",
|
||||
details: { measurementId, url, locations },
|
||||
});
|
||||
|
||||
return measurementId;
|
||||
@@ -165,8 +178,7 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
const successfulResults: GeoCheckResult[] = [];
|
||||
|
||||
for (const probeResult of probeResults) {
|
||||
// Skip failed or timeout results
|
||||
if (probeResult.result.status !== "finished" || !probeResult.result.statusCode || !probeResult.result.timings) {
|
||||
if (probeResult.result.status !== "finished") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -180,23 +192,38 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
latitude: probeResult.probe.latitude,
|
||||
};
|
||||
|
||||
const timings: GeoCheckTimings = {
|
||||
total: probeResult.result.timings.total,
|
||||
dns: probeResult.result.timings.dns,
|
||||
tcp: probeResult.result.timings.tcp,
|
||||
tls: probeResult.result.timings.tls,
|
||||
firstByte: probeResult.result.timings.firstByte,
|
||||
download: probeResult.result.timings.download,
|
||||
};
|
||||
// HTTP results have statusCode and timings, ping results have stats
|
||||
if (probeResult.result.statusCode && probeResult.result.timings) {
|
||||
const timings: GeoCheckTimings = {
|
||||
total: probeResult.result.timings.total,
|
||||
dns: probeResult.result.timings.dns,
|
||||
tcp: probeResult.result.timings.tcp,
|
||||
tls: probeResult.result.timings.tls,
|
||||
firstByte: probeResult.result.timings.firstByte,
|
||||
download: probeResult.result.timings.download,
|
||||
};
|
||||
|
||||
const result: GeoCheckResult = {
|
||||
location,
|
||||
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
|
||||
statusCode: probeResult.result.statusCode,
|
||||
timings,
|
||||
};
|
||||
|
||||
successfulResults.push(result);
|
||||
successfulResults.push({
|
||||
location,
|
||||
status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300,
|
||||
statusCode: probeResult.result.statusCode,
|
||||
timings,
|
||||
});
|
||||
} else if (probeResult.result.stats) {
|
||||
successfulResults.push({
|
||||
location,
|
||||
status: probeResult.result.stats.loss === 0,
|
||||
statusCode: probeResult.result.stats.loss === 0 ? 200 : 5000,
|
||||
timings: {
|
||||
total: probeResult.result.stats.avg,
|
||||
dns: 0,
|
||||
tcp: 0,
|
||||
tls: 0,
|
||||
firstByte: 0,
|
||||
download: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return successfulResults;
|
||||
@@ -206,4 +233,3 @@ export class GlobalPingService implements IGlobalPingService {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MonitorType, Monitor } from "@/types/monitor.js";
|
||||
import { MonitorStatusResponse } from "@/types/network.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import ping from "ping";
|
||||
import { buildStatusResponse, timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
import { timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
const SERVICE_NAME = "PingProvider";
|
||||
|
||||
type Ping = typeof ping;
|
||||
@@ -33,7 +33,7 @@ export class PingProvider implements IStatusProvider<PingStatusPayload> {
|
||||
|
||||
const sanitizedHost = this.sanitizeHost(monitor.url);
|
||||
const { response, error } = await timeRequest<PingStatusPayload>(() => this.ping.promise.probe(sanitizedHost));
|
||||
const safeTime = typeof response?.time === "number" ? response.time : parseFloat(String(response?.time)) || 0;
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -42,16 +42,18 @@ export class PingProvider implements IStatusProvider<PingStatusPayload> {
|
||||
throw new Error(`No response from ping for host: ${sanitizedHost}`);
|
||||
}
|
||||
|
||||
return buildStatusResponse<PingStatusPayload>({
|
||||
monitor,
|
||||
const responseTime = typeof response.time === "number" ? response.time : parseFloat(String(response.time)) || 0;
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: response.alive ?? false,
|
||||
code: response.alive ? 200 : 5000,
|
||||
message: response.alive ? "Success" : "Ping failed",
|
||||
responseTime,
|
||||
payload: response,
|
||||
overrides: {
|
||||
status: response.alive ?? false,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime: safeTime,
|
||||
},
|
||||
});
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new AppError({
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
||||
import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { Monitor, MonitorType } from "@/types/monitor.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
import type WebSocket from "ws";
|
||||
|
||||
type WebSocketConstructor = typeof WebSocket;
|
||||
|
||||
const SERVICE_NAME = "WebSocketProvider";
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
export class WebSocketProvider implements IStatusProvider<WebSocketStatusPayload> {
|
||||
readonly type = "websocket";
|
||||
|
||||
constructor(private WS: WebSocketConstructor) {}
|
||||
|
||||
supports(type: MonitorType): boolean {
|
||||
return type === "websocket";
|
||||
}
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<WebSocketStatusPayload>> {
|
||||
try {
|
||||
const { url } = monitor;
|
||||
if (!url) {
|
||||
throw new Error("URL is required for WebSocket monitoring");
|
||||
}
|
||||
|
||||
const { responseTime, error } = await timeRequest(async () => {
|
||||
return new Promise<{ connected: boolean }>((resolve, reject) => {
|
||||
const options: WebSocket.ClientOptions = {};
|
||||
if (monitor.ignoreTlsErrors) {
|
||||
options.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
const ws = new this.WS(url, options);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("WebSocket connection timeout"));
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
ws.on("open", () => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve({ connected: true });
|
||||
});
|
||||
|
||||
ws.on("error", (err: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "WebSocket check failed";
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: errorMessage,
|
||||
responseTime: responseTime,
|
||||
timings: undefined,
|
||||
payload: { connected: false },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "WebSocket check successful",
|
||||
responseTime: responseTime,
|
||||
timings: undefined,
|
||||
payload: { connected: true },
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const originalMessage = err instanceof Error ? err.message : String(err);
|
||||
throw new AppError({
|
||||
message: originalMessage || "Error performing WebSocket check",
|
||||
status: 500,
|
||||
service: SERVICE_NAME,
|
||||
method: "handle",
|
||||
details: { url: monitor.url },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
import { HTTPError, RequestError } from "got";
|
||||
import type { Response } from "got";
|
||||
import type { Monitor } from "@/types/monitor.js";
|
||||
import { MonitorStatusResponse, MonitorStatusResponseOverrides } from "@/types/network.js";
|
||||
|
||||
export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> => {
|
||||
const start = process.hrtime.bigint();
|
||||
try {
|
||||
@@ -17,69 +12,3 @@ export const timeRequest = async <T>(operation: () => Promise<T>): Promise<{ res
|
||||
|
||||
export const NETWORK_ERROR = 5000;
|
||||
export const PING_ERROR = 5001;
|
||||
|
||||
interface BuildStatusResponseArgs<T> {
|
||||
monitor: Monitor;
|
||||
response?: Response<T> | null;
|
||||
error?: Error | RequestError | HTTPError | null;
|
||||
payload?: T | null;
|
||||
jsonPath?: string;
|
||||
matchMethod?: MonitorStatusResponse["matchMethod"];
|
||||
expectedValue?: string;
|
||||
extracted?: unknown;
|
||||
overrides?: MonitorStatusResponseOverrides<T>;
|
||||
}
|
||||
|
||||
export const buildStatusResponse = <T>({
|
||||
monitor,
|
||||
response,
|
||||
error,
|
||||
payload,
|
||||
jsonPath,
|
||||
matchMethod,
|
||||
expectedValue,
|
||||
extracted,
|
||||
overrides,
|
||||
}: BuildStatusResponseArgs<T>): MonitorStatusResponse<T> => {
|
||||
if (error) {
|
||||
const statusResponse: MonitorStatusResponse<T> = {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: error.message ?? "Network error",
|
||||
responseTime: 0,
|
||||
timings: undefined,
|
||||
jsonPath,
|
||||
matchMethod,
|
||||
expectedValue,
|
||||
extracted,
|
||||
payload,
|
||||
};
|
||||
|
||||
if (error instanceof HTTPError || error instanceof RequestError) {
|
||||
statusResponse.code = error?.response?.statusCode ?? NETWORK_ERROR;
|
||||
statusResponse.message = error.message;
|
||||
statusResponse.responseTime = error.timings?.phases?.total ?? 0;
|
||||
statusResponse.timings = error.timings;
|
||||
}
|
||||
return { ...statusResponse, ...(overrides ?? {}) };
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: response?.ok ?? false,
|
||||
code: response?.statusCode ?? NETWORK_ERROR,
|
||||
message: response?.statusMessage ?? "",
|
||||
responseTime: response?.timings?.phases?.total ?? 0,
|
||||
timings: response?.timings,
|
||||
payload: payload ?? response?.body,
|
||||
jsonPath,
|
||||
matchMethod,
|
||||
expectedValue,
|
||||
extracted,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
|
||||
import type { Monitor, MonitorStatusResponse, MonitorPayloadMap, MonitorType } from "@/types/index.js";
|
||||
import type { AxiosStatic } from "axios";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js";
|
||||
@@ -8,7 +8,7 @@ const SERVICE_NAME = "NetworkService";
|
||||
|
||||
export interface INetworkService {
|
||||
readonly serviceName: string;
|
||||
requestStatus(monitor: Monitor): Promise<MonitorStatusResponse<unknown>>;
|
||||
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
requestWebhook(
|
||||
type: string,
|
||||
url: string,
|
||||
@@ -43,12 +43,12 @@ export class NetworkService implements INetworkService {
|
||||
}
|
||||
|
||||
// Main entry point
|
||||
async requestStatus(monitor: Monitor) {
|
||||
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
|
||||
const provider = this.providers.find((p) => p.supports(monitor.type));
|
||||
if (!provider) {
|
||||
return this.handleUnsupportedType(monitor.type);
|
||||
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
return provider.handle(monitor);
|
||||
return provider.handle(monitor) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
|
||||
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
|
||||
@@ -197,4 +197,3 @@ export class NetworkService implements INetworkService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
const SERVICE_NAME = "TeamsProvider";
|
||||
import type { Notification } from "@/types/index.js";
|
||||
import { INotificationProvider } from "@/service/index.js";
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
import got, { HTTPError } from "got";
|
||||
|
||||
// Types for Adaptive Card elements
|
||||
type TextBlock = {
|
||||
type: "TextBlock";
|
||||
text: string;
|
||||
weight?: "Bolder" | "Normal" | "Lighter";
|
||||
size?: "Small" | "Medium" | "Large" | "ExtraLarge";
|
||||
color?: "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention";
|
||||
wrap?: boolean;
|
||||
spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto";
|
||||
isSubtle?: boolean;
|
||||
};
|
||||
|
||||
type ColumnSet = {
|
||||
type: "ColumnSet";
|
||||
separator?: boolean;
|
||||
spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto";
|
||||
columns: unknown[];
|
||||
};
|
||||
|
||||
type Fact = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type FactSet = {
|
||||
type: "FactSet";
|
||||
facts: Fact[];
|
||||
};
|
||||
|
||||
type Action = {
|
||||
type: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type AdaptiveCard = {
|
||||
type: "AdaptiveCard";
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json";
|
||||
version: "1.4";
|
||||
body: (TextBlock | ColumnSet | FactSet)[];
|
||||
actions?: Action[];
|
||||
};
|
||||
|
||||
type TeamsMessage = {
|
||||
type: "message";
|
||||
attachments: Array<{
|
||||
contentType: "application/vnd.microsoft.card.adaptive";
|
||||
contentUrl: null;
|
||||
content: AdaptiveCard;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class TeamsProvider implements INotificationProvider {
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async sendTestAlert(notification: Notification): Promise<boolean> {
|
||||
if (!notification.address) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.wrapAdaptiveCard({
|
||||
type: "AdaptiveCard",
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
version: "1.4",
|
||||
body: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: getTestMessage(),
|
||||
weight: "Bolder",
|
||||
size: "Medium",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await got.post(notification.address, {
|
||||
json: payload,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as HTTPError;
|
||||
this.logger.warn({
|
||||
message: "Teams test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
|
||||
if (!notification.address) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = this.wrapAdaptiveCard(this.buildAdaptiveCard(message));
|
||||
|
||||
try {
|
||||
await got.post(notification.address, {
|
||||
json: payload,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.logger.info({
|
||||
message: "Teams notification sent via sendMessage",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as HTTPError;
|
||||
this.logger.warn({
|
||||
message: "Teams alert failed via sendMessage",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an Adaptive Card in the Teams webhook envelope format
|
||||
*/
|
||||
private wrapAdaptiveCard(card: AdaptiveCard): TeamsMessage {
|
||||
return {
|
||||
type: "message",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Adaptive Card from NotificationMessage
|
||||
*/
|
||||
private buildAdaptiveCard(message: NotificationMessage): AdaptiveCard {
|
||||
const colorMap: Record<string, string> = {
|
||||
critical: "attention",
|
||||
warning: "warning",
|
||||
success: "good",
|
||||
info: "accent",
|
||||
};
|
||||
const color = (colorMap[message.severity] || "default") as "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention" | undefined;
|
||||
|
||||
const body: (TextBlock | ColumnSet | FactSet)[] = [];
|
||||
|
||||
// Header with colored status indicator
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: message.content.title,
|
||||
weight: "Bolder",
|
||||
size: "Large",
|
||||
color,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
// Summary
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: message.content.summary,
|
||||
wrap: true,
|
||||
spacing: "Small",
|
||||
});
|
||||
|
||||
// Separator
|
||||
body.push({
|
||||
type: "ColumnSet",
|
||||
separator: true,
|
||||
spacing: "Medium",
|
||||
columns: [],
|
||||
});
|
||||
|
||||
// Monitor details as a FactSet
|
||||
body.push({
|
||||
type: "FactSet",
|
||||
facts: [
|
||||
{ title: "Name", value: message.monitor.name },
|
||||
{ title: "Type", value: message.monitor.type },
|
||||
{ title: "Status", value: message.monitor.status },
|
||||
{ title: "URL", value: message.monitor.url },
|
||||
],
|
||||
});
|
||||
|
||||
// Threshold breaches
|
||||
if (message.content.thresholds && message.content.thresholds.length > 0) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: "**Threshold Breaches**",
|
||||
weight: "Bolder",
|
||||
spacing: "Medium",
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
for (const breach of message.content.thresholds) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: `• **${breach.metric.toUpperCase()}**: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`,
|
||||
wrap: true,
|
||||
spacing: "Small",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Additional details
|
||||
if (message.content.details && message.content.details.length > 0) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: "**Additional Information**",
|
||||
weight: "Bolder",
|
||||
spacing: "Medium",
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
for (const detail of message.content.details) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: `• ${detail}`,
|
||||
wrap: true,
|
||||
spacing: "Small",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp footer
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`,
|
||||
size: "Small",
|
||||
isSubtle: true,
|
||||
spacing: "Medium",
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
// Actions (incident link)
|
||||
const actions: Action[] = [];
|
||||
if (message.content.incident) {
|
||||
actions.push({
|
||||
type: "Action.OpenUrl",
|
||||
title: "View Incident",
|
||||
url: `${message.clientHost}/incidents/${message.content.incident.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "AdaptiveCard",
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
version: "1.4",
|
||||
body,
|
||||
...(actions.length > 0 ? { actions } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export class NotificationsService implements INotificationsService {
|
||||
private discordProvider: INotificationProvider;
|
||||
private pagerDutyProvider: INotificationProvider;
|
||||
private matrixProvider: INotificationProvider;
|
||||
private teamsProvider: INotificationProvider;
|
||||
private logger: ILogger;
|
||||
private settingsService: ISettingsService;
|
||||
private notificationMessageBuilder: INotificationMessageBuilder;
|
||||
@@ -45,6 +46,7 @@ export class NotificationsService implements INotificationsService {
|
||||
discordProvider: INotificationProvider,
|
||||
pagerDutyProvider: INotificationProvider,
|
||||
matrixProvider: INotificationProvider,
|
||||
teamsProvider: INotificationProvider,
|
||||
settingsService: ISettingsService,
|
||||
logger: ILogger,
|
||||
notificationMessageBuilder: INotificationMessageBuilder
|
||||
@@ -57,6 +59,7 @@ export class NotificationsService implements INotificationsService {
|
||||
this.discordProvider = discordProvider;
|
||||
this.pagerDutyProvider = pagerDutyProvider;
|
||||
this.matrixProvider = matrixProvider;
|
||||
this.teamsProvider = teamsProvider;
|
||||
this.settingsService = settingsService;
|
||||
this.logger = logger;
|
||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||
@@ -92,6 +95,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.discordProvider.sendMessage!(notification, notificationMessage);
|
||||
case "email":
|
||||
return await this.emailProvider.sendMessage!(notification, notificationMessage);
|
||||
case "teams":
|
||||
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
|
||||
default:
|
||||
this.logger.warn({
|
||||
message: `Unknown notification type: ${notification.type}`,
|
||||
@@ -150,6 +155,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.matrixProvider.sendTestAlert(notification);
|
||||
case "webhook":
|
||||
return await this.webhookProvider.sendTestAlert(notification);
|
||||
case "teams":
|
||||
return await this.teamsProvider.sendTestAlert(notification);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -74,4 +74,3 @@ export class SettingsService implements ISettingsService {
|
||||
return settings;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { MonitorType } from "@/types/index.js";
|
||||
import type { Response } from "got";
|
||||
|
||||
export const CHECK_TTL_SENTINEL = 366;
|
||||
|
||||
export type GotTimings = Response["timings"];
|
||||
|
||||
export interface CheckMetadata {
|
||||
@@ -98,9 +101,6 @@ export interface Check {
|
||||
timings?: GotTimings;
|
||||
statusCode: number;
|
||||
message: string;
|
||||
ack: boolean;
|
||||
ackAt?: string | null;
|
||||
expiry: string;
|
||||
cpu?: CheckCpuInfo;
|
||||
memory?: CheckMemoryInfo;
|
||||
disk?: CheckDiskInfo[];
|
||||
@@ -113,7 +113,6 @@ export interface Check {
|
||||
seo?: number;
|
||||
performance?: number;
|
||||
audits?: CheckAudits;
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -205,4 +204,4 @@ export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
|
||||
originalAvgResponseTime: number;
|
||||
};
|
||||
|
||||
export type CheckSnapshot = Omit<Check, "metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt">;
|
||||
export type CheckSnapshot = Omit<Check, "metadata" | "updatedAt">;
|
||||
@@ -3,9 +3,12 @@ export type { CheckSnapshot } from "@/types/check.js";
|
||||
import type { GeoContinent } from "@/types/geoCheck.js";
|
||||
export type { GeoContinent } from "@/types/geoCheck.js";
|
||||
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "unknown"] as const;
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
|
||||
|
||||
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
|
||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ export interface MonitorStatusResponse<
|
||||
| HardwareStatusPayload
|
||||
| DockerStatusPayload
|
||||
| GameStatusPayload
|
||||
| GrpcStatusPayload,
|
||||
| GrpcStatusPayload
|
||||
| WebSocketStatusPayload,
|
||||
> {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
@@ -111,6 +112,10 @@ export interface GrpcStatusPayload {
|
||||
servingStatus: string;
|
||||
}
|
||||
|
||||
export interface WebSocketStatusPayload {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface MonitorPayloadMap {
|
||||
ping: PingStatusPayload;
|
||||
http: HttpStatusPayload;
|
||||
@@ -120,7 +125,8 @@ export interface MonitorPayloadMap {
|
||||
port: PortStatusPayload;
|
||||
game: GameStatusPayload;
|
||||
grpc: GrpcStatusPayload;
|
||||
default: unknown;
|
||||
websocket: WebSocketStatusPayload;
|
||||
unknown: unknown;
|
||||
}
|
||||
|
||||
export type StatusChangeResult = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as const;
|
||||
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
export interface Notification {
|
||||
|
||||
@@ -11,34 +11,32 @@ export const getChecksParamValidation = z.object({
|
||||
});
|
||||
|
||||
export const getChecksQueryValidation = z.object({
|
||||
type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"]).optional(),
|
||||
sortOrder: z.enum(["asc", "desc"]).optional(),
|
||||
type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(),
|
||||
sortOrder: z.enum(["asc", "desc"]),
|
||||
limit: z.coerce.number().optional(),
|
||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
|
||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
|
||||
filter: z.enum(["all", "up", "down", "resolve"]).optional(),
|
||||
ack: booleanCoercion.optional(),
|
||||
page: z.coerce.number().optional(),
|
||||
rowsPerPage: z.coerce.number().optional(),
|
||||
page: z.coerce.number(),
|
||||
rowsPerPage: z.coerce.number(),
|
||||
status: booleanCoercion.optional(),
|
||||
continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(),
|
||||
});
|
||||
|
||||
export const getTeamChecksQueryValidation = z.object({
|
||||
sortOrder: z.enum(["asc", "desc"]).optional(),
|
||||
sortOrder: z.enum(["asc", "desc"]),
|
||||
limit: z.coerce.number().optional(),
|
||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
|
||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
|
||||
filter: z.enum(["all", "up", "down", "resolve"]).optional(),
|
||||
ack: booleanCoercion.optional(),
|
||||
page: z.coerce.number().optional(),
|
||||
rowsPerPage: z.coerce.number().optional(),
|
||||
page: z.coerce.number(),
|
||||
rowsPerPage: z.coerce.number(),
|
||||
});
|
||||
|
||||
export const deleteChecksParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
|
||||
export const deleteChecksByTeamIdParamValidation = z.object({});
|
||||
|
||||
export const updateChecksTTLBodyValidation = z.object({
|
||||
ttl: z.number().min(1, "TTL is required"),
|
||||
export const getChecksSummaryByTeamIdQueryValidation = z.object({
|
||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
// Server Configuration
|
||||
PORT: z.string().default("52345"),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("debug"),
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export const getMonitorsByTeamIdParamValidation = z.object({});
|
||||
export const getMonitorsByTeamIdQueryValidation = z.object({
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])),
|
||||
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(""), z.null()]).optional(),
|
||||
@@ -37,8 +37,8 @@ export const getMonitorsWithChecksQueryValidation = z.object({
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])),
|
||||
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(),
|
||||
|
||||
@@ -18,7 +18,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("webhook"),
|
||||
address: z.string().url("Please enter a valid Webhook URL"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
||||
roomId: z.union([z.string(), z.literal("")]).optional(),
|
||||
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -27,7 +27,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("slack"),
|
||||
address: z.string().url("Please enter a valid Webhook URL"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
||||
roomId: z.union([z.string(), z.literal("")]).optional(),
|
||||
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -36,7 +36,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("discord"),
|
||||
address: z.string().url("Please enter a valid Webhook URL"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
||||
roomId: z.union([z.string(), z.literal("")]).optional(),
|
||||
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -55,10 +55,16 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("matrix"),
|
||||
address: z.union([z.string(), z.literal("")]).optional(),
|
||||
homeserverUrl: z.string().url("Please enter a valid Homeserver URL"),
|
||||
homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }),
|
||||
roomId: z.string().min(1, "Room ID is required"),
|
||||
accessToken: z.string().min(1, "Access Token is required"),
|
||||
}),
|
||||
// Teams notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("teams"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const sendTestEmailBodyValidation = z.object({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CHECK_TTL_SENTINEL } from "@/types/check.js";
|
||||
import { z } from "zod";
|
||||
|
||||
//****************************************
|
||||
@@ -6,9 +7,8 @@ import { z } from "zod";
|
||||
|
||||
export const updateAppSettingsBodyValidation = z
|
||||
.object({
|
||||
checkTTL: z.union([z.number(), z.literal("")]).optional(),
|
||||
checkTTL: z.union([z.number().int().min(1).max(CHECK_TTL_SENTINEL), z.literal("")]).optional(),
|
||||
systemEmailPort: z.union([z.number(), z.literal("")]).optional(),
|
||||
|
||||
pagespeedApiKey: z.union([z.string(), z.literal("")]).optional(),
|
||||
language: z.union([z.string(), z.literal("")]).optional(),
|
||||
timezone: z.union([z.string(), z.literal("")]).optional(),
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts";
|
||||
import type { Notification } from "../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../src/types/notificationMessage.ts";
|
||||
|
||||
// Mock got
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
default: { post: jest.fn() },
|
||||
}));
|
||||
|
||||
const createLogger = () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
});
|
||||
|
||||
const createNotification = (overrides?: Partial<Notification>): Notification => ({
|
||||
id: "notif-1",
|
||||
userId: "user-1",
|
||||
teamId: "team-1",
|
||||
type: "teams",
|
||||
notificationName: "Teams Alert",
|
||||
address: "https://xxxxx.webhook.office.com/webhookb2/test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMessage => ({
|
||||
type: "monitor_down",
|
||||
severity: "critical",
|
||||
monitor: {
|
||||
id: "mon-1",
|
||||
name: "API Server",
|
||||
url: "https://api.example.com",
|
||||
type: "http",
|
||||
status: "down",
|
||||
},
|
||||
content: {
|
||||
title: "🔴 Monitor Down",
|
||||
summary: "API Server is not responding",
|
||||
details: ["Response timeout after 30s"],
|
||||
thresholds: [],
|
||||
incident: {
|
||||
id: "inc-1",
|
||||
url: "https://app.example.com/incidents/inc-1",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
timestamp: new Date("2026-01-15T12:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: {
|
||||
teamId: "team-1",
|
||||
notificationReason: "monitor_down",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("TeamsProvider", () => {
|
||||
let provider: TeamsProvider;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
let gotPost: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
logger = createLogger();
|
||||
provider = new TeamsProvider(logger);
|
||||
const got = await import("got");
|
||||
gotPost = got.default.post as jest.Mock;
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("returns false when address is missing", async () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends an Adaptive Card test message and returns true on success", async () => {
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(true);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
// Verify Teams webhook envelope
|
||||
const payload = options.json;
|
||||
expect(payload.type).toBe("message");
|
||||
expect(payload.attachments).toHaveLength(1);
|
||||
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
|
||||
|
||||
// Verify Adaptive Card structure
|
||||
const card = payload.attachments[0].content;
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
expect(card.body[0].type).toBe("TextBlock");
|
||||
expect(card.body[0].text).toContain("test notification");
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
gotPost.mockRejectedValue(new Error("Network error"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Teams test alert failed",
|
||||
service: "TeamsProvider",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("returns false when address is missing", async () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a well-formed Adaptive Card and returns true", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage();
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
|
||||
const payload = options.json;
|
||||
expect(payload.type).toBe("message");
|
||||
|
||||
const card = payload.attachments[0].content;
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
|
||||
// Verify body contains expected elements
|
||||
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
|
||||
const factSets = card.body.filter((b: any) => b.type === "FactSet");
|
||||
|
||||
// Title block
|
||||
expect(textBlocks[0].text).toBe(message.content.title);
|
||||
expect(textBlocks[0].color).toBe("attention"); // critical -> attention
|
||||
|
||||
// Summary block
|
||||
expect(textBlocks[1].text).toBe(message.content.summary);
|
||||
|
||||
// FactSet with monitor details
|
||||
expect(factSets).toHaveLength(1);
|
||||
const facts = factSets[0].facts;
|
||||
expect(facts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "Name", value: "API Server" }),
|
||||
expect.objectContaining({ title: "URL", value: "https://api.example.com" }),
|
||||
expect.objectContaining({ title: "Status", value: "down" }),
|
||||
])
|
||||
);
|
||||
|
||||
// Incident action button
|
||||
expect(card.actions).toHaveLength(1);
|
||||
expect(card.actions[0].type).toBe("Action.OpenUrl");
|
||||
expect(card.actions[0].title).toBe("View Incident");
|
||||
expect(card.actions[0].url).toContain("/infrastructure/mon-1");
|
||||
});
|
||||
|
||||
it("omits actions when no incident is present", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({
|
||||
content: {
|
||||
title: "Test",
|
||||
summary: "Summary",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.actions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes threshold breaches when present", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({
|
||||
type: "threshold_breach",
|
||||
severity: "warning",
|
||||
content: {
|
||||
title: "Threshold Breach",
|
||||
summary: "CPU usage exceeded",
|
||||
thresholds: [
|
||||
{
|
||||
metric: "cpu",
|
||||
currentValue: 95,
|
||||
threshold: 80,
|
||||
unit: "%",
|
||||
formattedValue: "95%",
|
||||
},
|
||||
],
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
|
||||
// Title should use "warning" color
|
||||
expect(card.body[0].color).toBe("warning");
|
||||
|
||||
// Find threshold text blocks
|
||||
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
|
||||
expect(thresholdHeader).toBeDefined();
|
||||
|
||||
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("CPU"));
|
||||
expect(cpuBlock).toBeDefined();
|
||||
expect(cpuBlock.text).toContain("95%");
|
||||
expect(cpuBlock.text).toContain("threshold: 80%");
|
||||
});
|
||||
|
||||
it("maps severity to correct Adaptive Card colors", async () => {
|
||||
const notification = createNotification();
|
||||
|
||||
const severityMap = [
|
||||
{ severity: "critical", expected: "attention" },
|
||||
{ severity: "warning", expected: "warning" },
|
||||
{ severity: "success", expected: "good" },
|
||||
{ severity: "info", expected: "accent" },
|
||||
] as const;
|
||||
|
||||
for (const { severity, expected } of severityMap) {
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
|
||||
const message = createMessage({ severity });
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.body[0].color).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
gotPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Teams alert failed via sendMessage",
|
||||
service: "TeamsProvider",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,8 @@
|
||||
"moduleResolution": "nodenext",
|
||||
"skipLibCheck": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["tests", "dist", "node_modules"]
|
||||
|
||||
Reference in new issue
Block a user