From 3c99809e8e6f043f60fcce81f7f3b679373d312a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Mar 2026 23:24:05 +0000 Subject: [PATCH 01/12] rix reveresed checks --- client/src/Components/common/charts/HeatmapResponseTime.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Components/common/charts/HeatmapResponseTime.tsx b/client/src/Components/common/charts/HeatmapResponseTime.tsx index 8178ffb92..f2aed0b7a 100644 --- a/client/src/Components/common/charts/HeatmapResponseTime.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTime.tsx @@ -32,7 +32,7 @@ export const HeatmapResponseTime = ({ if (!checks || checks.length === 0) return null; - const latestChecks = checks.slice(-25).reverse(); + const latestChecks = checks.slice(-25); let data: Array; if (latestChecks.length !== 25) { From 82175f166feedf607c65ccc8ea8f444e86104752 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Mar 2026 23:24:48 +0000 Subject: [PATCH 02/12] pingProvider, httpProvider --- server/src/config/services.ts | 12 ++- .../repositories/checks/IChecksRepository.ts | 11 ++- .../checks/MongoChecksRepistory.ts | 2 +- server/src/service/business/checkService.ts | 11 ++- .../infrastructure/network/AdvancedMatcher.ts | 45 +++++++++ .../infrastructure/network/HttpProvider.ts | 94 +++++++++++++++++++ .../infrastructure/network/IStatusProvider.ts | 8 ++ .../infrastructure/network/PingProvider.ts | 64 +++++++++++++ .../service/infrastructure/network/utils.ts | 85 +++++++++++++++++ .../service/infrastructure/networkService.ts | 66 ++++--------- server/src/types/network.ts | 4 +- 11 files changed, 345 insertions(+), 57 deletions(-) create mode 100644 server/src/service/infrastructure/network/AdvancedMatcher.ts create mode 100644 server/src/service/infrastructure/network/HttpProvider.ts create mode 100644 server/src/service/infrastructure/network/IStatusProvider.ts create mode 100644 server/src/service/infrastructure/network/PingProvider.ts create mode 100644 server/src/service/infrastructure/network/utils.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 56aef2f19..485297fe0 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -75,6 +75,9 @@ import { } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; import { EnvConfig } from "@/service/system/settingsService.js"; +import { PingProvider } from "@/service/infrastructure/network/PingProvider.js"; +import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; +import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js"; export type InitializedServices = { settingsService: any; @@ -144,6 +147,11 @@ export const initializeServices = async ({ const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + // Providers + + const pingProvider = new PingProvider(ping); + const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); + const networkService = new NetworkService( axios, got, @@ -158,7 +166,9 @@ export const initializeServices = async ({ net, settingsService, grpc, - protoLoader + protoLoader, + pingProvider, + httpProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/repositories/checks/IChecksRepository.ts index 31aac0ec9..c89e5d5fc 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/repositories/checks/IChecksRepository.ts @@ -20,12 +20,19 @@ export interface IChecksRepository { monitorId: string, sortOrder: string, dateRange: string, - filter: string, + filter: string | undefined, page: number, rowsPerPage: number, status: boolean | undefined ): Promise; - findByTeamId(sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string): Promise; + findByTeamId( + sortOrder: string, + dateRange: string, + filter: string | undefined, + page: number, + rowsPerPage: number, + teamId: string + ): Promise; findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise; findByDateRangeAndMonitorId( monitorId: string, diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index 66e7f43f9..b019a0430 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -231,7 +231,7 @@ class MongoChecksRepository implements IChecksRepository { monitorId: string, sortOrder: string, dateRange: string, - filter: string, + filter: string | undefined, page: number, rowsPerPage: number, status: boolean | undefined diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts index 2d2bf0e01..abeb577ed 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -124,8 +124,13 @@ class CheckService implements ICheckService { await this.monitorsRepository.findById(monitorId, teamId); const { sortOrder, dateRange, filter, page, rowsPerPage, status } = query; - if (!sortOrder || !dateRange || !filter || !page || !rowsPerPage) { - throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 }); + 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; @@ -138,7 +143,7 @@ class CheckService implements ICheckService { getChecksByTeam = async ({ teamId, query }: { teamId: string; query: Record }) => { const { sortOrder, dateRange, filter, page, rowsPerPage } = query; - if (!sortOrder || !dateRange || !filter || !page || !rowsPerPage) { + if (!sortOrder || !dateRange || !page || !rowsPerPage) { throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 }); } diff --git a/server/src/service/infrastructure/network/AdvancedMatcher.ts b/server/src/service/infrastructure/network/AdvancedMatcher.ts new file mode 100644 index 000000000..57bf7022f --- /dev/null +++ b/server/src/service/infrastructure/network/AdvancedMatcher.ts @@ -0,0 +1,45 @@ +import { Monitor } from "@/types/monitor.js"; +import jmespath from "jmespath"; +type JmesPath = typeof jmespath; + +export class AdvancedMatcher { + constructor(private jmespath: JmesPath) {} + + private compare(actual: unknown, expected: string, method?: string): boolean { + if (method === "equal") return String(actual) === expected; + if (method === "include") return String(actual).includes(expected); + if (method === "regex") return new RegExp(expected).test(String(actual)); + return String(actual) === expected; // Default + } + + validate(payload: T, monitor: Monitor): { ok: boolean; message: string; extracted?: any } { + const { useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor; + if (!useAdvancedMatching) return { ok: true, message: "Success" }; + + let dataToValidate = payload; + + if (jsonPath) { + try { + dataToValidate = this.jmespath.search(payload, jsonPath); + } catch { + return { ok: false, message: "Error evaluating JSON path" }; + } + } + + if (expectedValue) { + const ok = this.compare(dataToValidate, expectedValue, matchMethod); + return { + ok, + message: ok ? "Success" : "Expected value did not match", + extracted: dataToValidate, + }; + } + + const isFalsey = dataToValidate === false || dataToValidate === "false" || dataToValidate === undefined || dataToValidate === null; + return { + ok: !isFalsey, + message: !isFalsey ? "Success" : "Extracted value is falsey", + extracted: dataToValidate, + }; + } +} diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts new file mode 100644 index 000000000..84f290b16 --- /dev/null +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -0,0 +1,94 @@ +import { type Got, HTTPError, RequestError } from "got"; +import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js"; +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { HttpStatusPayload } from "@/types/network.js"; +import { MonitorStatusResponse } from "@/types/network.js"; +import { Agent as HttpsAgent } from "https"; +import { Monitor } from "@/types/monitor.js"; +import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; + +export class HttpProvider implements IStatusProvider { + readonly type = "http"; + + constructor( + private got: Got, + private advancedMatcher: AdvancedMatcher + ) {} + + supports(type: string) { + return type === "http"; + } + + private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { + if (error instanceof HTTPError || error instanceof RequestError) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: error.response?.statusCode ?? NETWORK_ERROR, + message: error.message, + responseTime: error.timings?.phases?.total ?? 0, + timings: error.timings, + payload: null as T, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: error instanceof Error ? error.message : String(error), + responseTime: 0, + payload: null as T, + }; + } + + async handle(monitor: Monitor): Promise> { + const { url, secret, ignoreTlsErrors } = monitor; + const options: Record = { + headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, + }; + + options.agent = { + https: new HttpsAgent({ rejectUnauthorized: !ignoreTlsErrors }), + }; + + try { + const response = await this.got(url, options); + let payload: T; + const isJson = response.headers["content-type"]?.includes("application/json"); + + if (isJson) { + try { + payload = JSON.parse(response.body) as T; + } catch { + payload = response.body as unknown as T; + } + } else { + payload = response.body as unknown as T; + } + + const matchResult = this.advancedMatcher.validate(payload, monitor); + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: response.ok && matchResult.ok, + code: response.statusCode, + message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message, + responseTime: response.timings.phases.total ?? 0, + timings: response.timings, + payload, + extracted: matchResult.extracted, + jsonPath: monitor.jsonPath, + matchMethod: monitor.matchMethod, + expectedValue: monitor.expectedValue, + }; + } catch (error: unknown) { + return this.handleHttpError(error, monitor); + } + } +} diff --git a/server/src/service/infrastructure/network/IStatusProvider.ts b/server/src/service/infrastructure/network/IStatusProvider.ts new file mode 100644 index 000000000..bde6a2db0 --- /dev/null +++ b/server/src/service/infrastructure/network/IStatusProvider.ts @@ -0,0 +1,8 @@ +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { MonitorStatusResponse } from "@/types/network.js"; + +export interface IStatusProvider { + type: string; + supports: (type: MonitorType) => boolean; + handle(monitor: Monitor): Promise>; +} diff --git a/server/src/service/infrastructure/network/PingProvider.ts b/server/src/service/infrastructure/network/PingProvider.ts new file mode 100644 index 000000000..2749584f5 --- /dev/null +++ b/server/src/service/infrastructure/network/PingProvider.ts @@ -0,0 +1,64 @@ +import { PingStatusPayload } from "@/types/network.js"; +import { IStatusProvider } from "./IStatusProvider.js"; +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"; +const SERVICE_NAME = "PingProvider"; + +type Ping = typeof ping; + +export class PingProvider implements IStatusProvider { + readonly type = "ping"; + + constructor(private ping: Ping) {} + + supports(type: MonitorType): boolean { + return type === "ping"; + } + + private sanitizeHost(url: string): string { + return url + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/:.*/, ""); + } + + async handle(monitor: Monitor): Promise> { + try { + if (!monitor.url) { + throw new Error("URL is required for ping monitor"); + } + + 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; + } + + if (!response) { + throw new Error(`No response from ping for host: ${sanitizedHost}`); + } + + return buildStatusResponse({ + monitor, + 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({ + message, + service: SERVICE_NAME, + method: "handle", + }); + } + } +} diff --git a/server/src/service/infrastructure/network/utils.ts b/server/src/service/infrastructure/network/utils.ts new file mode 100644 index 000000000..c70f06bf8 --- /dev/null +++ b/server/src/service/infrastructure/network/utils.ts @@ -0,0 +1,85 @@ +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 { + const response = await operation(); + const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000); + return { response, responseTime: elapsedMs, error: null }; + } catch (error) { + const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000); + return { response: null, responseTime: elapsedMs, error }; + } +}; + +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 702affed2..2515b6145 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -16,14 +16,14 @@ import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; import path from "path"; import { fileURLToPath } from "url"; +import { MonitorStatusResponseOverrides } from "@/types/index.js"; import CacheableLookup from "cacheable-lookup"; import { ISettingsService } from "../system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; +import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService"; -type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>; - interface BuildStatusResponseArgs { monitor: Monitor; response?: Response | null; @@ -94,6 +94,10 @@ class NetworkService implements INetworkService { private grpc: typeof import("@grpc/grpc-js"); private protoLoader: typeof import("@grpc/proto-loader"); + // New providers + private pingProvider; + private httpProvider; + private buildStatusResponse = ({ monitor, response, @@ -160,7 +164,11 @@ class NetworkService implements INetworkService { net: typeof import("net"), settingsService: ISettingsService, grpc: typeof import("@grpc/grpc-js"), - protoLoader: typeof import("@grpc/proto-loader") + protoLoader: typeof import("@grpc/proto-loader"), + + // New providers + pingProvider: IStatusProvider, + httpProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -185,6 +193,10 @@ class NetworkService implements INetworkService { this.grpc = grpc; this.protoLoader = protoLoader; + // New providers + this.pingProvider = pingProvider; + this.httpProvider = httpProvider; + const cacheable = new CacheableLookup(); this.got = got.extend({ @@ -231,9 +243,9 @@ class NetworkService implements INetworkService { const type = monitor?.type || "unknown"; switch (type) { case this.TYPE_PING: - return await this.requestPing(monitor); + return await this.pingProvider.handle(monitor); case this.TYPE_HTTP: - return await this.requestHttp(monitor); + return await this.httpProvider.handle(monitor); case this.TYPE_PAGESPEED: return await this.requestPageSpeed(monitor); case this.TYPE_HARDWARE: @@ -251,50 +263,6 @@ class NetworkService implements INetworkService { } } - private async requestPing(monitor: Monitor): Promise> { - try { - if (!monitor?.url) { - throw new Error("Monitor URL is required"); - } - - const rawUrl = monitor.url; - const sanitizedHost = rawUrl - .replace(/^https?:\/\//, "") - .replace(/\/.*$/, "") - .replace(/:.*/, ""); - const { response, error } = await this.timeRequest(() => this.ping.promise.probe(sanitizedHost)); - - if (!response) { - if (error) { - throw error; - } - throw new Error("Ping failed - no result returned"); - } - - const pingResponse = this.buildStatusResponse({ - monitor, - payload: response as PingStatusPayload, - overrides: { - status: (response as { alive?: boolean })?.alive ?? false, - code: 200, - message: "Success", - responseTime: (response as { time?: number })?.time ?? 0, - payload: response as PingStatusPayload, - } as MonitorStatusResponseOverrides, - }); - - return pingResponse; - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing ping", - service: this.SERVICE_NAME, - method: "requestPing", - details: { url: monitor.url }, - }); - } - } - private async requestHttp(monitor: Monitor): Promise> { const { url, secret, ignoreTlsErrors, useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor; const httpResponse = this.buildStatusResponse({ diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 2018523cd..083193e06 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -49,7 +49,7 @@ export interface PingStatusPayload { host: string; numeric_host?: string; alive: boolean; - time: number; + time: number | unknown; times?: number[]; output?: string; min?: string; @@ -134,3 +134,5 @@ export type StatusChangeResult = { temp: boolean; }; }; + +export type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>; From 8dd846d6cddf4e0365ed60a8c4f8255235cbc634 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Mar 2026 23:36:36 +0000 Subject: [PATCH 03/12] harden http provider --- .../infrastructure/network/HttpProvider.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index 84f290b16..b47160e06 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -47,7 +47,12 @@ export class HttpProvider implements IStatusProvider { } async handle(monitor: Monitor): Promise> { - const { url, secret, ignoreTlsErrors } = monitor; + const { url, secret, jsonPath, ignoreTlsErrors } = monitor; + + if (!url) { + throw new Error("URL is required for HTTP monitor"); + } + const options: Record = { headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, }; @@ -58,9 +63,24 @@ export class HttpProvider implements IStatusProvider { try { const response = await this.got(url, options); - let payload: T; - const isJson = response.headers["content-type"]?.includes("application/json"); + const contentType = response.headers["content-type"] || ""; + const isJson = contentType.includes("application/json"); + if (jsonPath && !isJson) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: response.statusCode, + message: "Response is not JSON", + responseTime: response.timings.phases.total ?? 0, + timings: response.timings, + payload: response.body as unknown as T, + }; + } + + let payload: T; if (isJson) { try { payload = JSON.parse(response.body) as T; @@ -83,9 +103,6 @@ export class HttpProvider implements IStatusProvider { timings: response.timings, payload, extracted: matchResult.extracted, - jsonPath: monitor.jsonPath, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, }; } catch (error: unknown) { return this.handleHttpError(error, monitor); From ec82efebb47bd4a34e89c0acc5700b0f29631c83 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 17:54:16 +0000 Subject: [PATCH 04/12] pagespeed provider, hardware provider --- server/src/config/services.ts | 9 ++- .../network/HardwareProvider.ts | 29 ++++++++++ .../infrastructure/network/HttpProvider.ts | 4 +- .../network/PageSpeedProvider.ts | 55 +++++++++++++++++++ .../service/infrastructure/networkService.ts | 11 +++- 5 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 server/src/service/infrastructure/network/HardwareProvider.ts create mode 100644 server/src/service/infrastructure/network/PageSpeedProvider.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 485297fe0..11e795feb 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -78,6 +78,8 @@ import { EnvConfig } from "@/service/system/settingsService.js"; import { PingProvider } from "@/service/infrastructure/network/PingProvider.js"; import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js"; +import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js"; +import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js"; export type InitializedServices = { settingsService: any; @@ -151,7 +153,8 @@ export const initializeServices = async ({ const pingProvider = new PingProvider(ping); const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); - + const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); + const hardwareProvider = new HardwareProvider(httpProvider); const networkService = new NetworkService( axios, got, @@ -168,7 +171,9 @@ export const initializeServices = async ({ grpc, protoLoader, pingProvider, - httpProvider + httpProvider, + pageSpeedProvider, + hardwareProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/service/infrastructure/network/HardwareProvider.ts b/server/src/service/infrastructure/network/HardwareProvider.ts new file mode 100644 index 000000000..a9180381e --- /dev/null +++ b/server/src/service/infrastructure/network/HardwareProvider.ts @@ -0,0 +1,29 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; +import { AppError } from "@/utils/AppError.js"; + +export class HardwareProvider implements IStatusProvider { + readonly type = "hardware"; + constructor(private httpProvider: HttpProvider) {} + + supports(type: MonitorType) { + return type === "hardware"; + } + + async handle(monitor: Monitor): Promise> { + const { url } = monitor; + try { + if (!url) throw new Error("URL is required for Hardware monitor"); + return await this.httpProvider.handle(monitor); + } catch (err: unknown) { + throw new AppError({ + message: err instanceof Error ? err.message : "Error performing Hardware request", + service: "HardwareProvider", + method: "handle", + details: { url: monitor.url }, + }); + } + } +} diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index b47160e06..e373e2cff 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -4,7 +4,7 @@ import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvide import { HttpStatusPayload } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js"; import { Agent as HttpsAgent } from "https"; -import { Monitor } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/types/monitor.js"; import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; export class HttpProvider implements IStatusProvider { @@ -15,7 +15,7 @@ export class HttpProvider implements IStatusProvider { private advancedMatcher: AdvancedMatcher ) {} - supports(type: string) { + supports(type: MonitorType) { return type === "http"; } diff --git a/server/src/service/infrastructure/network/PageSpeedProvider.ts b/server/src/service/infrastructure/network/PageSpeedProvider.ts new file mode 100644 index 000000000..121fe9bf8 --- /dev/null +++ b/server/src/service/infrastructure/network/PageSpeedProvider.ts @@ -0,0 +1,55 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; +import { ISettingsService } from "@/service/system/settingsService.js"; +import { ILogger } from "@/utils/logger.js"; +import { AppError } from "@/utils/AppError.js"; + +export class PageSpeedProvider implements IStatusProvider { + readonly type = "pagespeed"; + constructor( + private httpProvider: HttpProvider, + private settingsService: ISettingsService, + private logger: ILogger + ) {} + + supports(type: MonitorType) { + return type === "pagespeed"; + } + + async handle(monitor: Monitor): Promise> { + const { url } = monitor; + try { + if (!url) throw new Error("URL is required for PageSpeed monitor"); + const dbSettings = await this.settingsService.getDBSettings(); + const apiKey = dbSettings?.pagespeedApiKey; + let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent( + url + )}&category=seo&category=accessibility&category=best-practices&category=performance`; + + if (apiKey) { + pageSpeedUrl += `&key=${apiKey}`; + } else { + this.logger.warn({ + message: "PageSpeed API key not found, performance may be throttled", + service: "PageSpeedProvider", + method: "handle", + details: { url }, + }); + } + + return await this.httpProvider.handle({ + ...monitor, + url: pageSpeedUrl, + }); + } catch (err: unknown) { + throw new AppError({ + message: err instanceof Error ? err.message : "Error performing PageSpeed request", + service: "PageSpeedProvider", + method: "handle", + details: { url: monitor.url }, + }); + } + } +} diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 2515b6145..539a1f8c2 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -97,6 +97,8 @@ class NetworkService implements INetworkService { // New providers private pingProvider; private httpProvider; + private pageSpeedProvider; + private hardwareProvider; private buildStatusResponse = ({ monitor, @@ -168,7 +170,9 @@ class NetworkService implements INetworkService { // New providers pingProvider: IStatusProvider, - httpProvider: IStatusProvider + httpProvider: IStatusProvider, + pagespeedProvider: IStatusProvider, + hardwareProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -196,7 +200,8 @@ class NetworkService implements INetworkService { // New providers this.pingProvider = pingProvider; this.httpProvider = httpProvider; - + this.pageSpeedProvider = pagespeedProvider; + this.hardwareProvider = hardwareProvider; const cacheable = new CacheableLookup(); this.got = got.extend({ @@ -247,7 +252,7 @@ class NetworkService implements INetworkService { case this.TYPE_HTTP: return await this.httpProvider.handle(monitor); case this.TYPE_PAGESPEED: - return await this.requestPageSpeed(monitor); + return await this.pageSpeedProvider.handle(monitor); case this.TYPE_HARDWARE: return await this.requestHardware(monitor); case this.TYPE_DOCKER: From f3b156f2d0c02c19983c65b6eb8b759aa6919a42 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 18:19:03 +0000 Subject: [PATCH 05/12] docker provider --- server/src/config/services.ts | 6 +- .../infrastructure/network/DockerProvider.ts | 160 ++++++++++++++++++ .../service/infrastructure/networkService.ts | 9 +- 3 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 server/src/service/infrastructure/network/DockerProvider.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 11e795feb..eed243817 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -80,6 +80,7 @@ import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js"; import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js"; import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js"; +import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js"; export type InitializedServices = { settingsService: any; @@ -155,6 +156,8 @@ export const initializeServices = async ({ const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); const hardwareProvider = new HardwareProvider(httpProvider); + const dockerProvider = new DockerProvider(logger, Docker); + const networkService = new NetworkService( axios, got, @@ -173,7 +176,8 @@ export const initializeServices = async ({ pingProvider, httpProvider, pageSpeedProvider, - hardwareProvider + hardwareProvider, + dockerProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/service/infrastructure/network/DockerProvider.ts b/server/src/service/infrastructure/network/DockerProvider.ts new file mode 100644 index 000000000..8e84b5dc6 --- /dev/null +++ b/server/src/service/infrastructure/network/DockerProvider.ts @@ -0,0 +1,160 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { ILogger } from "@/utils/logger.js"; +import { AppError } from "@/utils/AppError.js"; +import Dockerode from "dockerode"; +import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; + +type DockerodeType = typeof Dockerode; + +const SERVICE_NAME = "DockerProvider"; + +export interface DockerError extends Error { + statusCode?: number; + reason?: string; + json?: { message?: string }; +} + +export class DockerProvider implements IStatusProvider { + readonly type = "docker"; + private docker: Dockerode; + constructor( + private logger: ILogger, + private DockerLib: DockerodeType + ) { + this.docker = new this.DockerLib(); + } + + supports(type: MonitorType): boolean { + return type === "docker"; + } + + private isDockerError(error: unknown): error is DockerError { + return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error); + } + + async handle(monitor: Monitor): Promise> { + const { url: containerInput } = monitor; + try { + if (!containerInput) { + throw new Error("Container name or ID is required for Docker monitor"); + } + + const containers = await this.docker.listContainers({ all: true }); + const normalizedInput = containerInput.replace(/^\/+/, "").toLowerCase(); + + // Priority-based matching to avoid ambiguity: + // 1. Exact full ID match (64-char) + const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput); + + // 2. Exact container name match (case-insensitive) + const exactNameMatch = containers.find((c) => + c.Names.some((name: string) => { + const cleanName = name.replace(/^\/+/, "").toLowerCase(); + return cleanName === normalizedInput; + }) + ); + + // 3. Partial ID match (fallback for backwards compatibility) + const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput)); + + // Select container based on priority + const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch; + + // Handle no match + if (!targetContainer) { + this.logger.warn({ + message: `No container found for "${monitor.url}".`, + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url }, + }); + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: 404, + message: "Docker container not found", + responseTime: 0, + payload: null, + }; + } + + // 5. Handle Ambiguity Check + const matchTypes: string[] = []; + if (exactIdMatch) matchTypes.push("exact ID"); + if (exactNameMatch) matchTypes.push("exact name"); + if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); + + if (matchTypes.length > 1) { + const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; + + this.logger.warn({ + message, + service: SERVICE_NAME, + method: "handle", + details: { url: containerInput }, + }); + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message, + responseTime: 0, + payload: null, + }; + } + + // 6. Inspect Container Status + const container = this.docker.getContainer(targetContainer.Id); + const { response, responseTime, error } = await timeRequest(() => container.inspect()); + + if (error) { + let message = "Failed to fetch Docker container information"; + let code = NETWORK_ERROR; + + if (this.isDockerError(error)) { + code = error.statusCode ?? NETWORK_ERROR; + message = error.json?.message ?? error.reason ?? error.message; + } else if (error instanceof Error) { + message = error.message; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: code, + message: message, + responseTime, + payload: null, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: response?.State?.Status === "running", + code: 200, + message: "Docker container status fetched successfully", + responseTime, + payload: response as unknown as DockerStatusPayload, + }; + } catch (err: unknown) { + throw new AppError({ + message: err instanceof Error ? err.message : "Error performing Docker request", + service: SERVICE_NAME, + method: "handle", + details: { url: containerInput }, + }); + } + } +} diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 539a1f8c2..c79a2cee6 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -99,6 +99,7 @@ class NetworkService implements INetworkService { private httpProvider; private pageSpeedProvider; private hardwareProvider; + private dockerProvider; private buildStatusResponse = ({ monitor, @@ -172,7 +173,8 @@ class NetworkService implements INetworkService { pingProvider: IStatusProvider, httpProvider: IStatusProvider, pagespeedProvider: IStatusProvider, - hardwareProvider: IStatusProvider + hardwareProvider: IStatusProvider, + dockerProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -202,6 +204,7 @@ class NetworkService implements INetworkService { this.httpProvider = httpProvider; this.pageSpeedProvider = pagespeedProvider; this.hardwareProvider = hardwareProvider; + this.dockerProvider = dockerProvider; const cacheable = new CacheableLookup(); this.got = got.extend({ @@ -254,9 +257,9 @@ class NetworkService implements INetworkService { case this.TYPE_PAGESPEED: return await this.pageSpeedProvider.handle(monitor); case this.TYPE_HARDWARE: - return await this.requestHardware(monitor); + return await this.hardwareProvider.handle(monitor); case this.TYPE_DOCKER: - return await this.requestDocker(monitor); + return await this.dockerProvider.handle(monitor); case this.TYPE_PORT: return await this.requestPort(monitor); case this.TYPE_GAME: From 80603c9a4214012c7092cf7869c0e96eaa507da8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 18:30:06 +0000 Subject: [PATCH 06/12] port provider --- .../infrastructure/network/PortProvider.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 server/src/service/infrastructure/network/PortProvider.ts diff --git a/server/src/service/infrastructure/network/PortProvider.ts b/server/src/service/infrastructure/network/PortProvider.ts new file mode 100644 index 000000000..bacbd19ff --- /dev/null +++ b/server/src/service/infrastructure/network/PortProvider.ts @@ -0,0 +1,98 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { PortStatusPayload, 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 * as net from "net"; +type NetType = typeof net; + +const SERVICE_NAME = "PortProvider"; + +export class PortProvider implements IStatusProvider { + readonly type = "port"; + + constructor(private net: NetType) {} + + supports(type: MonitorType): boolean { + return type === "port"; + } + + async handle(monitor: Monitor): Promise> { + try { + const { url, port } = monitor; + if (!url || !port) { + throw new Error("URL and port are required for port monitoring"); + } + + const { responseTime, error } = await timeRequest(async () => { + return new Promise((resolve, reject) => { + const socket = this.net.createConnection( + { + host: url, + port, + }, + () => { + socket.end(); + socket.destroy(); + resolve({ success: true }); + } + ); + + socket.setTimeout(5000); + socket.on("timeout", () => { + socket.destroy(); + reject(new Error("Connection timeout")); + }); + + socket.on("error", (err: unknown) => { + socket.destroy(); + reject(err); + }); + }); + }); + if (error) { + const errorMessage = error instanceof Error ? error.message : "Port check failed"; + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: errorMessage, + responseTime: responseTime, + timings: undefined, + payload: { success: false }, + jsonPath: monitor.jsonPath, + matchMethod: monitor.matchMethod, + expectedValue: monitor.expectedValue, + extracted: undefined, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: true, + code: 200, + message: "Port check successful", + responseTime: responseTime, + timings: undefined, + payload: { success: true }, + jsonPath: monitor.jsonPath, + matchMethod: monitor.matchMethod, + expectedValue: monitor.expectedValue, + extracted: undefined, + }; + } catch (err: unknown) { + const originalMessage = err instanceof Error ? err.message : String(err); + throw new AppError({ + message: originalMessage || "Error performing port check", + status: 500, + service: SERVICE_NAME, + method: "requestPort", + details: { url: monitor.url, port: monitor.port }, + }); + } + } +} From 49672db563524412ccd49d22ff710f1b8605b61a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 18:35:19 +0000 Subject: [PATCH 07/12] port provider --- server/src/config/services.ts | 5 ++++- .../src/service/infrastructure/network/PortProvider.ts | 10 +--------- server/src/service/infrastructure/networkService.ts | 7 +++++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index eed243817..1d8558f9c 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -81,6 +81,7 @@ import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatche import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js"; import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js"; import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js"; +import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; export type InitializedServices = { settingsService: any; @@ -157,6 +158,7 @@ export const initializeServices = async ({ const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); const hardwareProvider = new HardwareProvider(httpProvider); const dockerProvider = new DockerProvider(logger, Docker); + const portProvider = new PortProvider(net); const networkService = new NetworkService( axios, @@ -177,7 +179,8 @@ export const initializeServices = async ({ httpProvider, pageSpeedProvider, hardwareProvider, - dockerProvider + dockerProvider, + portProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/service/infrastructure/network/PortProvider.ts b/server/src/service/infrastructure/network/PortProvider.ts index bacbd19ff..7c1c96ce4 100644 --- a/server/src/service/infrastructure/network/PortProvider.ts +++ b/server/src/service/infrastructure/network/PortProvider.ts @@ -62,10 +62,6 @@ export class PortProvider implements IStatusProvider { responseTime: responseTime, timings: undefined, payload: { success: false }, - jsonPath: monitor.jsonPath, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, - extracted: undefined, }; } @@ -79,10 +75,6 @@ export class PortProvider implements IStatusProvider { responseTime: responseTime, timings: undefined, payload: { success: true }, - jsonPath: monitor.jsonPath, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, - extracted: undefined, }; } catch (err: unknown) { const originalMessage = err instanceof Error ? err.message : String(err); @@ -90,7 +82,7 @@ export class PortProvider implements IStatusProvider { message: originalMessage || "Error performing port check", status: 500, service: SERVICE_NAME, - method: "requestPort", + method: "handle", details: { url: monitor.url, port: monitor.port }, }); } diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index c79a2cee6..f535eae6c 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -100,6 +100,7 @@ class NetworkService implements INetworkService { private pageSpeedProvider; private hardwareProvider; private dockerProvider; + private portProvider; private buildStatusResponse = ({ monitor, @@ -174,7 +175,8 @@ class NetworkService implements INetworkService { httpProvider: IStatusProvider, pagespeedProvider: IStatusProvider, hardwareProvider: IStatusProvider, - dockerProvider: IStatusProvider + dockerProvider: IStatusProvider, + portProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -205,6 +207,7 @@ class NetworkService implements INetworkService { this.pageSpeedProvider = pagespeedProvider; this.hardwareProvider = hardwareProvider; this.dockerProvider = dockerProvider; + this.portProvider = portProvider; const cacheable = new CacheableLookup(); this.got = got.extend({ @@ -261,7 +264,7 @@ class NetworkService implements INetworkService { case this.TYPE_DOCKER: return await this.dockerProvider.handle(monitor); case this.TYPE_PORT: - return await this.requestPort(monitor); + return await this.portProvider.handle(monitor); case this.TYPE_GAME: return await this.requestGame(monitor); case this.TYPE_GRPC: From 190d9dd2b1ae829db40391e2fd5816c9293cb74b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 18:45:27 +0000 Subject: [PATCH 08/12] gameProvider --- .../infrastructure/network/GameProvider.ts | 78 +++++++++++++++++++ .../infrastructure/network/PortProvider.ts | 1 + server/src/types/network.ts | 4 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 server/src/service/infrastructure/network/GameProvider.ts diff --git a/server/src/service/infrastructure/network/GameProvider.ts b/server/src/service/infrastructure/network/GameProvider.ts new file mode 100644 index 000000000..e2ace7433 --- /dev/null +++ b/server/src/service/infrastructure/network/GameProvider.ts @@ -0,0 +1,78 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { GameDig } from "gamedig"; +import { AppError } from "@/utils/AppError.js"; +import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; +import { ILogger } from "@/utils/logger.js"; + +type GameDigType = typeof GameDig; + +const SERVICE_NAME = "GameProvider"; +export class GameProvider implements IStatusProvider { + readonly type = "game"; + constructor( + private logger: ILogger, + private gameDig: GameDigType + ) {} + + supports(type: MonitorType): boolean { + return type === "game"; + } + + async handle(monitor: Monitor): Promise> { + const { url, port, gameId, id, teamId, type } = monitor; + + // Clean the host in case a full URL was passed + const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0] || ""; + try { + const state = await this.gameDig + .query({ + type: gameId ?? "unknown", + host: host, + port: port ?? 0, + }) + .catch((error: unknown) => { + this.logger.warn({ + message: error instanceof Error ? error.message : String(error), + service: SERVICE_NAME, + method: "handle", + details: { host, port, gameId }, + }); + return undefined; + }); + + if (!state) { + return { + monitorId: id, + teamId: teamId, + type: type, + status: false, + code: NETWORK_ERROR, + message: "No response from game server", + responseTime: 0, + payload: null, + }; + } + + return { + monitorId: id, + teamId: teamId, + type: type, + status: true, + code: 200, + message: "Success", + responseTime: state.ping ?? 0, + payload: state, + }; + } catch (err: unknown) { + const originalMessage = err instanceof Error ? err.message : String(err); + throw new AppError({ + message: originalMessage || "Error performing game server check", + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url, port: monitor.port, gameId: monitor.gameId }, + }); + } + } +} diff --git a/server/src/service/infrastructure/network/PortProvider.ts b/server/src/service/infrastructure/network/PortProvider.ts index 7c1c96ce4..66fefd0fe 100644 --- a/server/src/service/infrastructure/network/PortProvider.ts +++ b/server/src/service/infrastructure/network/PortProvider.ts @@ -51,6 +51,7 @@ export class PortProvider implements IStatusProvider { }); }); if (error) { + console.log(error); const errorMessage = error instanceof Error ? error.message : "Port check failed"; return { monitorId: monitor.id, diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 083193e06..93ddc4ac9 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -14,6 +14,8 @@ import type { MonitorStatus, } from "@/types/index.js"; +import type { QueryResult } from "gamedig"; + export interface MonitorStatusResponse< T = | HttpStatusPayload @@ -100,7 +102,7 @@ export interface PortStatusPayload { success: boolean; } -export type GameStatusPayload = Record; +export type GameStatusPayload = QueryResult; export interface GrpcStatusPayload { grpcStatusCode: number; From 27c44a0fbd69c55a4a7de994ca945972037d34e0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 18:48:19 +0000 Subject: [PATCH 09/12] config --- server/src/config/services.ts | 5 +- .../service/infrastructure/networkService.ts | 54 ++----------------- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 1d8558f9c..953d89b0f 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -82,6 +82,7 @@ import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedPro import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js"; import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js"; import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; +import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; export type InitializedServices = { settingsService: any; @@ -159,6 +160,7 @@ export const initializeServices = async ({ const hardwareProvider = new HardwareProvider(httpProvider); const dockerProvider = new DockerProvider(logger, Docker); const portProvider = new PortProvider(net); + const gameProvider = new GameProvider(logger, GameDig); const networkService = new NetworkService( axios, @@ -180,7 +182,8 @@ export const initializeServices = async ({ pageSpeedProvider, hardwareProvider, dockerProvider, - portProvider + portProvider, + gameProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index f535eae6c..d727345da 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -101,6 +101,7 @@ class NetworkService implements INetworkService { private hardwareProvider; private dockerProvider; private portProvider; + private gameProvider; private buildStatusResponse = ({ monitor, @@ -176,7 +177,8 @@ class NetworkService implements INetworkService { pagespeedProvider: IStatusProvider, hardwareProvider: IStatusProvider, dockerProvider: IStatusProvider, - portProvider: IStatusProvider + portProvider: IStatusProvider, + gameProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -208,6 +210,7 @@ class NetworkService implements INetworkService { this.hardwareProvider = hardwareProvider; this.dockerProvider = dockerProvider; this.portProvider = portProvider; + this.gameProvider = gameProvider; const cacheable = new CacheableLookup(); this.got = got.extend({ @@ -266,7 +269,7 @@ class NetworkService implements INetworkService { case this.TYPE_PORT: return await this.portProvider.handle(monitor); case this.TYPE_GAME: - return await this.requestGame(monitor); + return await this.gameProvider.handle(monitor); case this.TYPE_GRPC: return await this.requestGrpc(monitor); default: @@ -628,53 +631,6 @@ class NetworkService implements INetworkService { } } - private async requestGame(monitor: Monitor): Promise> { - try { - const { url, port, gameId } = monitor; - - const gameResponse = this.buildStatusResponse({ - monitor, - overrides: { - code: 200, - status: true, - message: "Success", - }, - }); - - const state = await this.GameDig.query({ - type: gameId ?? "unknown", - host: url, - port: port ?? 0, - }).catch((error: unknown): undefined => { - this.logger.warn({ - message: error instanceof Error ? error.message : String(error), - service: this.SERVICE_NAME, - method: "requestGame", - details: { url, port, gameId }, - }); - return undefined; - }); - - if (!state) { - gameResponse.code = this.NETWORK_ERROR; - gameResponse.status = false; - gameResponse.message = "No response"; - return gameResponse; - } - - gameResponse.responseTime = state.ping ?? 0; - gameResponse.payload = state as GameStatusPayload; - return gameResponse; - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing game server check", - service: this.SERVICE_NAME, - method: "requestGame", - details: { url: monitor.url, port: monitor.port, gameId: monitor.gameId }, - }); - } - } private async requestGrpc(monitor: Monitor): Promise> { try { const { url, port, ignoreTlsErrors } = monitor; From c7689cc64a691f71d1fb06d5a5ce4085f7d1c255 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 19:18:29 +0000 Subject: [PATCH 10/12] grpc --- .../infrastructure/network/GrpcProvider.ts | 187 +++++++++ .../service/infrastructure/networkService.ts | 354 ------------------ 2 files changed, 187 insertions(+), 354 deletions(-) create mode 100644 server/src/service/infrastructure/network/GrpcProvider.ts diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts new file mode 100644 index 000000000..4bf127785 --- /dev/null +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -0,0 +1,187 @@ +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { GrpcStatusPayload, 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 { ILogger } from "@/utils/logger.js"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; + +import path from "path"; +import { fileURLToPath } from "url"; +import { Network } from "inspector/promises"; + +type GrpcType = typeof grpc; +type ProtoLoaderType = typeof protoLoader; + +const SERVICE_NAME = "GrpcProvider"; + +export interface GrpcError extends Error { + code: number; + details?: string; +} + +export class GrpcProvider implements IStatusProvider { + readonly type = "grpc"; + constructor( + private logger: ILogger, + private grpc: GrpcType, + private protoLoader: ProtoLoaderType + ) {} + + supports(type: MonitorType): boolean { + return type === "grpc"; + } + + private isGrpcError(error: unknown): error is GrpcError { + return error instanceof Error && "code" in error && typeof (error as Record).code === "number"; + } + + 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"; + } + + async handle(monitor: Monitor): Promise> { + const { id, teamId, type, url, port, ignoreTlsErrors } = monitor; + const grpcServiceName = monitor.grpcServiceName || ""; + const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; + + try { + if (!host || !port) { + throw new Error("Valid host and port are required"); + } + const target = `${host}:${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) as any; + const HealthClient = 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 HealthClient(target, credentials); + const deadline = new Date(Date.now() + 10000); // 10s timeout + const { response, responseTime, error } = await timeRequest(() => { + return new Promise((resolve, reject) => { + client.Check({ service: grpcServiceName }, { deadline }, (err: unknown, response: unknown) => { + client.close(); + + if (err) { + const grpcErr = err as { code?: number; details?: string; message?: string }; + const payload: GrpcStatusPayload = { + grpcStatusCode: grpcErr.code ?? -1, + grpcStatusName: this.getGrpcStatusName(grpcErr.code ?? -1), + serviceName: grpcServiceName, + servingStatus: "UNKNOWN", + }; + const grpcError = new AppError({ + message: grpcErr.details || grpcErr.message || "gRPC error", + service: SERVICE_NAME, + method: "handle", + }) as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; + grpcError.grpcPayload = payload; + grpcError.grpcCode = grpcErr.code; + reject(grpcError); + return; + } + + const resp = response as { status?: string } | undefined; + const servingStatus = resp?.status ?? "UNKNOWN"; + resolve({ + grpcStatusCode: 0, + grpcStatusName: "OK", + serviceName: grpcServiceName, + servingStatus, + }); + }); + }); + }); + + if (error) { + let grpcCode = -1; + let message = error instanceof Error ? error.message : "gRPC health check failed"; + + if (this.isGrpcError(error)) { + grpcCode = error.code; + message = error.details || error.message; + } + + return { + monitorId: id, + teamId: teamId, + type: type, + status: false, + code: 500, + message: message, + responseTime, + payload: { + grpcStatusCode: grpcCode, + grpcStatusName: this.getGrpcStatusName(grpcCode), + serviceName: grpcServiceName, + servingStatus: "UNKNOWN", + }, + }; + } + + if (!response) { + throw new Error("No response received from gRPC health check"); + } + + const isServing = response.servingStatus === "SERVING"; + + return { + monitorId: id, + teamId: teamId, + type: type, + status: isServing, + code: isServing ? 200 : NETWORK_ERROR, + message: isServing ? "gRPC health check successful" : "gRPC health check failed", + responseTime, + payload: { + grpcStatusCode: isServing ? 200 : NETWORK_ERROR, + grpcStatusName: response.grpcStatusName, + serviceName: response.serviceName, + servingStatus: response.servingStatus || "UNKNOWN", + }, + }; + } catch (err: unknown) { + const originalMessage = err instanceof Error ? err.message : String(err); + throw new AppError({ + message: originalMessage || "Error performing gRPC health check", + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url, port: monitor.port, grpcServiceName: monitor.grpcServiceName }, + }); + } + } +} diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index d727345da..1a0d71553 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -277,360 +277,6 @@ class NetworkService implements INetworkService { } } - private async requestHttp(monitor: Monitor): Promise> { - const { url, secret, ignoreTlsErrors, useAdvancedMatching, jsonPath, matchMethod, expectedValue } = monitor; - const httpResponse = this.buildStatusResponse({ - monitor, - overrides: { - status: false, - code: this.NETWORK_ERROR, - message: "Request not executed", - }, - }); - - try { - if (!url) { - throw new Error("Monitor URL is required"); - } - const config: Record = { - headers: secret ? { Authorization: `Bearer ${secret}` } : undefined, - }; - - if (ignoreTlsErrors) { - config.agent = { - https: new this.https.Agent({ - rejectUnauthorized: false, - }), - }; - } - - const response = await this.got(url, config); - - let payload: T | string | undefined; - const contentType = response.headers["content-type"]; - - if (contentType && contentType.includes("application/json")) { - try { - payload = JSON.parse(response.body); - } catch { - payload = response.body; - } - } else { - payload = response.body; - } - - Object.assign(httpResponse, { - code: response.statusCode, - status: response.ok, - message: response.statusMessage ?? "", - responseTime: response.timings.phases.total || 0, - payload, - timings: response.timings, - }); - - if (!useAdvancedMatching) { - return httpResponse; - } - - if (expectedValue && !jsonPath) { - let ok = false; - if (matchMethod === "equal") ok = payload === expectedValue; - if (matchMethod === "include" && typeof payload === "string") ok = payload.includes(expectedValue); - if (matchMethod === "regex" && typeof payload === "string") ok = new RegExp(expectedValue).test(payload); - - if (ok === true) { - return httpResponse; - } else { - httpResponse.code = 500; - httpResponse.status = false; - httpResponse.message = "Expected value did not match"; - return httpResponse; - } - } - - if (jsonPath) { - const contentType = response.headers["content-type"]; - const isJson = contentType?.includes("application/json"); - if (!isJson) { - httpResponse.status = false; - httpResponse.message = "Response is not JSON"; - return httpResponse; - } - try { - const extracted = this.jmespath.search(payload, jsonPath); - if (expectedValue) { - let ok = false; - if (matchMethod === "equal") ok = extracted === expectedValue; - if (matchMethod === "include" && typeof extracted === "string") ok = extracted.includes(expectedValue); - if (matchMethod === "regex" && typeof extracted === "string") ok = new RegExp(expectedValue).test(extracted); - - if (ok) { - httpResponse.extracted = extracted; - return httpResponse; - } else { - httpResponse.status = false; - httpResponse.code = 500; - httpResponse.message = "Expected value did not match"; - httpResponse.extracted = extracted; - return httpResponse; - } - } else { - const isFalsey = extracted === false || extracted === "false" || extracted === undefined || extracted === null; - if (!isFalsey) { - httpResponse.extracted = extracted; - return httpResponse; - } else { - httpResponse.status = false; - httpResponse.code = 500; - httpResponse.message = "Expected value did not match"; - httpResponse.extracted = extracted; - return httpResponse; - } - } - } catch { - httpResponse.status = false; - httpResponse.message = "Error evaluating JSON path"; - return httpResponse; - } - } - return httpResponse; - } catch (err: unknown) { - if (err instanceof HTTPError || err instanceof RequestError) { - httpResponse.code = err.response?.statusCode || this.NETWORK_ERROR; - httpResponse.status = false; - httpResponse.message = String(err.response?.statusCode) || err.message; - httpResponse.responseTime = err.timings?.phases?.total || 0; - httpResponse.payload = null; - httpResponse.timings = err.timings || undefined; - return httpResponse; - } - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing HTTP request", - service: this.SERVICE_NAME, - method: "requestHttp", - details: { url: monitor.url }, - }); - } - } - - private async requestPageSpeed(monitor: Monitor): Promise> { - try { - const url = monitor.url; - if (!url) { - throw new Error("Monitor URL is required"); - } - let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`; - const dbSettings = await this.settingsService.getDBSettings(); - if (dbSettings?.pagespeedApiKey) { - pageSpeedUrl += `&key=${dbSettings.pagespeedApiKey}`; - } else { - this.logger.warn({ - message: "PageSpeed API key not found, job not executed", - service: this.SERVICE_NAME, - method: "requestPagespeed", - details: { url }, - }); - } - return await this.requestHttp({ - ...monitor, - url: pageSpeedUrl, - }); - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing PageSpeed request", - service: this.SERVICE_NAME, - method: "requestPageSpeed", - details: { url: monitor.url }, - }); - } - } - - private async requestHardware(monitor: Monitor): Promise> { - try { - return await this.requestHttp(monitor); - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing hardware request", - service: this.SERVICE_NAME, - method: "requestHardware", - details: { url: monitor.url }, - }); - } - } - - private async requestDocker(monitor: Monitor): Promise> { - try { - if (!monitor.url) { - throw new Error("Monitor URL is required"); - } - - const docker = new this.Docker({ - socketPath: "/var/run/docker.sock", - }); - - const dockerResponse = this.buildStatusResponse({ - monitor, - overrides: { - status: false, - code: this.NETWORK_ERROR, - message: "No response", - }, - }); - - const containers = await docker.listContainers({ all: true }); - - // Normalize input: strip leading slashes and convert to lowercase for comparison - const normalizedInput = monitor.url.replace(/^\/+/, "").toLowerCase(); - - // Priority-based matching to avoid ambiguity: - // 1. Exact full ID match (64-char) - const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput); - - // 2. Exact container name match (case-insensitive) - const exactNameMatch = containers.find((c) => - c.Names.some((name: string) => { - const cleanName = name.replace(/^\/+/, "").toLowerCase(); - return cleanName === normalizedInput; - }) - ); - - // 3. Partial ID match (fallback for backwards compatibility) - const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput)); - - // Select container based on priority - const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch; - - // Return negative response if no container - if (!targetContainer) { - this.logger.warn({ - message: `No container found for "${monitor.url}".`, - service: this.SERVICE_NAME, - method: "requestDocker", - details: { url: monitor.url }, - }); - - dockerResponse.code = 404; - dockerResponse.status = false; - dockerResponse.message = "Docker container not found"; - return dockerResponse; - } - - // Return negative response if ambiguous matches exist - const matchTypes = []; - if (exactIdMatch) matchTypes.push("exact ID"); - if (exactNameMatch) matchTypes.push("exact name"); - if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); - - if (matchTypes.length > 1) { - this.logger.warn({ - message: `Ambiguous container match for "${monitor.url}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`, - service: this.SERVICE_NAME, - method: "requestDocker", - details: { url: monitor.url }, - }); - dockerResponse.status = false; - dockerResponse.message = `Ambiguous container match for "${monitor.url}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; - return dockerResponse; - } - - const container = docker.getContainer(targetContainer.Id); - const { response, responseTime, error }: { response?: { State: { Status: string } } | null; responseTime: number; error: unknown } = - await this.timeRequest(() => container.inspect()); - - dockerResponse.responseTime = responseTime; - dockerResponse.status = response?.State?.Status === "running" ? true : false; - dockerResponse.code = 200; - dockerResponse.message = "Docker container status fetched successfully"; - - if (error) { - dockerResponse.status = false; - dockerResponse.code = - (error && typeof error === "object" && "statusCode" in error ? (error as { statusCode?: number }).statusCode : undefined) || - this.NETWORK_ERROR; - dockerResponse.message = - (error && typeof error === "object" && "reason" in error ? (error as { reason?: string }).reason : undefined) || - "Failed to fetch Docker container information"; - return dockerResponse; - } - - return dockerResponse; - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing Docker request", - service: this.SERVICE_NAME, - method: "requestDocker", - details: { url: monitor.url }, - }); - } - } - - private async requestPort(monitor: Monitor): Promise> { - try { - const { url, port } = monitor; - if (!port) { - throw new Error("Port is required for port monitor"); - } - - const { response, responseTime, error } = await this.timeRequest(async () => { - return new Promise((resolve, reject) => { - const socket = this.net.createConnection( - { - host: url, - port, - }, - () => { - socket.end(); - socket.destroy(); - resolve({ success: true }); - } - ); - - socket.setTimeout(5000); - socket.on("timeout", () => { - socket.destroy(); - reject(new Error("Connection timeout")); - }); - - socket.on("error", (err: unknown) => { - socket.destroy(); - reject(err); - }); - }); - }); - - const portResponse = this.buildStatusResponse({ - monitor, - overrides: { - code: 200, - status: (response as { success?: boolean })?.success ?? false, - message: "Port check successful", - responseTime, - }, - }); - - if (error) { - portResponse.code = this.NETWORK_ERROR; - portResponse.status = false; - portResponse.message = "Port check failed"; - return portResponse; - } - - return portResponse; - } catch (err: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing port check", - service: this.SERVICE_NAME, - method: "requestPort", - details: { url: monitor.url, port: monitor.port }, - }); - } - } - private async requestGrpc(monitor: Monitor): Promise> { try { const { url, port, ignoreTlsErrors } = monitor; From ad7d4e54c397ee2c9cfe147e31434c3af00f1c99 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 19:34:57 +0000 Subject: [PATCH 11/12] cleanup --- server/src/config/services.ts | 18 +- .../infrastructure/network/GrpcProvider.ts | 97 +++--- .../infrastructure/network/HttpProvider.ts | 12 +- .../service/infrastructure/networkService.ts | 293 +----------------- 4 files changed, 66 insertions(+), 354 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 953d89b0f..bd13b7dfc 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -29,7 +29,6 @@ import { NotificationMessageBuilder, INotificationMessageBuilder } from "../serv import axios from "axios"; import got from "got"; import ping from "ping"; -import https from "https"; import Docker from "dockerode"; import net from "net"; import fs from "fs"; @@ -83,6 +82,7 @@ import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvi import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js"; import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; +import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js"; export type InitializedServices = { settingsService: any; @@ -161,29 +161,19 @@ export const initializeServices = async ({ const dockerProvider = new DockerProvider(logger, Docker); const portProvider = new PortProvider(net); const gameProvider = new GameProvider(logger, GameDig); + const grpcProvider = new GrpcProvider(grpc, protoLoader); const networkService = new NetworkService( axios, - got, - https, - jmespath, - GameDig as unknown as { - query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }>; - }, - ping, logger, - Docker, - net, - settingsService, - grpc, - protoLoader, pingProvider, httpProvider, pageSpeedProvider, hardwareProvider, dockerProvider, portProvider, - gameProvider + gameProvider, + grpcProvider ); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts index 4bf127785..66684ba0c 100644 --- a/server/src/service/infrastructure/network/GrpcProvider.ts +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -2,29 +2,21 @@ import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvide import { GrpcStatusPayload, 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 { ILogger } from "@/utils/logger.js"; +import { timeRequest } from "@/service/infrastructure/network/utils.js"; import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; import path from "path"; import { fileURLToPath } from "url"; -import { Network } from "inspector/promises"; type GrpcType = typeof grpc; type ProtoLoaderType = typeof protoLoader; const SERVICE_NAME = "GrpcProvider"; -export interface GrpcError extends Error { - code: number; - details?: string; -} - export class GrpcProvider implements IStatusProvider { readonly type = "grpc"; constructor( - private logger: ILogger, private grpc: GrpcType, private protoLoader: ProtoLoaderType ) {} @@ -33,10 +25,6 @@ export class GrpcProvider implements IStatusProvider { return type === "grpc"; } - private isGrpcError(error: unknown): error is GrpcError { - return error instanceof Error && "code" in error && typeof (error as Record).code === "number"; - } - private getGrpcStatusName(code: number): string { const statusNames: Record = { 0: "OK", @@ -61,15 +49,20 @@ export class GrpcProvider implements IStatusProvider { } async handle(monitor: Monitor): Promise> { - const { id, teamId, type, url, port, ignoreTlsErrors } = monitor; - const grpcServiceName = monitor.grpcServiceName || ""; - const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; - try { - if (!host || !port) { - throw new Error("Valid host and port are required"); + const { id, teamId, type, url, port, ignoreTlsErrors } = monitor; + const grpcServiceName = monitor.grpcServiceName || ""; + + if (!url) { + throw new AppError({ message: "Monitor host is required", service: SERVICE_NAME, method: "handle" }); } + if (!port) { + throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" }); + } + + const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; const target = `${host}:${port}`; + const currentFilePath = fileURLToPath(import.meta.url); const protoPath = path.join(path.dirname(currentFilePath), "protos", "health.proto"); const packageDefinition = this.protoLoader.loadSync(protoPath, { @@ -79,8 +72,23 @@ export class GrpcProvider implements IStatusProvider { defaults: true, oneofs: true, }); - const grpcObject = this.grpc.loadPackageDefinition(packageDefinition) as any; - const HealthClient = grpcObject.grpc.health.v1.Health; + const grpcObject = this.grpc.loadPackageDefinition(packageDefinition) as unknown as { + grpc: { + health: { + v1: { + Health: new ( + target: string, + credentials: unknown + ) => { + Check: (request: { service: string }, options: { deadline: Date }, callback: (err: unknown, response: unknown) => void) => void; + close: () => void; + }; + }; + }; + }; + }; + const healthService = grpcObject.grpc.health.v1.Health; + let credentials; if (ignoreTlsErrors) { credentials = this.grpc.credentials.createSsl(null, null, null, { @@ -89,8 +97,12 @@ export class GrpcProvider implements IStatusProvider { } else { credentials = this.grpc.credentials.createInsecure(); } - const client = new HealthClient(target, credentials); - const deadline = new Date(Date.now() + 10000); // 10s timeout + + const client = new healthService(target, credentials); + + const TIMEOUT_MS = 10000; + const deadline = new Date(Date.now() + TIMEOUT_MS); + const { response, responseTime, error } = await timeRequest(() => { return new Promise((resolve, reject) => { client.Check({ service: grpcServiceName }, { deadline }, (err: unknown, response: unknown) => { @@ -128,51 +140,32 @@ export class GrpcProvider implements IStatusProvider { }); if (error) { - let grpcCode = -1; - let message = error instanceof Error ? error.message : "gRPC health check failed"; - - if (this.isGrpcError(error)) { - grpcCode = error.code; - message = error.details || error.message; - } - + const grpcError = error as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; + const payload = grpcError.grpcPayload; return { monitorId: id, teamId: teamId, type: type, status: false, - code: 500, - message: message, + code: grpcError.grpcCode ?? 5000, + message: grpcError.message ?? "gRPC health check failed", responseTime, - payload: { - grpcStatusCode: grpcCode, - grpcStatusName: this.getGrpcStatusName(grpcCode), - serviceName: grpcServiceName, - servingStatus: "UNKNOWN", - }, + payload: payload ?? null, }; } - if (!response) { - throw new Error("No response received from gRPC health check"); - } - - const isServing = response.servingStatus === "SERVING"; + const grpcPayload = response as GrpcStatusPayload; + const isServing = grpcPayload.servingStatus === "SERVING"; return { monitorId: id, teamId: teamId, type: type, status: isServing, - code: isServing ? 200 : NETWORK_ERROR, - message: isServing ? "gRPC health check successful" : "gRPC health check failed", + code: isServing ? 200 : 5000, + message: isServing ? `gRPC service healthy (${grpcPayload.servingStatus})` : `gRPC service unhealthy (${grpcPayload.servingStatus})`, responseTime, - payload: { - grpcStatusCode: isServing ? 200 : NETWORK_ERROR, - grpcStatusName: response.grpcStatusName, - serviceName: response.serviceName, - servingStatus: response.servingStatus || "UNKNOWN", - }, + payload: grpcPayload, }; } catch (err: unknown) { const originalMessage = err instanceof Error ? err.message : String(err); diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index e373e2cff..42fa39a40 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -6,6 +6,7 @@ import { MonitorStatusResponse } from "@/types/network.js"; import { Agent as HttpsAgent } from "https"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; +import CacheableLookup from "cacheable-lookup"; export class HttpProvider implements IStatusProvider { readonly type = "http"; @@ -13,7 +14,16 @@ export class HttpProvider implements IStatusProvider { constructor( private got: Got, private advancedMatcher: AdvancedMatcher - ) {} + ) { + const cacheable = new CacheableLookup(); + this.got = got.extend({ + dnsCache: cacheable, + timeout: { + request: 30000, + }, + retry: { limit: 1 }, + }); + } supports(type: MonitorType) { return type === "http"; diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 1a0d71553..7b7c739d8 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -1,5 +1,4 @@ -import { HTTPError, RequestError } from "got"; -import type { Got, Response } from "got"; +import type { Got } from "got"; import type { Monitor, MonitorStatusResponse, @@ -14,28 +13,12 @@ import type { } from "@/types/index.js"; import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; -import path from "path"; -import { fileURLToPath } from "url"; -import { MonitorStatusResponseOverrides } from "@/types/index.js"; -import CacheableLookup from "cacheable-lookup"; import { ISettingsService } from "../system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService"; -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 interface INetworkService { readonly serviceName: string; requestStatus( @@ -82,19 +65,7 @@ class NetworkService implements INetworkService { private PING_ERROR: number; private axios: AxiosStatic; - private got: Got; - private https: typeof import("https"); - private jmespath: { search: (data: unknown, expression: string) => unknown }; - private GameDig: { query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }> }; - private ping: typeof import("ping"); private logger: ILogger; - private Docker: typeof import("dockerode"); - private net: typeof import("net"); - private settingsService: ISettingsService; - private grpc: typeof import("@grpc/grpc-js"); - private protoLoader: typeof import("@grpc/proto-loader"); - - // New providers private pingProvider; private httpProvider; private pageSpeedProvider; @@ -102,83 +73,19 @@ class NetworkService implements INetworkService { private dockerProvider; private portProvider; private gameProvider; - - private 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: this.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 ?? this.NETWORK_ERROR; - statusResponse.message = error.message; - statusResponse.responseTime = error.timings?.phases?.total ?? 0; - statusResponse.timings = error.timings; - } - return { ...statusResponse, ...(overrides ?? {}) } as MonitorStatusResponse; - } - - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: response?.ok ?? false, - code: response?.statusCode ?? this.NETWORK_ERROR, - message: response?.statusMessage ?? "", - responseTime: response?.timings?.phases?.total ?? 0, - timings: response?.timings, - payload: payload ?? response?.body, - jsonPath, - matchMethod, - expectedValue, - extracted, - ...(overrides ?? {}), - } as MonitorStatusResponse; - }; + private grpcProvider; constructor( axios: AxiosStatic, - got: Got, - https: typeof import("https"), - jmespath: { search: (data: unknown, expression: string) => unknown }, - GameDig: { query: (options: { type: string; host: string; port?: number }) => Promise<{ ping?: number } & { [key: string]: unknown }> }, - ping: typeof import("ping"), logger: ILogger, - Docker: typeof import("dockerode"), - net: typeof import("net"), - settingsService: ISettingsService, - grpc: typeof import("@grpc/grpc-js"), - protoLoader: typeof import("@grpc/proto-loader"), - - // New providers pingProvider: IStatusProvider, httpProvider: IStatusProvider, pagespeedProvider: IStatusProvider, hardwareProvider: IStatusProvider, dockerProvider: IStatusProvider, portProvider: IStatusProvider, - gameProvider: IStatusProvider + gameProvider: IStatusProvider, + grpcProvider: IStatusProvider ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; @@ -192,18 +99,8 @@ class NetworkService implements INetworkService { this.NETWORK_ERROR = 5000; this.PING_ERROR = 5001; this.axios = axios; - this.https = https; - this.jmespath = jmespath; - this.GameDig = GameDig; - this.ping = ping; this.logger = logger; - this.Docker = Docker; - this.net = net; - this.settingsService = settingsService; - this.grpc = grpc; - this.protoLoader = protoLoader; - // New providers this.pingProvider = pingProvider; this.httpProvider = httpProvider; this.pageSpeedProvider = pagespeedProvider; @@ -211,34 +108,13 @@ class NetworkService implements INetworkService { this.dockerProvider = dockerProvider; this.portProvider = portProvider; this.gameProvider = gameProvider; - const cacheable = new CacheableLookup(); - - this.got = got.extend({ - dnsCache: cacheable, - timeout: { - request: 30000, - }, - retry: { limit: 1 }, - }); + this.grpcProvider = grpcProvider; } get serviceName(): string { return NetworkService.SERVICE_NAME; } - // Helper functions - private async timeRequest(operation: () => Promise): Promise<{ response: T | null; responseTime: number; error: unknown }> { - const start = process.hrtime.bigint(); - try { - const response = await operation(); - const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000); - return { response, responseTime: elapsedMs, error: null }; - } catch (error) { - const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000); - return { response: null, responseTime: elapsedMs, error }; - } - } - // Main entry point async requestStatus( monitor: Monitor @@ -271,169 +147,12 @@ class NetworkService implements INetworkService { case this.TYPE_GAME: return await this.gameProvider.handle(monitor); case this.TYPE_GRPC: - return await this.requestGrpc(monitor); + return await this.grpcProvider.handle(monitor); default: return this.handleUnsupportedType(type); } } - 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) as unknown as { - grpc: { - health: { - v1: { - Health: new ( - target: string, - credentials: unknown - ) => { - Check: (request: { service: string }, options: { deadline: Date }, callback: (err: unknown, response: unknown) => void) => void; - close: () => void; - }; - }; - }; - }; - }; - 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: unknown, response: unknown) => { - client.close(); - - if (err) { - const grpcErr = err as { code?: number; details?: string; message?: string }; - const payload: GrpcStatusPayload = { - grpcStatusCode: grpcErr.code ?? -1, - grpcStatusName: this.getGrpcStatusName(grpcErr.code ?? -1), - serviceName: grpcServiceName, - servingStatus: "UNKNOWN", - }; - const grpcError = new AppError({ - message: grpcErr.details || grpcErr.message || "gRPC error", - service: this.SERVICE_NAME, - method: "requestGrpc", - }) as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; - grpcError.grpcPayload = payload; - grpcError.grpcCode = grpcErr.code; - reject(grpcError); - return; - } - - const resp = response as { status?: string } | undefined; - const servingStatus = resp?.status ?? "UNKNOWN"; - resolve({ - grpcStatusCode: 0, - grpcStatusName: "OK", - serviceName: grpcServiceName, - servingStatus, - }); - }); - }); - }); - - if (error) { - const grpcError = error as AppError & { grpcPayload?: GrpcStatusPayload; grpcCode?: number }; - const payload = grpcError.grpcPayload; - 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: unknown) { - const originalMessage = err instanceof Error ? err.message : String(err); - throw new AppError({ - message: originalMessage || "Error performing gRPC health check", - service: this.SERVICE_NAME, - method: "requestGrpc", - details: { url: monitor.url, port: monitor.port, grpcServiceName: monitor.grpcServiceName }, - }); - } - } - - 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", From f8c0b57d212882ed36c9d5317f43d05343aa82d5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 5 Mar 2026 19:37:32 +0000 Subject: [PATCH 12/12] remove unused imports --- server/src/service/infrastructure/networkService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 7b7c739d8..2d6c3fe08 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -1,4 +1,3 @@ -import type { Got } from "got"; import type { Monitor, MonitorStatusResponse, @@ -14,7 +13,6 @@ import type { import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; -import { ISettingsService } from "../system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService";