From 82175f166feedf607c65ccc8ea8f444e86104752 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Mar 2026 23:24:48 +0000 Subject: [PATCH] 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">>;