diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 4bd29b941..8f3f9f7bb 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -29,7 +29,7 @@ import { } from "@/Components/inputs"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; -import type { Monitor, MonitorType, GamesMap, GeoCheckLocation } from "@/Types/Monitor"; +import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor"; import { GeoCheckLocations } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { MonitorFormData } from "@/Validation/monitor"; diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index 12dfcab35..a7d513cef 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -457,116 +457,133 @@ class MongoChecksRepository implements IChecksRepository { endDate: Date, dateString: string ) => { - const matchStage = { + // Match stage for primary checks (exclude geo checks) + const primaryCheckMatchStage = { "metadata.monitorId": monitorObjectId, createdAt: { $gte: startDate, $lte: endDate }, + geoResults: { $exists: false }, }; - const [result] = await CheckModel.aggregate([ - { $match: matchStage }, - { $sort: { createdAt: 1 } }, - { - $facet: { - uptimePercentage: [ - { - $group: { - _id: null, - upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, - totalChecks: { $sum: 1 }, - }, - }, - { - $project: { - _id: 0, - percentage: { - $cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }], + + // Match stage for geo checks only + const geoCheckMatchStage = { + "metadata.monitorId": monitorObjectId, + createdAt: { $gte: startDate, $lte: endDate }, + geoResults: { $exists: true, $ne: null }, + }; + + // Run two separate aggregations - one for primary checks, one for geo checks + // TODO, only do this if the monitor has geochecks enabled, otherwise we only need one aggregation + const [primaryResult, geoResults] = await Promise.all([ + CheckModel.aggregate([ + { $match: primaryCheckMatchStage }, + { $sort: { createdAt: 1 } }, + { + $facet: { + uptimePercentage: [ + { + $group: { + _id: null, + upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + totalChecks: { $sum: 1 }, }, }, - }, - ], - groupedAvgResponseTime: [ - { - $group: { - _id: null, - avgResponseTime: { $avg: "$responseTime" }, - }, - }, - ], - groupedChecks: [ - { - $group: { - _id: { - $dateToString: { format: dateString, date: "$createdAt" }, - }, - avgResponseTime: { $avg: "$responseTime" }, - totalChecks: { $sum: 1 }, - }, - }, - { $sort: { _id: 1 } }, - { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, - ], - groupedUpChecks: [ - { $match: { status: true } }, - { - $group: { - _id: { - $dateToString: { format: dateString, date: "$createdAt" }, - }, - totalChecks: { $sum: 1 }, - avgResponseTime: { $avg: "$responseTime" }, - }, - }, - { $sort: { _id: 1 } }, - { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, - ], - groupedDownChecks: [ - { $match: { status: false } }, - { - $group: { - _id: { - $dateToString: { format: dateString, date: "$createdAt" }, - }, - totalChecks: { $sum: 1 }, - avgResponseTime: { $avg: "$responseTime" }, - }, - }, - { $sort: { _id: 1 } }, - { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, - ], - groupedGeoChecks: [ - { $match: { geoResults: { $exists: true, $ne: null, $not: { $size: 0 } } } }, - { $unwind: "$geoResults" }, - { - $group: { - _id: { - date: { $dateToString: { format: dateString, date: "$createdAt" } }, - continent: "$geoResults.continent", - }, - avgResponseTime: { $avg: "$geoResults.timings.total" }, - totalChecks: { $sum: 1 }, - upChecks: { $sum: { $cond: [{ $eq: ["$geoResults.status", true] }, 1, 0] } }, - }, - }, - { - $project: { - _id: 0, - bucketDate: "$_id.date", - continent: "$_id.continent", - avgResponseTime: 1, - totalChecks: 1, - uptimePercentage: { - $cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }], + { + $project: { + _id: 0, + percentage: { + $cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }], + }, }, }, - }, - { $sort: { bucketDate: 1, continent: 1 } }, - ], + ], + groupedAvgResponseTime: [ + { + $group: { + _id: null, + avgResponseTime: { $avg: "$responseTime" }, + }, + }, + ], + groupedChecks: [ + { + $group: { + _id: { + $dateToString: { format: dateString, date: "$createdAt" }, + }, + avgResponseTime: { $avg: "$responseTime" }, + totalChecks: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, + ], + groupedUpChecks: [ + { $match: { status: true } }, + { + $group: { + _id: { + $dateToString: { format: dateString, date: "$createdAt" }, + }, + totalChecks: { $sum: 1 }, + avgResponseTime: { $avg: "$responseTime" }, + }, + }, + { $sort: { _id: 1 } }, + { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, + ], + groupedDownChecks: [ + { $match: { status: false } }, + { + $group: { + _id: { + $dateToString: { format: dateString, date: "$createdAt" }, + }, + totalChecks: { $sum: 1 }, + avgResponseTime: { $avg: "$responseTime" }, + }, + }, + { $sort: { _id: 1 } }, + { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, + ], + }, }, - }, + ]), + CheckModel.aggregate([ + { $match: geoCheckMatchStage }, + { $sort: { createdAt: 1 } }, + { $unwind: "$geoResults" }, + { + $group: { + _id: { + date: { $dateToString: { format: dateString, date: "$createdAt" } }, + continent: "$geoResults.continent", + }, + avgResponseTime: { $avg: "$geoResults.timings.total" }, + totalChecks: { $sum: 1 }, + upChecks: { $sum: { $cond: [{ $eq: ["$geoResults.status", true] }, 1, 0] } }, + }, + }, + { + $project: { + _id: 0, + bucketDate: "$_id.date", + continent: "$_id.continent", + avgResponseTime: 1, + totalChecks: 1, + uptimePercentage: { + $cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }], + }, + }, + }, + { $sort: { bucketDate: 1, continent: 1 } }, + ]), ]); + const [result] = primaryResult; + const groupedGeoChecks = geoResults ?? []; + const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0; const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0; - const groupedGeoChecks = result?.groupedGeoChecks ?? []; return { monitorType, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 1947549c9..31d0e795c 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -97,6 +97,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue { this.scheduler.start(); this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); + this.scheduler.addTemplate("geo-check-job", this.helper.getGeoCheckJob()); this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); const monitors = await this.monitorsRepository.findAll(); if (!monitors) { @@ -131,14 +132,28 @@ class SuperSimpleQueue implements ISuperSimpleQueue { active: monitor.isActive, data: monitor, }); + + // Add geo check job if enabled + if (monitor.geoCheckEnabled && monitor.type === "http") { + this.scheduler.addJob({ + id: `${monitorId}-geo`, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + }); + } }; deleteJob = async (monitor: any) => { this.scheduler.removeJob(monitor.id); + this.scheduler.removeJob(`${monitor.id}-geo`); }; pauseJob = async (monitor: any) => { const result = await this.scheduler.pauseJob(monitor.id); + // Also pause geo check job if it exists + await this.scheduler.pauseJob(`${monitor.id}-geo`); if (result === false) { throw new Error("Failed to resume monitor"); } @@ -151,6 +166,8 @@ class SuperSimpleQueue implements ISuperSimpleQueue { resumeJob = async (monitor: any) => { const result = await this.scheduler.resumeJob(monitor.id); + // Also resume geo check job if it exists + await this.scheduler.resumeJob(`${monitor.id}-geo`); if (result === false) { throw new Error("Failed to resume monitor"); } @@ -163,6 +180,29 @@ class SuperSimpleQueue implements ISuperSimpleQueue { updateJob = async (monitor: any) => { this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); + + // Handle geo check job + const geoJobId = `${monitor.id}-geo`; + const geoJobExists = await this.scheduler.getJob(geoJobId); + + if (monitor.geoCheckEnabled && monitor.type === "http") { + if (geoJobExists) { + // Update existing geo job + this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, data: monitor, active: monitor.isActive }); + } else { + // Add new geo job + this.scheduler.addJob({ + id: geoJobId, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + }); + } + } else if (geoJobExists) { + // Remove geo job if it exists but is no longer enabled + this.scheduler.removeJob(geoJobId); + } }; shutdown = async () => { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 4d9876f2f..aefb1f51b 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -176,6 +176,98 @@ class SuperSimpleQueueHelper { }; }; + getGeoCheckJob = () => { + return async (monitor: Monitor) => { + try { + const monitorId = monitor.id; + const teamId = monitor.teamId; + + if (!monitorId) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" }); + } + + // Safety checks + if (!monitor.geoCheckEnabled || monitor.type !== "http") { + this.logger.debug({ + message: `Geo check skipped for monitor ${monitorId} - not enabled or not HTTP type`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + return; + } + + if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { + this.logger.warn({ + message: `Geo check skipped for monitor ${monitorId} - no locations configured`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + return; + } + + // Check for maintenance window + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Geo check skipped for monitor ${monitorId} - in maintenance window`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + return; + } + + this.logger.debug({ + message: `Starting geo check for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + + // TODO: Implement GlobalPing API integration + // Step 1: Create measurement via GlobalPing API + // const measurementId = await this.globalPingService.createMeasurement( + // monitor.url, + // monitor.geoCheckLocations + // ); + + // Step 2: Poll for results (blocks 2-30s, but doesn't affect primary monitor checks) + // const geoResults = await this.globalPingService.pollForResults(measurementId); + + // Step 3: Create check document with only geo results + // const check = { + // metadata: { + // monitorId: monitor.id, + // teamId: monitor.teamId, + // type: monitor.type, + // }, + // status: true, + // responseTime: 0, + // statusCode: 200, + // message: "Geo check", + // geoResults, + // ack: false, + // }; + + // Step 4: Save check + // await this.checksRepository.create([check]); + + this.logger.info({ + message: `Geo check completed for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + } catch (error: any) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getGeoCheckJob", + details: error, + stack: error.stack, + }); + throw error; + } + }; + }; + getCleanupOrphanedJob = () => { return async () => { try {