diff --git a/client/src/Pages/Incidents2/index.jsx b/client/src/Pages/Incidents2/index.jsx index 966388b51..2f31864bf 100644 --- a/client/src/Pages/Incidents2/index.jsx +++ b/client/src/Pages/Incidents2/index.jsx @@ -87,8 +87,8 @@ const Incidents2 = () => { useEffect(() => { const lookup = monitors?.reduce((acc, monitor) => { - acc[monitor._id] = { - _id: monitor._id, + acc[monitor.id] = { + _id: monitor.id, name: monitor.name, type: monitor.type, }; diff --git a/server/jest.config.ts b/server/jest.config.ts index 2bcfcbf21..fd0c87ad7 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -8,6 +8,11 @@ const config: Config = { "^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }], }, moduleNameMapper: { + "^@/validation/(.*)\\.js$": "/src/validation/$1.js", + "^@/utils/(AppError)\\.js$": "/src/utils/$1.ts", + "^@/utils/(.*)\\.js$": "/src/utils/$1.js", + "^@/(.*)\\.ts$": "/src/$1.ts", + "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", }, testMatch: ["/test/**/*.test.ts"], diff --git a/server/src/config/services.ts b/server/src/config/services.ts index c526a8abb..af49cc2d4 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -155,7 +155,7 @@ export const initializeServices = async ({ // Repositories const monitorsRepository = new MongoMonitorsRepository(); - const checksRepository = new MongoChecksRepository(); + const checksRepository = new MongoChecksRepository(logger); const monitorStatsRepository = new MongoMonitorStatsRepository(); const statusPagesRepository = new MongoStatusPagesRepository(); @@ -183,7 +183,17 @@ export const initializeServices = async ({ stringService, }); - const bufferService = new BufferService({ db, logger, envSettings, incidentService }); + const checkService = new CheckService({ + db, + settingsService, + stringService, + errorService, + monitorsRepository, + logger, + checksRepository, + }); + + const bufferService = new BufferService({ db, logger, envSettings, incidentService, checkService }); const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService, monitorsRepository }); @@ -208,11 +218,11 @@ export const initializeServices = async ({ networkService, statusService, notificationService, + checkService, + buffer: bufferService, }); const superSimpleQueue = await SuperSimpleQueue.create({ - envSettings, - db, logger, helper: superSimpleQueueHelper, monitorsRepository, @@ -231,13 +241,7 @@ export const initializeServices = async ({ jobQueue: superSimpleQueue, monitorsRepository, }); - const checkService = new CheckService({ - db, - settingsService, - stringService, - errorService, - monitorsRepository, - }); + const diagnosticService = new DiagnosticService(); const inviteService = new InviteService({ db, diff --git a/server/src/db/models/Check.ts b/server/src/db/models/Check.ts index c176aa3bb..94dfa873c 100644 --- a/server/src/db/models/Check.ts +++ b/server/src/db/models/Check.ts @@ -11,8 +11,7 @@ import type { CheckMemoryInfo, CheckMetadata, CheckNetworkInterfaceInfo, - CheckTimings, - CheckTimingPhases, + GotTimings, ILighthouseAudit, } from "@/types/check.js"; @@ -35,7 +34,7 @@ interface CheckDocument extends CheckDocumentBase { _id: Types.ObjectId; } -const timingPhasesSchema = new Schema( +const timingPhasesSchema = new Schema( { wait: { type: Number, default: 0 }, dns: { type: Number, default: 0 }, @@ -49,7 +48,7 @@ const timingPhasesSchema = new Schema( { _id: false } ); -const timingsSchema = new Schema( +const timingsSchema = new Schema( { start: { type: Number, default: 0 }, socket: { type: Number, default: 0 }, diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/repositories/checks/IChecksRepository.ts index 19faa0159..7b98ae7b6 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/repositories/checks/IChecksRepository.ts @@ -1,4 +1,4 @@ -import type { Check, CheckAudits, MonitorType } from "@/types/index.js"; +import type { Check, MonitorType } from "@/types/index.js"; import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js"; export interface PageSpeedChecksResult { @@ -55,6 +55,8 @@ export interface UptimeChecksResult { export interface IChecksRepository { // create + createChecks(checks: Check[]): Promise; + // single fetch // collection fetch findLatestChecksByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise; diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index 3816d8cc5..7707a7b17 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -10,18 +10,27 @@ import type { CheckMemoryInfo, CheckMetadata, CheckNetworkInterfaceInfo, - CheckTimings, + GotTimings, MonitorType, } from "@/types/index.js"; import { CheckModel, type CheckDocument } from "@/db/models/index.js"; import mongoose from "mongoose"; +const SERVICE_NAME = "StatusService"; + export type LatestChecksMap = Record; type DateRange = { start: Date; end: Date }; type HardwareAggregateData = { latestCheck: CheckDocument | null; totalChecks: number }; type HardwareUpChecks = { totalChecks: number }; class MongoChecksRepository implements IChecksRepository { + static SERVICE_NAME = SERVICE_NAME; + + private logger: any; + constructor(logger: any) { + this.logger = logger; + } + private toEntity = (doc: CheckDocument): Check => { const toStringId = (value: mongoose.Types.ObjectId | string | undefined | null): string => { if (!value) { @@ -44,7 +53,7 @@ class MongoChecksRepository implements IChecksRepository { return toDateString(value); }; - const mapTimings = (timings?: CheckTimings): CheckTimings => { + const mapTimings = (timings?: GotTimings): GotTimings => { const phases = timings?.phases ?? { wait: 0, dns: 0, @@ -133,11 +142,11 @@ class MongoChecksRepository implements IChecksRepository { return undefined; } return { - cls: audits.cls ?? 0, - si: audits.si ?? 0, - fcp: audits.fcp ?? 0, - lcp: audits.lcp ?? 0, - tbt: audits.tbt ?? 0, + cls: audits.cls, + si: audits.si, + fcp: audits.fcp, + lcp: audits.lcp, + tbt: audits.tbt, }; }; @@ -176,6 +185,10 @@ class MongoChecksRepository implements IChecksRepository { }; }; + createChecks = async (checks: Check[]) => { + return await CheckModel.insertMany(checks); + }; + findLatestChecksByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { if (monitorIds.length === 0) { return {}; diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts index 61e2fb6ad..3e6c727b5 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -1,4 +1,6 @@ -import { IMonitorsRepository } from "@/repositories/index.js"; +import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js"; +import type { MonitorType, MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js"; +import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; const SERVICE_NAME = "checkService"; @@ -10,31 +12,114 @@ class CheckService { private stringService: any; private errorService: any; private monitorsRepository: IMonitorsRepository; - + private checksRepository: IChecksRepository; + private logger: any; constructor({ db, settingsService, stringService, errorService, monitorsRepository, + logger, + checksRepository, }: { db: any; settingsService: any; stringService: any; errorService: any; monitorsRepository: IMonitorsRepository; + logger: any; + checksRepository: IChecksRepository; }) { this.db = db; this.settingsService = settingsService; this.stringService = stringService; this.errorService = errorService; this.monitorsRepository = monitorsRepository; + this.logger = logger; + this.checksRepository = checksRepository; } get serviceName() { return CheckService.SERVICE_NAME; } + createChecks = async (checks: Check[]) => { + return this.checksRepository.createChecks(checks); + }; + + buildCheck = (statusResponse: MonitorStatusResponse) => { + const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse; + + const check: Partial = { + metadata: { + monitorId, + teamId, + type, + }, + status, + statusCode: code, + responseTime: responseTime || 0, + timings: timings, + message, + }; + + if (type === "pagespeed") { + const pageSpeedPayload = payload as PageSpeedStatusPayload | undefined; + if (!pageSpeedPayload) { + this.logger.warn({ + message: "Failed to build check", + service: SERVICE_NAME, + method: "buildCheck", + details: "empty payload", + }); + return undefined; + } + const categories = pageSpeedPayload.lighthouseResult?.categories ?? {}; + const audits = pageSpeedPayload.lighthouseResult?.audits ?? {}; + const mapAudit = (audit: any) => { + if (!audit || typeof audit !== "object") { + return undefined; + } + return { + id: audit.id, + title: audit.title, + score: typeof audit.score === "number" ? audit.score : (audit.score ?? null), + displayValue: audit.displayValue, + numericValue: typeof audit.numericValue === "number" ? audit.numericValue : undefined, + numericUnit: audit.numericUnit, + }; + }; + check.accessibility = (categories?.accessibility?.score || 0) * 100; + check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100; + check.seo = (categories?.seo?.score || 0) * 100; + check.performance = (categories?.performance?.score || 0) * 100; + check.audits = { + cls: mapAudit(audits?.["cumulative-layout-shift"]), + si: mapAudit(audits?.["speed-index"]), + fcp: mapAudit(audits?.["first-contentful-paint"]), + lcp: mapAudit(audits?.["largest-contentful-paint"]), + tbt: mapAudit(audits?.["total-blocking-time"]), + }; + } + + if (type === "hardware") { + const hardwarePayload = payload as HardwareStatusPayload | undefined; + const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {}; + const errorsSource = Array.isArray(hardwarePayload?.errors) + ? hardwarePayload?.errors + : (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors; + check.cpu = cpu; + check.memory = memory; + check.disk = disk; + check.host = host; + check.errors = errorsSource; + check.capture = hardwarePayload?.capture; + check.net = net; + } + return check; + }; + getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => { if (!monitorId) { throw this.errorService.createBadRequestError("No monitor ID in request"); diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 4793f055b..a094f8b01 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -1 +1,2 @@ export * from "@/service/business/monitorService.js"; +export * from "@/service/infrastructure/networkService.js"; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js deleted file mode 100644 index a6637d4cc..000000000 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js +++ /dev/null @@ -1,177 +0,0 @@ -import Scheduler from "super-simple-scheduler"; -const SERVICE_NAME = "JobQueue"; - -class SuperSimpleQueue { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ envSettings, db, logger, helper, monitorsRepository }) { - this.envSettings = envSettings; - this.db = db; - this.logger = logger; - this.helper = helper; - this.monitorsRepository = monitorsRepository; - } - - get serviceName() { - return SuperSimpleQueue.SERVICE_NAME; - } - - static async create({ envSettings, db, logger, helper, monitorsRepository }) { - const instance = new SuperSimpleQueue({ envSettings, db, logger, helper, monitorsRepository }); - await instance.init(); - return instance; - } - - init = async () => { - try { - this.scheduler = new Scheduler({ - // storeType: "mongo", - // storeType: "redis", - logLevel: "debug", - debug: true, - // dbUri: this.envSettings.dbConnectionString, - }); - this.scheduler.start(); - - this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); - const monitors = await this.monitorsRepository.findAll(); - for (const monitor of monitors) { - const randomOffset = Math.floor(Math.random() * 100); - setTimeout(() => { - this.addJob(monitor.id, monitor); - }, randomOffset); - } - - return true; - } catch (error) { - this.logger.error({ - message: "Failed to initialize SuperSimpleQueue", - service: SERVICE_NAME, - method: "init", - details: error, - }); - return false; - } - }; - - addJob = async (monitorId, monitor) => { - this.scheduler.addJob({ - id: monitorId, - template: "monitor-job", - repeat: monitor.interval, - active: monitor.isActive, - data: monitor, - }); - }; - - deleteJob = async (monitor) => { - this.scheduler.removeJob(monitor.id); - }; - - pauseJob = async (monitor) => { - const result = this.scheduler.pauseJob(monitor.id); - if (result === false) { - throw new Error("Failed to resume monitor"); - } - this.logger.debug({ - message: `Paused monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - }; - - resumeJob = async (monitor) => { - const result = this.scheduler.resumeJob(monitor.id); - if (result === false) { - throw new Error("Failed to resume monitor"); - } - this.logger.debug({ - message: `Resumed monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - }; - - updateJob = async (monitor) => { - this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); - }; - - shutdown = async () => { - this.scheduler.stop(); - }; - - getMetrics = async () => { - const jobs = await this.scheduler.getJobs(); - const metrics = jobs.reduce( - (acc, job) => { - acc.totalRuns += job.runCount || 0; - acc.totalFailures += job.failCount || 0; - acc.jobs++; - if (job.failCount > 0 && job.lastFailedAt >= job.lsatRunAt) { - acc.failingJobs++; - } - - if (job.lockedAt) { - acc.activeJobs++; - } - - if (job.failCount > 0) { - acc.jobsWithFailures.push({ - monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, - failedAt: job.lastFailedAt, - failCount: job.failCount, - failReason: job.lastFailReason, - }); - } - return acc; - }, - { - jobs: 0, - activeJobs: 0, - failingJobs: 0, - jobsWithFailures: [], - totalRuns: 0, - totalFailures: 0, - } - ); - return metrics; - }; - - getJobs = async () => { - const jobs = await this.scheduler.getJobs(); - return jobs.map((job) => { - return { - monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, - monitorInterval: job?.data?.interval || null, - active: job.active, - lockedAt: job.lockedAt, - runCount: job.runCount || 0, - failCount: job.failCount || 0, - failReason: job.lastFailReason, - lastRunAt: job.lastRunAt, - lastFinishedAt: job.lastFinishedAt, - lastRunTook: job.lockedAt ? null : job.lastFinishedAt - job.lastRunAt, - lastFailedAt: job.lastFailedAt, - }; - }); - }; - - flushQueues = async () => { - const stopRes = this.scheduler.stop(); - const flushRes = this.scheduler.flushJobs(); - const initRes = await this.init(); - return { - success: stopRes && flushRes && initRes, - }; - }; - - obliterate = async () => { - console.log("obliterate not implemented"); - }; -} - -export default SuperSimpleQueue; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts new file mode 100644 index 000000000..8fd55ff70 --- /dev/null +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -0,0 +1,254 @@ +import { IMonitorsRepository } from "@/repositories/index.js"; +import Scheduler from "super-simple-scheduler"; +const SERVICE_NAME = "JobQueue"; + +type QueueJobFailure = { + monitorId: string | number; + monitorUrl: string | null; + monitorType: string | null; + failedAt: number | null; + failCount: number; + failReason: string | null; +}; + +type QueueMetrics = { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: QueueJobFailure[]; + totalRuns: number; + totalFailures: number; +}; + +type QueueJobSummary = { + monitorId: string | number; + monitorUrl: string | null; + monitorType: string | null; + monitorInterval: number | null; + active: boolean; + lockedAt: number | null; + runCount: number; + failCount: number; + failReason: string | null; + lastRunAt: number | null; + lastFinishedAt: number | null; + lastRunTook: number | null; + lastFailedAt: number | null; +}; + +export interface ISuperSimpleQueue { + readonly serviceName: string; + init(): Promise; + addJob(monitorId: string, monitor: any): Promise; + deleteJob(monitor: any): Promise; + pauseJob(monitor: any): Promise; + resumeJob(monitor: any): Promise; + updateJob(monitor: any): Promise; + shutdown(): Promise; + getMetrics(): Promise; + getJobs(): Promise; + flushQueues(): Promise<{ success: boolean }>; + obliterate(): Promise; +} + +class SuperSimpleQueue implements ISuperSimpleQueue { + static SERVICE_NAME = SERVICE_NAME; + + private logger: any; + private helper: any; + private monitorsRepository: IMonitorsRepository; + private readonly scheduler: Scheduler; + + constructor({ + logger, + helper, + monitorsRepository, + scheduler, + }: { + logger: any; + helper: any; + monitorsRepository: IMonitorsRepository; + scheduler: Scheduler; + }) { + this.logger = logger; + this.helper = helper; + this.monitorsRepository = monitorsRepository; + this.scheduler = scheduler; + } + + get serviceName() { + return SuperSimpleQueue.SERVICE_NAME; + } + + static async create({ logger, helper, monitorsRepository }: { logger: any; helper: any; monitorsRepository: IMonitorsRepository }) { + const scheduler = new Scheduler({ + // storeType: "mongo", + // storeType: "redis", + logLevel: "debug", + // dbUri: envSettings.dbConnectionString, + }); + const instance = new SuperSimpleQueue({ logger, helper, monitorsRepository, scheduler }); + await instance.init(); + return instance; + } + + init = async () => { + try { + this.scheduler.start(); + + this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); + const monitors = await this.monitorsRepository.findAll(); + if (!monitors) { + return true; + } + for (const monitor of monitors) { + const randomOffset = Math.floor(Math.random() * 100); + setTimeout(() => { + this.addJob(monitor.id, monitor); + }, randomOffset); + } + + return true; + } catch (error) { + this.logger.error({ + message: "Failed to initialize SuperSimpleQueue", + service: SERVICE_NAME, + method: "init", + details: error, + }); + return false; + } + }; + + addJob = async (monitorId: string, monitor: any) => { + this.scheduler.addJob({ + id: monitorId, + template: "monitor-job", + repeat: monitor.interval, + active: monitor.isActive, + data: monitor, + }); + }; + + deleteJob = async (monitor: any) => { + this.scheduler.removeJob(monitor.id); + }; + + pauseJob = async (monitor: any) => { + const result = await this.scheduler.pauseJob(monitor.id); + if (result === false) { + throw new Error("Failed to resume monitor"); + } + this.logger.debug({ + message: `Paused monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + }; + + resumeJob = async (monitor: any) => { + const result = await this.scheduler.resumeJob(monitor.id); + if (result === false) { + throw new Error("Failed to resume monitor"); + } + this.logger.debug({ + message: `Resumed monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + }; + + updateJob = async (monitor: any) => { + this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); + }; + + shutdown = async () => { + this.scheduler.stop(); + }; + + getMetrics = async () => { + const jobs = await this.scheduler.getJobs(); + const metrics = jobs.reduce( + (acc, job) => { + const runCount = job.runCount ?? 0; + const failCount = job.failCount ?? 0; + const lastFailedAt = job.lastFailedAt ?? 0; + const lastRunAt = job.lastRunAt ?? 0; + acc.totalRuns += runCount; + acc.totalFailures += failCount; + acc.jobs++; + if (failCount > 0 && lastFailedAt >= lastRunAt) { + acc.failingJobs++; + } + + if (job.lockedAt) { + acc.activeJobs++; + } + + if (failCount > 0) { + acc.jobsWithFailures.push({ + monitorId: job.id, + monitorUrl: job?.data?.url || null, + monitorType: job?.data?.type || null, + failedAt: job.lastFailedAt ?? null, + failCount, + failReason: job.lastFailReason ?? null, + }); + } + return acc; + }, + { + jobs: 0, + activeJobs: 0, + failingJobs: 0, + jobsWithFailures: [] as Array<{ + monitorId: string | number; + monitorUrl: any; + monitorType: any; + failedAt: number | null; + failCount: number; + failReason: string | null; + }>, + totalRuns: 0, + totalFailures: 0, + } + ); + return metrics; + }; + + getJobs = async () => { + const jobs = await this.scheduler.getJobs(); + return jobs.map((job) => { + return { + monitorId: job.id, + monitorUrl: job?.data?.url || null, + monitorType: job?.data?.type || null, + monitorInterval: job?.data?.interval || null, + active: job.active, + lockedAt: job.lockedAt ?? null, + runCount: job.runCount ?? 0, + failCount: job.failCount ?? 0, + failReason: job.lastFailReason ?? null, + lastRunAt: job.lastRunAt ?? null, + lastFinishedAt: job.lastFinishedAt ?? null, + lastRunTook: job.lockedAt ? null : (job.lastFinishedAt ?? 0) - (job.lastRunAt ?? 0), + lastFailedAt: job.lastFailedAt ?? null, + }; + }); + }; + + flushQueues = async () => { + const stopRes = await this.scheduler.stop(); + const flushRes = await this.scheduler.flushJobs(); + const initRes = await this.init(); + return { + success: Boolean(stopRes && flushRes && initRes), + }; + }; + + obliterate = async () => { + console.log("obliterate not implemented"); + }; +} + +export default SuperSimpleQueue; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts similarity index 57% rename from server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js rename to server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 3cd6b37a8..891635d56 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -1,23 +1,42 @@ const SERVICE_NAME = "JobQueueHelper"; - +import type { Monitor } from "@/types/monitor.js"; +import { AppError } from "@/utils/AppError.js"; +import { INetworkService } from "@/service/index.js"; class SuperSimpleQueueHelper { static SERVICE_NAME = SERVICE_NAME; - /** - * @param {{ - * db: import("../database.js").Database, - * logger: import("../logger.js").Logger, - * networkService: import("../networkService.js").NetworkService, - * statusService: import("../statusService.js").StatusService, - * notificationService: import("../notificationService.js").NotificationService - * }} - */ - constructor({ db, logger, networkService, statusService, notificationService }) { + private db: any; + private logger: any; + private networkService: INetworkService; + private statusService: any; + private notificationService: any; + private checkService: any; + private buffer: any; + + constructor({ + db, + logger, + networkService, + statusService, + notificationService, + checkService, + buffer, + }: { + db: any; + logger: any; + networkService: INetworkService; + statusService: any; + notificationService: any; + checkService: any; + buffer: any; + }) { this.db = db; this.logger = logger; this.networkService = networkService; this.statusService = statusService; this.notificationService = notificationService; + this.checkService = checkService; + this.buffer = buffer; } get serviceName() { @@ -25,39 +44,48 @@ class SuperSimpleQueueHelper { } getMonitorJob = () => { - return async (monitor) => { + return async (monitor: Monitor) => { try { const monitorId = monitor.id; const teamId = monitor.teamId; if (!monitorId) { - throw new Error("No monitor id"); + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" }); } + // Step 1. Check for maintenacne window, if found, skip the check const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); if (maintenanceWindowActive) { - this.logger.info({ + this.logger.debug({ message: `Monitor ${monitorId} is in maintenance window`, service: SERVICE_NAME, method: "getMonitorJob", }); return; } - const networkResponse = await this.networkService.requestStatus(monitor); - if (!networkResponse) { + // Step 2. Request monitor status + const status = await this.networkService.requestStatus(monitor); + if (!status) { throw new Error("No network response"); } - const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse); + // Step 3. Build check + const check = await this.checkService.buildCheck(status); + + // Step 4 Add check to buffer + this.buffer.addToBuffer({ check }); + + // Step 4. Update monitor status + const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); this.notificationService .handleNotifications({ - ...networkResponse, - monitor: updatedMonitor, - prevStatus, - statusChanged, + ...status, + monitor: statusChangeResult.monitor, + prevStatus: statusChangeResult.prevStatus, + statusChanged: statusChangeResult.statusChanged, }) - .catch((error) => { + .catch((error: any) => { this.logger.error({ message: error.message, service: SERVICE_NAME, @@ -66,7 +94,7 @@ class SuperSimpleQueueHelper { stack: error.stack, }); }); - } catch (error) { + } catch (error: any) { this.logger.warn({ message: error.message, service: error.service || SERVICE_NAME, @@ -78,13 +106,13 @@ class SuperSimpleQueueHelper { }; }; - async isInMaintenanceWindow(monitorId, teamId) { + async isInMaintenanceWindow(monitorId: string, teamId: string) { const maintenanceWindows = await this.db.maintenanceWindowModule.getMaintenanceWindowsByMonitorId({ - monitorId: monitorId.toString(), - teamId: teamId.toString(), + monitorId: monitorId, + teamId: teamId, }); // Check for active maintenance window: - const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => { + const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: any, window: any) => { if (window.active) { const start = new Date(window.start); const end = new Date(window.end); diff --git a/server/src/service/infrastructure/bufferService.js b/server/src/service/infrastructure/bufferService.ts similarity index 71% rename from server/src/service/infrastructure/bufferService.js rename to server/src/service/infrastructure/bufferService.ts index a218f56a0..5a809eea5 100755 --- a/server/src/service/infrastructure/bufferService.js +++ b/server/src/service/infrastructure/bufferService.ts @@ -1,15 +1,38 @@ import { config } from "@/config/index.js"; +import type { Check } from "@/types/index.js"; const SERVICE_NAME = "BufferService"; class BufferService { static SERVICE_NAME = SERVICE_NAME; - constructor({ db, logger, envSettings, incidentService }) { - console.log(envSettings); + private BUFFER_TIMEOUT: number; + private db: any; + private logger: any; + private incidentService: any; + private SERVICE_NAME: string; + private buffer: any[]; + private incidentBuffer: any[]; + private bufferTimer: NodeJS.Timeout | null = null; + private checksService: any; + + constructor({ + db, + logger, + envSettings, + incidentService, + checkService, + }: { + db: any; + logger: any; + envSettings: any; + incidentService: any; + checkService: any; + }) { this.BUFFER_TIMEOUT = config.NODE_ENV === "development" ? 10 : 1000 * 60 * 1; // 1 minute this.db = db; this.logger = logger; this.incidentService = incidentService; + this.checksService = checkService; this.SERVICE_NAME = SERVICE_NAME; this.buffer = []; this.incidentBuffer = []; @@ -25,10 +48,10 @@ class BufferService { return BufferService.SERVICE_NAME; } - addToBuffer({ check }) { + addToBuffer({ check }: { check: Check }) { try { this.buffer.push(check); - } catch (error) { + } catch (error: any) { this.logger.error({ message: error.message, service: this.SERVICE_NAME, @@ -38,7 +61,7 @@ class BufferService { } } - addIncidentToBuffer({ monitor, check, action = "create" }) { + addIncidentToBuffer({ monitor, check, action = "create" }: { monitor: any; check: Check; action?: string }) { try { if (!monitor || !check) { this.logger.warn({ @@ -50,7 +73,7 @@ class BufferService { } this.incidentBuffer.push({ monitor, check, action }); - } catch (error) { + } catch (error: any) { this.logger.error({ message: error.message, service: this.SERVICE_NAME, @@ -60,20 +83,20 @@ class BufferService { } } - removeCheckFromBuffer(checkToRemove) { + removeCheckFromBuffer(checkToRemove: Check) { try { if (!checkToRemove) { return false; } const index = this.buffer.findIndex((check) => { - if (checkToRemove._id && check._id) { - return check._id.toString() === checkToRemove._id.toString(); + if (checkToRemove.id && check.id) { + return check.id.toString() === checkToRemove.id.toString(); } return ( - check.monitorId?.toString() === checkToRemove.monitorId?.toString() && - check.teamId?.toString() === checkToRemove.teamId?.toString() && - check.type === checkToRemove.type && + check.monitorId?.toString() === checkToRemove.metadata.monitorId && + check.teamId?.toString() === checkToRemove.metadata.teamId && + check.type === checkToRemove.metadata.type && check.status === checkToRemove.status && check.statusCode === checkToRemove.statusCode && check.responseTime === checkToRemove.responseTime && @@ -87,7 +110,7 @@ class BufferService { } return false; - } catch (error) { + } catch (error: any) { this.logger.error({ message: error.message, service: this.SERVICE_NAME, @@ -99,10 +122,13 @@ class BufferService { } scheduleNextFlush() { + if (this.bufferTimer) { + clearTimeout(this.bufferTimer); + } this.bufferTimer = setTimeout(async () => { try { await this.flushBuffer(); - } catch (error) { + } catch (error: any) { this.logger.error({ message: `Error in flush cycle: ${error.message}`, service: this.SERVICE_NAME, @@ -118,9 +144,9 @@ class BufferService { async flushBuffer() { try { if (this.buffer.length > 0) { - await this.db.checkModule.createChecks(this.buffer); + await this.checksService.createChecks(this.buffer); } - } catch (error) { + } catch (error: any) { this.logger.error({ message: error.message, service: this.SERVICE_NAME, @@ -133,7 +159,7 @@ class BufferService { if (this.incidentBuffer.length > 0 && this.incidentService) { await this.flushIncidentBuffer(); } - } catch (error) { + } catch (error: any) { this.logger.error({ message: error.message, service: this.SERVICE_NAME, @@ -154,7 +180,7 @@ class BufferService { try { const itemsToProcess = [...this.incidentBuffer]; await this.incidentService.processIncidentsFromBuffer(itemsToProcess); - } catch (error) { + } catch (error: any) { this.logger.error({ message: `Error flushing incident buffer: ${error.message}`, service: this.SERVICE_NAME, diff --git a/server/src/service/infrastructure/networkService.js b/server/src/service/infrastructure/networkService.ts similarity index 65% rename from server/src/service/infrastructure/networkService.js rename to server/src/service/infrastructure/networkService.ts index 2375e7233..e4e5972af 100644 --- a/server/src/service/infrastructure/networkService.js +++ b/server/src/service/infrastructure/networkService.ts @@ -1,10 +1,145 @@ +import { HTTPError, RequestError } from "got"; +import type { Got, Response } from "got"; +import type { Monitor, MonitorStatusResponse } from "@/types/index.js"; + import CacheableLookup from "cacheable-lookup"; const SERVICE_NAME = "NetworkService"; -class NetworkService { +type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>; + +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(monitor: Monitor): Promise; + requestWebhook(type: string, url: string, body: any): Promise<{ type: string; status: boolean; code: number; message: string; payload?: unknown }>; + requestPagerDuty(args: { message: string; routingKey: string; monitorUrl: string }): Promise; + requestMatrix(args: { homeserverUrl: string; accessToken: string; roomId: string; message: string }): Promise<{ + status: boolean; + code: number; + message: string; + payload?: unknown; + }>; +} + +class NetworkService implements INetworkService { static SERVICE_NAME = SERVICE_NAME; - constructor({ axios, got, https, jmespath, GameDig, ping, logger, http, Docker, net, stringService, settingsService }) { + private TYPE_PING: string; + private TYPE_HTTP: string; + private TYPE_PAGESPEED: string; + private TYPE_HARDWARE: string; + private TYPE_DOCKER: string; + private TYPE_PORT: string; + private TYPE_GAME: string; + private SERVICE_NAME: string; + private NETWORK_ERROR: number; + private PING_ERROR: number; + + private axios: any; + private got: Got; + private https: any; + private jmespath: any; + private GameDig: any; + private ping: any; + private logger: any; + private http: any; + private Docker: any; + private net: any; + private stringService: any; + private settingsService: any; + + 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 ?? {}) }; + } + + 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 ?? {}), + }; + }; + + constructor({ + axios, + got, + https, + jmespath, + GameDig, + ping, + logger, + http, + Docker, + net, + stringService, + settingsService, + }: { + axios: any; + got: Got; + https: any; + jmespath: any; + GameDig: any; + ping: any; + logger: any; + http: any; + Docker: any; + net: any; + stringService: any; + settingsService: any; + }) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; this.TYPE_PAGESPEED = "pagespeed"; @@ -17,11 +152,11 @@ class NetworkService { this.PING_ERROR = 5001; this.axios = axios; this.https = https; + this.http = http; this.jmespath = jmespath; this.GameDig = GameDig; this.ping = ping; this.logger = logger; - this.http = http; this.Docker = Docker; this.net = net; this.stringService = stringService; @@ -38,13 +173,17 @@ class NetworkService { }); } + get serviceName(): string { + return NetworkService.SERVICE_NAME; + } + // Helper functions - async timeRequest(operation) { + 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 }; + 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 }; @@ -52,7 +191,7 @@ class NetworkService { } // Main entry point - async requestStatus(monitor) { + async requestStatus(monitor: Monitor): Promise { const type = monitor?.type || "unknown"; switch (type) { case this.TYPE_PING: @@ -70,11 +209,11 @@ class NetworkService { case this.TYPE_GAME: return await this.requestGame(monitor); default: - return await this.handleUnsupportedType(type); + return this.handleUnsupportedType(type); } } - async requestPing(monitor) { + private async requestPing(monitor: Monitor): Promise { try { if (!monitor?.url) { throw new Error("Monitor URL is required"); @@ -94,44 +233,49 @@ class NetworkService { throw new Error("Ping failed - no result returned"); } - const pingResponse = { - monitorId: monitor.id, - type: "ping", - status: response.alive, - code: 200, - responseTime: response.time, - message: "Success", + const pingResponse = this.buildStatusResponse({ + monitor, payload: response, - }; + overrides: { + status: (response as { alive?: boolean })?.alive ?? false, + code: 200, + message: "Success", + responseTime: (response as { time?: number })?.time ?? 0, + payload: response, + }, + }); if (error) { pingResponse.status = false; - pingResponse.code = 200; + pingResponse.code = this.PING_ERROR; pingResponse.message = "Ping failed"; return pingResponse; } return pingResponse; - } catch (err) { + } catch (err: any) { err.service = this.SERVICE_NAME; err.method = "requestPing"; throw err; } } - async requestHttp(monitor) { + private async requestHttp(monitor: Monitor): Promise { const { url, secret, id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor; - const httpResponse = { - monitorId: id, - teamId: teamId, - type, - }; + 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 = { + const config: Record = { headers: secret ? { Authorization: `Bearer ${secret}` } : undefined, }; @@ -145,7 +289,7 @@ class NetworkService { const response = await this.got(url, config); - let payload; + let payload: any; const contentType = response.headers["content-type"]; if (contentType && contentType.includes("application/json")) { @@ -158,12 +302,14 @@ class NetworkService { payload = response.body; } - httpResponse.code = response.statusCode; - httpResponse.status = response.ok; - httpResponse.message = response.statusMessage; - httpResponse.responseTime = response.timings.phases.total || 0; - httpResponse.payload = payload; - httpResponse.timings = response.timings || {}; + Object.assign(httpResponse, { + code: response.statusCode, + status: response.ok, + message: response.statusMessage ?? "", + responseTime: response.timings.phases.total || 0, + payload, + timings: response.timings, + }); if (!expectedValue && !jsonPath) { return httpResponse; @@ -231,7 +377,7 @@ class NetworkService { } } return httpResponse; - } catch (err) { + } catch (err: any) { if (err.name === "HTTPError" || err.name === "RequestError") { httpResponse.code = err?.response?.statusCode || this.NETWORK_ERROR; httpResponse.status = false; @@ -247,7 +393,7 @@ class NetworkService { } } - async requestPageSpeed(monitor) { + private async requestPageSpeed(monitor: Monitor): Promise { try { const url = monitor.url; if (!url) { @@ -269,24 +415,24 @@ class NetworkService { ...monitor, url: pageSpeedUrl, }); - } catch (err) { + } catch (err: any) { err.service = this.SERVICE_NAME; err.method = "requestPageSpeed"; throw err; } } - async requestHardware(monitor) { + private async requestHardware(monitor: Monitor): Promise { try { return await this.requestHttp(monitor); - } catch (err) { + } catch (err: any) { err.service = this.SERVICE_NAME; err.method = "requestHardware"; throw err; } } - async requestDocker(monitor) { + private async requestDocker(monitor: Monitor): Promise { try { if (!monitor.url) { throw new Error("Monitor URL is required"); @@ -297,10 +443,14 @@ class NetworkService { handleError: true, // Enable error handling }); - const dockerResponse = { - monitorId: monitor.id, - type: monitor.type, - }; + const dockerResponse = this.buildStatusResponse({ + monitor, + overrides: { + status: false, + code: this.NETWORK_ERROR, + message: "No response", + }, + }); const containers = await docker.listContainers({ all: true }); @@ -309,18 +459,18 @@ class NetworkService { // Priority-based matching to avoid ambiguity: // 1. Exact full ID match (64-char) - let exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput); + let exactIdMatch = containers.find((c: any) => c.Id.toLowerCase() === normalizedInput); // 2. Exact container name match (case-insensitive) - let exactNameMatch = containers.find((c) => - c.Names.some((name) => { + let exactNameMatch = containers.find((c: any) => + c.Names.some((name: string) => { const cleanName = name.replace(/^\/+/, "").toLowerCase(); return cleanName === normalizedInput; }) ); // 3. Partial ID match (fallback for backwards compatibility) - let partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput)); + let partialIdMatch = containers.find((c: any) => c.Id.toLowerCase().startsWith(normalizedInput)); // Select container based on priority let targetContainer = exactIdMatch || exactNameMatch || partialIdMatch; @@ -353,14 +503,15 @@ class NetworkService { method: "requestDocker", details: { url: monitor.url }, }); - dockerResponse.status = 404; 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 } = await this.timeRequest(() => container.inspect()); + const { response, responseTime, error }: { response?: any; responseTime?: number; error?: any } = await this.timeRequest(() => + container.inspect() + ); dockerResponse.responseTime = responseTime; dockerResponse.status = response?.State?.Status === "running" ? true : false; @@ -375,14 +526,14 @@ class NetworkService { } return dockerResponse; - } catch (err) { + } catch (err: any) { err.service = this.SERVICE_NAME; err.method = "requestDocker"; throw err; } } - async requestPort(monitor) { + private async requestPort(monitor: Monitor): Promise { try { const { url, port } = monitor; const { response, responseTime, error } = await this.timeRequest(async () => { @@ -405,21 +556,22 @@ class NetworkService { reject(new Error("Connection timeout")); }); - socket.on("error", (err) => { + socket.on("error", (err: any) => { socket.destroy(); reject(err); }); }); }); - const portResponse = { - code: 200, - status: response.success, - message: this.stringService.portSuccess, - monitorId: monitor.id, - type: monitor.type, - responseTime: responseTime, - }; + const portResponse = this.buildStatusResponse({ + monitor, + overrides: { + code: 200, + status: (response as { success?: boolean })?.success ?? false, + message: this.stringService.portSuccess, + responseTime, + }, + }); if (error) { portResponse.code = this.NETWORK_ERROR; @@ -429,30 +581,31 @@ class NetworkService { } return portResponse; - } catch (error) { + } catch (error: any) { error.service = this.SERVICE_NAME; error.method = "requestTCP"; throw error; } } - async requestGame(monitor) { + private async requestGame(monitor: Monitor): Promise { try { const { url, port, gameId } = monitor; - const gameResponse = { - code: 200, - status: true, - message: "Success", - monitorId: monitor.id, - type: "game", - }; + const gameResponse = this.buildStatusResponse({ + monitor, + overrides: { + code: 200, + status: true, + message: "Success", + }, + }); const state = await this.GameDig.query({ type: gameId, host: url, port: port, - }).catch((error) => { + }).catch((error: any) => { this.logger.warn({ message: error.message, service: this.SERVICE_NAME, @@ -471,21 +624,25 @@ class NetworkService { gameResponse.responseTime = state.ping; gameResponse.payload = state; return gameResponse; - } catch (error) { + } catch (error: any) { error.service = this.SERVICE_NAME; error.method = "requestPing"; throw error; } } - async handleUnsupportedType(type) { - const err = new Error(`Unsupported type: ${type}`); - err.service = this.SERVICE_NAME; - err.method = "getStatus"; - throw err; + private async handleUnsupportedType(type: string): Promise { + return { + monitorId: "unknown", + teamId: "unknown", + type: "unknown", + status: false, + code: this.NETWORK_ERROR, + message: `Unsupported type: ${type}`, + }; } // Other network requests unrelated to monitoring: - async requestWebhook(type, url, body) { + async requestWebhook(type: string, url: string, body: any) { try { const response = await this.axios.post(url, body, { headers: { @@ -500,7 +657,7 @@ class NetworkService { message: `Successfully sent ${type} notification`, payload: response.data, }; - } catch (error) { + } catch (error: any) { this.logger.warn({ message: error.message, service: this.SERVICE_NAME, @@ -517,7 +674,7 @@ class NetworkService { } } - async requestPagerDuty({ message, routingKey, monitorUrl }) { + async requestPagerDuty({ message, routingKey, monitorUrl }: { message: string; routingKey: string; monitorUrl: string }) { try { const response = await this.axios.post(`https://events.pagerduty.com/v2/enqueue`, { routing_key: routingKey, @@ -532,7 +689,7 @@ class NetworkService { if (response?.data?.status !== "success") return false; return true; - } catch (error) { + } catch (error: any) { error.details = error.response?.data; error.service = this.SERVICE_NAME; error.method = "requestPagerDuty"; @@ -540,7 +697,17 @@ class NetworkService { } } - async requestMatrix({ homeserverUrl, accessToken, roomId, message }) { + async requestMatrix({ + homeserverUrl, + accessToken, + roomId, + message, + }: { + homeserverUrl: string; + accessToken: string; + roomId: string; + message: string; + }) { try { const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`; const body = { @@ -560,7 +727,7 @@ class NetworkService { code: response.status, message: "Successfully sent Matrix notification", }; - } catch (error) { + } catch (error: any) { this.logger.warn({ message: error.message, service: this.SERVICE_NAME, diff --git a/server/src/service/infrastructure/statusService.js b/server/src/service/infrastructure/statusService.ts similarity index 56% rename from server/src/service/infrastructure/statusService.js rename to server/src/service/infrastructure/statusService.ts index 763b6d1fa..7d34017a5 100755 --- a/server/src/service/infrastructure/statusService.js +++ b/server/src/service/infrastructure/statusService.ts @@ -1,11 +1,38 @@ +import { IMonitorsRepository } from "@/repositories/index.js"; import MonitorStats from "../../db/models/MonitorStats.js"; import { CheckModel } from "@/db/models/index.js"; +import type { + CheckErrorInfo, + Monitor, + MonitorStatusResponse, + StatusChangeResult, + Check, + HardwareStatusPayload, + PageSpeedStatusPayload, +} from "@/types/index.js"; const SERVICE_NAME = "StatusService"; class StatusService { static SERVICE_NAME = SERVICE_NAME; + private db: any; + private logger: any; + private buffer: any; + private incidentService: any; + private monitorsRepository: IMonitorsRepository; - constructor({ db, logger, buffer, incidentService, monitorsRepository }) { + constructor({ + db, + logger, + buffer, + incidentService, + monitorsRepository, + }: { + db: any; + logger: any; + buffer: any; + incidentService: any; + monitorsRepository: IMonitorsRepository; + }) { this.db = db; this.logger = logger; this.buffer = buffer; @@ -17,7 +44,7 @@ class StatusService { return StatusService.SERVICE_NAME; } - async updateRunningStats({ monitor, networkResponse }) { + async updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: any }) { try { const monitorId = monitor.id; const { responseTime, status } = networkResponse; @@ -78,9 +105,9 @@ class StatusService { await stats.save(); return true; - } catch (error) { + } catch (error: any) { this.logger.error({ - service: this.SERVICE_NAME, + service: SERVICE_NAME, message: error.message, method: "updateRunningStats", stack: error.stack, @@ -89,23 +116,13 @@ class StatusService { } } - getStatusString = (status) => { + getStatusString = (status: boolean | undefined) => { if (status === true) return "up"; if (status === false) return "down"; return "unknown"; }; - /** - * Saves check if needed and adds to incident buffer - * Removes check from checks buffer if it was saved immediately - * - * @param {Object} check - The check object - * @param {Object} monitor - The monitor object - * @param {string} action - The incident action ("create" or "resolve") - * @param {string} errorContext - Context for error messages - * @returns {Promise} - */ - handleIncidentForCheck = async (check, monitor, action, errorContext = "incident handling") => { + handleIncidentForCheck = async (check: any, monitor: Monitor, action: any, errorContext = "incident handling") => { try { let savedCheck = check; @@ -115,9 +132,9 @@ class StatusService { savedCheck = await checkModel.save(); this.buffer.removeCheckFromBuffer(check); - } catch (checkError) { + } catch (checkError: any) { this.logger.error({ - service: this.SERVICE_NAME, + service: SERVICE_NAME, method: "handleIncidentForCheck", message: `Failed to save check immediately for ${errorContext}: ${checkError.message}`, monitorId: monitor.id, @@ -130,9 +147,9 @@ class StatusService { if (savedCheck && savedCheck._id) { try { this.buffer.addIncidentToBuffer({ monitor, check: savedCheck, action }); - } catch (incidentError) { + } catch (incidentError: any) { this.logger.error({ - service: this.SERVICE_NAME, + service: SERVICE_NAME, method: "handleIncidentForCheck", message: `Failed to add incident to buffer for ${errorContext}: ${incidentError.message}`, monitorId: monitor.id, @@ -141,9 +158,9 @@ class StatusService { }); } } - } catch (error) { + } catch (error: any) { this.logger.error({ - service: this.SERVICE_NAME, + service: SERVICE_NAME, method: "handleIncidentForCheck", message: `Error in ${errorContext}: ${error.message}`, monitorId: monitor?.id, @@ -151,27 +168,17 @@ class StatusService { }); } }; - /** - * Updates the status of a monitor based on the network response. - * - * @param {Object} networkResponse - The network response containing monitorId and status. - * @param {string} networkResponse.monitorId - The ID of the monitor. - * @param {string} networkResponse.status - The new status of the monitor. - * @returns {Promise} - A promise that resolves to an object containinfg the monitor, statusChanged flag, and previous status if the status changed, or false if an error occurred. - * @returns {Promise} returnObject - The object returned by the function. - * @returns {Object} returnObject.monitor - The monitor object. - * @returns {boolean} returnObject.statusChanged - Flag indicating if the status has changed. - * @returns {boolean} returnObject.prevStatus - The previous status of the monitor - */ - updateStatus = async (networkResponse) => { - const check = this.buildCheck(networkResponse); - await this.insertCheck(check); + + updateMonitorStatus = async ( + statusResponse: MonitorStatusResponse, + check: Check + ): Promise => { try { - const { monitorId, teamId, status, code } = networkResponse; + const { monitorId, teamId, status, code } = statusResponse; const monitor = await this.monitorsRepository.findById(monitorId, teamId); // Update running stats - this.updateRunningStats({ monitor, networkResponse }); + this.updateRunningStats({ monitor, networkResponse: statusResponse }); // If the status window size has changed, empty while (monitor.statusWindow.length > monitor.statusWindowSize) { @@ -221,7 +228,7 @@ class StatusService { if (statusChanged) { this.logger.info({ - service: this.SERVICE_NAME, + service: SERVICE_NAME, message: `${monitor.name} went from ${this.getStatusString(prevStatus)} to ${this.getStatusString(newStatus)}`, prevStatus, newStatus, @@ -242,7 +249,7 @@ class StatusService { if (lastManuallyResolvedIncident && lastManuallyResolvedIncident.endTime) { try { - const checksAfterResolution = await Check.find({ + const checksAfterResolution = await CheckModel.find({ monitorId: monitor.id, createdAt: { $gt: lastManuallyResolvedIncident.endTime }, }) @@ -258,9 +265,9 @@ class StatusService { } else { calculatedFailureRate = 0; } - } catch (checkQueryError) { + } catch (checkQueryError: any) { this.logger.error({ - service: this.SERVICE_NAME, + service: SERVICE_NAME, method: "updateStatus", message: `Failed to query checks after manual resolution: ${checkQueryError.message}`, monitorId: monitor.id, @@ -272,9 +279,9 @@ class StatusService { if (calculatedFailureRate >= monitor.statusWindowThreshold) { await this.handleIncidentForCheck(check, monitor, "create", "threshold check without status change"); } - } catch (error) { + } catch (error: any) { this.logger.error({ - service: this.SERVICE_NAME, + service: SERVICE_NAME, method: "updateStatus", message: `Error handling threshold check without status change: ${error.message}`, monitorId: monitor.id, @@ -293,147 +300,31 @@ class StatusService { code, timestamp: new Date().getTime(), }; - } catch (error) { - error.service = this.SERVICE_NAME; + } catch (error: any) { + error.service = SERVICE_NAME; error.method = "updateStatus"; throw error; } }; - /** - * Builds a check object from the network response. - * - * @param {Object} networkResponse - The network response object. - * @param {string} networkResponse.monitorId - The monitor ID. - * @param {string} networkResponse.type - The type of the response. - * @param {string} networkResponse.status - The status of the response. - * @param {number} networkResponse.responseTime - The response time. - * @param {number} networkResponse.code - The status code. - * @param {string} networkResponse.message - The message. - * @param {Object} networkResponse.payload - The payload of the response. - * @returns {Object} The check object. - */ - buildCheck = (networkResponse) => { - const { - monitorId, - teamId, - type, - status, - responseTime, - code, - message, - payload, - first_byte_took, - body_read_took, - dns_took, - conn_took, - connect_took, - tls_took, - timings, - } = networkResponse; - - const check = { - metadata: { - monitorId, - teamId, - type, - }, - status, - statusCode: code, - responseTime, - timings: timings || {}, - message, - first_byte_took, - body_read_took, - dns_took, - conn_took, - connect_took, - tls_took, - }; - - if (type === "pagespeed") { - if (typeof payload === "undefined") { - this.logger.warn({ - message: "Failed to build check", - service: this.SERVICE_NAME, - method: "buildCheck", - details: "empty payload", - }); - return undefined; - } - const categories = payload?.lighthouseResult?.categories ?? {}; - const audits = payload?.lighthouseResult?.audits ?? {}; - const mapAudit = (audit) => { - if (!audit || typeof audit !== "object") { - return undefined; - } - return { - id: audit.id, - title: audit.title, - score: typeof audit.score === "number" ? audit.score : (audit.score ?? null), - displayValue: audit.displayValue, - numericValue: typeof audit.numericValue === "number" ? audit.numericValue : undefined, - numericUnit: audit.numericUnit, - }; - }; - check.accessibility = (categories?.accessibility?.score || 0) * 100; - check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100; - check.seo = (categories?.seo?.score || 0) * 100; - check.performance = (categories?.performance?.score || 0) * 100; - check.audits = { - cls: mapAudit(audits?.["cumulative-layout-shift"]), - si: mapAudit(audits?.["speed-index"]), - fcp: mapAudit(audits?.["first-contentful-paint"]), - lcp: mapAudit(audits?.["largest-contentful-paint"]), - tbt: mapAudit(audits?.["total-blocking-time"]), - }; - } - - if (type === "hardware") { - const { cpu, memory, disk, host, net } = payload?.data ?? {}; - const { errors } = payload?.errors ?? []; - check.cpu = cpu ?? {}; - check.memory = memory ?? {}; - check.disk = disk ?? {}; - check.host = host ?? {}; - check.errors = errors ?? []; - check.capture = payload?.capture ?? {}; - check.net = net ?? {}; - } - return check; - }; - - /** - * Inserts a check into the database based on the network response. - * - * @param {Object} networkResponse - The network response object. - * @param {string} networkResponse.monitorId - The monitor ID. - * @param {string} networkResponse.type - The type of the response. - * @param {string} networkResponse.status - The status of the response. - * @param {number} networkResponse.responseTime - The response time. - * @param {number} networkResponse.code - The status code. - * @param {string} networkResponse.message - The message. - * @param {Object} networkResponse.payload - The payload of the response. - * @returns {Promise} A promise that resolves when the check is inserted. - */ - insertCheck = async (check) => { + insertCheck = async (check: Check) => { try { if (typeof check === "undefined") { this.logger.warn({ message: "Failed to build check", - service: this.SERVICE_NAME, + service: SERVICE_NAME, method: "insertCheck", }); return false; } this.buffer.addToBuffer({ check }); return true; - } catch (error) { + } catch (error: any) { this.logger.error({ message: error.message, - service: error.service || this.SERVICE_NAME, + service: error.service || SERVICE_NAME, method: error.method || "insertCheck", - details: error.details || `Error inserting check for monitor: ${check?.monitorId}`, + details: error.details || `Error inserting check for monitor: ${check?.metadata.monitorId}`, stack: error.stack, }); } diff --git a/server/src/types/check.ts b/server/src/types/check.ts index 28353497e..96c6f6e7f 100644 --- a/server/src/types/check.ts +++ b/server/src/types/check.ts @@ -1,4 +1,6 @@ import type { MonitorType } from "@/types/index.js"; +import type { Response } from "got"; +export type GotTimings = Response["timings"]; export interface CheckMetadata { monitorId: string; @@ -6,64 +8,41 @@ export interface CheckMetadata { type: MonitorType; } -export interface CheckTimingPhases { - wait: number; - dns: number; - tcp: number; - tls: number; - request: number; - firstByte: number; - download: number; - total: number; -} - -export interface CheckTimings { - start: number; - socket: number; - lookup: number; - connect: number; - secureConnect: number; - upload: number; - response: number; - end: number; - phases: CheckTimingPhases; -} - export interface CheckCpuInfo { - physical_core: number; - logical_core: number; - frequency: number; - temperature: number[]; - free_percent: number; - usage_percent: number; + physical_core?: number; + logical_core?: number; + frequency?: number; + temperature?: number[]; + free_percent?: number; + usage_percent?: number; } export interface CheckMemoryInfo { - total_bytes: number; - available_bytes: number; - used_bytes: number; - usage_percent: number; + total_bytes?: number; + available_bytes?: number; + used_bytes?: number; + usage_percent?: number; } export interface CheckHostInfo { - os: string; - platform: string; - kernel_version: string; + os?: string; + platform?: string; + kernel_version?: string; } export interface CheckCaptureInfo { - version: string; - mode: string; + version?: string; + mode?: string; } export interface CheckDiskInfo { - device: string; - mountpoint: string; - read_speed_bytes: number; - write_speed_bytes: number; - total_bytes: number; - free_bytes: number; - usage_percent: number; + device?: string; + mountpoint?: string; + read_speed_bytes?: number; + write_speed_bytes?: number; + total_bytes?: number; + free_bytes?: number; + usage_percent?: number; } export interface CheckErrorInfo { @@ -86,11 +65,11 @@ export interface CheckNetworkInterfaceInfo { } export interface CheckAudits { - cls: ILighthouseAudit; - si: ILighthouseAudit; - fcp: ILighthouseAudit; - lcp: ILighthouseAudit; - tbt: ILighthouseAudit; + cls?: ILighthouseAudit; + si?: ILighthouseAudit; + fcp?: ILighthouseAudit; + lcp?: ILighthouseAudit; + tbt?: ILighthouseAudit; } export interface ILighthouseAudit { @@ -107,19 +86,19 @@ export interface Check { metadata: CheckMetadata; status: boolean; responseTime: number; - timings: CheckTimings; + timings?: GotTimings; statusCode: number; message: string; ack: boolean; ackAt?: string | null; expiry: string; - cpu: CheckCpuInfo; - memory: CheckMemoryInfo; - disk: CheckDiskInfo[]; - host: CheckHostInfo; - errors: CheckErrorInfo[]; - capture: CheckCaptureInfo; - net: CheckNetworkInterfaceInfo[]; + cpu?: CheckCpuInfo; + memory?: CheckMemoryInfo; + disk?: CheckDiskInfo[]; + host?: CheckHostInfo; + errors?: CheckErrorInfo[]; + capture?: CheckCaptureInfo; + net?: CheckNetworkInterfaceInfo[]; accessibility?: number; bestPractices?: number; seo?: number; diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 81620da03..4f71788f2 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -2,3 +2,4 @@ export * from "@/types/check.js"; export * from "@/types/monitor.js"; export * from "@/types/monitorStats.js"; export * from "@/types/statusPage.js"; +export * from "@/types/network.js"; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 4c8dc7528..dc3bd8c09 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -1,4 +1,4 @@ -export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game"] as const; +export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; export interface MonitorThresholds { diff --git a/server/src/types/network.ts b/server/src/types/network.ts new file mode 100644 index 000000000..10f89842f --- /dev/null +++ b/server/src/types/network.ts @@ -0,0 +1,112 @@ +import type { + CheckCaptureInfo, + CheckCpuInfo, + CheckDiskInfo, + CheckErrorInfo, + CheckHostInfo, + CheckMemoryInfo, + CheckNetworkInterfaceInfo, + GotTimings, + ILighthouseAudit, + Monitor, + MonitorMatchMethod, + MonitorType, +} from "@/types/index.js"; + +export interface MonitorStatusResponse { + monitorId: string; + teamId: string; + type: MonitorType; + status: boolean; + code: number; + message: string; + responseTime?: number; + payload?: T | null; + timings?: GotTimings; + first_byte_took?: number; + body_read_took?: number; + dns_took?: number; + conn_took?: number; + connect_took?: number; + tls_took?: number; + jsonPath?: string; + matchMethod?: MonitorMatchMethod; + expectedValue?: string; + extracted?: unknown; +} + +export interface PingStatusPayload { + host: string; + numeric_host?: string; + alive: boolean; + time: number; + times?: number[]; + output?: string; + min?: string; + max?: string; + avg?: string; + stddev?: string; + packetLoss?: string; +} + +export type HttpStatusPayload = unknown; + +export interface PageSpeedCategoryScore { + score?: number | null; +} + +export interface PageSpeedStatusPayload { + lighthouseResult?: { + categories?: { + accessibility?: PageSpeedCategoryScore; + "best-practices"?: PageSpeedCategoryScore; + performance?: PageSpeedCategoryScore; + seo?: PageSpeedCategoryScore; + [key: string]: PageSpeedCategoryScore | undefined; + }; + audits?: Record; + }; + [key: string]: unknown; +} + +export interface HardwareStatusMetrics { + cpu?: CheckCpuInfo; + memory?: CheckMemoryInfo; + disk?: CheckDiskInfo[]; + host?: CheckHostInfo; + net?: CheckNetworkInterfaceInfo[]; +} + +export interface HardwareStatusPayload { + data?: HardwareStatusMetrics; + errors?: CheckErrorInfo[] | { errors?: CheckErrorInfo[] }; + capture?: CheckCaptureInfo; + [key: string]: unknown; +} + +export type DockerStatusPayload = Record; + +export interface PortStatusPayload { + success: boolean; +} + +export type GameStatusPayload = Record; + +export interface MonitorPayloadMap { + ping: PingStatusPayload; + http: HttpStatusPayload; + pagespeed: PageSpeedStatusPayload; + hardware: HardwareStatusPayload; + docker: DockerStatusPayload; + port: PortStatusPayload; + game: GameStatusPayload; + default: unknown; +} + +export type StatusChangeResult = { + monitor: Monitor; + statusChanged: boolean; + prevStatus: boolean | undefined; + code: number; + timestamp: number; +}; diff --git a/server/test/superSimpleQueueHelper.test.ts b/server/test/superSimpleQueueHelper.test.ts new file mode 100644 index 000000000..19caef876 --- /dev/null +++ b/server/test/superSimpleQueueHelper.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { Monitor } from "../src/types/monitor.ts"; + +const createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }); + +const createHelper = (overrides?: Partial[0]>) => { + const maintenanceWindowModule = { + getMaintenanceWindowsByMonitorId: jest.fn().mockResolvedValue([]), + }; + const statusServiceMock = { + updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }), + }; + const helper = new SuperSimpleQueueHelper({ + db: { maintenanceWindowModule }, + logger: createLogger(), + networkService: { requestStatus: jest.fn() }, + statusService: statusServiceMock, + notificationService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, + ...overrides, + }); + return { helper, maintenanceWindowModule }; +}; + +describe("SuperSimpleQueueHelper", () => { + describe("getMonitorJob", () => { + it("skips execution when monitor is in maintenance window", async () => { + const { helper } = createHelper(); + const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getMonitorJob(); + await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor); + expect(helper["networkService"].requestStatus).not.toHaveBeenCalled(); + expect(helper["logger"].debug).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") }) + ); + spy.mockRestore(); + }); + + it("processes monitor status and notifications when active", async () => { + const networkResponse = { monitor: { id: "m1" }, status: true }; + const updatedMonitor = { id: "m1", status: true }; + const { helper } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { + updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false }), + }, + notificationService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getMonitorJob(); + const monitor = { id: "m1", teamId: "team" } as Monitor; + await job(monitor); + expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor); + expect(helper["statusService"].updateMonitorStatus).toHaveBeenCalledWith(networkResponse); + expect(helper["notificationService"].handleNotifications).toHaveBeenCalledWith( + expect.objectContaining({ monitor: updatedMonitor, statusChanged: true, prevStatus: false }) + ); + }); + + it("throws when monitor id is missing", async () => { + const { helper } = createHelper(); + const job = helper.getMonitorJob(); + await expect(job({} as Monitor)).rejects.toThrow("No monitor id"); + expect(helper["logger"].warn).toHaveBeenCalled(); + }); + }); + + describe("isInMaintenanceWindow", () => { + it("returns true when an active window spans now", async () => { + const now = new Date(); + const { helper, maintenanceWindowModule } = createHelper(); + maintenanceWindowModule.getMaintenanceWindowsByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); + }); + + it("returns true when repeat interval advances window into current time", async () => { + const now = Date.now(); + const { helper, maintenanceWindowModule } = createHelper(); + maintenanceWindowModule.getMaintenanceWindowsByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 7200000).toISOString(), + end: new Date(now - 6600000).toISOString(), + repeat: 3600000, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); + }); + + it("returns false when no active windows exist", async () => { + const { helper } = createHelper(); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + }); +}); diff --git a/server/tsconfig.jest.json b/server/tsconfig.jest.json index 928b0977c..80c321cf6 100644 --- a/server/tsconfig.jest.json +++ b/server/tsconfig.jest.json @@ -3,7 +3,8 @@ "compilerOptions": { "rootDir": ".", "types": ["jest"], - "noEmit": true + "noEmit": true, + "resolveJsonModule": true }, "include": ["src", "test"] } diff --git a/server/tsconfig.json b/server/tsconfig.json index cbb874656..bdb3a7b0e 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,7 +4,7 @@ "allowJs": true, "checkJs": false, "paths": { - "@/*": ["./src/*"] // allows "@/db" -> "./src/db" + "@/*": ["./src/*"] }, "outDir": "./dist", "strict": true,