diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx index d3f6e130e..0f26e04a7 100644 --- a/client/src/Components/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -7,7 +7,15 @@ import type { MonitorType } from "@/Types/Monitor"; import { Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -const types = ["http", "ping", "port", "docker", "game"]; +const types = ["http", "ping", "port", "docker", "game", "grpc"]; +const typeDisplayNames: Record = { + http: "HTTP", + ping: "Ping", + port: "Port", + docker: "Docker", + game: "Game", + grpc: "gRPC", +}; const statuses = ["up", "down"]; const states = ["active", "paused"]; @@ -52,7 +60,7 @@ export const ControlsFilter = ({ key={type} value={type} > - {type} + {typeDisplayNames[type] ?? type} ))} diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 14d6bbd68..8b5252879 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -73,6 +73,16 @@ export const useMonitorForm = ({ gameId: data?.gameId || "", }; break; + case "grpc": + defaults = { + ...base, + type: "grpc", + url: data?.url || "", + port: data?.port || 50051, + grpcServiceName: data?.grpcServiceName || "", + ignoreTlsErrors: data?.ignoreTlsErrors || false, + }; + break; case "pagespeed": defaults = { ...base, diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 5699e8ea8..6ebc4b0fd 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -42,6 +42,8 @@ interface GeneralSettingsConfig { showPort: boolean; showGameSelect: boolean; showSecret: boolean; + showGrpcServiceName: boolean; + showIgnoreTls: boolean; } const getGeneralSettingsConfig = ( @@ -57,6 +59,8 @@ const getGeneralSettingsConfig = ( showPort: false, showGameSelect: false, showSecret: false, + showGrpcServiceName: false, + showIgnoreTls: false, }, ping: { urlLabel: t("pages.createMonitor.form.general.option.host.label"), @@ -66,6 +70,8 @@ const getGeneralSettingsConfig = ( showPort: false, showGameSelect: false, showSecret: false, + showGrpcServiceName: false, + showIgnoreTls: false, }, docker: { urlLabel: t("pages.createMonitor.form.general.option.container.label"), @@ -75,6 +81,8 @@ const getGeneralSettingsConfig = ( showPort: false, showGameSelect: false, showSecret: false, + showGrpcServiceName: false, + showIgnoreTls: false, }, port: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -84,6 +92,8 @@ const getGeneralSettingsConfig = ( showPort: true, showGameSelect: false, showSecret: false, + showGrpcServiceName: false, + showIgnoreTls: false, }, game: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -93,6 +103,19 @@ const getGeneralSettingsConfig = ( showPort: true, showGameSelect: true, showSecret: false, + showGrpcServiceName: false, + showIgnoreTls: false, + }, + grpc: { + urlLabel: t("pages.createMonitor.form.general.option.host.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), + namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), + showUrl: true, + showPort: true, + showGameSelect: false, + showSecret: false, + showGrpcServiceName: true, + showIgnoreTls: true, }, pagespeed: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -102,6 +125,8 @@ const getGeneralSettingsConfig = ( showPort: false, showGameSelect: false, showSecret: false, + showGrpcServiceName: false, + showIgnoreTls: false, }, hardware: { urlLabel: t("pages.createMonitor.form.general.option.url.label"), @@ -111,6 +136,8 @@ const getGeneralSettingsConfig = ( showPort: false, showGameSelect: false, showSecret: true, + showGrpcServiceName: false, + showIgnoreTls: false, }, }; return configs[type] || configs.http; @@ -290,6 +317,13 @@ const CreateMonitorPage = () => { "pages.createMonitor.form.type.optionGameDescription" )} /> + )} @@ -378,6 +412,30 @@ const CreateMonitorPage = () => { /> )} + {/* gRPC Service Name field - only for grpc type */} + {generalSettingsConfig.showGrpcServiceName && ( + ( + + )} + /> + )} + {/* Secret field - only for hardware type */} {generalSettingsConfig.showSecret && ( { } /> - {watchedType === "http" && ( + {(watchedType === "http" || watchedType === "grpc") && ( { // Default to all types when none selected const effectiveTypes = - selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port", "game"]; + selectedTypes.length > 0 + ? selectedTypes + : ["http", "ping", "docker", "port", "game", "grpc"]; // Build URL for monitors with checks const monitorsWithChecksUrl = useMemo(() => { diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index b396f93ac..01cae62e9 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -11,6 +11,7 @@ export const MonitorTypes = [ "docker", "port", "game", + "grpc", "unknown", ] as const; export type MonitorType = (typeof MonitorTypes)[number]; @@ -60,6 +61,7 @@ export interface Monitor { tempAlertCounter: number; selectedDisks: string[]; gameId?: string; + grpcServiceName?: string; group: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index a19316c62..ad744ba6d 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -8,6 +8,7 @@ export const getMonitorPath = (type: MonitorType): string => { port: "uptime", ping: "uptime", game: "uptime", + grpc: "uptime", unknown: "uptime", docker: "uptime", hardware: "infrastructure", diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index a2966f2a0..439af977f 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -73,6 +73,18 @@ const gameSchema = baseSchema.extend({ gameId: z.string().min(1, "Game type is required"), }); +// gRPC monitor schema +const grpcSchema = baseSchema.extend({ + type: z.literal("grpc"), + url: z.string().min(1, "Host is required"), + port: z + .number() + .min(1, "Port must be at least 1") + .max(65535, "Port must be at most 65535"), + grpcServiceName: z.string().optional(), + ignoreTlsErrors: z.boolean(), +}); + // PageSpeed monitor schema const pagespeedSchema = baseSchema.extend({ type: z.literal("pagespeed"), @@ -110,6 +122,7 @@ export const monitorSchema = z.discriminatedUnion("type", [ portSchema, dockerSchema, gameSchema, + grpcSchema, pagespeedSchema, hardwareSchema, ]); @@ -123,6 +136,7 @@ export { portSchema, dockerSchema, gameSchema, + grpcSchema, pagespeedSchema, hardwareSchema, }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index dcfa68281..e0fb84911 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -483,6 +483,10 @@ "port": { "label": "Port to monitor", "placeholder": 80 + }, + "grpcServiceName": { + "label": "Service name", + "placeholder": "e.g. my.service.v1 (leave empty for overall health)" } }, "title": "General settings", @@ -493,6 +497,7 @@ "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", "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.", "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure." } }, @@ -527,6 +532,8 @@ "optionDockerDescription": "Use Docker to monitor if a container is running.", "optionGame": "Game", "optionGameDescription": "Monitor if a specific game server is online.", + "optionGrpc": "gRPC", + "optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.", "optionHttp": "HTTP(S)", "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", "optionPing": "Ping", diff --git a/server/package-lock.json b/server/package-lock.json index d89613ecf..30b610df5 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", "axios": "^1.7.2", "bcryptjs": "3.0.2", @@ -874,7 +876,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1504,7 +1505,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1549,7 +1549,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2198,9 +2197,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz", - "integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -2210,7 +2209,7 @@ "node": ">=12.10.0" } }, - "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "node_modules/@grpc/proto-loader": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", @@ -2228,24 +2227,6 @@ "node": ">=6" } }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -4223,16 +4204,6 @@ "node": ">=14.16" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -4405,7 +4376,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", @@ -4566,7 +4536,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" } @@ -5053,7 +5022,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5632,7 +5600,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6575,7 +6542,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" @@ -6948,6 +6914,24 @@ "node": ">= 8.0" } }, + "node_modules/dockerode/node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dockerode/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -7330,7 +7314,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7692,7 +7675,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", @@ -9341,7 +9323,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12231,7 +12212,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14173,63 +14153,6 @@ "node": ">=8" } }, - "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@trysound/sax": "0.2.0", - "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" - }, - "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", @@ -14513,7 +14436,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", @@ -14676,7 +14598,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/server/package.json b/server/package.json index c1ef2b8ec..9e005bca4 100755 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,8 @@ "node": ">=20" }, "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", "axios": "^1.7.2", "bcryptjs": "3.0.2", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index ce2472588..d7648803f 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -43,6 +43,8 @@ import jwt from "jsonwebtoken"; import crypto from "crypto"; import { games, GameDig } from "gamedig"; import jmespath from "jmespath"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; // repositories import { @@ -143,7 +145,7 @@ export const initializeServices = async ({ const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); - const networkService = new NetworkService(axios, got, https, jmespath, GameDig, ping, logger, Docker, net, settingsService); + const networkService = new NetworkService(axios, got, https, jmespath, GameDig, ping, logger, Docker, net, settingsService, grpc, protoLoader); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); const notificationMessageBuilder = new NotificationMessageBuilder(); diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 380f6d27c..2def7701c 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -160,6 +160,10 @@ const MonitorSchema = new Schema( gameId: { type: String, }, + grpcServiceName: { + type: String, + default: "", + }, group: { type: String, trim: true, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 0e7ca34ee..a3b40f459 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -352,6 +352,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { tempAlertCounter: doc.tempAlertCounter, selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, + grpcServiceName: doc.grpcServiceName ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, @@ -410,6 +411,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { tempAlertCounter: doc.tempAlertCounter, selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, + grpcServiceName: doc.grpcServiceName ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 662e5775a..b94d99c90 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -225,7 +225,8 @@ export class MonitorService implements IMonitorService { checksData.monitorType !== "ping" && checksData.monitorType !== "docker" && checksData.monitorType !== "port" && - checksData.monitorType !== "game" + checksData.monitorType !== "game" && + checksData.monitorType !== "grpc" ) { throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 }); } diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 958bbdfd8..3cf24ccf6 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -1,8 +1,10 @@ import { HTTPError, RequestError } from "got"; import type { Got, Response } from "got"; -import type { Monitor, MonitorStatusResponse } from "@/types/index.js"; +import type { Monitor, MonitorStatusResponse, GrpcStatusPayload } from "@/types/index.js"; import type { AxiosStatic } from "axios"; -import type Docker from "dockerode"; +import { AppError } from "@/utils/AppError.js"; +import path from "path"; +import { fileURLToPath } from "url"; import CacheableLookup from "cacheable-lookup"; import { ISettingsService } from "../system/settingsService.js"; @@ -46,6 +48,7 @@ class NetworkService implements INetworkService { private TYPE_DOCKER: string; private TYPE_PORT: string; private TYPE_GAME: string; + private TYPE_GRPC: string; private SERVICE_NAME: string; private NETWORK_ERROR: number; private PING_ERROR: number; @@ -60,6 +63,8 @@ class NetworkService implements INetworkService { private Docker: any; private net: any; private settingsService: ISettingsService; + private grpc: any; + private protoLoader: any; private buildStatusResponse = ({ monitor, @@ -125,7 +130,9 @@ class NetworkService implements INetworkService { logger: ILogger, Docker: any, net: any, - settingsService: ISettingsService + settingsService: ISettingsService, + grpc: any, + protoLoader: any ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -134,6 +141,7 @@ class NetworkService implements INetworkService { this.TYPE_DOCKER = "docker"; this.TYPE_PORT = "port"; this.TYPE_GAME = "game"; + this.TYPE_GRPC = "grpc"; this.SERVICE_NAME = SERVICE_NAME; this.NETWORK_ERROR = 5000; this.PING_ERROR = 5001; @@ -146,6 +154,8 @@ class NetworkService implements INetworkService { this.Docker = Docker; this.net = net; this.settingsService = settingsService; + this.grpc = grpc; + this.protoLoader = protoLoader; const cacheable = new CacheableLookup(); @@ -193,6 +203,8 @@ class NetworkService implements INetworkService { return await this.requestPort(monitor); case this.TYPE_GAME: return await this.requestGame(monitor); + case this.TYPE_GRPC: + return await this.requestGrpc(monitor); default: return this.handleUnsupportedType(type); } @@ -618,6 +630,139 @@ class NetworkService implements INetworkService { throw error; } } + private async requestGrpc(monitor: Monitor): Promise { + try { + const { url, port, ignoreTlsErrors } = monitor; + const grpcServiceName = monitor.grpcServiceName || ""; + + if (!url) { + throw new AppError({ message: "Monitor host is required", service: this.SERVICE_NAME, method: "requestGrpc" }); + } + if (!port) { + throw new AppError({ message: "Monitor port is required", service: this.SERVICE_NAME, method: "requestGrpc" }); + } + + const target = `${url}:${port}`; + + const currentFilePath = fileURLToPath(import.meta.url); + const protoPath = path.join(path.dirname(currentFilePath), "protos", "health.proto"); + + const packageDefinition = this.protoLoader.loadSync(protoPath, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }); + const grpcObject = this.grpc.loadPackageDefinition(packageDefinition); + const healthService = grpcObject.grpc.health.v1.Health; + + let credentials; + if (ignoreTlsErrors) { + credentials = this.grpc.credentials.createSsl(null, null, null, { + checkServerIdentity: () => undefined, + }); + } else { + credentials = this.grpc.credentials.createInsecure(); + } + + const client = new healthService(target, credentials); + + const TIMEOUT_MS = 10000; + const deadline = new Date(Date.now() + TIMEOUT_MS); + + const grpcResponse = this.buildStatusResponse({ + monitor, + overrides: { + status: false, + code: this.NETWORK_ERROR, + message: "gRPC health check not executed", + }, + }); + + const { response, responseTime, error } = await this.timeRequest(() => { + return new Promise((resolve, reject) => { + client.Check({ service: grpcServiceName }, { deadline }, (err: any, response: any) => { + client.close(); + + if (err) { + const payload: GrpcStatusPayload = { + grpcStatusCode: err.code ?? -1, + grpcStatusName: this.getGrpcStatusName(err.code), + serviceName: grpcServiceName, + servingStatus: "UNKNOWN", + }; + const grpcError = new AppError({ message: err.details || err.message, service: this.SERVICE_NAME, method: "requestGrpc" }); + (grpcError as any).grpcPayload = payload; + (grpcError as any).grpcCode = err.code; + reject(grpcError); + return; + } + + const servingStatus = response?.status || "UNKNOWN"; + resolve({ + grpcStatusCode: 0, + grpcStatusName: "OK", + serviceName: grpcServiceName, + servingStatus, + }); + }); + }); + }); + + if (error) { + const grpcError = error as any; + const payload = grpcError.grpcPayload as GrpcStatusPayload | undefined; + grpcResponse.status = false; + grpcResponse.code = grpcError.grpcCode ?? this.NETWORK_ERROR; + grpcResponse.message = grpcError.message || "gRPC health check failed"; + grpcResponse.responseTime = responseTime; + grpcResponse.payload = payload || null; + return grpcResponse; + } + + const grpcPayload = response as GrpcStatusPayload; + const isServing = grpcPayload.servingStatus === "SERVING"; + + grpcResponse.status = isServing; + grpcResponse.code = isServing ? 200 : this.NETWORK_ERROR; + grpcResponse.message = isServing + ? `gRPC service healthy (${grpcPayload.servingStatus})` + : `gRPC service unhealthy (${grpcPayload.servingStatus})`; + grpcResponse.responseTime = responseTime; + grpcResponse.payload = grpcPayload; + + return grpcResponse; + } catch (err: any) { + err.service = this.SERVICE_NAME; + err.method = "requestGrpc"; + throw err; + } + } + + private getGrpcStatusName(code: number): string { + const statusNames: Record = { + 0: "OK", + 1: "CANCELLED", + 2: "UNKNOWN", + 3: "INVALID_ARGUMENT", + 4: "DEADLINE_EXCEEDED", + 5: "NOT_FOUND", + 6: "ALREADY_EXISTS", + 7: "PERMISSION_DENIED", + 8: "RESOURCE_EXHAUSTED", + 9: "FAILED_PRECONDITION", + 10: "ABORTED", + 11: "OUT_OF_RANGE", + 12: "UNIMPLEMENTED", + 13: "INTERNAL", + 14: "UNAVAILABLE", + 15: "DATA_LOSS", + 16: "UNAUTHENTICATED", + }; + return statusNames[code] || "UNKNOWN"; + } + private async handleUnsupportedType(type: string): Promise { return { monitorId: "unknown", diff --git a/server/src/service/infrastructure/protos/health.proto b/server/src/service/infrastructure/protos/health.proto new file mode 100644 index 000000000..791354e64 --- /dev/null +++ b/server/src/service/infrastructure/protos/health.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package grpc.health.v1; + +message HealthCheckRequest { + string service = 1; +} + +message HealthCheckResponse { + enum ServingStatus { + UNKNOWN = 0; + SERVING = 1; + NOT_SERVING = 2; + SERVICE_UNKNOWN = 3; + } + ServingStatus status = 1; +} + +service Health { + rpc Check(HealthCheckRequest) returns (HealthCheckResponse); +} diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index cb949c0c2..a6ecc4ef4 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -3,7 +3,7 @@ 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", "unknown"] as const; +export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const; @@ -44,6 +44,7 @@ export interface Monitor { tempAlertCounter: number; selectedDisks: string[]; gameId?: string; + grpcServiceName?: string; group: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index d87f1ea4f..5a8b1c443 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -93,6 +93,13 @@ export interface PortStatusPayload { export type GameStatusPayload = Record; +export interface GrpcStatusPayload { + grpcStatusCode: number; + grpcStatusName: string; + serviceName: string; + servingStatus: string; +} + export interface MonitorPayloadMap { ping: PingStatusPayload; http: HttpStatusPayload; @@ -101,6 +108,7 @@ export interface MonitorPayloadMap { docker: DockerStatusPayload; port: PortStatusPayload; game: GameStatusPayload; + grpc: GrpcStatusPayload; default: unknown; } diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 4002c97e6..415f3732c 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -122,8 +122,8 @@ const getMonitorsByTeamIdQueryValidation = joi.object({ type: joi .alternatives() .try( - joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"), - joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game")) + joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), + joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) ), filter: joi.string().allow("", null), }); @@ -138,8 +138,8 @@ const getMonitorsWithChecksQueryValidation = joi.object({ type: joi .alternatives() .try( - joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game"), - joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game")) + joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), + joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) ) .optional(), explain: joi.boolean().optional(), @@ -172,6 +172,7 @@ const createMonitorBodyValidation = joi.object({ expectedValue: joi.string().allow(""), matchMethod: joi.string().allow(null, ""), gameId: joi.string().allow(""), + grpcServiceName: joi.string().allow("").default(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), geoCheckEnabled: joi.boolean().optional(), @@ -209,6 +210,7 @@ const editMonitorBodyValidation = joi diskAlertThreshold: joi.number(), tempAlertThreshold: joi.number(), gameId: joi.string().allow(""), + grpcServiceName: joi.string().allow(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), geoCheckEnabled: joi.boolean().optional(), @@ -315,7 +317,7 @@ const getChecksParamValidation = joi.object({ }); const getChecksQueryValidation = joi.object({ - type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "game"), + type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"), sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"),