diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 5726fd1fe..c6230fa55 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -102,6 +102,7 @@ export const useMonitorForm = ({ matchMethod: "", expectedValue: "", jsonPath: "", + globalpingEnabled: false, }; } diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 819f3a1b1..177944c0e 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -222,8 +222,8 @@ const CreateMonitorPage = () => { } }; - const onError = (errors: unknown) => { - console.log(errors); + const onError = (_errors: unknown) => { + // Validation errors are shown inline via react-hook-form field errors }; return ( @@ -500,7 +500,7 @@ const CreateMonitorPage = () => { render={({ field }) => ( field.onChange(e.target.checked)} /> )} diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 798e9ace3..2fa25c6a6 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -17,7 +17,7 @@ import { MonitorStatBoxes } from "@/Components/v2/monitors"; import { useTheme } from "@mui/material/styles"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useParams } from "react-router-dom"; import { useSelector } from "react-redux"; import { useGet } from "@/Hooks/UseApi"; @@ -113,10 +113,11 @@ const UptimeDetailsPage = () => { const checks = checksData?.checks ?? []; const checksCount = checksData?.checksCount ?? 0; + const globalpingLocationsUrl = monitor?.globalpingEnabled ? "/monitors/globalping/locations" : null; const { data: globalpingData } = useGet<{ locations: { id: string; label: string }[]; labels: Record; - }>("/monitors/globalping/locations"); + }>(globalpingLocationsUrl); const locationLabels = globalpingData?.labels ?? {}; const locationChecks = monitorData?.locationChecks; @@ -124,6 +125,12 @@ const UptimeDetailsPage = () => { () => (locationChecks ? Object.keys(locationChecks).sort() : []), [locationChecks] ); + + // Reset tab index when location keys change to prevent out-of-bounds + useEffect(() => { + setSelectedLocationTab(0); + }, [locationKeys.length]); + const selectedLocation = locationKeys[selectedLocationTab] ?? null; const selectedLocationData: LocationCheckData | null = selectedLocation && locationChecks ? locationChecks[selectedLocation] : null; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 4541aa3c0..320675100 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -177,7 +177,7 @@ export const MonitorTable = ({ {row?.name} {row.globalpingEnabled && ( - + )} diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index e6519bcbb..0d8e1c584 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -58,7 +58,6 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; group: string | null; - locations: string[]; globalpingEnabled: boolean; recentChecks: CheckSnapshot[]; createdAt: string; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 7a51f7934..b810b8a38 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -496,12 +496,6 @@ "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure." } }, - "locations": { - "title": "Global locations", - "description": "Run uptime checks from multiple geographic regions using Globalping. Select up to 5 locations.", - "label": "Check locations", - "placeholder": "Select locations" - }, "ignoreTls": { "description": "Configure TLS/SSL certificate validation for HTTPS connections.", "option": { @@ -1158,7 +1152,6 @@ "table": { "headers": { "responseTime": "Response time", - "locations": "Locations", "globalpingEnabled": "Global monitoring enabled" } }, diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 56dfb26a2..a0482deec 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -160,10 +160,6 @@ const MonitorSchema = new Schema( gameId: { type: String, }, - locations: { - type: [String], - default: [], - }, globalpingEnabled: { type: Boolean, default: false, diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index e115ee8df..3a2b99f87 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -472,10 +472,7 @@ class MongoChecksRepository implements IChecksRepository { const uptimePercentage = loc.totalAllChecks > 0 ? loc.totalUpChecks / loc.totalAllChecks : 0; locationChecks[loc._id] = { - monitorType: "http" as const, groupedChecks: loc.groupedChecks, - groupedUpChecks: [], - groupedDownChecks: [], uptimePercentage, avgResponseTime: loc.overallAvgResponseTime ?? 0, }; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 5ecd1e660..718c44cc6 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -343,7 +343,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, - locations: doc.locations ?? [], globalpingEnabled: doc.globalpingEnabled ?? false, recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), createdAt: toDateString(doc.createdAt), @@ -400,7 +399,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, - locations: doc.locations ?? [], globalpingEnabled: doc.globalpingEnabled ?? false, recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), createdAt: toDateString(doc.createdAt), diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index fd0afc658..2a5a93443 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -136,6 +136,12 @@ class SuperSimpleQueueHelper { const locCheck = this.checkService.buildCheck(locStatus); if (locCheck) { this.buffer.addToBuffer(locCheck); + } else { + this.logger.warn({ + message: `Failed to build Globalping check for monitor ${monitorId}, location: ${locStatus.location}`, + service: SERVICE_NAME, + method: "getMonitorJob", + }); } } }) @@ -148,7 +154,7 @@ class SuperSimpleQueueHelper { }); } - // Step 3. Build check + // Step 3. Build check const check = await this.checkService.buildCheck(status); if (!check) { this.logger.warn({ @@ -159,15 +165,15 @@ class SuperSimpleQueueHelper { }); return; } - // Step 4 Add check to buffer + // Step 4. Add check to buffer this.buffer.addToBuffer(check); - // Step 4. Update monitor status + // Step 5. Update monitor status const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); - // Step 5. Get decisions + // Step 6. Get decisions const decision = this.evaluateMonitorAction(statusChangeResult); - // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) + // Step 7. Handle notifications (best effort, continue even in event of failure, don't wait) if (decision.shouldSendNotification) { this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => { this.logger.error({ @@ -180,7 +186,7 @@ class SuperSimpleQueueHelper { }); } - // Step 7. Handle incidents (best effort, don't wait) + // Step 8. Handle incidents (best effort, don't wait) this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => { this.logger.warn({ message: error.message, diff --git a/server/src/service/infrastructure/globalpingService.ts b/server/src/service/infrastructure/globalpingService.ts index 0f6094a24..0d46d7335 100644 --- a/server/src/service/infrastructure/globalpingService.ts +++ b/server/src/service/infrastructure/globalpingService.ts @@ -5,7 +5,7 @@ import type { MonitorStatusResponse } from "@/types/network.js"; const SERVICE_NAME = "GlobalpingService"; -interface GlobalpingLocationOption { +export interface GlobalpingLocationOption { id: string; label: string; } @@ -80,6 +80,8 @@ for (const tier of Object.values(LOCATIONS_BY_TIER)) { } } +const HOSTNAME_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; + export const getGlobalpingLocationsByTier = (tier: number): GlobalpingLocationOption[] => { return LOCATIONS_BY_TIER[tier] ?? LOCATIONS_TIER_6; }; @@ -91,6 +93,7 @@ class GlobalpingService { private settingsService: ISettingsService; private client: Globalping | null = null; private lastApiKey: string | undefined; + private inFlightChecks = new Set(); constructor({ settingsService, logger }: { settingsService: ISettingsService; logger: any }) { this.settingsService = settingsService; @@ -101,31 +104,30 @@ class GlobalpingService { return GlobalpingService.SERVICE_NAME; } - private async getClient(): Promise | null> { + /** + * Reads settings once, returns both the SDK client and configured location IDs. + * Returns null client if the API key is not configured. + */ + private async getClientAndLocations(): Promise<{ + client: Globalping | null; + locationIds: string[]; + }> { const dbSettings = await this.settingsService.getDBSettings(); const apiKey = dbSettings.globalpingApiKey; if (!apiKey) { - return null; + return { client: null, locationIds: [] }; } - if (this.client && this.lastApiKey === apiKey) { - return this.client; + if (!this.client || this.lastApiKey !== apiKey) { + this.client = new Globalping({ auth: apiKey, throwApiErrors: false, timeout: 30000 }); + this.lastApiKey = apiKey; } - this.client = new Globalping({ auth: apiKey, throwApiErrors: false, timeout: 30000 }); - this.lastApiKey = apiKey; - return this.client; - } - - /** - * Gets the current tier's location IDs from settings. - * This is the source of truth for which locations to check. - */ - private async getTierLocationIds(): Promise { - const dbSettings = await this.settingsService.getDBSettings(); const tier = dbSettings.globalpingLocationsTier ?? 6; - return getGlobalpingLocationsByTier(tier).map((loc) => loc.id); + const locationIds = getGlobalpingLocationsByTier(tier).map((loc) => loc.id); + + return { client: this.client, locationIds }; } async runChecks(monitor: Monitor): Promise { @@ -133,13 +135,22 @@ class GlobalpingService { return []; } - const client = await this.getClient(); - if (!client) { + // Prevent overlapping checks for the same monitor + if (this.inFlightChecks.has(monitor.id)) { return []; } - const locationIds = await this.getTierLocationIds(); - if (locationIds.length === 0) { + this.inFlightChecks.add(monitor.id); + try { + return await this.executeChecks(monitor); + } finally { + this.inFlightChecks.delete(monitor.id); + } + } + + private async executeChecks(monitor: Monitor): Promise { + const { client, locationIds } = await this.getClientAndLocations(); + if (!client || locationIds.length === 0) { return []; } @@ -158,6 +169,16 @@ class GlobalpingService { target = monitorUrl; } + if (!target || !HOSTNAME_REGEX.test(target)) { + this.logger.warn({ + message: `Invalid target hostname for Globalping: "${target}"`, + service: SERVICE_NAME, + method: "executeChecks", + monitorId: monitor.id, + }); + return []; + } + const locations = locationIds .map((loc) => LOCATION_MAP[loc]) .filter(Boolean); @@ -184,7 +205,7 @@ class GlobalpingService { this.logger.warn({ message: "Failed to create Globalping measurement", service: SERVICE_NAME, - method: "runChecks", + method: "executeChecks", details: JSON.stringify(createResult.data), }); return []; @@ -197,7 +218,7 @@ class GlobalpingService { this.logger.warn({ message: "Failed to await Globalping measurement", service: SERVICE_NAME, - method: "runChecks", + method: "executeChecks", details: JSON.stringify(awaitResult.data), }); return []; @@ -223,6 +244,7 @@ class GlobalpingService { location: locationId, }); } else { + // Ping: loss < 100% is considered up (consistent with local ping checks) const stats = result.stats; results.push({ monitorId: monitor.id, @@ -242,7 +264,7 @@ class GlobalpingService { this.logger.warn({ message: `Globalping check failed: ${error.message}`, service: SERVICE_NAME, - method: "runChecks", + method: "executeChecks", stack: error.stack, }); return []; @@ -281,23 +303,6 @@ class GlobalpingService { return continent && country ? `${continent}-${country}` : continent || country || "unknown"; } - - async getLimits(): Promise { - const client = await this.getClient(); - if (!client) { - return null; - } - - try { - const result = await client.getLimits(); - if (result.ok) { - return result.data; - } - return null; - } catch { - return null; - } - } } export { LOCATION_LABELS }; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index c055f4bef..90fd50c17 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -43,7 +43,6 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; group: string | null; - locations: string[]; globalpingEnabled: boolean; recentChecks: CheckSnapshot[]; createdAt: string; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index ac163a348..600a8620b 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -172,7 +172,6 @@ const createMonitorBodyValidation = joi.object({ gameId: joi.string().allow(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), - locations: joi.array().items(joi.string()).max(5).optional(), globalpingEnabled: joi.boolean().optional(), }); @@ -205,7 +204,6 @@ const editMonitorBodyValidation = joi gameId: joi.string().allow(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), - locations: joi.array().items(joi.string()).max(5).optional(), globalpingEnabled: joi.boolean().optional(), }) .options({ stripUnknown: true });