diff --git a/client/package-lock.json b/client/package-lock.json index e1dfadad5..9cf2824ae 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", @@ -7359,6 +7359,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/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) && ( { ); 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/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/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/locales/en.json b/client/src/locales/en.json index c6bf41478..5f582cac5 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": { 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 889c08f15..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", @@ -871,7 +873,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1501,7 +1502,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1546,7 +1546,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4338,7 +4337,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4499,7 +4497,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4665,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", @@ -4727,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", @@ -5341,7 +5347,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5920,7 +5925,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6863,7 +6867,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7631,7 +7634,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7993,7 +7995,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9638,7 +9639,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12509,7 +12509,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14443,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", @@ -14662,7 +14604,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14831,7 +14772,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14994,7 +14934,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15628,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 e9d7f659d..9434b3ae5 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -59,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"; @@ -78,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 { @@ -186,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, @@ -196,6 +199,7 @@ export const initializeServices = async ({ portProvider, gameProvider, grpcProvider, + webSocketProvider, ]); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 8add39087..f196e4dd5 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -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/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/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/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index 4a31348b0..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,19 +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/monitorService.ts b/server/src/service/business/monitorService.ts index b94d99c90..a7a30111c 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, @@ -226,7 +227,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 }); } @@ -340,7 +342,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 af845d6ad..f224e9ba4 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; @@ -281,7 +282,12 @@ export class UserService implements IUserService { url, }); if (!html) { - throw new Error("Failed to build password reset email HTML"); + 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; @@ -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/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 2857f5b82..a96e7855b 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 { supportsGeoCheck } from "@/types/monitor.js"; const SERVICE_NAME = "JobQueue"; type QueueJobFailure = { @@ -125,8 +126,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", @@ -175,7 +181,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { // 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) { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 011008b32..eef0a75be 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -1,5 +1,6 @@ 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"; @@ -280,19 +281,18 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" }); } - if (monitor.type !== "http") { + if (!monitor.geoCheckEnabled) { + return; + } + if (!supportsGeoCheck(monitor.type)) { this.logger.debug({ - message: `Monitor ${monitorId} is not HTTP type, skipping geo check`, + message: `Monitor ${monitorId} type does not support geo checks, skipping`, service: SERVICE_NAME, method: "getGeoCheckJob", }); return; } - if (!monitor.geoCheckEnabled) { - return; - } - if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { this.logger.warn({ message: `No geo check locations configured for monitor ${monitorId}`, diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index b58486ad3..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; 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/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..07e7c3736 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,6 +125,7 @@ export interface MonitorPayloadMap { port: PortStatusPayload; game: GameStatusPayload; grpc: GrpcStatusPayload; + websocket: WebSocketStatusPayload; default: unknown; } diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index 547281d68..d4f75f3ac 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -11,7 +11,7 @@ export const getChecksParamValidation = z.object({ }); export const getChecksQueryValidation = z.object({ - type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"]).optional(), + type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(), sortOrder: z.enum(["asc", "desc"]).optional(), limit: z.coerce.number().optional(), 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(),