diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx index 101ac11af..467e2f3a7 100644 --- a/client/src/Pages/Logs/index.tsx +++ b/client/src/Pages/Logs/index.tsx @@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next"; const LogsPage = () => { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState(2); + const [activeTab, setActiveTab] = useState(1); return ( ): Promise; + create(geoChecks: Omit[]): Promise; findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise; findGroupedByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date, dateFormat: string): Promise; deleteByMonitorId(monitorId: string): Promise; diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index 0310a1c72..8717eb173 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -72,23 +72,26 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { }; }; - create = async (geoCheck: Omit): Promise => { + create = async (geoChecks: Omit[]): Promise => { try { - const doc = await GeoCheckModel.create({ - metadata: { - monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId), - teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId), - type: geoCheck.metadata.type, - }, - results: geoCheck.results, - expiry: new Date(geoCheck.expiry), - }); - return this.toEntity(doc); + const docs = await GeoCheckModel.insertMany( + geoChecks.map((geoCheck) => ({ + metadata: { + monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId), + teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId), + type: geoCheck.metadata.type, + }, + results: geoCheck.results, + expiry: new Date(geoCheck.expiry), + })) + ); + return docs.map((doc) => this.toEntity(doc)); } catch (error: any) { this.logger.error({ - message: `Error creating geo check: ${error.message}`, + message: `Failed to create geo checks: ${error.message}`, service: SERVICE_NAME, method: "create", + stack: error.stack, }); throw error; } diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts new file mode 100644 index 000000000..0ffa84150 --- /dev/null +++ b/server/src/service/business/geoChecksService.ts @@ -0,0 +1,153 @@ +import type { Monitor, GeoCheck } from "@/types/index.js"; +import { Types } from "mongoose"; +import type { IGeoChecksRepository } from "@/repositories/index.js"; +import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js"; +import type { IBufferService } from "@/service/infrastructure/bufferService.js"; + +const SERVICE_NAME = "GeoChecksService"; + +export interface IGeoChecksService { + readonly serviceName: string; + executeGeoCheck(monitor: Monitor): Promise; +} + +class GeoChecksService implements IGeoChecksService { + static SERVICE_NAME = SERVICE_NAME; + + private logger: any; + private geoChecksRepository: IGeoChecksRepository; + private globalPingService: IGlobalPingService; + private bufferService: IBufferService; + private TTL_DAYS: number; + + constructor({ + logger, + geoChecksRepository, + globalPingService, + bufferService, + settingsService, + }: { + logger: any; + geoChecksRepository: IGeoChecksRepository; + globalPingService: IGlobalPingService; + bufferService: IBufferService; + settingsService: any; + }) { + this.logger = logger; + this.geoChecksRepository = geoChecksRepository; + this.globalPingService = globalPingService; + this.bufferService = bufferService; + this.TTL_DAYS = settingsService.getSettings().checksTTL || 90; + } + + get serviceName() { + return GeoChecksService.SERVICE_NAME; + } + + /** + * Execute a geo-distributed check for a monitor + * 1. Create measurement request with GlobalPing API + * 2. Poll for results (with 30s timeout) + * 3. Transform and save results to buffer + */ + async executeGeoCheck(monitor: Monitor): Promise { + try { + if (!monitor.url) { + this.logger.warn({ + message: "Monitor missing URL for geo check", + service: SERVICE_NAME, + method: "executeGeoCheck", + details: { monitorId: monitor.id }, + }); + return; + } + + if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { + this.logger.warn({ + message: "Monitor missing geo check locations", + service: SERVICE_NAME, + method: "executeGeoCheck", + details: { monitorId: monitor.id }, + }); + return; + } + + // Step 1: Create measurement request + const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations); + + if (!measurementId) { + // GlobalPing API is down, skip this check + this.logger.debug({ + message: "Skipping geo check due to API unavailability", + service: SERVICE_NAME, + method: "executeGeoCheck", + details: { monitorId: monitor.id }, + }); + return; + } + + // Step 2: Poll for results + const results = await this.globalPingService.pollForResults(measurementId); + + if (results.length === 0) { + // No successful results (all locations timed out or failed) + this.logger.debug({ + message: "No successful geo check results", + service: SERVICE_NAME, + method: "executeGeoCheck", + details: { monitorId: monitor.id, measurementId }, + }); + return; + } + + // Step 3: Build GeoCheck document + const geoCheck = this.buildGeoCheck(monitor, results); + + // Step 4: Add to buffer for batched insertion + this.bufferService.addGeoCheckToBuffer(geoCheck); + + this.logger.debug({ + message: `Geo check completed for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "executeGeoCheck", + details: { monitorId: monitor.id, resultsCount: results.length }, + }); + } catch (error: any) { + this.logger.error({ + message: "Error executing geo check", + service: SERVICE_NAME, + method: "executeGeoCheck", + details: { monitorId: monitor.id, error: error.message }, + stack: error.stack, + }); + } + } + + private buildGeoCheck(monitor: Monitor, results: any[]): GeoCheck { + const now = new Date(); + const expiryDate = new Date(now.getTime() + this.TTL_DAYS * 24 * 60 * 60 * 1000); + + return { + id: new Types.ObjectId().toString(), + metadata: { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + }, + results, + expiry: expiryDate.toISOString(), + __v: 0, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + } + + /** + * Create geo checks (called by buffer service) + */ + createGeoChecks = async (geoChecks: GeoCheck[]) => { + return this.geoChecksRepository.create(geoChecks); + }; +} + +export default GeoChecksService; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index bee981906..90827408d 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -4,6 +4,7 @@ import { AppError } from "@/utils/AppError.js"; import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js"; import IncidentService from "@/service/business/incidentService.js"; +import type { IGeoChecksService } from "@/service/business/geoChecksService.js"; import { IMaintenanceWindowsRepository, IMonitorsRepository, @@ -43,6 +44,7 @@ class SuperSimpleQueueHelper { private monitorStatsRepository: IMonitorStatsRepository; private checksRepository: IChecksRepository; private incidentsRepository: IIncidentsRepository; + private geoChecksService: IGeoChecksService; constructor({ logger, @@ -58,6 +60,7 @@ class SuperSimpleQueueHelper { monitorStatsRepository, checksRepository, incidentsRepository, + geoChecksService, }: { logger: any; networkService: INetworkService; @@ -72,6 +75,7 @@ class SuperSimpleQueueHelper { monitorStatsRepository: IMonitorStatsRepository; checksRepository: IChecksRepository; incidentsRepository: IIncidentsRepository; + geoChecksService: IGeoChecksService; }) { this.logger = logger; this.networkService = networkService; @@ -86,6 +90,7 @@ class SuperSimpleQueueHelper { this.monitorStatsRepository = monitorStatsRepository; this.checksRepository = checksRepository; this.incidentsRepository = incidentsRepository; + this.geoChecksService = geoChecksService; } get serviceName() { @@ -301,23 +306,8 @@ class SuperSimpleQueueHelper { return; } - // Step 3: Call GlobalPing API - // TODO: Implement GlobalPing API service - // const measurementId = await this.globalPingService.createMeasurement(monitor.url, monitor.geoCheckLocations); - // const geoResults = await this.globalPingService.pollForResults(measurementId); - - // Step 4: Create GeoCheck document - // TODO: Build and save GeoCheck - // const geoCheck = { - // metadata: { - // monitorId, - // teamId, - // type: monitor.type, - // }, - // results: geoResults, - // expiry: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), - // }; - // await this.geoChecksRepository.create(geoCheck); + // Step 3: Execute geo check (handles API calls, polling, and saving) + await this.geoChecksService.executeGeoCheck(monitor); this.logger.debug({ message: `Geo check job executed for monitor ${monitorId}`, diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index c38fd52a4..9870ae783 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -1,9 +1,11 @@ import type { Check } from "@/types/index.js"; +import type { GeoCheck } from "@/types/index.js"; const SERVICE_NAME = "BufferService"; export interface IBufferService { addToBuffer(check: Check): void; + addGeoCheckToBuffer(geoCheck: GeoCheck): void; removeCheckFromBuffer(check: Check): boolean; scheduleNextFlush(): void; flushBuffer(): Promise; @@ -15,15 +17,29 @@ class BufferService implements IBufferService { private logger: any; private SERVICE_NAME: string; private buffer: any[]; + private geoBuffer: any[]; private bufferTimer: NodeJS.Timeout | null = null; private checksService: any; + private geoChecksService: any; - constructor({ logger, checkService, settingsService }: { logger: any; checkService: any; settingsService: any }) { + constructor({ + logger, + checkService, + geoChecksService, + settingsService, + }: { + logger: any; + checkService: any; + geoChecksService?: any; + settingsService: any; + }) { this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute this.logger = logger; this.checksService = checkService; + this.geoChecksService = geoChecksService; this.SERVICE_NAME = SERVICE_NAME; this.buffer = []; + this.geoBuffer = []; this.scheduleNextFlush(); this.logger.info({ message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`, @@ -49,6 +65,19 @@ class BufferService implements IBufferService { } } + addGeoCheckToBuffer(geoCheck: GeoCheck) { + try { + this.geoBuffer.push(geoCheck); + } catch (error: any) { + this.logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "addGeoCheckToBuffer", + stack: error.stack, + }); + } + } + removeCheckFromBuffer(checkToRemove: Check) { try { if (!checkToRemove) { @@ -112,6 +141,10 @@ class BufferService implements IBufferService { if (this.buffer.length > 0) { await this.checksService.createChecks(this.buffer); } + + if (this.geoBuffer.length > 0 && this.geoChecksService) { + await this.geoChecksService.createGeoChecks(this.geoBuffer); + } } catch (error: any) { this.logger.error({ message: error.message, @@ -122,6 +155,7 @@ class BufferService implements IBufferService { } this.buffer = []; + this.geoBuffer = []; } } diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts new file mode 100644 index 000000000..3b6244849 --- /dev/null +++ b/server/src/service/infrastructure/globalPingService.ts @@ -0,0 +1,206 @@ +import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js"; +import got from "got"; + +const SERVICE_NAME = "GlobalPingService"; +const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1"; +const POLL_INTERVAL_MS = 2000; +const MAX_POLL_TIMEOUT_MS = 30000; + +interface GlobalPingMeasurementRequest { + type: "http"; + target: string; + locations: Array<{ continent: GeoContinent }>; + limit: number; +} + +interface GlobalPingMeasurementResponse { + id: string; + type: string; + status: "in-progress" | "finished" | "failed"; + probesCount: number; + results?: GlobalPingProbeResult[]; +} + +interface GlobalPingProbeResult { + probe: { + continent: GeoContinent; + region: string; + country: string; + state: string | null; + city: string; + longitude: number; + latitude: number; + }; + result: { + status: "finished" | "failed" | "timeout"; + statusCode?: number; + statusCodeName?: string; + timings?: { + total: number; + dns: number; + tcp: number; + tls: number; + firstByte: number; + download: number; + }; + rawOutput?: string; + }; +} + +export interface IGlobalPingService { + readonly serviceName: string; + createMeasurement(url: string, locations: GeoContinent[]): Promise; + pollForResults(measurementId: string, timeoutMs?: number): Promise; +} + +class GlobalPingService implements IGlobalPingService { + static SERVICE_NAME = SERVICE_NAME; + + private logger: any; + + constructor({ logger }: { logger: any }) { + this.logger = logger; + } + + get serviceName() { + return GlobalPingService.SERVICE_NAME; + } + + async createMeasurement(url: string, locations: GeoContinent[]): Promise { + try { + const requestBody: GlobalPingMeasurementRequest = { + type: "http", + target: url, + locations: locations.map((continent) => ({ continent })), + limit: locations.length, + }; + + const response = await got.post(`${GLOBAL_PING_API_BASE}/measurements`, { + json: requestBody, + responseType: "json", + timeout: { request: 10000 }, + }); + + const measurementId = response.body.id; + + this.logger.debug({ + message: `Created GlobalPing measurement: ${measurementId}`, + service: SERVICE_NAME, + method: "createMeasurement", + details: { measurementId, url, locations }, + }); + + return measurementId; + } catch (error: any) { + this.logger.error({ + message: "GlobalPing API unavailable, skipping geo check", + service: SERVICE_NAME, + method: "createMeasurement", + details: error.message, + }); + return null; + } + } + + async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + try { + const response = await got.get(`${GLOBAL_PING_API_BASE}/measurements/${measurementId}`, { + responseType: "json", + timeout: { request: 5000 }, + }); + + const measurement = response.body; + + if (measurement.status === "finished") { + const results = this.transformResults(measurement.results || []); + this.logger.debug({ + message: `GlobalPing measurement completed: ${measurementId}`, + service: SERVICE_NAME, + method: "pollForResults", + details: { measurementId, resultsCount: results.length }, + }); + return results; + } + + if (measurement.status === "failed") { + this.logger.warn({ + message: `GlobalPing measurement failed: ${measurementId}`, + service: SERVICE_NAME, + method: "pollForResults", + }); + return []; + } + + // Still in-progress, wait and poll again + await this.sleep(POLL_INTERVAL_MS); + } catch (error: any) { + this.logger.error({ + message: "Error polling GlobalPing API", + service: SERVICE_NAME, + method: "pollForResults", + details: error.message, + }); + return []; + } + } + + // Timeout reached + this.logger.warn({ + message: `GlobalPing measurement polling timeout: ${measurementId}`, + service: SERVICE_NAME, + method: "pollForResults", + details: { measurementId, timeoutMs }, + }); + return []; + } + + private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] { + const successfulResults: GeoCheckResult[] = []; + + for (const probeResult of probeResults) { + // Skip failed or timeout results + if (probeResult.result.status !== "finished" || !probeResult.result.statusCode || !probeResult.result.timings) { + continue; + } + + const location: GeoCheckLocation = { + continent: probeResult.probe.continent, + region: probeResult.probe.region, + country: probeResult.probe.country, + state: probeResult.probe.state || "", + city: probeResult.probe.city, + longitude: probeResult.probe.longitude, + latitude: probeResult.probe.latitude, + }; + + const timings: GeoCheckTimings = { + total: probeResult.result.timings.total, + dns: probeResult.result.timings.dns, + tcp: probeResult.result.timings.tcp, + tls: probeResult.result.timings.tls, + firstByte: probeResult.result.timings.firstByte, + download: probeResult.result.timings.download, + }; + + const result: GeoCheckResult = { + location, + status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300, + statusCode: probeResult.result.statusCode, + timings, + }; + + successfulResults.push(result); + } + + return successfulResults; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export default GlobalPingService;