diff --git a/client/package-lock.json b/client/package-lock.json index 710d4cdd5..7462d95d7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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" diff --git a/client/package.json b/client/package.json index ba2d59275..2e6b5cfcb 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/Components/inputs/Slider.tsx b/client/src/Components/inputs/Slider.tsx index 83037ab54..a17fc8a10 100644 --- a/client/src/Components/inputs/Slider.tsx +++ b/client/src/Components/inputs/Slider.tsx @@ -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( - ({ 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 ( - {showValue && {props.value}} + {showValue && {displayValue}} string; sliderMaxWidth?: ResponsiveStyleValue; } export const SliderWithLabel = forwardRef( ( - { 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 diff --git a/client/src/Components/layout/AppLayout.tsx b/client/src/Components/layout/AppLayout.tsx index baadd85ca..34822c5ae 100644 --- a/client/src/Components/layout/AppLayout.tsx +++ b/client/src/Components/layout/AppLayout.tsx @@ -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(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", }} > diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx index 0f26e04a7..a617d454c 100644 --- a/client/src/Components/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -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 = { http: "HTTP", ping: "Ping", @@ -15,6 +15,7 @@ const typeDisplayNames: Record = { docker: "Docker", game: "Game", grpc: "gRPC", + websocket: "WebSocket", }; const statuses = ["up", "down"]; const states = ["active", "paused"]; diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 8b5252879..963409fc8 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -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, diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index a3ddebaba..86a52bbbb 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -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(`/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 = () => { } /> - + {canDeleteUser && ( + + )} + + + setShowDeleteDialog(false)} + onConfirm={handleDeleteUser} + confirmText={t("common.buttons.removeUser")} + loading={isDeleting} + /> ); }; diff --git a/client/src/Pages/Account/components/TeamTable.tsx b/client/src/Pages/Account/components/TeamTable.tsx index ab8dcc3b4..289bfb331 100644 --- a/client/src/Pages/Account/components/TeamTable.tsx +++ b/client/src/Pages/Account/components/TeamTable.tsx @@ -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[] = [ { @@ -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) => { ); }; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 34e141795..b80cddaaa 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -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" )} /> + )} @@ -742,7 +765,9 @@ const CreateMonitorPage = () => { } /> - {(watchedType === "http" || watchedType === "grpc") && ( + {(watchedType === "http" || + watchedType === "grpc" || + watchedType === "websocket") && ( { /> )} - {watchedType === "http" && ( + {supportsGeoCheck(watchedType) && ( { /> )} + {/* Check Retention */} + {isAdmin && ( + ( + 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 && ( { ); 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(); diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 80ee8e2d9..792e0fd78 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -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(() => { diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index c69fe7972..9f7771222 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -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 & { originalResponseTime: number; }; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 01cae62e9..053b517d1 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -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", diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 5b9645c9d..610f73547 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -5,6 +5,7 @@ export const NotificationChannels = [ "webhook", "pager_duty", "matrix", + "teams", ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index ad744ba6d..d0ede67e1 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -9,6 +9,7 @@ export const getMonitorPath = (type: MonitorType): string => { ping: "uptime", game: "uptime", grpc: "uptime", + websocket: "uptime", unknown: "uptime", docker: "uptime", hardware: "infrastructure", diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index d314c8e27..9c19f0cbf 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -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, + }, }; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 439af977f..9acffe6fe 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -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; @@ -139,4 +147,5 @@ export { grpcSchema, pagespeedSchema, hardwareSchema, + websocketSchema, }; diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index e505c695f..2bbb871c9 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -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; diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 7eddaafe2..975927d1c 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -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())) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c6bf41478..4c4965aec 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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.", diff --git a/client/vite.config.ts b/client/vite.config.ts index 7408636dd..373a50763 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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: "/", diff --git a/server/package-lock.json b/server/package-lock.json index 3dfd2740a..5d9d920ee 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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" diff --git a/server/package.json b/server/package.json index ce2c0822e..7bceee766 100755 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b55bd0f87..b31c8a5e9 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -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, diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 8add39087..3f837f632 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -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(); diff --git a/server/src/controllers/checkController.ts b/server/src/controllers/checkController.ts index ec6722bd3..879ceaffe 100755 --- a/server/src/controllers/checkController.ts +++ b/server/src/controllers/checkController.ts @@ -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; diff --git a/server/src/db/models/Check.ts b/server/src/db/models/Check.ts index 7bd1cec6f..541abb4d9 100644 --- a/server/src/db/models/Check.ts +++ b/server/src/db/models/Check.ts @@ -21,13 +21,10 @@ type CheckMetadataDocument = Omit & { type: MonitorType; }; -type CheckDocumentBase = Omit & { +type CheckDocumentBase = Omit & { 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( message: { type: String, }, - expiry: { - type: Date, - default: Date.now, - }, - ack: { - type: Boolean, - default: false, - }, - ackAt: { - type: Date, - default: undefined, - }, + cpu: { type: cpuSchema, default: () => ({}), diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 97e2d43fb..9ea3f17bd 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,7 @@ const NotificationSchema = new Schema( }, 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: { diff --git a/server/src/index.ts b/server/src/index.ts index 1a6195d80..2286d3887 100755 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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); diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/repositories/checks/IChecksRepository.ts index c89e5d5fc..b96d14933 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/repositories/checks/IChecksRepository.ts @@ -47,4 +47,5 @@ export interface IChecksRepository { deleteByMonitorId(monitorId: string): Promise; deleteByTeamId(teamId: string): Promise; deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; + deleteOlderThan(date: Date): Promise; } diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index b019a0430..4055ddfc3 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -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 = { + const matchStage: Record = { "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 = { + const matchStage: Record = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), ...(getDateForRange(dateRange) && { createdAt: { @@ -358,7 +348,7 @@ class MongoChecksRepository implements IChecksRepository { ); const mapped = results.reduce((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 => { + // 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, monitorObjectId: mongoose.Types.ObjectId, diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index 7633fcf7c..e1db5ab1d 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -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; } diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index cc1ec3fd6..2e5fa1069 100755 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -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); diff --git a/server/src/routes/checkRoute.ts b/server/src/routes/checkRoute.ts index 4df286846..71a0cd5f3 100755 --- a/server/src/routes/checkRoute.ts +++ b/server/src/routes/checkRoute.ts @@ -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); diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts index 8779b0a00..374090709 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -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; - buildCheck(statusResponse: MonitorStatusResponse): Partial | undefined; - getChecksByMonitor(params: { monitorId: string; query: Record; teamId: string }): Promise; - getChecksByTeam(params: { teamId: string; query: Record }): Promise; + buildCheck(statusResponse: MonitorStatusResponse): Check | undefined; + getChecksByMonitor(params: { + monitorId: string; + teamId: string; + sortOrder: string; + dateRange: string; + page: number; + rowsPerPage: number; + filter?: string; + status?: boolean; + }): Promise; + getChecksByTeam(params: { + teamId: string; + sortOrder: string; + dateRange: string; + page: number; + rowsPerPage: number; + filter?: string; + }): Promise; getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise; deleteChecks(params: { monitorId: string; teamId: string }): Promise; deleteChecksByTeamId(params: { teamId: string }): Promise; - updateChecksTTL(params: { teamId: string; ttl: string }): Promise; + deleteOlderThan(date: Date): Promise; } export class CheckService implements ICheckService { @@ -39,12 +62,14 @@ export class CheckService implements ICheckService { return this.checksRepository.createChecks(checks); }; - buildCheck = (statusResponse: MonitorStatusResponse) => { + buildCheck = (statusResponse: MonitorStatusResponse): Check | undefined => { const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse; - const check: Partial = { + 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; 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 }) => { - 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; }; } - diff --git a/server/src/service/business/diagnosticService.ts b/server/src/service/business/diagnosticService.ts index f7a8b8ff9..f9c53f633 100644 --- a/server/src/service/business/diagnosticService.ts +++ b/server/src/service/business/diagnosticService.ts @@ -107,4 +107,3 @@ export class DiagnosticService implements IDiagnosticService { return diagnostics; }; } - diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index 5c5869d94..f35826a8f 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -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; }; } - diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 81a98893a..710336016 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -266,4 +266,3 @@ export class IncidentService implements IIncidentService { } }; } - diff --git a/server/src/service/business/inviteService.ts b/server/src/service/business/inviteService.ts index 26b3b9dbe..9d1cbf59d 100644 --- a/server/src/service/business/inviteService.ts +++ b/server/src/service/business/inviteService.ts @@ -94,4 +94,3 @@ export class InviteService implements IInviteService { return await this.invitesRepository.findByToken(inviteToken); }; } - diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index 2257718f3..abf513eb5 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -96,4 +96,3 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { return await this.maintenanceWindowsRepository.updateById(id, teamId, body); }; } - diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index f1fbcedd3..fc52af6bd 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -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 => { - 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: [] }; } diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 3e0d968be..51764667e 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -29,6 +29,7 @@ export interface IUserService { validateRecovery(recoveryToken: string): Promise; resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>; deleteUser(user: User): Promise; + deleteUserById(actor: User, targetUserId: string): Promise; getAllUsers(): Promise; getUserById(roles: UserRole[], userId: string): Promise; editUserById(userId: string, patch: Partial): Promise; @@ -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); diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 464866e09..9686d2a13 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -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 diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 216a821b1..f4fd5b1e6 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -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; - addJob(monitorId: string, monitor: any): Promise; - deleteJob(monitor: any): Promise; - pauseJob(monitor: any): Promise; - resumeJob(monitor: any): Promise; - updateJob(monitor: any): Promise; + addJob(monitorId: string, monitor: Monitor): Promise; + deleteJob(monitor: Monitor): Promise; + pauseJob(monitor: Monitor): Promise; + resumeJob(monitor: Monitor): Promise; + updateJob(monitor: Monitor): Promise; shutdown(): Promise; getMetrics(): Promise; getJobs(): Promise; @@ -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 { }); }; } - diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 11a44d1bd..b6908127b 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -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; + getHeartbeatJob(): (monitor: Monitor) => Promise; + getHeartbeatGeoJob(): (monitor: Monitor) => Promise; getCleanupOrphanedJob(): () => Promise; - getGeoCheckJob(): (monitor: Monitor) => Promise; + getCleanupRetentionJob(): () => Promise; isInMaintenanceWindow(monitorId: string, teamId: string): Promise; } @@ -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; } } - diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index ae89a1c09..229c5e7d4 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -177,4 +177,3 @@ export class BufferService implements IBufferService { } } } - diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 180a728f3..f536f3f18 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -178,4 +178,3 @@ export class EmailService implements IEmailService { } }; } - diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index 0a65412f4..41f96b286 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -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; + createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise; pollForResults(measurementId: string, timeoutMs?: number): Promise; } @@ -67,13 +78,16 @@ export class GlobalPingService implements IGlobalPingService { return GlobalPingService.SERVICE_NAME; } - async createMeasurement(url: string, locations: GeoContinent[]): Promise { + async createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise { 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)); } } - diff --git a/server/src/service/infrastructure/network/PingProvider.ts b/server/src/service/infrastructure/network/PingProvider.ts index 2749584f5..0b9691da9 100644 --- a/server/src/service/infrastructure/network/PingProvider.ts +++ b/server/src/service/infrastructure/network/PingProvider.ts @@ -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 { const sanitizedHost = this.sanitizeHost(monitor.url); const { response, error } = await timeRequest(() => 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 { throw new Error(`No response from ping for host: ${sanitizedHost}`); } - return buildStatusResponse({ - 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({ diff --git a/server/src/service/infrastructure/network/WebSocketProvider.ts b/server/src/service/infrastructure/network/WebSocketProvider.ts new file mode 100644 index 000000000..0af436067 --- /dev/null +++ b/server/src/service/infrastructure/network/WebSocketProvider.ts @@ -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 { + readonly type = "websocket"; + + constructor(private WS: WebSocketConstructor) {} + + supports(type: MonitorType): boolean { + return type === "websocket"; + } + + async handle(monitor: Monitor): Promise> { + 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 }, + }); + } + } +} diff --git a/server/src/service/infrastructure/network/utils.ts b/server/src/service/infrastructure/network/utils.ts index c70f06bf8..ddb962f47 100644 --- a/server/src/service/infrastructure/network/utils.ts +++ b/server/src/service/infrastructure/network/utils.ts @@ -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 (operation: () => Promise): Promise<{ response: T | null; responseTime: number; error: unknown }> => { const start = process.hrtime.bigint(); try { @@ -17,69 +12,3 @@ export const timeRequest = async (operation: () => Promise): Promise<{ res export const NETWORK_ERROR = 5000; export const PING_ERROR = 5001; - -interface BuildStatusResponseArgs { - monitor: Monitor; - response?: Response | null; - error?: Error | RequestError | HTTPError | null; - payload?: T | null; - jsonPath?: string; - matchMethod?: MonitorStatusResponse["matchMethod"]; - expectedValue?: string; - extracted?: unknown; - overrides?: MonitorStatusResponseOverrides; -} - -export const buildStatusResponse = ({ - monitor, - response, - error, - payload, - jsonPath, - matchMethod, - expectedValue, - extracted, - overrides, -}: BuildStatusResponseArgs): MonitorStatusResponse => { - if (error) { - const statusResponse: MonitorStatusResponse = { - 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, - }; -}; diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index a459c6480..9111ef7b2 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -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>; + requestStatus(monitor: Monitor & { type: T }): Promise>; requestWebhook( type: string, url: string, @@ -43,12 +43,12 @@ export class NetworkService implements INetworkService { } // Main entry point - async requestStatus(monitor: Monitor) { + async requestStatus(monitor: Monitor & { type: T }): Promise> { const provider = this.providers.find((p) => p.supports(monitor.type)); if (!provider) { - return this.handleUnsupportedType(monitor.type); + return this.handleUnsupportedType(monitor.type) as Promise>; } - return provider.handle(monitor); + return provider.handle(monitor) as Promise>; } private async handleUnsupportedType(type: string): Promise { @@ -197,4 +197,3 @@ export class NetworkService implements INetworkService { } } } - diff --git a/server/src/service/infrastructure/notificationProviders/teams.ts b/server/src/service/infrastructure/notificationProviders/teams.ts new file mode 100644 index 000000000..2a4476807 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/teams.ts @@ -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 { + 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 { + 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 = { + 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 } : {}), + }; + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 1da308678..ea7b78a4f 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -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; } diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index 46959c554..1d75c0a36 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -74,4 +74,3 @@ export class SettingsService implements ISettingsService { return settings; }; } - diff --git a/server/src/types/check.ts b/server/src/types/check.ts index 6c4c26445..f4c64f926 100644 --- a/server/src/types/check.ts +++ b/server/src/types/check.ts @@ -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 & { originalAvgResponseTime: number; }; -export type CheckSnapshot = Omit; +export type CheckSnapshot = Omit; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index a6ecc4ef4..89740162b 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -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]; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 93ddc4ac9..6c7172724 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -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 = { diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index 669c840ca..a121cd8e0 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -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 { diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index 547281d68..e511118e6 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -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(), }); diff --git a/server/src/validation/envValidation.ts b/server/src/validation/envValidation.ts index 6dffdd0cd..2b0e36fcf 100644 --- a/server/src/validation/envValidation.ts +++ b/server/src/validation/envValidation.ts @@ -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"), diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 4694143ac..75ffccd19 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -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(), diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 810d51431..cf7ea9ef5 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -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({ diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index ee2a2ee87..e7f0dc9b4 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -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(), diff --git a/server/test/teamsProvider.test.ts b/server/test/teamsProvider.test.ts new file mode 100644 index 000000000..e6104f568 --- /dev/null +++ b/server/test/teamsProvider.test.ts @@ -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 => ({ + 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 => ({ + 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; + 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", + }) + ); + }); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 34deaf8de..fe8233c0c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -14,7 +14,8 @@ "moduleResolution": "nodenext", "skipLibCheck": true, "noUncheckedIndexedAccess": true, - "isolatedModules": true + "isolatedModules": true, + "resolveJsonModule": true }, "include": ["src"], "exclude": ["tests", "dist", "node_modules"]