From 42fdc47d860f4b2341e621009b26d306f9b717ed Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 23 Feb 2026 17:48:53 +0000 Subject: [PATCH 01/28] initial commit --- client/src/Types/GeoCheck.ts | 57 +++++ client/src/Types/Monitor.ts | 5 + client/src/Validation/monitor.ts | 7 + server/src/config/services.ts | 5 + server/src/db/models/GeoCheck.ts | 118 +++++++++ server/src/db/models/Monitor.ts | 12 + server/src/db/models/index.ts | 3 + .../geo-checks/IGeoChecksRepository.ts | 9 + .../geo-checks/MongoGeoChecksRepository.ts | 233 ++++++++++++++++++ server/src/repositories/index.ts | 3 + server/src/types/geoCheck.ts | 59 +++++ server/src/types/index.ts | 1 + server/src/types/monitor.ts | 5 + server/src/validation/joi.ts | 13 + 14 files changed, 530 insertions(+) create mode 100644 client/src/Types/GeoCheck.ts create mode 100644 server/src/db/models/GeoCheck.ts create mode 100644 server/src/repositories/geo-checks/IGeoChecksRepository.ts create mode 100644 server/src/repositories/geo-checks/MongoGeoChecksRepository.ts create mode 100644 server/src/types/geoCheck.ts diff --git a/client/src/Types/GeoCheck.ts b/client/src/Types/GeoCheck.ts new file mode 100644 index 000000000..a90dea58f --- /dev/null +++ b/client/src/Types/GeoCheck.ts @@ -0,0 +1,57 @@ +export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const; +export type GeoContinent = (typeof GeoContinents)[number]; + +export interface GeoCheckMetadata { + monitorId: string; + teamId: string; + type: string; +} + +export interface GeoCheckTimings { + total: number; + dns: number; + tcp: number; + tls: number; + firstByte: number; + download: number; +} + +export interface GeoCheckLocation { + continent: GeoContinent; + region: string; + country: string; + state: string; + city: string; + longitude: number; + latitude: number; +} + +export interface GeoCheckResult { + location: GeoCheckLocation; + status: boolean; + statusCode: number; + timings: GeoCheckTimings; +} + +export interface GeoCheck { + id: string; + metadata: GeoCheckMetadata; + results: GeoCheckResult[]; + expiry: string; + __v: number; + createdAt: string; + updatedAt: string; +} + +export interface GroupedGeoCheck { + bucketDate: string; + continent: GeoContinent; + avgResponseTime: number; + totalChecks: number; + uptimePercentage: number; +} + +export interface GeoChecksResult { + monitorType: string; + groupedGeoChecks: GroupedGeoCheck[]; +} diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 636e632e2..b396f93ac 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,5 +1,7 @@ import type { GroupedCheck, CheckSnapshot } from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; +import type { GeoContinent } from "@/Types/GeoCheck"; +export type { GeoContinent } from "@/Types/GeoCheck"; export const MonitorTypes = [ "http", @@ -59,6 +61,9 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; group: string | null; + geoCheckEnabled?: boolean; + geoCheckLocations?: GeoContinent[]; + geoCheckInterval?: number; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 68a15fbec..a2966f2a0 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { GeoContinents } from "@/Types/GeoCheck"; // URL schema with custom error message const urlSchema = z.url({ message: "Please enter a valid URL" }); @@ -20,6 +21,12 @@ const baseSchema = z.object({ .number({ message: "Threshold percentage is required" }) .min(1, "Incident percentage must be at least 1") .max(100, "Incident percentage must be at most 100"), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z + .number() + .min(300000, "Interval must be at least 5 minutes") + .optional(), }); // HTTP monitor schema diff --git a/server/src/config/services.ts b/server/src/config/services.ts index ecb49ab40..903d6cebe 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -46,6 +46,7 @@ import jmespath from "jmespath"; import { MongoMonitorsRepository, MongoChecksRepository, + MongoGeoChecksRepository, MongoMonitorStatsRepository, MongoStatusPagesRepository, MongoUsersRepository, @@ -57,6 +58,7 @@ import { MongoMaintenanceWindowsRepository, IMonitorsRepository, IChecksRepository, + IGeoChecksRepository, IMonitorStatsRepository, IStatusPagesRepository, IUsersRepository, @@ -94,6 +96,7 @@ export type InitializedServices = { // Repositories monitorsRepository: IMonitorsRepository; checksRepository: IChecksRepository; + geoChecksRepository: IGeoChecksRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; usersRepository: IUsersRepository; @@ -126,6 +129,7 @@ export const initializeServices = async ({ // Repositories const monitorsRepository = new MongoMonitorsRepository(); const checksRepository = new MongoChecksRepository(logger); + const geoChecksRepository = new MongoGeoChecksRepository(logger); const monitorStatsRepository = new MongoMonitorStatsRepository(); const statusPagesRepository = new MongoStatusPagesRepository(); const usersRepository = new MongoUsersRepository(); @@ -278,6 +282,7 @@ export const initializeServices = async ({ // Repositories monitorsRepository, checksRepository, + geoChecksRepository, monitorStatsRepository, statusPagesRepository, usersRepository, diff --git a/server/src/db/models/GeoCheck.ts b/server/src/db/models/GeoCheck.ts new file mode 100644 index 000000000..689130f20 --- /dev/null +++ b/server/src/db/models/GeoCheck.ts @@ -0,0 +1,118 @@ +import { Schema, model, Types } from "mongoose"; +import { MonitorTypes, type MonitorType } from "@/types/monitor.js"; +import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/types/geoCheck.js"; + +type GeoCheckMetadataDocument = Omit & { + monitorId: Types.ObjectId; + teamId: Types.ObjectId; + type: MonitorType; +}; + +type GeoCheckDocumentBase = Omit & { + metadata: GeoCheckMetadataDocument; + results: GeoCheckResult[]; + expiry: Date; + createdAt: Date; + updatedAt: Date; + __v: number; +}; + +export interface GeoCheckDocument extends GeoCheckDocumentBase { + _id: Types.ObjectId; +} + +const geoCheckMetadataSchema = new Schema( + { + monitorId: { type: Schema.Types.ObjectId, required: true, index: true }, + teamId: { type: Schema.Types.ObjectId, required: true, index: true }, + type: { type: String, required: true, enum: MonitorTypes }, + }, + { _id: false } +); + +const geoCheckTimingsSchema = new Schema( + { + total: { type: Number, default: 0 }, + dns: { type: Number, default: 0 }, + tcp: { type: Number, default: 0 }, + tls: { type: Number, default: 0 }, + firstByte: { type: Number, default: 0 }, + download: { type: Number, default: 0 }, + }, + { _id: false } +); + +const geoCheckLocationSchema = new Schema( + { + continent: { type: String, required: true }, + region: { type: String, default: "" }, + country: { type: String, default: "" }, + state: { type: String, default: "" }, + city: { type: String, default: "" }, + longitude: { type: Number, default: 0 }, + latitude: { type: Number, default: 0 }, + }, + { _id: false } +); + +const geoCheckResultSchema = new Schema( + { + location: { + type: geoCheckLocationSchema, + required: true, + }, + status: { + type: Boolean, + required: true, + }, + statusCode: { + type: Number, + required: true, + }, + timings: { + type: geoCheckTimingsSchema, + required: true, + }, + }, + { _id: false } +); + +const GeoCheckSchema = new Schema( + { + metadata: { + type: geoCheckMetadataSchema, + required: true, + }, + results: { + type: [geoCheckResultSchema], + required: true, + default: [], + }, + expiry: { + type: Date, + default: Date.now, + }, + }, + { + timestamps: true, + strict: false, + timeseries: { + timeField: "createdAt", + metaField: "metadata", + granularity: "seconds", + }, + } +); + +GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: -1 }); +GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: 1 }); +GeoCheckSchema.index({ "metadata.monitorId": 1, "results.location.continent": 1, createdAt: -1 }); +GeoCheckSchema.index({ "metadata.teamId": 1, createdAt: -1 }); +GeoCheckSchema.index({ "metadata.teamId": 1, "results.status": 1, createdAt: -1 }); +GeoCheckSchema.index({ createdAt: 1 }); + +const GeoCheckModel = model("GeoCheck", GeoCheckSchema); + +export type { GeoCheckMetadataDocument }; +export { GeoCheckModel }; +export default GeoCheckModel; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index a459cdf3e..380f6d27c 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -169,6 +169,18 @@ const MonitorSchema = new Schema( return value && value.trim() ? value.trim() : null; }, }, + geoCheckEnabled: { + type: Boolean, + default: false, + }, + geoCheckLocations: { + type: [String], + default: [], + }, + geoCheckInterval: { + type: Number, + default: 300000, + }, recentChecks: { type: [checkSnapshotSchema], default: [], diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index fee369e2b..1024b2b93 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -33,3 +33,6 @@ export { default as TeamModel } from "@/db/models/Team.js"; export * from "@/db/models/MaintenanceWindow.js"; export { default as MaintenanceWindowModel } from "@/db/models/MaintenanceWindow.js"; + +export * from "@/db/models/GeoCheck.js"; +export { default as GeoCheckModel } from "@/db/models/GeoCheck.js"; diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts new file mode 100644 index 000000000..360cfb621 --- /dev/null +++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts @@ -0,0 +1,9 @@ +import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js"; + +export interface IGeoChecksRepository { + create(geoCheck: Omit): Promise; + findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise; + findGroupedByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date, dateFormat: string): Promise; + deleteByMonitorId(monitorId: string): Promise; + deleteByTeamId(teamId: string): Promise; +} diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts new file mode 100644 index 000000000..0310a1c72 --- /dev/null +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -0,0 +1,233 @@ +import { IGeoChecksRepository } from "./IGeoChecksRepository.js"; +import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck } from "@/types/geoCheck.js"; +import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js"; +import mongoose from "mongoose"; + +const SERVICE_NAME = "GeoChecksRepository"; + +class MongoGeoChecksRepository implements IGeoChecksRepository { + static SERVICE_NAME = SERVICE_NAME; + + private logger: any; + constructor(logger: any) { + this.logger = logger; + } + + private toEntity = (doc: GeoCheckDocument): GeoCheck => { + const toStringId = (value: mongoose.Types.ObjectId | string | undefined | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + const toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + const mapMetadata = (metadata: any): GeoCheckMetadata => ({ + monitorId: toStringId(metadata.monitorId), + teamId: toStringId(metadata.teamId), + type: metadata.type, + }); + + const mapResults = (results: any[]): GeoCheckResult[] => { + if (!results || !Array.isArray(results)) { + return []; + } + return results.map((result) => ({ + location: { + continent: result.location?.continent ?? "", + region: result.location?.region ?? "", + country: result.location?.country ?? "", + state: result.location?.state ?? "", + city: result.location?.city ?? "", + longitude: result.location?.longitude ?? 0, + latitude: result.location?.latitude ?? 0, + }, + status: result.status ?? false, + statusCode: result.statusCode ?? 0, + timings: { + total: result.timings?.total ?? 0, + dns: result.timings?.dns ?? 0, + tcp: result.timings?.tcp ?? 0, + tls: result.timings?.tls ?? 0, + firstByte: result.timings?.firstByte ?? 0, + download: result.timings?.download ?? 0, + }, + })); + }; + + return { + id: toStringId(doc._id), + metadata: mapMetadata(doc.metadata), + results: mapResults(doc.results), + expiry: toDateString(doc.expiry), + __v: doc.__v ?? 0, + createdAt: toDateString(doc.createdAt), + updatedAt: toDateString(doc.updatedAt), + }; + }; + + create = async (geoCheck: 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); + } catch (error: any) { + this.logger.error({ + message: `Error creating geo check: ${error.message}`, + service: SERVICE_NAME, + method: "create", + }); + throw error; + } + }; + + findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise => { + try { + const docs = await GeoCheckModel.find({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + createdAt: { + $gte: startDate, + $lte: endDate, + }, + }).sort({ createdAt: -1 }); + return docs.map(this.toEntity); + } catch (error: any) { + this.logger.error({ + message: `Error finding geo checks by monitor ID and date range: ${error.message}`, + service: SERVICE_NAME, + method: "findByMonitorIdAndDateRange", + }); + throw error; + } + }; + + findGroupedByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date, dateFormat: string): Promise => { + try { + const pipeline: any[] = [ + // Match geo checks for this monitor in date range + { + $match: { + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + createdAt: { + $gte: startDate, + $lte: endDate, + }, + }, + }, + // Unwind the results array to process each location separately + { + $unwind: "$results", + }, + // Group by date bucket and continent + { + $group: { + _id: { + bucketDate: { + $dateToString: { + format: dateFormat, + date: "$createdAt", + }, + }, + continent: "$results.location.continent", + }, + avgResponseTime: { $avg: "$results.timings.total" }, + totalChecks: { $sum: 1 }, + upChecks: { + $sum: { + $cond: ["$results.status", 1, 0], + }, + }, + }, + }, + // Calculate uptime percentage + { + $project: { + _id: 0, + bucketDate: "$_id.bucketDate", + continent: "$_id.continent", + avgResponseTime: { $round: ["$avgResponseTime", 2] }, + totalChecks: 1, + uptimePercentage: { + $round: [ + { + $multiply: [ + { + $divide: ["$upChecks", "$totalChecks"], + }, + 100, + ], + }, + 2, + ], + }, + }, + }, + // Sort by date and continent + { + $sort: { + bucketDate: 1, + continent: 1, + }, + }, + ]; + + const results = await GeoCheckModel.aggregate(pipeline); + return results as GroupedGeoCheck[]; + } catch (error: any) { + this.logger.error({ + message: `Error finding grouped geo checks: ${error.message}`, + service: SERVICE_NAME, + method: "findGroupedByMonitorIdAndDateRange", + }); + throw error; + } + }; + + deleteByMonitorId = async (monitorId: string): Promise => { + try { + const result = await GeoCheckModel.deleteMany({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + }); + return result.deletedCount || 0; + } catch (error: any) { + this.logger.error({ + message: `Error deleting geo checks by monitor ID: ${error.message}`, + service: SERVICE_NAME, + method: "deleteByMonitorId", + }); + throw error; + } + }; + + deleteByTeamId = async (teamId: string): Promise => { + try { + const result = await GeoCheckModel.deleteMany({ + "metadata.teamId": new mongoose.Types.ObjectId(teamId), + }); + return result.deletedCount || 0; + } catch (error: any) { + this.logger.error({ + message: `Error deleting geo checks by team ID: ${error.message}`, + service: SERVICE_NAME, + method: "deleteByTeamId", + }); + throw error; + } + }; +} + +export { MongoGeoChecksRepository }; +export default MongoGeoChecksRepository; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index da31a8c75..c1d8c2ecf 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -33,3 +33,6 @@ export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeams export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js"; export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js"; + +export * from "@/repositories/geo-checks/IGeoChecksRepository.js"; +export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js"; diff --git a/server/src/types/geoCheck.ts b/server/src/types/geoCheck.ts new file mode 100644 index 000000000..742083a27 --- /dev/null +++ b/server/src/types/geoCheck.ts @@ -0,0 +1,59 @@ +import type { MonitorType } from "@/types/index.js"; + +export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const; +export type GeoContinent = (typeof GeoContinents)[number]; + +export interface GeoCheckMetadata { + monitorId: string; + teamId: string; + type: MonitorType; +} + +export interface GeoCheckTimings { + total: number; + dns: number; + tcp: number; + tls: number; + firstByte: number; + download: number; +} + +export interface GeoCheckLocation { + continent: GeoContinent; + region: string; + country: string; + state: string; + city: string; + longitude: number; + latitude: number; +} + +export interface GeoCheckResult { + location: GeoCheckLocation; + status: boolean; + statusCode: number; + timings: GeoCheckTimings; +} + +export interface GeoCheck { + id: string; + metadata: GeoCheckMetadata; + results: GeoCheckResult[]; + expiry: string; + __v: number; + createdAt: string; + updatedAt: string; +} + +export interface GroupedGeoCheck { + bucketDate: string; + continent: GeoContinent; + avgResponseTime: number; + totalChecks: number; + uptimePercentage: number; +} + +export interface GeoChecksResult { + monitorType: Exclude; + groupedGeoChecks: GroupedGeoCheck[]; +} diff --git a/server/src/types/index.ts b/server/src/types/index.ts index ad92ea1fe..e5b7bf59d 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -1,4 +1,5 @@ export * from "@/types/check.js"; +export * from "@/types/geoCheck.js"; export * from "@/types/monitor.js"; export * from "@/types/monitorStats.js"; export * from "@/types/statusPage.js"; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index ac7ca02d0..cb949c0c2 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -1,5 +1,7 @@ import type { CheckSnapshot } from "@/types/check.js"; export type { CheckSnapshot } from "@/types/check.js"; +import type { GeoContinent } from "@/types/geoCheck.js"; +export type { GeoContinent } from "@/types/geoCheck.js"; export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; @@ -43,6 +45,9 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; group: string | null; + geoCheckEnabled?: boolean; + geoCheckLocations?: GeoContinent[]; + geoCheckInterval?: number; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 912767587..16aaa34a9 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -1,5 +1,6 @@ import joi, { type CustomHelpers } from "joi"; import { type UserRole, UserRoles } from "@/types/user.js"; +import { GeoContinents } from "@/types/geoCheck.js"; //**************************************** // Custom Validators @@ -172,6 +173,12 @@ const createMonitorBodyValidation = joi.object({ gameId: joi.string().allow(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), + geoCheckEnabled: joi.boolean().optional(), + geoCheckLocations: joi + .array() + .items(joi.string().valid(...GeoContinents)) + .optional(), + geoCheckInterval: joi.number().min(300000).optional(), }); const createMonitorsBodyValidation = joi.array().items( @@ -203,6 +210,12 @@ const editMonitorBodyValidation = joi gameId: joi.string().allow(""), selectedDisks: joi.array().items(joi.string()).optional(), group: joi.string().max(50).trim().allow(null, "").optional(), + geoCheckEnabled: joi.boolean().optional(), + geoCheckLocations: joi + .array() + .items(joi.string().valid(...GeoContinents)) + .optional(), + geoCheckInterval: joi.number().min(300000).optional(), }) .options({ stripUnknown: true }); From 858aecce621bdd5b3833755011935e032a86c2cc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 23 Feb 2026 18:08:01 +0000 Subject: [PATCH 02/28] fe config box --- client/src/Hooks/useMonitorForm.ts | 3 + client/src/Pages/CreateMonitor/index.tsx | 136 +++++++++++++++++++++++ client/src/locales/en.json | 30 +++++ 3 files changed, 169 insertions(+) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 633fcb5c9..392b5064c 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -14,6 +14,9 @@ const getBaseDefaults = (data?: Monitor | null) => ({ notifications: data?.notifications || [], statusWindowSize: data?.statusWindowSize || 5, statusWindowThreshold: data?.statusWindowThreshold || 60, + geoCheckEnabled: data?.geoCheckEnabled || false, + geoCheckLocations: data?.geoCheckLocations || [], + geoCheckInterval: data?.geoCheckInterval || 300000, }); export const useMonitorForm = ({ diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 5012c1154..5699e8ea8 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -15,6 +15,7 @@ import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; +import { GeoContinents } from "@/Types/GeoCheck"; import { BasePage, ConfigBox } from "@/Components/design-elements"; import { @@ -165,6 +166,7 @@ const CreateMonitorPage = () => { const watchedType = watch("type") as MonitorType; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; + const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; useEffect(() => { clearErrors(); @@ -823,6 +825,140 @@ const CreateMonitorPage = () => { /> )} + {watchedType === "http" && ( + + ( + + field.onChange(e.target.checked)} + /> + + {t("pages.createMonitor.form.geoChecks.option.enabled.label")} + + + )} + /> + {watchGeoCheckEnabled && ( + + { + // Map continents to have 'name' property for Autocomplete + const locationOptions = GeoContinents.map((continent) => ({ + id: continent, + name: t( + `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` + ), + })); + const selectedLocations = locationOptions.filter((loc) => + (field.value ?? []).includes(loc.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof locationOptions) => { + field.onChange(newValue.map((loc) => loc.id)); + }} + isOptionEqualToValue={(option, value) => + option.id === value.id + } + fieldLabel={t( + "pages.createMonitor.form.geoChecks.option.locations.label" + )} + /> + {selectedLocations.length > 0 && ( + + {selectedLocations.map((location, index) => ( + + {location.name} + { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== location.id + ) + ); + }} + aria-label="Remove location" + > + + + {index < selectedLocations.length - 1 && } + + ))} + + )} + + ); + }} + /> + ( + + )} + /> + + )} + + } + /> + )} + Date: Tue, 24 Feb 2026 15:05:27 +0530 Subject: [PATCH 03/28] Fix: Uptime Status Filter --- client/src/Components/monitors/ControlsFilter.tsx | 4 +++- client/src/Pages/Uptime/Monitors/index.tsx | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx index e37408819..821a31dd9 100644 --- a/client/src/Components/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -5,6 +5,7 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { MonitorType } from "@/Types/Monitor"; import { Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; const types = ["http", "ping", "port", "docker", "game"]; const statuses = ["up", "down"]; @@ -30,6 +31,7 @@ export const ControlsFilter = ({ onClearFilters: () => void; }) => { const theme = useTheme(); + const { t } = useTranslation(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isFilterActive = (selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== ""; @@ -88,7 +90,7 @@ export const ControlsFilter = ({ variant="contained" onClick={onClearFilters} > - Clear Filters + {t("pages.incidents.filters.clearFilters")} )} diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 5c419738b..5e1672f8b 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -42,11 +42,11 @@ const UptimeMonitorsPage = () => { const debouncedSearch = useDebounce(search, 300); // Convert filter selections to API filter values - // Status: "up" -> true, "down" -> false + // Status: pass "up"/"down" directly to the API // State: "active" -> true, "paused" -> false const toFilterStatus = useMemo(() => { - if (selectedStatus === "up") return "true"; - if (selectedStatus === "down") return "false"; + if (selectedStatus === "up") return "up"; + if (selectedStatus === "down") return "down"; return undefined; }, [selectedStatus]); From 7d3abb3f9851857fc5d8e713956889dbd7ec3c2c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 17:18:22 +0000 Subject: [PATCH 04/28] map fields --- client/src/Hooks/useMonitorForm.ts | 2 +- server/src/repositories/monitors/MongoMonitorsRepository.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 392b5064c..14d6bbd68 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -14,7 +14,7 @@ const getBaseDefaults = (data?: Monitor | null) => ({ notifications: data?.notifications || [], statusWindowSize: data?.statusWindowSize || 5, statusWindowThreshold: data?.statusWindowThreshold || 60, - geoCheckEnabled: data?.geoCheckEnabled || false, + geoCheckEnabled: data?.geoCheckEnabled ?? false, geoCheckLocations: data?.geoCheckLocations || [], geoCheckInterval: data?.geoCheckInterval || 300000, }); diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 4ae4394d4..0e7ca34ee 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -354,6 +354,9 @@ class MongoMonitorsRepository implements IMonitorsRepository { gameId: doc.gameId ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), + geoCheckEnabled: doc.geoCheckEnabled ?? false, + geoCheckLocations: doc.geoCheckLocations ?? [], + geoCheckInterval: doc.geoCheckInterval ?? 300000, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -409,6 +412,9 @@ class MongoMonitorsRepository implements IMonitorsRepository { gameId: doc.gameId ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), + geoCheckEnabled: doc.geoCheckEnabled ?? false, + geoCheckLocations: doc.geoCheckLocations ?? [], + geoCheckInterval: doc.geoCheckInterval ?? 300000, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; From b870f25d1a179e1aa39f417d3c318a2d00a3ec5f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 17:39:09 +0000 Subject: [PATCH 05/28] geo job template --- .../SuperSimpleQueueHelper.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 4d9876f2f..bee981906 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -257,6 +257,85 @@ class SuperSimpleQueueHelper { }; }; + getGeoCheckJob = () => { + return async (monitor: Monitor) => { + try { + const monitorId = monitor.id; + const teamId = monitor.teamId; + + // Step 1: Validate monitor eligibility + if (!monitorId) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" }); + } + + if (monitor.type !== "http") { + this.logger.debug({ + message: `Monitor ${monitorId} is not HTTP type, skipping geo check`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + return; + } + + if (!monitor.geoCheckEnabled) { + return; + } + + if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { + this.logger.warn({ + message: `No geo check locations configured for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + return; + } + + // Step 2: Check for maintenance window + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Monitor ${monitorId} is in maintenance window, skipping geo check`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + 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); + + this.logger.debug({ + message: `Geo check job executed for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + } catch (error: any) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getGeoCheckJob", + stack: error.stack, + }); + // Don't throw - geo check failures shouldn't crash the job scheduler + } + }; + }; + async isInMaintenanceWindow(monitorId: string, teamId: string) { const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); // Check for active maintenance window: From 8e37ed7ba29f7dfa14522652ebc320a460eb38f2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 17:55:06 +0000 Subject: [PATCH 06/28] geo jobs scheduling --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 1947549c9..4ad0bea4d 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,10 +132,22 @@ class SuperSimpleQueue implements ISuperSimpleQueue { active: monitor.isActive, data: monitor, }); + + // Add geo check job if enabled for HTTP monitors + 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) => { @@ -142,6 +155,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to resume monitor"); } + await this.scheduler.pauseJob(`${monitor.id}-geo`); this.logger.debug({ message: `Paused monitor ${monitor.id}`, service: SERVICE_NAME, @@ -154,6 +168,10 @@ class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to resume monitor"); } + const geoResult = await this.scheduler.resumeJob(`${monitor.id}-geo`); + if (geoResult === false) { + throw new Error("Failed to resume geo check for monitor"); + } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, service: SERVICE_NAME, @@ -163,6 +181,29 @@ class SuperSimpleQueue implements ISuperSimpleQueue { updateJob = async (monitor: any) => { this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); + + // Handle geo check job lifecycle + const geoJobId = `${monitor.id}-geo`; + if (monitor.geoCheckEnabled && monitor.type === "http") { + // Check if geo job exists + const existingGeoJob = await this.scheduler.getJob(geoJobId); + if (existingGeoJob) { + // Update existing geo job + this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor }); + } else { + // Create new geo job + this.scheduler.addJob({ + id: geoJobId, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + }); + } + } else { + // Remove geo job if disabled or monitor type changed + this.scheduler.removeJob(geoJobId); + } }; shutdown = async () => { From 1d4e9d680629e318e2c433fe48da90e215e77503 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 18:10:20 +0000 Subject: [PATCH 07/28] geochecks iniital commit --- client/src/Pages/Logs/index.tsx | 2 +- server/src/config/services.ts | 21 +- .../geo-checks/IGeoChecksRepository.ts | 2 +- .../geo-checks/MongoGeoChecksRepository.ts | 27 ++- .../src/service/business/geoChecksService.ts | 153 +++++++++++++ .../SuperSimpleQueueHelper.ts | 24 +- .../service/infrastructure/bufferService.ts | 36 ++- .../infrastructure/globalPingService.ts | 206 ++++++++++++++++++ 8 files changed, 438 insertions(+), 33 deletions(-) create mode 100644 server/src/service/business/geoChecksService.ts create mode 100644 server/src/service/infrastructure/globalPingService.ts 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; From 705ecf41c2e7fd167e4158b711912ce417e32771 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 18:19:51 +0000 Subject: [PATCH 08/28] rename interface --- .../src/repositories/geo-checks/IGeoChecksRepository.ts | 2 +- .../repositories/geo-checks/MongoGeoChecksRepository.ts | 6 +++--- server/src/service/business/geoChecksService.ts | 8 +++----- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts index e0ee96e36..9cba699ec 100644 --- a/server/src/repositories/geo-checks/IGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts @@ -1,7 +1,7 @@ import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js"; export interface IGeoChecksRepository { - create(geoChecks: Omit[]): Promise; + createGeoChecks(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 8717eb173..539f9addf 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -72,7 +72,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { }; }; - create = async (geoChecks: Omit[]): Promise => { + createGeoChecks = async (geoChecks: Omit[]): Promise => { try { const docs = await GeoCheckModel.insertMany( geoChecks.map((geoCheck) => ({ @@ -88,9 +88,9 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { return docs.map((doc) => this.toEntity(doc)); } catch (error: any) { this.logger.error({ - message: `Failed to create geo checks: ${error.message}`, + message: `Failed to createGeoChecks: ${error.message}`, service: SERVICE_NAME, - method: "create", + method: "createGeoChecks", stack: error.stack, }); throw error; diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index 0ffa84150..351943143 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -1,4 +1,5 @@ import type { Monitor, GeoCheck } from "@/types/index.js"; +import type { GeoCheckResult } from "@/types/geoCheck.js"; import { Types } from "mongoose"; import type { IGeoChecksRepository } from "@/repositories/index.js"; import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js"; @@ -123,7 +124,7 @@ class GeoChecksService implements IGeoChecksService { } } - private buildGeoCheck(monitor: Monitor, results: any[]): GeoCheck { + private buildGeoCheck(monitor: Monitor, results: GeoCheckResult[]): GeoCheck { const now = new Date(); const expiryDate = new Date(now.getTime() + this.TTL_DAYS * 24 * 60 * 60 * 1000); @@ -142,11 +143,8 @@ class GeoChecksService implements IGeoChecksService { }; } - /** - * Create geo checks (called by buffer service) - */ createGeoChecks = async (geoChecks: GeoCheck[]) => { - return this.geoChecksRepository.create(geoChecks); + return this.geoChecksRepository.createGeoChecks(geoChecks); }; } From 5a74411ea21c6fdcc9e37217fc5e03194ff36f7d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 18:44:47 +0000 Subject: [PATCH 09/28] geo bufer --- server/src/db/models/GeoCheck.ts | 2 - .../service/infrastructure/bufferService.ts | 45 +++++++++++++++---- .../infrastructure/globalPingService.ts | 12 +++-- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/server/src/db/models/GeoCheck.ts b/server/src/db/models/GeoCheck.ts index 689130f20..8c0749f00 100644 --- a/server/src/db/models/GeoCheck.ts +++ b/server/src/db/models/GeoCheck.ts @@ -106,9 +106,7 @@ const GeoCheckSchema = new Schema( GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: -1 }); GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: 1 }); -GeoCheckSchema.index({ "metadata.monitorId": 1, "results.location.continent": 1, createdAt: -1 }); GeoCheckSchema.index({ "metadata.teamId": 1, createdAt: -1 }); -GeoCheckSchema.index({ "metadata.teamId": 1, "results.status": 1, createdAt: -1 }); GeoCheckSchema.index({ createdAt: 1 }); const GeoCheckModel = model("GeoCheck", GeoCheckSchema); diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 9870ae783..a32a57dde 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -1,5 +1,6 @@ import type { Check } from "@/types/index.js"; import type { GeoCheck } from "@/types/index.js"; +import type Logger from "@/utils/logger.js"; const SERVICE_NAME = "BufferService"; @@ -9,12 +10,13 @@ export interface IBufferService { removeCheckFromBuffer(check: Check): boolean; scheduleNextFlush(): void; flushBuffer(): Promise; + flushGeoBuffer(): Promise; } class BufferService implements IBufferService { static SERVICE_NAME = SERVICE_NAME; private BUFFER_TIMEOUT: number; - private logger: any; + private logger: Logger; private SERVICE_NAME: string; private buffer: any[]; private geoBuffer: any[]; @@ -28,7 +30,7 @@ class BufferService implements IBufferService { geoChecksService, settingsService, }: { - logger: any; + logger: Logger; checkService: any; geoChecksService?: any; settingsService: any; @@ -123,6 +125,7 @@ class BufferService implements IBufferService { this.bufferTimer = setTimeout(async () => { try { await this.flushBuffer(); + await this.flushGeoBuffer(); } catch (error: any) { this.logger.error({ message: `Error in flush cycle: ${error.message}`, @@ -139,23 +142,47 @@ class BufferService implements IBufferService { async flushBuffer() { try { if (this.buffer.length > 0) { + this.logger.debug({ + message: `Flushing ${this.buffer.length} checks to database`, + service: this.SERVICE_NAME, + method: "flushBuffer", + }); await this.checksService.createChecks(this.buffer); - } - - if (this.geoBuffer.length > 0 && this.geoChecksService) { - await this.geoChecksService.createGeoChecks(this.geoBuffer); + this.buffer = []; } } catch (error: any) { this.logger.error({ - message: error.message, + message: `Error flushing checks buffer: ${error.message}`, service: this.SERVICE_NAME, method: "flushBuffer", stack: error.stack, }); + // Clear buffer even on error to prevent infinite retry loops + this.buffer = []; } + } - this.buffer = []; - this.geoBuffer = []; + async flushGeoBuffer() { + try { + if (this.geoBuffer.length > 0) { + this.logger.debug({ + message: `Flushing ${this.geoBuffer.length} geo checks to database`, + service: this.SERVICE_NAME, + method: "flushGeoBuffer", + }); + await this.geoChecksService.createGeoChecks(this.geoBuffer); + this.geoBuffer = []; + } + } catch (error: any) { + this.logger.error({ + message: `Error flushing geo checks buffer: ${error.message}`, + service: this.SERVICE_NAME, + method: "flushGeoBuffer", + stack: error.stack, + }); + // Clear buffer even on error to prevent infinite retry loops + this.geoBuffer = []; + } } } diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index 3b6244849..720eb246a 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -1,4 +1,5 @@ import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js"; +import type Logger from "@/utils/logger.js"; import got from "got"; const SERVICE_NAME = "GlobalPingService"; @@ -56,9 +57,9 @@ export interface IGlobalPingService { class GlobalPingService implements IGlobalPingService { static SERVICE_NAME = SERVICE_NAME; - private logger: any; + private logger: Logger; - constructor({ logger }: { logger: any }) { + constructor({ logger }: { logger: Logger }) { this.logger = logger; } @@ -68,9 +69,12 @@ class GlobalPingService implements IGlobalPingService { async createMeasurement(url: string, locations: GeoContinent[]): Promise { try { + // GlobalPing API expects target without protocol (http:// or https://) + const cleanTarget = url.replace(/^https?:\/\//, ""); + const requestBody: GlobalPingMeasurementRequest = { type: "http", - target: url, + target: cleanTarget, locations: locations.map((continent) => ({ continent })), limit: locations.length, }; @@ -96,7 +100,7 @@ class GlobalPingService implements IGlobalPingService { message: "GlobalPing API unavailable, skipping geo check", service: SERVICE_NAME, method: "createMeasurement", - details: error.message, + details: error, }); return null; } From 432d1d8ccfb2741ab33f669672ef9f23dd946c54 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 19:19:32 +0000 Subject: [PATCH 10/28] remove circular dep --- server/src/config/services.ts | 47 +++----------- server/src/controllers/monitorController.ts | 25 +++++++ server/src/routes/monitorRoute.ts | 3 + server/src/service/business/checkService.ts | 14 +--- .../src/service/business/geoChecksService.ts | 65 ++++++++----------- .../src/service/business/incidentService.ts | 33 ++++------ server/src/service/business/monitorService.ts | 24 +++++++ .../SuperSimpleQueueHelper.ts | 15 ++++- .../service/infrastructure/bufferService.ts | 21 ++---- .../infrastructure/globalPingService.ts | 6 +- .../service/infrastructure/networkService.ts | 39 ++++------- 11 files changed, 139 insertions(+), 153 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 6ed260a64..2a85c47c1 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -91,7 +91,7 @@ export type InitializedServices = { maintenanceWindowService: any; monitorService: any; incidentService: any; - logger: any; + logger: ILogger; notificationsService: INotificationsService; statusPageService: IStatusPageService; notificationMessageBuilder: INotificationMessageBuilder; @@ -143,52 +143,20 @@ export const initializeServices = async ({ const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); - const networkService = new NetworkService({ - axios, - got, - https, - jmespath, - GameDig, - ping, - logger, - http, - Docker, - net, - settingsService, - }); + const networkService = new NetworkService(axios, got, https, jmespath, GameDig, ping, logger, Docker, net, settingsService); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); const notificationMessageBuilder = new NotificationMessageBuilder(); - const incidentService = new IncidentService({ - logger, - incidentsRepository, - monitorsRepository, - usersRepository, - notificationMessageBuilder, - }); + const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder); - const checkService = new CheckService({ - monitorsRepository, - logger, - checksRepository, - }); + const checkService = new CheckService(monitorsRepository, logger, checksRepository); - const globalPingService = new GlobalPingService({ logger }); + const globalPingService = new GlobalPingService(logger); - // Create geoChecksService with circular dependency workaround - const geoChecksService = new GeoChecksService({ - logger, - geoChecksRepository, - globalPingService, - bufferService: null as any, - settingsService, - }); + const geoChecksService = new GeoChecksService(logger, geoChecksRepository, globalPingService); - const bufferService = new BufferService({ logger, checkService, geoChecksService, settingsService }); - - // Set bufferService reference - (geoChecksService as any).bufferService = bufferService; + const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService); const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository); @@ -269,6 +237,7 @@ export const initializeServices = async ({ games, monitorsRepository, checksRepository, + geoChecksRepository, monitorStatsRepository, statusPagesRepository, incidentsRepository, diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 8bd131dfc..52884163d 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -134,6 +134,31 @@ class MonitorController { } }; + getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => { + try { + await getHardwareDetailsByIdParamValidation.validateAsync(req.params); + await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); + + const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); + const dateRange = requireString(req?.query?.dateRange, "dateRange"); + const teamId = requireTeamId(req?.user?.teamId); + + const data = await this.monitorService.getGeoChecksByMonitorId({ + teamId, + monitorId, + dateRange, + }); + + return res.status(200).json({ + success: true, + msg: "Geo checks retrieved successfully", + data, + }); + } catch (error) { + next(error); + } + }; + getMonitorById = async (req: Request, res: Response, next: NextFunction) => { try { await getMonitorByIdParamValidation.validateAsync(req.params); diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index afe8c7819..476e421ce 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -30,6 +30,9 @@ class MonitorRoutes { // PageSpeed routes this.router.get("/pagespeed/details/:monitorId", this.monitorController.getPageSpeedDetailsById); + // Geo checks routes + this.router.get("/:monitorId/geo-checks", this.monitorController.getGeoChecksByMonitorId); + // General monitor routes this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor); diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts index 5564d1d7f..fd7f07a55 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -1,10 +1,10 @@ -import { requireTeamId } from "@/controllers/controllerUtils.js"; import { Types } from "mongoose"; import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js"; -import type { MonitorType, MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js"; +import type { MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js"; import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ParseBoolean } from "@/utils/utils.js"; +import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "checkService"; @@ -14,15 +14,7 @@ class CheckService { private monitorsRepository: IMonitorsRepository; private checksRepository: IChecksRepository; private logger: any; - constructor({ - monitorsRepository, - logger, - checksRepository, - }: { - monitorsRepository: IMonitorsRepository; - logger: any; - checksRepository: IChecksRepository; - }) { + constructor(monitorsRepository: IMonitorsRepository, logger: ILogger, checksRepository: IChecksRepository) { this.monitorsRepository = monitorsRepository; this.logger = logger; this.checksRepository = checksRepository; diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index 351943143..a40ce82c7 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -3,42 +3,28 @@ import type { GeoCheckResult } from "@/types/geoCheck.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"; +import type { ILogger } from "@/utils/logger.js"; +import type { ISettingsService } from "@/service/system/settingsService.js"; const SERVICE_NAME = "GeoChecksService"; export interface IGeoChecksService { readonly serviceName: string; - executeGeoCheck(monitor: Monitor): Promise; + buildGeoCheck(monitor: Monitor): Promise; + createGeoChecks(geoChecks: GeoCheck[]): Promise; } class GeoChecksService implements IGeoChecksService { static SERVICE_NAME = SERVICE_NAME; - private logger: any; + private logger: ILogger; 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; - }) { + constructor(logger: ILogger, geoChecksRepository: IGeoChecksRepository, globalPingService: IGlobalPingService) { this.logger = logger; this.geoChecksRepository = geoChecksRepository; this.globalPingService = globalPingService; - this.bufferService = bufferService; - this.TTL_DAYS = settingsService.getSettings().checksTTL || 90; } get serviceName() { @@ -46,31 +32,31 @@ class GeoChecksService implements IGeoChecksService { } /** - * Execute a geo-distributed check for a monitor + * Build 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 + * 3. Transform and return GeoCheck document */ - async executeGeoCheck(monitor: Monitor): Promise { + async buildGeoCheck(monitor: Monitor): Promise { try { if (!monitor.url) { this.logger.warn({ message: "Monitor missing URL for geo check", service: SERVICE_NAME, - method: "executeGeoCheck", + method: "buildGeoCheck", details: { monitorId: monitor.id }, }); - return; + return null; } if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { this.logger.warn({ message: "Monitor missing geo check locations", service: SERVICE_NAME, - method: "executeGeoCheck", + method: "buildGeoCheck", details: { monitorId: monitor.id }, }); - return; + return null; } // Step 1: Create measurement request @@ -81,10 +67,10 @@ class GeoChecksService implements IGeoChecksService { this.logger.debug({ message: "Skipping geo check due to API unavailability", service: SERVICE_NAME, - method: "executeGeoCheck", + method: "buildGeoCheck", details: { monitorId: monitor.id }, }); - return; + return null; } // Step 2: Poll for results @@ -95,38 +81,39 @@ class GeoChecksService implements IGeoChecksService { this.logger.debug({ message: "No successful geo check results", service: SERVICE_NAME, - method: "executeGeoCheck", + method: "buildGeoCheck", details: { monitorId: monitor.id, measurementId }, }); - return; + return null; } // Step 3: Build GeoCheck document - const geoCheck = this.buildGeoCheck(monitor, results); - - // Step 4: Add to buffer for batched insertion - this.bufferService.addGeoCheckToBuffer(geoCheck); + const geoCheck = this.createGeoCheckDocument(monitor, results); this.logger.debug({ message: `Geo check completed for monitor ${monitor.id}`, service: SERVICE_NAME, - method: "executeGeoCheck", + method: "buildGeoCheck", details: { monitorId: monitor.id, resultsCount: results.length }, }); + + return geoCheck; } catch (error: any) { this.logger.error({ message: "Error executing geo check", service: SERVICE_NAME, - method: "executeGeoCheck", + method: "buildGeoCheck", details: { monitorId: monitor.id, error: error.message }, stack: error.stack, }); + return null; } } - private buildGeoCheck(monitor: Monitor, results: GeoCheckResult[]): GeoCheck { + private createGeoCheckDocument(monitor: Monitor, results: GeoCheckResult[]): GeoCheck { const now = new Date(); - const expiryDate = new Date(now.getTime() + this.TTL_DAYS * 24 * 60 * 60 * 1000); + const ttl = 90 * 24 * 60 * 60 * 1000; // 90 days in ms + const expiryDate = new Date(now.getTime() + ttl); return { id: new Types.ObjectId().toString(), diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 9b3344f11..6559a5c57 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -7,6 +7,7 @@ import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from import type { Incident } from "@/types/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; +import type { ILogger } from "@/utils/logger.js"; const dateRangeLookup: Record = { recent: new Date(new Date().setHours(new Date().getHours() - 2)), @@ -20,25 +21,19 @@ const dateRangeLookup: Record = { class IncidentService { static SERVICE_NAME = SERVICE_NAME; - private logger: any; + private logger: ILogger; private incidentsRepository: IIncidentsRepository; private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; private notificationMessageBuilder: INotificationMessageBuilder; - constructor({ - logger, - incidentsRepository, - monitorsRepository, - usersRepository, - notificationMessageBuilder, - }: { - logger: any; - incidentsRepository: IIncidentsRepository; - monitorsRepository: IMonitorsRepository; - usersRepository: IUsersRepository; - notificationMessageBuilder: INotificationMessageBuilder; - }) { + constructor( + logger: ILogger, + incidentsRepository: IIncidentsRepository, + monitorsRepository: IMonitorsRepository, + usersRepository: IUsersRepository, + notificationMessageBuilder: INotificationMessageBuilder + ) { this.logger = logger; this.incidentsRepository = incidentsRepository; this.monitorsRepository = monitorsRepository; @@ -151,7 +146,7 @@ class IncidentService { service: SERVICE_NAME, method: "resolveIncidentManually", message: `Incident manually resolved by user`, - details: resolvedIncident.id, + details: { incidentId: resolvedIncident.id }, }); return resolvedIncident; @@ -160,7 +155,7 @@ class IncidentService { service: SERVICE_NAME, method: "resolveIncident", message: error.message, - details: incidentId, + details: { id: incidentId }, stack: error.stack, }); throw error; @@ -207,7 +202,7 @@ class IncidentService { service: SERVICE_NAME, method: "getIncidentsByTeam", message: error.message, - details: teamId, + details: { teamId }, stack: error.stack, }); throw error; @@ -229,7 +224,7 @@ class IncidentService { service: SERVICE_NAME, method: "getIncidentSummary", message: error.message, - details: teamId, + details: { teamId }, stack: error.stack, }); throw error; @@ -250,7 +245,7 @@ class IncidentService { service: SERVICE_NAME, method: "getIncidentById", message: error.message, - details: incidentId, + details: { incidentId }, stack: error.stack, }); throw error; diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 2005a4a5d..157727755 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -10,6 +10,7 @@ import type { } from "@/types/monitor.js"; import type { IChecksRepository, + IGeoChecksRepository, IIncidentsRepository, IMonitorsRepository, IMonitorStatsRepository, @@ -36,6 +37,7 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise; + getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string }): Promise; getMonitorById(args: { teamId: string; monitorId: string }): Promise; getMonitorsByTeamId(args: { teamId: string; @@ -84,6 +86,7 @@ export class MonitorService implements IMonitorService { private games: any; private monitorsRepository: IMonitorsRepository; private checksRepository: IChecksRepository; + private geoChecksRepository: IGeoChecksRepository; private monitorStatsRepository: IMonitorStatsRepository; private statusPagesRepository: IStatusPagesRepository; private incidentsRepository: IIncidentsRepository; @@ -95,6 +98,7 @@ export class MonitorService implements IMonitorService { games, monitorsRepository, checksRepository, + geoChecksRepository, monitorStatsRepository, statusPagesRepository, incidentsRepository, @@ -105,6 +109,7 @@ export class MonitorService implements IMonitorService { games: any; monitorsRepository: IMonitorsRepository; checksRepository: IChecksRepository; + geoChecksRepository: IGeoChecksRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; incidentsRepository: IIncidentsRepository; @@ -115,6 +120,7 @@ export class MonitorService implements IMonitorService { this.games = games; this.monitorsRepository = monitorsRepository; this.checksRepository = checksRepository; + this.geoChecksRepository = geoChecksRepository; this.monitorStatsRepository = monitorStatsRepository; this.statusPagesRepository = statusPagesRepository; this.incidentsRepository = incidentsRepository; @@ -315,6 +321,24 @@ export class MonitorService implements IMonitorService { monitorStats, }; }; + + getGeoChecksByMonitorId = async ({ teamId, monitorId, dateRange }: { teamId: string; monitorId: string; dateRange: string }): Promise => { + const monitor = await this.monitorsRepository.findById(monitorId, teamId); + if (!monitor) { + throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 }); + } + + if (monitor.type !== "http" || !monitor.geoCheckEnabled) { + return { groupedGeoChecks: [] }; + } + + const rangeKey = (dateRange as DateRangeKey) ?? "recent"; + const { start, end } = this.getDateRange(rangeKey); + const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(monitor.id, start, end, this.getDateFormat(rangeKey)); + + return { groupedGeoChecks }; + }; + getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.findById(monitorId, teamId); return monitor; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 90827408d..c2304de57 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -306,8 +306,19 @@ class SuperSimpleQueueHelper { return; } - // Step 3: Execute geo check (handles API calls, polling, and saving) - await this.geoChecksService.executeGeoCheck(monitor); + // Step 3: Build geo check (handles API calls and polling) + const geoCheck = await this.geoChecksService.buildGeoCheck(monitor); + if (!geoCheck) { + this.logger.warn({ + message: `No geo check could be built for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getGeoCheckJob", + }); + return; + } + + // Step 4: Add geo check to buffer + this.buffer.addGeoCheckToBuffer(geoCheck); 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 a32a57dde..a56ae4bd8 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -1,7 +1,8 @@ import type { Check } from "@/types/index.js"; import type { GeoCheck } from "@/types/index.js"; -import type Logger from "@/utils/logger.js"; - +import type { IGeoChecksService } from "../business/geoChecksService.js"; +import type { ILogger } from "@/utils/logger.js"; +import type { ISettingsService } from "@/service/system/settingsService.js"; const SERVICE_NAME = "BufferService"; export interface IBufferService { @@ -16,25 +17,15 @@ export interface IBufferService { class BufferService implements IBufferService { static SERVICE_NAME = SERVICE_NAME; private BUFFER_TIMEOUT: number; - private logger: Logger; + private logger: ILogger; private SERVICE_NAME: string; private buffer: any[]; private geoBuffer: any[]; private bufferTimer: NodeJS.Timeout | null = null; private checksService: any; - private geoChecksService: any; + private geoChecksService: IGeoChecksService; - constructor({ - logger, - checkService, - geoChecksService, - settingsService, - }: { - logger: Logger; - checkService: any; - geoChecksService?: any; - settingsService: any; - }) { + constructor(logger: ILogger, checkService: any, geoChecksService: IGeoChecksService, settingsService: ISettingsService) { this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute this.logger = logger; this.checksService = checkService; diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/infrastructure/globalPingService.ts index 720eb246a..1d49c6679 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/infrastructure/globalPingService.ts @@ -1,5 +1,5 @@ import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js"; -import type Logger from "@/utils/logger.js"; +import type { ILogger } from "@/utils/logger.js"; import got from "got"; const SERVICE_NAME = "GlobalPingService"; @@ -57,9 +57,9 @@ export interface IGlobalPingService { class GlobalPingService implements IGlobalPingService { static SERVICE_NAME = SERVICE_NAME; - private logger: Logger; + private logger: ILogger; - constructor({ logger }: { logger: Logger }) { + constructor(logger: ILogger) { this.logger = logger; } diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 92d269808..914929bfb 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -3,6 +3,8 @@ import type { Got, Response } from "got"; import type { Monitor, MonitorStatusResponse } from "@/types/index.js"; import CacheableLookup from "cacheable-lookup"; +import { ISettingsService } from "../system/settingsService.js"; +import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "NetworkService"; type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>; @@ -111,31 +113,18 @@ class NetworkService implements INetworkService { }; }; - constructor({ - axios, - got, - https, - jmespath, - GameDig, - ping, - logger, - http, - Docker, - net, - settingsService, - }: { - axios: any; - got: Got; - https: any; - jmespath: any; - GameDig: any; - ping: any; - logger: any; - http: any; - Docker: any; - net: any; - settingsService: any; - }) { + constructor( + axios: any, + got: Got, + https: any, + jmespath: any, + GameDig: any, + ping: any, + logger: ILogger, + Docker: any, + net: any, + settingsService: ISettingsService + ) { this.TYPE_PING = "ping"; this.TYPE_HTTP = "http"; this.TYPE_PAGESPEED = "pagespeed"; From 89a7fdbe346077a060f0b6f7ad343e5c3ca146ac Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 19:36:45 +0000 Subject: [PATCH 11/28] cleanup imports --- server/src/config/services.ts | 14 ++--- .../SuperSimpleQueue/SuperSimpleQueue.ts | 24 +++----- .../SuperSimpleQueueHelper.ts | 61 +++++++++---------- .../service/infrastructure/networkService.ts | 19 +++--- 4 files changed, 53 insertions(+), 65 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 2a85c47c1..db6016b04 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -181,13 +181,13 @@ export const initializeServices = async ({ notificationMessageBuilder ); - const superSimpleQueueHelper = new SuperSimpleQueueHelper({ + const superSimpleQueueHelper = new SuperSimpleQueueHelper( logger, networkService, statusService, notificationsService, checkService, - buffer: bufferService, + bufferService, incidentService, maintenanceWindowsRepository, monitorsRepository, @@ -195,14 +195,10 @@ export const initializeServices = async ({ monitorStatsRepository, checksRepository, incidentsRepository, - geoChecksService, - }); + geoChecksService + ); - const superSimpleQueue = await SuperSimpleQueue.create({ - logger, - helper: superSimpleQueueHelper, - monitorsRepository, - }); + const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository); // Business services const userService = new UserService({ diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 4ad0bea4d..f8cb8cef0 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -1,5 +1,7 @@ import { IMonitorsRepository } from "@/repositories/index.js"; +import { ILogger } from "@/utils/logger.js"; import Scheduler from "super-simple-scheduler"; +import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; const SERVICE_NAME = "JobQueue"; type QueueJobFailure = { @@ -54,22 +56,12 @@ export interface ISuperSimpleQueue { class SuperSimpleQueue implements ISuperSimpleQueue { static SERVICE_NAME = SERVICE_NAME; - private logger: any; - private helper: any; + private logger: ILogger; + private helper: ISuperSimpleQueueHelper; private monitorsRepository: IMonitorsRepository; private readonly scheduler: Scheduler; - constructor({ - logger, - helper, - monitorsRepository, - scheduler, - }: { - logger: any; - helper: any; - monitorsRepository: IMonitorsRepository; - scheduler: Scheduler; - }) { + constructor(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { this.logger = logger; this.helper = helper; this.monitorsRepository = monitorsRepository; @@ -80,14 +72,14 @@ class SuperSimpleQueue implements ISuperSimpleQueue { return SuperSimpleQueue.SERVICE_NAME; } - static async create({ logger, helper, monitorsRepository }: { logger: any; helper: any; monitorsRepository: IMonitorsRepository }) { + static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) { const scheduler = new Scheduler({ // storeType: "mongo", // storeType: "redis", logLevel: "debug", // dbUri: envSettings.dbConnectionString, }); - const instance = new SuperSimpleQueue({ logger, helper, monitorsRepository, scheduler }); + const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler); await instance.init(); return instance; } @@ -113,7 +105,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue { this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true }); return true; - } catch (error) { + } catch (error: any) { this.logger.error({ message: "Failed to initialize SuperSimpleQueue", service: SERVICE_NAME, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index c2304de57..1c57bd59f 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -13,6 +13,16 @@ import { IChecksRepository, IIncidentsRepository, } from "@/repositories/index.js"; +import { ILogger } from "@/utils/logger.js"; +import { IBufferService } from "../bufferService.js"; + +export interface ISuperSimpleQueueHelper { + readonly serviceName: string; + getMonitorJob(): (monitor: Monitor) => Promise; + getCleanupOrphanedJob(): () => Promise; + getGeoCheckJob(): (monitor: Monitor) => Promise; + isInMaintenanceWindow(monitorId: string, teamId: string): Promise; +} export interface MonitorActionDecision { shouldCreateIncident: boolean; @@ -28,7 +38,7 @@ export interface MonitorActionDecision { }; } -class SuperSimpleQueueHelper { +class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { static SERVICE_NAME = SERVICE_NAME; private logger: any; @@ -36,7 +46,7 @@ class SuperSimpleQueueHelper { private statusService: IStatusService; private notificationsService: INotificationsService; private checkService: any; - private buffer: any; + private buffer: IBufferService; private incidentService: IncidentService; private maintenanceWindowsRepository: IMaintenanceWindowsRepository; private monitorsRepository: IMonitorsRepository; @@ -46,37 +56,22 @@ class SuperSimpleQueueHelper { private incidentsRepository: IIncidentsRepository; private geoChecksService: IGeoChecksService; - constructor({ - logger, - networkService, - statusService, - notificationsService, - checkService, - buffer, - incidentService, - maintenanceWindowsRepository, - monitorsRepository, - teamsRepository, - monitorStatsRepository, - checksRepository, - incidentsRepository, - geoChecksService, - }: { - logger: any; - networkService: INetworkService; - statusService: IStatusService; - notificationsService: INotificationsService; - checkService: any; - buffer: any; - incidentService: IncidentService; - maintenanceWindowsRepository: IMaintenanceWindowsRepository; - monitorsRepository: IMonitorsRepository; - teamsRepository: ITeamsRepository; - monitorStatsRepository: IMonitorStatsRepository; - checksRepository: IChecksRepository; - incidentsRepository: IIncidentsRepository; - geoChecksService: IGeoChecksService; - }) { + constructor( + logger: ILogger, + networkService: INetworkService, + statusService: IStatusService, + notificationsService: INotificationsService, + checkService: any, + buffer: IBufferService, + incidentService: IncidentService, + maintenanceWindowsRepository: IMaintenanceWindowsRepository, + monitorsRepository: IMonitorsRepository, + teamsRepository: ITeamsRepository, + monitorStatsRepository: IMonitorStatsRepository, + checksRepository: IChecksRepository, + incidentsRepository: IIncidentsRepository, + geoChecksService: IGeoChecksService + ) { this.logger = logger; this.networkService = networkService; this.statusService = statusService; diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 914929bfb..958bbdfd8 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -1,6 +1,8 @@ import { HTTPError, RequestError } from "got"; import type { Got, Response } from "got"; import type { Monitor, MonitorStatusResponse } from "@/types/index.js"; +import type { AxiosStatic } from "axios"; +import type Docker from "dockerode"; import CacheableLookup from "cacheable-lookup"; import { ISettingsService } from "../system/settingsService.js"; @@ -48,16 +50,16 @@ class NetworkService implements INetworkService { private NETWORK_ERROR: number; private PING_ERROR: number; - private axios: any; + private axios: AxiosStatic; private got: Got; private https: any; private jmespath: any; private GameDig: any; private ping: any; - private logger: any; + private logger: ILogger; private Docker: any; private net: any; - private settingsService: any; + private settingsService: ISettingsService; private buildStatusResponse = ({ monitor, @@ -114,7 +116,7 @@ class NetworkService implements INetworkService { }; constructor( - axios: any, + axios: AxiosStatic, got: Got, https: any, jmespath: any, @@ -423,7 +425,6 @@ class NetworkService implements INetworkService { const docker = new this.Docker({ socketPath: "/var/run/docker.sock", - handleError: true, // Enable error handling }); const dockerResponse = this.buildStatusResponse({ @@ -519,6 +520,10 @@ class NetworkService implements INetworkService { private async requestPort(monitor: Monitor): Promise { try { const { url, port } = monitor; + if (!port) { + throw new Error("Port is required for port monitor"); + } + const { response, responseTime, error } = await this.timeRequest(async () => { return new Promise((resolve, reject) => { const socket = this.net.createConnection( @@ -585,9 +590,9 @@ class NetworkService implements INetworkService { }); const state = await this.GameDig.query({ - type: gameId, + type: gameId ?? "unknown", host: url, - port: port, + port: port ?? 0, }).catch((error: any) => { this.logger.warn({ message: error.message, From 2989968fa1c186ce6c218b794f55d596f93445bd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 13:31:36 -0800 Subject: [PATCH 12/28] button string --- client/src/Components/monitors/ControlsFilter.tsx | 2 +- client/src/locales/en.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx index 821a31dd9..d3f6e130e 100644 --- a/client/src/Components/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -90,7 +90,7 @@ export const ControlsFilter = ({ variant="contained" onClick={onClearFilters} > - {t("pages.incidents.filters.clearFilters")} + {t("common.buttons.clearFilters")} )} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index bb740cd13..b3f3bbcf5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -45,7 +45,8 @@ "removeMonitors": "Remove monitors", "sendTestEmail": "Send test email", "exportToJSON": "Export to JSON", - "importFromJSON": "Import from JSON" + "importFromJSON": "Import from JSON", + "clearFilters": "Clear filters" }, "charts": { "labels": { @@ -620,7 +621,6 @@ }, "filters": { "allMonitors": "All monitors", - "clearFilters": "Clear filters", "monitor": "Monitor", "resolutionType": "Resolution type", "resolutionTypes": { From 10313102ee5f7190020aa1eda5967a569ac44377 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 21:45:55 +0000 Subject: [PATCH 13/28] use correct validation --- server/src/controllers/monitorController.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 52884163d..07aa65361 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -136,8 +136,8 @@ class MonitorController { getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => { try { - await getHardwareDetailsByIdParamValidation.validateAsync(req.params); - await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); + await getMonitorByIdParamValidation.validateAsync(req.params); + await getMonitorByIdQueryValidation.validateAsync(req.query); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const dateRange = requireString(req?.query?.dateRange, "dateRange"); From c3e4b5b95fb69d619220abcccbf85e1f9a8756f5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 22:16:02 +0000 Subject: [PATCH 14/28] ui initial commit --- .../src/Components/monitors/HeaderGeoTabs.tsx | 56 +++++++++++++++++++ client/src/Components/monitors/index.tsx | 1 + client/src/Pages/Uptime/Details/index.tsx | 33 +++++++++++ 3 files changed, 90 insertions(+) create mode 100644 client/src/Components/monitors/HeaderGeoTabs.tsx diff --git a/client/src/Components/monitors/HeaderGeoTabs.tsx b/client/src/Components/monitors/HeaderGeoTabs.tsx new file mode 100644 index 000000000..06753bc14 --- /dev/null +++ b/client/src/Components/monitors/HeaderGeoTabs.tsx @@ -0,0 +1,56 @@ +import Stack from "@mui/material/Stack"; +import { Tabs, Tab } from "@/Components/design-elements"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import type { GeoContinent } from "@/Types/GeoCheck"; + +interface HeaderGeoTabsProps { + geoCheckEnabled: boolean; + locations: GeoContinent[] | undefined; + selectedLocation: GeoContinent; + onLocationChange: (location: GeoContinent) => void; +} + +export const HeaderGeoTabs = ({ + geoCheckEnabled, + locations, + selectedLocation, + onLocationChange, +}: HeaderGeoTabsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + if (!geoCheckEnabled || !locations || locations.length === 0) { + return null; + } + + const handleChange = (_event: React.SyntheticEvent, newValue: GeoContinent) => { + onLocationChange(newValue); + }; + + console.log(locations); + + return ( + + + {locations.map((location) => ( + + ))} + + + ); +}; diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx index 9496395f6..d06a374a3 100644 --- a/client/src/Components/monitors/index.tsx +++ b/client/src/Components/monitors/index.tsx @@ -1,6 +1,7 @@ export * from "./ControlsFilter"; export * from "./MonitorStatBoxes"; export * from "./HeaderMonitorControls"; +export * from "./HeaderGeoTabs"; export * from "./charts/HistogramStatus"; export * from "./charts/RadialAvgResponse"; export * from "./charts/HistogramDetails"; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 23f9a2196..d6ffc213c 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -6,6 +6,7 @@ import { RadialAvgResponse, HistogramDetails, HeaderMonitorControls, + HeaderGeoTabs, } from "@/Components/monitors"; import { TrendingUp, AlertTriangle } from "lucide-react"; import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; @@ -19,9 +20,11 @@ import { useSelector } from "react-redux"; import { useGet } from "@/Hooks/UseApi"; import type { MonitorDetailsResponse } from "@/Types/Monitor"; import type { ChecksResponse } from "@/Types/Check"; +import type { GeoChecksResult, GeoContinent } from "@/Types/GeoCheck"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { t } from "i18next"; +import { Typography } from "@mui/material"; const certificateDateFormat = "MMM D, YYYY h A"; @@ -38,6 +41,7 @@ const UptimeDetailsPage = () => { const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(5); const [dateRange, setDateRange] = useState("recent"); + const [selectedLocation, setSelectedLocation] = useState("NA"); const monitorDetailsUrl = useMemo(() => { if (!monitorId) { @@ -105,6 +109,22 @@ const UptimeDetailsPage = () => { { keepPreviousData: true } ); + // Geo checks fetch - only for HTTP monitors with geo checks enabled + const geoChecksUrl = useMemo(() => { + if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) { + return null; + } + const params = new URLSearchParams(); + params.append("dateRange", dateRange); + return `/monitors/${monitorId}/geo-checks?${params.toString()}`; + }, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange]); + + const { data: _geoChecksData, isLoading: _geoChecksIsLoading } = + useGet(geoChecksUrl, {}, { keepPreviousData: true }); + + // Extract unique locations from geo checks data + const geoLocations = monitor?.geoCheckLocations; + const checks = checksData?.checks ?? []; const checksCount = checksData?.checksCount ?? 0; @@ -127,6 +147,7 @@ const UptimeDetailsPage = () => { dateRange={dateRange} setDateRange={setDateRange} /> + { rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} /> + + {monitor?.geoCheckEnabled && ( + + Location breakdown + + + )} ); }; From 64fdd04cc79207c38a27a060fe847a16504c5f1f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 22:30:28 +0000 Subject: [PATCH 15/28] conitnent based query --- client/src/Pages/Uptime/Details/index.tsx | 3 ++- server/src/controllers/monitorController.ts | 3 +++ .../geo-checks/IGeoChecksRepository.ts | 9 +++++++- .../geo-checks/MongoGeoChecksRepository.ts | 18 ++++++++++++++- server/src/service/business/monitorService.ts | 23 ++++++++++++++++--- server/src/validation/joi.ts | 1 + 6 files changed, 51 insertions(+), 6 deletions(-) diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index d6ffc213c..061c4e0b1 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -116,8 +116,9 @@ const UptimeDetailsPage = () => { } const params = new URLSearchParams(); params.append("dateRange", dateRange); + params.append("continent", selectedLocation); return `/monitors/${monitorId}/geo-checks?${params.toString()}`; - }, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange]); + }, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]); const { data: _geoChecksData, isLoading: _geoChecksIsLoading } = useGet(geoChecksUrl, {}, { keepPreviousData: true }); diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 07aa65361..662f35b50 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -27,6 +27,7 @@ import { } from "./controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorService } from "@/service/index.js"; +import { GeoContinent } from "@/types/geoCheck.js"; const SERVICE_NAME = "monitorController"; class MonitorController { @@ -141,12 +142,14 @@ class MonitorController { const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const dateRange = requireString(req?.query?.dateRange, "dateRange"); + const continent = optionalString(req?.query?.continent, "continent") as GeoContinent | undefined; const teamId = requireTeamId(req?.user?.teamId); const data = await this.monitorService.getGeoChecksByMonitorId({ teamId, monitorId, dateRange, + continent, }); return res.status(200).json({ diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts index 9cba699ec..31b2dd2a5 100644 --- a/server/src/repositories/geo-checks/IGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts @@ -1,9 +1,16 @@ import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js"; +import type { GeoContinent } from "@/types/geoCheck.js"; export interface IGeoChecksRepository { createGeoChecks(geoChecks: Omit[]): Promise; findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise; - findGroupedByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date, dateFormat: string): Promise; + findGroupedByMonitorIdAndDateRange( + monitorId: string, + startDate: Date, + endDate: Date, + dateFormat: string, + continent?: GeoContinent + ): Promise; deleteByMonitorId(monitorId: string): Promise; deleteByTeamId(teamId: string): Promise; } diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index 539f9addf..b40dc2cf8 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -117,7 +117,13 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { } }; - findGroupedByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date, dateFormat: string): Promise => { + findGroupedByMonitorIdAndDateRange = async ( + monitorId: string, + startDate: Date, + endDate: Date, + dateFormat: string, + continent?: string + ): Promise => { try { const pipeline: any[] = [ // Match geo checks for this monitor in date range @@ -134,6 +140,16 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { { $unwind: "$results", }, + // Filter by continent if specified + ...(continent + ? [ + { + $match: { + "results.location.continent": continent, + }, + }, + ] + : []), // Group by date bucket and continent { $group: { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 157727755..2a1e9d0ed 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -8,6 +8,7 @@ import type { PageSpeedDetailsResult, GamesMap, } from "@/types/monitor.js"; +import type { GeoContinent } from "@/types/geoCheck.js"; import type { IChecksRepository, IGeoChecksRepository, @@ -37,7 +38,7 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise; - getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string }): Promise; + getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continent?: GeoContinent }): Promise; getMonitorById(args: { teamId: string; monitorId: string }): Promise; getMonitorsByTeamId(args: { teamId: string; @@ -322,7 +323,17 @@ export class MonitorService implements IMonitorService { }; }; - getGeoChecksByMonitorId = async ({ teamId, monitorId, dateRange }: { teamId: string; monitorId: string; dateRange: string }): Promise => { + getGeoChecksByMonitorId = async ({ + teamId, + monitorId, + dateRange, + continent, + }: { + teamId: string; + monitorId: string; + dateRange: string; + continent?: GeoContinent; + }): Promise => { const monitor = await this.monitorsRepository.findById(monitorId, teamId); if (!monitor) { throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 }); @@ -334,7 +345,13 @@ export class MonitorService implements IMonitorService { const rangeKey = (dateRange as DateRangeKey) ?? "recent"; const { start, end } = this.getDateRange(rangeKey); - const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(monitor.id, start, end, this.getDateFormat(rangeKey)); + const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange( + monitor.id, + start, + end, + this.getDateFormat(rangeKey), + continent + ); return { groupedGeoChecks }; }; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 16aaa34a9..34283f461 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -113,6 +113,7 @@ const getMonitorByIdQueryValidation = joi.object({ dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), numToDisplay: joi.number(), normalize: joi.boolean(), + continent: joi.string().valid(...GeoContinents), }); const getMonitorsByTeamIdParamValidation = joi.object({}); From 651fbd7cbbba5bf53f5f5974300ea1acc07794e5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 22:45:59 +0000 Subject: [PATCH 16/28] add graph --- client/src/Pages/Uptime/Details/index.tsx | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 061c4e0b1..5f6d347b1 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -60,7 +60,7 @@ const UptimeDetailsPage = () => { } = useGet( monitorDetailsUrl, {}, - { refreshInterval: 10000, keepPreviousData: true } + { refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false } ); const monitorData = monitorDetailsData?.monitorData; @@ -75,7 +75,11 @@ const UptimeDetailsPage = () => { return `/monitors/certificate/${monitorId}`; }, [monitorId, monitor?.type]); - const { data: certificateData } = useGet(certificateUrl); + const { data: certificateData } = useGet( + certificateUrl, + {}, + { revalidateOnFocus: false } + ); const certificateExpiry = useMemo(() => { if (!certificateData?.certificateDate) { @@ -106,7 +110,7 @@ const UptimeDetailsPage = () => { const { data: checksData, isLoading: checksIsLoading } = useGet( checksUrl, {}, - { keepPreviousData: true } + { keepPreviousData: true, revalidateOnFocus: false } ); // Geo checks fetch - only for HTTP monitors with geo checks enabled @@ -121,7 +125,13 @@ const UptimeDetailsPage = () => { }, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]); const { data: _geoChecksData, isLoading: _geoChecksIsLoading } = - useGet(geoChecksUrl, {}, { keepPreviousData: true }); + useGet( + geoChecksUrl, + {}, + { keepPreviousData: true, revalidateOnFocus: false } + ); + + const geoChecks = _geoChecksData?.groupedGeoChecks ?? []; // Extract unique locations from geo checks data const geoLocations = monitor?.geoCheckLocations; @@ -184,15 +194,19 @@ const UptimeDetailsPage = () => { /> {monitor?.geoCheckEnabled && ( - - Location breakdown + <> + Location breakdown - + + )} ); From e7ba3cb5f9bc066a0cd24a776381e1eed7cb202a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 23:20:36 +0000 Subject: [PATCH 17/28] checks route, checks table --- .../Uptime/Details/Components/ChecksTable.tsx | 2 +- .../Details/Components/GeoChecksTable.tsx | 110 ++++++++++++++++++ client/src/Pages/Uptime/Details/index.tsx | 62 ++++++++-- client/src/Types/GeoCheck.ts | 5 + client/src/locales/en.json | 7 +- server/src/config/controllers.ts | 3 + server/src/config/routes.ts | 3 + server/src/config/services.ts | 9 +- server/src/controllers/geoCheckController.ts | 41 +++++++ .../geo-checks/IGeoChecksRepository.ts | 13 +++ .../geo-checks/MongoGeoChecksRepository.ts | 97 ++++++++++++++- server/src/routes/geoCheckRoutes.ts | 22 ++++ .../src/service/business/geoChecksService.ts | 55 ++++++++- server/src/service/business/monitorService.ts | 9 ++ server/src/validation/joi.ts | 1 + 15 files changed, 420 insertions(+), 19 deletions(-) create mode 100644 client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx create mode 100644 server/src/controllers/geoCheckController.ts create mode 100644 server/src/routes/geoCheckRoutes.ts diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 26905e7cc..0aaede394 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -20,7 +20,7 @@ const getHeaders = (t: Function, uiTimezone: string) => { }, { id: "date", - content: t("pages.checks.table.headers.dateTime"), + content: t("common.table.headers.dateTime"), render: (row) => { return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone); }, diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx new file mode 100644 index 000000000..eec7dbc4a --- /dev/null +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -0,0 +1,110 @@ +import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import Box from "@mui/material/Box"; +import type { Header } from "@/Components/design-elements"; +import type { GeoCheck } from "@/Types/GeoCheck"; +import { useTranslation } from "react-i18next"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { RootState } from "@/Types/state"; +import { useSelector } from "react-redux"; +import prettyMilliseconds from "pretty-ms"; + +const getHeaders = (t: Function, uiTimezone: string) => { + const headers: Header[] = [ + { + id: "status", + content: t("common.table.headers.status"), + render: (row) => { + const firstResult = row.results[0]; + const status = firstResult?.status ? "up" : "down"; + return ; + }, + }, + { + id: "date", + content: t("common.table.headers.dateTime"), + render: (row) => { + return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone); + }, + }, + { + id: "statusCode", + content: t("pages.checks.table.headers.statusCode"), + render: (row) => { + const firstResult = row.results[0]; + return firstResult?.statusCode || "N/A"; + }, + }, + { + id: "location", + content: t("pages.checks.table.headers.location"), + render: (row) => { + const firstResult = row.results[0]; + if (!firstResult) return "N/A"; + const { continent, country, city } = firstResult.location; + return `${continent} - ${country}, ${city}`; + }, + }, + { + id: "responseTime", + content: t("common.table.headers.responseTime"), + render: (row) => { + const firstResult = row.results[0]; + if (!firstResult?.timings?.total) return "N/A"; + return prettyMilliseconds(firstResult.timings.total, { compact: true }); + }, + }, + ]; + return headers; +}; + +export const GeoChecksTable = ({ + geoChecks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + geoChecks: GeoCheck[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const headers = getHeaders(t, uiTimezone); + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + const value = Number(e.target.value); + setPage(0); + setRowsPerPage(value); + }; + + return ( + + + + + ); +}; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 5f6d347b1..45cf2e9b9 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -10,6 +10,7 @@ import { } from "@/Components/monitors"; import { TrendingUp, AlertTriangle } from "lucide-react"; import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; +import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable"; import { MonitorStatBoxes } from "@/Components/monitors"; import { useTheme } from "@mui/material/styles"; @@ -20,7 +21,7 @@ import { useSelector } from "react-redux"; import { useGet } from "@/Hooks/UseApi"; import type { MonitorDetailsResponse } from "@/Types/Monitor"; import type { ChecksResponse } from "@/Types/Check"; -import type { GeoChecksResult, GeoContinent } from "@/Types/GeoCheck"; +import type { GeoChecksResult, GeoChecksResponse, GeoContinent } from "@/Types/GeoCheck"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { t } from "i18next"; @@ -40,6 +41,8 @@ const UptimeDetailsPage = () => { const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(5); + const [geoPage, setGeoPage] = useState(0); + const [geoRowsPerPage, setGeoRowsPerPage] = useState(5); const [dateRange, setDateRange] = useState("recent"); const [selectedLocation, setSelectedLocation] = useState("NA"); @@ -113,7 +116,6 @@ const UptimeDetailsPage = () => { { keepPreviousData: true, revalidateOnFocus: false } ); - // Geo checks fetch - only for HTTP monitors with geo checks enabled const geoChecksUrl = useMemo(() => { if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) { return null; @@ -124,14 +126,46 @@ const UptimeDetailsPage = () => { return `/monitors/${monitorId}/geo-checks?${params.toString()}`; }, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]); - const { data: _geoChecksData, isLoading: _geoChecksIsLoading } = - useGet( - geoChecksUrl, - {}, - { keepPreviousData: true, revalidateOnFocus: false } - ); + const { data: geoGroupedData } = useGet( + geoChecksUrl, + {}, + { keepPreviousData: true, revalidateOnFocus: false } + ); - const geoChecks = _geoChecksData?.groupedGeoChecks ?? []; + const geoGroupedChecks = geoGroupedData?.groupedGeoChecks ?? []; + + // Fetch paginated geo checks for the table + const geoChecksTableUrl = useMemo(() => { + if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) { + return null; + } + const params = new URLSearchParams(); + params.append("sortOrder", "desc"); + params.append("dateRange", dateRange); + params.append("page", String(geoPage)); + params.append("rowsPerPage", String(geoRowsPerPage)); + if (selectedLocation) { + params.append("continent", selectedLocation); + } + return `/geo-checks/${monitorId}?${params.toString()}`; + }, [ + monitorId, + monitor?.type, + monitor?.geoCheckEnabled, + dateRange, + geoPage, + geoRowsPerPage, + selectedLocation, + ]); + + const { data: geoChecksTableData } = useGet( + geoChecksTableUrl, + {}, + { keepPreviousData: true, revalidateOnFocus: false } + ); + + const geoChecksForTable = geoChecksTableData?.geoChecks ?? []; + const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0; // Extract unique locations from geo checks data const geoLocations = monitor?.geoCheckLocations; @@ -203,9 +237,17 @@ const UptimeDetailsPage = () => { onLocationChange={setSelectedLocation} /> + )} diff --git a/client/src/Types/GeoCheck.ts b/client/src/Types/GeoCheck.ts index a90dea58f..af625ec5b 100644 --- a/client/src/Types/GeoCheck.ts +++ b/client/src/Types/GeoCheck.ts @@ -55,3 +55,8 @@ export interface GeoChecksResult { monitorType: string; groupedGeoChecks: GroupedGeoCheck[]; } + +export interface GeoChecksResponse { + geoChecks: GeoCheck[]; + geoChecksCount: number; +} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index dff21384e..c60869c7d 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -115,7 +115,8 @@ "type": "Type", "url": "Url", "interval": "Interval", - "active": "Active" + "active": "Active", + "responseTime": "Response time" } } }, @@ -372,8 +373,8 @@ "table": { "empty": "No down checks in this time range", "headers": { - "dateTime": "Date & time", - "statusCode": "Status code" + "statusCode": "Status code", + "location": "Location" } } }, diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 8a83a0f63..98e6ded8e 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -2,6 +2,7 @@ import MonitorController from "../controllers/monitorController.js"; import AuthController from "../controllers/authController.js"; import SettingsController from "../controllers/settingsController.js"; import CheckController from "../controllers/checkController.js"; +import GeoCheckController from "../controllers/geoCheckController.js"; import InviteController from "../controllers/inviteController.js"; import MaintenanceWindowController from "../controllers/maintenanceWindowController.js"; import QueueController from "../controllers/queueController.js"; @@ -17,6 +18,7 @@ export interface InitializedControllers { monitorController: MonitorController; settingsController: SettingsController; checkController: CheckController; + geoCheckController: GeoCheckController; inviteController: InviteController; maintenanceWindowController: MaintenanceWindowController; queueController: QueueController; @@ -32,6 +34,7 @@ export const initializeControllers = (services: InitializedServices): Initialize monitorController: new MonitorController(services.monitorService), settingsController: new SettingsController(services.settingsService, services.emailService), checkController: new CheckController(services.checkService), + geoCheckController: new GeoCheckController(services.geoChecksService), inviteController: new InviteController(services.inviteService), maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService), queueController: new QueueController(services.jobQueue), diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index 8707009a8..672866dc1 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -6,6 +6,7 @@ import AuthRoutes from "../routes/authRoute.js"; import InviteRoutes from "../routes/inviteRoute.js"; import MonitorRoutes from "../routes/monitorRoute.js"; import CheckRoutes from "../routes/checkRoute.js"; +import GeoCheckRoutes from "../routes/geoCheckRoutes.js"; import SettingsRoutes from "../routes/settingsRoute.js"; import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js"; import StatusPageRoutes from "../routes/statusPageRoute.js"; @@ -22,6 +23,7 @@ export const setupRoutes = (app: any, controllers: Record, services const monitorRoutes = new MonitorRoutes(controllers.monitorController); const settingsRoutes = new SettingsRoutes(controllers.settingsController); const checkRoutes = new CheckRoutes(controllers.checkController); + const geoCheckRoutes = new GeoCheckRoutes(controllers.geoCheckController); const inviteRoutes = new InviteRoutes(controllers.inviteController, verifyJWT); const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController); const queueRoutes = new QueueRoutes(controllers.queueController); @@ -36,6 +38,7 @@ export const setupRoutes = (app: any, controllers: Record, services app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); + app.use("/api/v1/geo-checks", verifyJWT, geoCheckRoutes.getRouter()); app.use("/api/v1/invite", inviteRoutes.getRouter()); app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); diff --git a/server/src/config/services.ts b/server/src/config/services.ts index db6016b04..7cc908d0b 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -154,7 +154,12 @@ export const initializeServices = async ({ const globalPingService = new GlobalPingService(logger); - const geoChecksService = new GeoChecksService(logger, geoChecksRepository, globalPingService); + const geoChecksService = new GeoChecksService({ + logger, + geoChecksRepository, + globalPingService, + monitorsRepository, + }); const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService); @@ -252,12 +257,12 @@ export const initializeServices = async ({ jobQueue: superSimpleQueue, userService, checkService, + geoChecksService, diagnosticService, inviteService, maintenanceWindowService, monitorService, incidentService, - geoChecksService, logger, notificationsService, statusPageService, diff --git a/server/src/controllers/geoCheckController.ts b/server/src/controllers/geoCheckController.ts new file mode 100644 index 000000000..ec9d489f1 --- /dev/null +++ b/server/src/controllers/geoCheckController.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from "express"; +import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/joi.js"; +import type { IGeoChecksService } from "@/service/business/geoChecksService.js"; + +const SERVICE_NAME = "geoCheckController"; + +class GeoCheckController { + static SERVICE_NAME = SERVICE_NAME; + + private geoChecksService: IGeoChecksService; + constructor(geoChecksService: IGeoChecksService) { + this.geoChecksService = geoChecksService; + } + + get serviceName() { + return GeoCheckController.SERVICE_NAME; + } + + getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => { + try { + await getChecksParamValidation.validateAsync(req.params); + await getChecksQueryValidation.validateAsync(req.query); + + const result = await this.geoChecksService.getGeoChecksByMonitor({ + monitorId: req?.params?.monitorId as string, + query: req?.query, + teamId: req?.user?.teamId as string, + }); + + return res.status(200).json({ + success: true, + msg: "Geo checks retrieved successfully", + data: result, + }); + } catch (error) { + next(error); + } + }; +} + +export default GeoCheckController; diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts index 31b2dd2a5..69c9c68b6 100644 --- a/server/src/repositories/geo-checks/IGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts @@ -1,8 +1,21 @@ import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js"; import type { GeoContinent } from "@/types/geoCheck.js"; +export interface GeoChecksQueryResult { + geoChecksCount: number; + geoChecks: GeoCheck[]; +} + export interface IGeoChecksRepository { createGeoChecks(geoChecks: Omit[]): Promise; + findByMonitorId( + monitorId: string, + sortOrder: string, + dateRange: string, + page: number, + rowsPerPage: number, + continent?: GeoContinent + ): Promise; findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise; findGroupedByMonitorIdAndDateRange( monitorId: string, diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index b40dc2cf8..6f7f4d064 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -1,10 +1,18 @@ import { IGeoChecksRepository } from "./IGeoChecksRepository.js"; -import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck } from "@/types/geoCheck.js"; +import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent } from "@/types/geoCheck.js"; +import type { GeoChecksQueryResult } from "./IGeoChecksRepository.js"; import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js"; import mongoose from "mongoose"; const SERVICE_NAME = "GeoChecksRepository"; +const dateRangeLookup: Record = { + recent: new Date(Date.now() - 60 * 60 * 1000), + day: new Date(Date.now() - 24 * 60 * 60 * 1000), + week: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + month: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), +}; + class MongoGeoChecksRepository implements IGeoChecksRepository { static SERVICE_NAME = SERVICE_NAME; @@ -97,6 +105,93 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { } }; + findByMonitorId = async ( + monitorId: string, + sortOrder: string, + dateRange: string, + page: number, + rowsPerPage: number, + continent?: GeoContinent + ): Promise => { + try { + const matchStage: Record = { + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + ...(dateRangeLookup[dateRange] && { + createdAt: { + $gte: dateRangeLookup[dateRange], + }, + }), + }; + + const convertedSortOrder = sortOrder === "asc" ? 1 : -1; + + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + if (continent) { + const pipeline: any[] = [ + { $match: matchStage }, + { $unwind: "$results" }, + { + $match: { + "results.location.continent": continent, + }, + }, + { + $group: { + _id: "$_id", + doc: { $first: "$$ROOT" }, + results: { $push: "$results" }, + }, + }, + { + $replaceRoot: { + newRoot: { + $mergeObjects: ["$doc", { results: "$results" }], + }, + }, + }, + { $sort: { createdAt: convertedSortOrder } }, + { $skip: skip }, + { $limit: rowsPerPage }, + ]; + + const [countPipeline, dataResults] = await Promise.all([ + GeoCheckModel.aggregate([ + { $match: matchStage }, + { $unwind: "$results" }, + { $match: { "results.location.continent": continent } }, + { $group: { _id: "$_id" } }, + { $count: "count" }, + ]), + GeoCheckModel.aggregate(pipeline), + ]); + + const geoChecksCount = countPipeline[0]?.count || 0; + const geoChecks = dataResults.map(this.toEntity); + + return { geoChecksCount, geoChecks }; + } else { + const [geoChecksCount, docs] = await Promise.all([ + GeoCheckModel.countDocuments(matchStage), + GeoCheckModel.find(matchStage).sort({ createdAt: convertedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise, + ]); + + return { geoChecksCount, geoChecks: docs.map(this.toEntity) }; + } + } catch (error: any) { + this.logger.error({ + message: `Error finding geo checks by monitor ID: ${error.message}`, + service: SERVICE_NAME, + method: "findByMonitorId", + stack: error.stack, + }); + throw error; + } + }; + findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise => { try { const docs = await GeoCheckModel.find({ diff --git a/server/src/routes/geoCheckRoutes.ts b/server/src/routes/geoCheckRoutes.ts new file mode 100644 index 000000000..4c34df56e --- /dev/null +++ b/server/src/routes/geoCheckRoutes.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; + +class GeoCheckRoutes { + private router: Router; + private geoCheckController: any; + + constructor(geoCheckController: any) { + this.router = Router(); + this.geoCheckController = geoCheckController; + this.initRoutes(); + } + + initRoutes() { + this.router.get("/:monitorId", this.geoCheckController.getGeoChecksByMonitor); + } + + getRouter() { + return this.router; + } +} + +export default GeoCheckRoutes; diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index a40ce82c7..e104b9ea4 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -2,9 +2,10 @@ import type { Monitor, GeoCheck } from "@/types/index.js"; import type { GeoCheckResult } from "@/types/geoCheck.js"; import { Types } from "mongoose"; import type { IGeoChecksRepository } from "@/repositories/index.js"; +import type { IMonitorsRepository } from "@/repositories/index.js"; import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js"; import type { ILogger } from "@/utils/logger.js"; -import type { ISettingsService } from "@/service/system/settingsService.js"; +import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "GeoChecksService"; @@ -12,6 +13,7 @@ export interface IGeoChecksService { readonly serviceName: string; buildGeoCheck(monitor: Monitor): Promise; createGeoChecks(geoChecks: GeoCheck[]): Promise; + getGeoChecksByMonitor(args: { monitorId: string; query: any; teamId: string }): Promise; } class GeoChecksService implements IGeoChecksService { @@ -20,11 +22,23 @@ class GeoChecksService implements IGeoChecksService { private logger: ILogger; private geoChecksRepository: IGeoChecksRepository; private globalPingService: IGlobalPingService; + private monitorsRepository: IMonitorsRepository; - constructor(logger: ILogger, geoChecksRepository: IGeoChecksRepository, globalPingService: IGlobalPingService) { + constructor({ + logger, + geoChecksRepository, + globalPingService, + monitorsRepository, + }: { + logger: ILogger; + geoChecksRepository: IGeoChecksRepository; + globalPingService: IGlobalPingService; + monitorsRepository: IMonitorsRepository; + }) { this.logger = logger; this.geoChecksRepository = geoChecksRepository; this.globalPingService = globalPingService; + this.monitorsRepository = monitorsRepository; } get serviceName() { @@ -133,6 +147,43 @@ class GeoChecksService implements IGeoChecksService { createGeoChecks = async (geoChecks: GeoCheck[]) => { return this.geoChecksRepository.createGeoChecks(geoChecks); }; + + getGeoChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => { + if (!monitorId) { + throw new AppError({ + message: "No monitor ID in request", + service: SERVICE_NAME, + method: "getGeoChecksByMonitor", + status: 400, + }); + } + if (!teamId) { + throw new AppError({ + message: "No team ID in request", + service: SERVICE_NAME, + method: "getGeoChecksByMonitor", + status: 400, + }); + } + + const monitor = await this.monitorsRepository.findById(monitorId, teamId); + if (!monitor) { + throw new AppError({ + message: `Monitor with ID ${monitorId} not found.`, + service: SERVICE_NAME, + method: "getGeoChecksByMonitor", + status: 404, + }); + } + + let { sortOrder, dateRange, page, rowsPerPage, continent } = query; + const parsedPage = page ? parseInt(page) : page; + const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage; + + const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continent); + + return result; + }; } export default GeoChecksService; diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 2a1e9d0ed..7006d7bc8 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -479,6 +479,14 @@ export class MonitorService implements IMonitorService { }); }); + await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: any) => { + this.logger.warn({ + message: `Error deleting geo checks for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); + await this.jobQueue.deleteJob(monitor); return monitor; }; @@ -490,6 +498,7 @@ export class MonitorService implements IMonitorService { try { await this.jobQueue.deleteJob(monitor); await this.checksRepository.deleteByMonitorId(monitor.id); + await this.geoChecksRepository.deleteByMonitorId(monitor.id); await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id); await this.monitorStatsRepository.deleteByMonitorId(monitor.id); } catch (error: any) { diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 34283f461..26ba7b26a 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -324,6 +324,7 @@ const getChecksQueryValidation = joi.object({ page: joi.number(), rowsPerPage: joi.number(), status: joi.boolean(), + continent: joi.string().valid(...GeoContinents), }); const getTeamChecksQueryValidation = joi.object({ From 88e28b9819a20896c390fe27fbc776b95b16de25 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 24 Feb 2026 23:28:27 +0000 Subject: [PATCH 18/28] cleanup on delete, cleanup orphans --- server/src/config/services.ts | 3 ++- .../geo-checks/IGeoChecksRepository.ts | 1 + .../geo-checks/MongoGeoChecksRepository.ts | 15 +++++++++++++++ .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 16 +++++++++++++++- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 7cc908d0b..ce2472588 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -200,7 +200,8 @@ export const initializeServices = async ({ monitorStatsRepository, checksRepository, incidentsRepository, - geoChecksService + geoChecksService, + geoChecksRepository ); const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository); diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts index 69c9c68b6..516bbd7fc 100644 --- a/server/src/repositories/geo-checks/IGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts @@ -26,4 +26,5 @@ export interface IGeoChecksRepository { ): Promise; deleteByMonitorId(monitorId: string): Promise; deleteByTeamId(teamId: string): Promise; + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; } diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index 6f7f4d064..e4a6e14e5 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -341,6 +341,21 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { throw error; } }; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { + try { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } }); + return result.deletedCount || 0; + } catch (error: any) { + this.logger.error({ + message: `Error deleting orphaned geo checks: ${error.message}`, + service: SERVICE_NAME, + method: "deleteByMonitorIdsNotIn", + }); + throw error; + } + }; } export { MongoGeoChecksRepository }; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 1c57bd59f..454b52b0d 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -12,6 +12,7 @@ import { IMonitorStatsRepository, IChecksRepository, IIncidentsRepository, + IGeoChecksRepository, } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; import { IBufferService } from "../bufferService.js"; @@ -55,6 +56,7 @@ class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { private checksRepository: IChecksRepository; private incidentsRepository: IIncidentsRepository; private geoChecksService: IGeoChecksService; + private geoChecksRepository: IGeoChecksRepository; constructor( logger: ILogger, @@ -70,7 +72,8 @@ class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { monitorStatsRepository: IMonitorStatsRepository, checksRepository: IChecksRepository, incidentsRepository: IIncidentsRepository, - geoChecksService: IGeoChecksService + geoChecksService: IGeoChecksService, + geoChecksRepository: IGeoChecksRepository ) { this.logger = logger; this.networkService = networkService; @@ -86,6 +89,7 @@ class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { this.checksRepository = checksRepository; this.incidentsRepository = incidentsRepository; this.geoChecksService = geoChecksService; + this.geoChecksRepository = geoChecksRepository; } get serviceName() { @@ -240,6 +244,16 @@ class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { }); } + // Remove orphaned geo checks + const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedGeoChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + this.logger.info({ message: "Cleanup of orphaned data completed", service: SERVICE_NAME, From 477c30286d102b36fa74cd5c68e1fe1cee07f3cd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 18:43:12 +0000 Subject: [PATCH 19/28] show all checks --- .../src/Components/monitors/HeaderGeoTabs.tsx | 2 - .../Details/Components/GeoChecksTable.tsx | 55 +++++++++++---- client/src/Pages/Uptime/Details/index.tsx | 4 -- server/src/controllers/monitorController.ts | 9 ++- .../geo-checks/IGeoChecksRepository.ts | 4 +- .../geo-checks/MongoGeoChecksRepository.ts | 67 ++++++++++++++++--- .../src/service/business/geoChecksService.ts | 11 ++- server/src/service/business/monitorService.ts | 8 +-- server/src/validation/joi.ts | 2 +- 9 files changed, 123 insertions(+), 39 deletions(-) diff --git a/client/src/Components/monitors/HeaderGeoTabs.tsx b/client/src/Components/monitors/HeaderGeoTabs.tsx index 06753bc14..7351078d4 100644 --- a/client/src/Components/monitors/HeaderGeoTabs.tsx +++ b/client/src/Components/monitors/HeaderGeoTabs.tsx @@ -28,8 +28,6 @@ export const HeaderGeoTabs = ({ onLocationChange(newValue); }; - console.log(locations); - return ( { + const flattened: FlattenedGeoCheckResult[] = []; + for (const check of geoChecks) { + for (const result of check.results) { + flattened.push({ + id: `${check.id}-${result.location.continent}-${result.location.city}`, + monitorId: check.metadata.monitorId, + createdAt: check.createdAt, + location: result.location, + status: result.status, + statusCode: result.statusCode, + timings: result.timings, + }); + } + } + return flattened; +}; + const getHeaders = (t: Function, uiTimezone: string) => { - const headers: Header[] = [ + const headers: Header[] = [ { id: "status", content: t("common.table.headers.status"), render: (row) => { - const firstResult = row.results[0]; - const status = firstResult?.status ? "up" : "down"; + const status = row.status ? "up" : "down"; return ; }, }, @@ -30,17 +64,14 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - const firstResult = row.results[0]; - return firstResult?.statusCode || "N/A"; + return row.statusCode || "N/A"; }, }, { id: "location", content: t("pages.checks.table.headers.location"), render: (row) => { - const firstResult = row.results[0]; - if (!firstResult) return "N/A"; - const { continent, country, city } = firstResult.location; + const { continent, country, city } = row.location; return `${continent} - ${country}, ${city}`; }, }, @@ -48,9 +79,8 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "responseTime", content: t("common.table.headers.responseTime"), render: (row) => { - const firstResult = row.results[0]; - if (!firstResult?.timings?.total) return "N/A"; - return prettyMilliseconds(firstResult.timings.total, { compact: true }); + if (!row.timings?.total) return "N/A"; + return prettyMilliseconds(row.timings.total, { compact: true }); }, }, ]; @@ -75,6 +105,7 @@ export const GeoChecksTable = ({ const { t } = useTranslation(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const headers = getHeaders(t, uiTimezone); + const flattenedData = flattenGeoChecks(geoChecks); const handlePageChange = ( _e: React.MouseEvent | null, @@ -95,7 +126,7 @@ export const GeoChecksTable = ({
{ params.append("dateRange", dateRange); params.append("page", String(geoPage)); params.append("rowsPerPage", String(geoRowsPerPage)); - if (selectedLocation) { - params.append("continent", selectedLocation); - } return `/geo-checks/${monitorId}?${params.toString()}`; }, [ monitorId, @@ -155,7 +152,6 @@ const UptimeDetailsPage = () => { dateRange, geoPage, geoRowsPerPage, - selectedLocation, ]); const { data: geoChecksTableData } = useGet( diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 662f35b50..a11e3fc65 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -142,14 +142,19 @@ class MonitorController { const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const dateRange = requireString(req?.query?.dateRange, "dateRange"); - const continent = optionalString(req?.query?.continent, "continent") as GeoContinent | undefined; + const continentParam = req?.query?.continent; + const continents = continentParam + ? Array.isArray(continentParam) + ? (continentParam as GeoContinent[]) + : [continentParam as GeoContinent] + : undefined; const teamId = requireTeamId(req?.user?.teamId); const data = await this.monitorService.getGeoChecksByMonitorId({ teamId, monitorId, dateRange, - continent, + continents, }); return res.status(200).json({ diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts index 516bbd7fc..3eedf5fa3 100644 --- a/server/src/repositories/geo-checks/IGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts @@ -14,7 +14,7 @@ export interface IGeoChecksRepository { dateRange: string, page: number, rowsPerPage: number, - continent?: GeoContinent + continents?: GeoContinent[] ): Promise; findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise; findGroupedByMonitorIdAndDateRange( @@ -22,7 +22,7 @@ export interface IGeoChecksRepository { startDate: Date, endDate: Date, dateFormat: string, - continent?: GeoContinent + continents?: GeoContinent[] ): Promise; deleteByMonitorId(monitorId: string): Promise; deleteByTeamId(teamId: string): Promise; diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index e4a6e14e5..cae1be74f 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -111,9 +111,16 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { dateRange: string, page: number, rowsPerPage: number, - continent?: GeoContinent + continents?: GeoContinent[] ): Promise => { try { + this.logger.debug({ + message: "findByMonitorId called", + service: SERVICE_NAME, + method: "findByMonitorId", + details: { monitorId, continents, continentsLength: continents?.length }, + }); + const matchStage: Record = { "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), ...(dateRangeLookup[dateRange] && { @@ -130,13 +137,19 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { skip = page * rowsPerPage; } - if (continent) { + if (continents && continents.length > 0) { + this.logger.debug({ + message: "Filtering by continents", + service: SERVICE_NAME, + method: "findByMonitorId", + details: { continents }, + }); const pipeline: any[] = [ { $match: matchStage }, { $unwind: "$results" }, { $match: { - "results.location.continent": continent, + "results.location.continent": { $in: continents }, }, }, { @@ -162,7 +175,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { GeoCheckModel.aggregate([ { $match: matchStage }, { $unwind: "$results" }, - { $match: { "results.location.continent": continent } }, + { $match: { "results.location.continent": { $in: continents } } }, { $group: { _id: "$_id" } }, { $count: "count" }, ]), @@ -174,12 +187,44 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { return { geoChecksCount, geoChecks }; } else { - const [geoChecksCount, docs] = await Promise.all([ - GeoCheckModel.countDocuments(matchStage), - GeoCheckModel.find(matchStage).sort({ createdAt: convertedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise, + this.logger.debug({ + message: "No continent filtering - returning all checks", + service: SERVICE_NAME, + method: "findByMonitorId", + }); + + // Still need to unwind to show each location as a separate row + const pipeline: any[] = [ + { $match: matchStage }, + { $unwind: "$results" }, + { + $group: { + _id: "$_id", + doc: { $first: "$$ROOT" }, + results: { $push: "$results" }, + }, + }, + { + $replaceRoot: { + newRoot: { + $mergeObjects: ["$doc", { results: "$results" }], + }, + }, + }, + { $sort: { createdAt: convertedSortOrder } }, + { $skip: skip }, + { $limit: rowsPerPage }, + ]; + + const [countPipeline, dataResults] = await Promise.all([ + GeoCheckModel.aggregate([{ $match: matchStage }, { $unwind: "$results" }, { $group: { _id: "$_id" } }, { $count: "count" }]), + GeoCheckModel.aggregate(pipeline), ]); - return { geoChecksCount, geoChecks: docs.map(this.toEntity) }; + const geoChecksCount = countPipeline[0]?.count || 0; + const geoChecks = dataResults.map(this.toEntity); + + return { geoChecksCount, geoChecks }; } } catch (error: any) { this.logger.error({ @@ -217,7 +262,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { startDate: Date, endDate: Date, dateFormat: string, - continent?: string + continents?: GeoContinent[] ): Promise => { try { const pipeline: any[] = [ @@ -236,11 +281,11 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { $unwind: "$results", }, // Filter by continent if specified - ...(continent + ...(continents && continents.length > 0 ? [ { $match: { - "results.location.continent": continent, + "results.location.continent": { $in: continents }, }, }, ] diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index e104b9ea4..ff0f0f31c 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -177,10 +177,19 @@ class GeoChecksService implements IGeoChecksService { } let { sortOrder, dateRange, page, rowsPerPage, continent } = query; + const continents = continent ? (Array.isArray(continent) ? continent : [continent]) : undefined; + + this.logger.debug({ + message: "getGeoChecksByMonitor query params", + service: SERVICE_NAME, + method: "getGeoChecksByMonitor", + details: { continent, continents, query }, + }); + const parsedPage = page ? parseInt(page) : page; const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage; - const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continent); + const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continents); return result; }; diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 7006d7bc8..662e5775a 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -38,7 +38,7 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise; - getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continent?: GeoContinent }): Promise; + getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continents?: GeoContinent[] }): Promise; getMonitorById(args: { teamId: string; monitorId: string }): Promise; getMonitorsByTeamId(args: { teamId: string; @@ -327,12 +327,12 @@ export class MonitorService implements IMonitorService { teamId, monitorId, dateRange, - continent, + continents, }: { teamId: string; monitorId: string; dateRange: string; - continent?: GeoContinent; + continents?: GeoContinent[]; }): Promise => { const monitor = await this.monitorsRepository.findById(monitorId, teamId); if (!monitor) { @@ -350,7 +350,7 @@ export class MonitorService implements IMonitorService { start, end, this.getDateFormat(rangeKey), - continent + continents ); return { groupedGeoChecks }; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 26ba7b26a..4002c97e6 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -324,7 +324,7 @@ const getChecksQueryValidation = joi.object({ page: joi.number(), rowsPerPage: joi.number(), status: joi.boolean(), - continent: joi.string().valid(...GeoContinents), + continent: joi.alternatives().try(joi.string().valid(...GeoContinents), joi.array().items(joi.string().valid(...GeoContinents))), }); const getTeamChecksQueryValidation = joi.object({ From 885acf6fe6eb0c5638427a699deb2c047cdd4327 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 19:34:24 +0000 Subject: [PATCH 20/28] all continents in table --- client/src/Hooks/UseApi.ts | 2 +- .../Details/Components/GeoChecksTable.tsx | 48 +----- client/src/Pages/Uptime/Details/index.tsx | 9 +- client/src/Types/GeoCheck.ts | 18 ++ .../geo-checks/IGeoChecksRepository.ts | 9 +- .../geo-checks/MongoGeoChecksRepository.ts | 159 ++++++++---------- server/src/types/geoCheck.ts | 13 ++ 7 files changed, 124 insertions(+), 134 deletions(-) diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts index e1faa2b29..2a5d514f6 100644 --- a/client/src/Hooks/UseApi.ts +++ b/client/src/Hooks/UseApi.ts @@ -17,7 +17,7 @@ const fetcher = async (url: string, config?: AxiosRequestConfig) => { }; export const useGet = ( - url: string | null, + url: string | null | undefined, axiosConfig?: AxiosRequestConfig, swrConfig?: SWRConfiguration ) => { diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index f125e03b5..31ad6a3dd 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -1,50 +1,15 @@ import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; -import type { GeoCheck } from "@/Types/GeoCheck"; +import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; import prettyMilliseconds from "pretty-ms"; -// Flatten geo check results - each location becomes a separate row -interface FlattenedGeoCheckResult { - id: string; - monitorId: string; - createdAt: string; - location: { - continent: string; - country: string; - city: string; - }; - status: boolean; - statusCode: number; - timings: { - total: number; - }; -} - -const flattenGeoChecks = (geoChecks: GeoCheck[]): FlattenedGeoCheckResult[] => { - const flattened: FlattenedGeoCheckResult[] = []; - for (const check of geoChecks) { - for (const result of check.results) { - flattened.push({ - id: `${check.id}-${result.location.continent}-${result.location.city}`, - monitorId: check.metadata.monitorId, - createdAt: check.createdAt, - location: result.location, - status: result.status, - statusCode: result.statusCode, - timings: result.timings, - }); - } - } - return flattened; -}; - const getHeaders = (t: Function, uiTimezone: string) => { - const headers: Header[] = [ + const headers: Header[] = [ { id: "status", content: t("common.table.headers.status"), @@ -71,7 +36,9 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "location", content: t("pages.checks.table.headers.location"), render: (row) => { - const { continent, country, city } = row.location; + const location = row.location; + if (!location) return "N/A"; + const { continent, country, city } = location; return `${continent} - ${country}, ${city}`; }, }, @@ -95,7 +62,7 @@ export const GeoChecksTable = ({ rowsPerPage, setRowsPerPage, }: { - geoChecks: GeoCheck[]; + geoChecks: FlatGeoCheck[]; count: number; page: number; setPage: (page: number) => void; @@ -105,7 +72,6 @@ export const GeoChecksTable = ({ const { t } = useTranslation(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const headers = getHeaders(t, uiTimezone); - const flattenedData = flattenGeoChecks(geoChecks); const handlePageChange = ( _e: React.MouseEvent | null, @@ -126,7 +92,7 @@ export const GeoChecksTable = ({
{ geoRowsPerPage, ]); - const { data: geoChecksTableData } = useGet( + const { data: geoChecksTableData } = useGet( geoChecksTableUrl, {}, { keepPreviousData: true, revalidateOnFocus: false } @@ -163,7 +167,6 @@ const UptimeDetailsPage = () => { const geoChecksForTable = geoChecksTableData?.geoChecks ?? []; const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0; - // Extract unique locations from geo checks data const geoLocations = monitor?.geoCheckLocations; const checks = checksData?.checks ?? []; diff --git a/client/src/Types/GeoCheck.ts b/client/src/Types/GeoCheck.ts index af625ec5b..ec46eeac1 100644 --- a/client/src/Types/GeoCheck.ts +++ b/client/src/Types/GeoCheck.ts @@ -43,6 +43,19 @@ export interface GeoCheck { updatedAt: string; } +export interface FlatGeoCheck { + id: string; + monitorId: string; + teamId: string; + type: string; + location: GeoCheckLocation; + status: boolean; + statusCode: number; + timings: GeoCheckTimings; + createdAt: string; + updatedAt: string; +} + export interface GroupedGeoCheck { bucketDate: string; continent: GeoContinent; @@ -60,3 +73,8 @@ export interface GeoChecksResponse { geoChecks: GeoCheck[]; geoChecksCount: number; } + +export interface FlatGeoChecksResponse { + geoChecks: FlatGeoCheck[]; + geoChecksCount: number; +} diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts index 3eedf5fa3..fe4beb796 100644 --- a/server/src/repositories/geo-checks/IGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts @@ -1,11 +1,16 @@ import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js"; -import type { GeoContinent } from "@/types/geoCheck.js"; +import type { GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js"; export interface GeoChecksQueryResult { geoChecksCount: number; geoChecks: GeoCheck[]; } +export interface FlatGeoChecksQueryResult { + geoChecksCount: number; + geoChecks: FlatGeoCheck[]; +} + export interface IGeoChecksRepository { createGeoChecks(geoChecks: Omit[]): Promise; findByMonitorId( @@ -15,7 +20,7 @@ export interface IGeoChecksRepository { page: number, rowsPerPage: number, continents?: GeoContinent[] - ): Promise; + ): Promise; findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise; findGroupedByMonitorIdAndDateRange( monitorId: string, diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index cae1be74f..89688afec 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -1,6 +1,6 @@ import { IGeoChecksRepository } from "./IGeoChecksRepository.js"; -import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent } from "@/types/geoCheck.js"; -import type { GeoChecksQueryResult } from "./IGeoChecksRepository.js"; +import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js"; +import type { GeoChecksQueryResult, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js"; import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js"; import mongoose from "mongoose"; @@ -112,15 +112,8 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { page: number, rowsPerPage: number, continents?: GeoContinent[] - ): Promise => { + ): Promise => { try { - this.logger.debug({ - message: "findByMonitorId called", - service: SERVICE_NAME, - method: "findByMonitorId", - details: { monitorId, continents, continentsLength: continents?.length }, - }); - const matchStage: Record = { "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), ...(dateRangeLookup[dateRange] && { @@ -138,12 +131,6 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { } if (continents && continents.length > 0) { - this.logger.debug({ - message: "Filtering by continents", - service: SERVICE_NAME, - method: "findByMonitorId", - details: { continents }, - }); const pipeline: any[] = [ { $match: matchStage }, { $unwind: "$results" }, @@ -152,80 +139,78 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { "results.location.continent": { $in: continents }, }, }, - { - $group: { - _id: "$_id", - doc: { $first: "$$ROOT" }, - results: { $push: "$results" }, - }, - }, - { - $replaceRoot: { - newRoot: { - $mergeObjects: ["$doc", { results: "$results" }], - }, - }, - }, - { $sort: { createdAt: convertedSortOrder } }, - { $skip: skip }, - { $limit: rowsPerPage }, ]; - - const [countPipeline, dataResults] = await Promise.all([ - GeoCheckModel.aggregate([ - { $match: matchStage }, - { $unwind: "$results" }, - { $match: { "results.location.continent": { $in: continents } } }, - { $group: { _id: "$_id" } }, - { $count: "count" }, - ]), - GeoCheckModel.aggregate(pipeline), - ]); - - const geoChecksCount = countPipeline[0]?.count || 0; - const geoChecks = dataResults.map(this.toEntity); - - return { geoChecksCount, geoChecks }; } else { - this.logger.debug({ - message: "No continent filtering - returning all checks", - service: SERVICE_NAME, - method: "findByMonitorId", - }); - - // Still need to unwind to show each location as a separate row - const pipeline: any[] = [ - { $match: matchStage }, - { $unwind: "$results" }, - { - $group: { - _id: "$_id", - doc: { $first: "$$ROOT" }, - results: { $push: "$results" }, - }, - }, - { - $replaceRoot: { - newRoot: { - $mergeObjects: ["$doc", { results: "$results" }], - }, - }, - }, - { $sort: { createdAt: convertedSortOrder } }, - { $skip: skip }, - { $limit: rowsPerPage }, - ]; - - const [countPipeline, dataResults] = await Promise.all([ - GeoCheckModel.aggregate([{ $match: matchStage }, { $unwind: "$results" }, { $group: { _id: "$_id" } }, { $count: "count" }]), - GeoCheckModel.aggregate(pipeline), - ]); - - const geoChecksCount = countPipeline[0]?.count || 0; - const geoChecks = dataResults.map(this.toEntity); - - return { geoChecksCount, geoChecks }; + const pipeline: any[] = [{ $match: matchStage }, { $unwind: "$results" }]; } + + // Common pipeline stages for both paths + const pipeline: any[] = [ + { $match: matchStage }, + { $unwind: "$results" }, + // Filter by continent if specified + ...(continents && continents.length > 0 + ? [ + { + $match: { + "results.location.continent": { $in: continents }, + }, + }, + ] + : []), + // Project to flat structure + { + $project: { + _id: 0, + monitorId: "$metadata.monitorId", + teamId: "$metadata.teamId", + type: "$metadata.type", + location: "$results.location", + status: "$results.status", + statusCode: "$results.statusCode", + timings: "$results.timings", + createdAt: 1, + updatedAt: 1, + }, + }, + { $sort: { createdAt: convertedSortOrder } }, + { $skip: skip }, + { $limit: rowsPerPage }, + ]; + + // Count pipeline + const countPipeline: any[] = [ + { $match: matchStage }, + { $unwind: "$results" }, + ...(continents && continents.length > 0 + ? [ + { + $match: { + "results.location.continent": { $in: continents }, + }, + }, + ] + : []), + { $count: "count" }, + ]; + + const [countResult, dataResults] = await Promise.all([GeoCheckModel.aggregate(countPipeline), GeoCheckModel.aggregate(pipeline)]); + + const geoChecksCount = countResult[0]?.count || 0; + const geoChecks: FlatGeoCheck[] = dataResults.map((doc) => ({ + id: `${doc.monitorId.toString()}-${new Date(doc.createdAt).getTime()}-${doc.location.continent}-${doc.location.city}-${Math.random().toString(36).substring(2, 15)}`, + monitorId: doc.monitorId.toString(), + teamId: doc.teamId.toString(), + type: doc.type, + location: doc.location, + status: doc.status, + statusCode: doc.statusCode, + timings: doc.timings, + createdAt: new Date(doc.createdAt).toISOString(), + updatedAt: new Date(doc.updatedAt).toISOString(), + })); + + return { geoChecksCount, geoChecks }; } catch (error: any) { this.logger.error({ message: `Error finding geo checks by monitor ID: ${error.message}`, diff --git a/server/src/types/geoCheck.ts b/server/src/types/geoCheck.ts index 742083a27..548c4c65f 100644 --- a/server/src/types/geoCheck.ts +++ b/server/src/types/geoCheck.ts @@ -45,6 +45,19 @@ export interface GeoCheck { updatedAt: string; } +export interface FlatGeoCheck { + id: string; + monitorId: string; + teamId: string; + type: string; + location: GeoCheckLocation; + status: boolean; + statusCode: number; + timings: GeoCheckTimings; + createdAt: string; + updatedAt: string; +} + export interface GroupedGeoCheck { bucketDate: string; continent: GeoContinent; From ba6e931c59a5a2282d42a8ab34d6e0d403ca2605 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 20:13:07 +0000 Subject: [PATCH 21/28] date range extraction, minor fixes --- .../checks/MongoChecksRepistory.ts | 22 ++++++---------- .../geo-checks/MongoGeoChecksRepository.ts | 12 +++------ .../src/service/business/incidentService.ts | 12 ++------- .../SuperSimpleQueue/SuperSimpleQueue.ts | 9 +++---- server/src/utils/dataUtils.ts | 25 +++++++++++++++++++ 5 files changed, 41 insertions(+), 39 deletions(-) diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/repositories/checks/MongoChecksRepistory.ts index a03472b4c..66e7f43f9 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/repositories/checks/MongoChecksRepistory.ts @@ -15,18 +15,10 @@ import type { } from "@/types/index.js"; import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js"; import mongoose from "mongoose"; +import { getDateForRange } from "@/utils/dataUtils.js"; const SERVICE_NAME = "StatusService"; -const dateRangeLookup: Record = { - recent: new Date(new Date().setHours(new Date().getHours() - 2)), - hour: new Date(new Date().setHours(new Date().getHours() - 1)), - day: new Date(new Date().setDate(new Date().getDate() - 1)), - week: new Date(new Date().setDate(new Date().getDate() - 7)), - month: new Date(new Date().setMonth(new Date().getMonth() - 1)), - all: undefined, -}; - export type LatestChecksMap = Record; type DateRange = { start: Date; end: Date }; type HardwareUpChecks = { totalChecks: number }; @@ -248,9 +240,9 @@ class MongoChecksRepository implements IChecksRepository { const matchStage: Record = { "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), ...(typeof status !== "undefined" && { status }), - ...(dateRangeLookup[dateRange] && { + ...(getDateForRange(dateRange) && { createdAt: { - $gte: dateRangeLookup[dateRange], + $gte: getDateForRange(dateRange), }, }), }; @@ -299,9 +291,9 @@ class MongoChecksRepository implements IChecksRepository { findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => { const matchStage: Record = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), - ...(dateRangeLookup[dateRange] && { + ...(getDateForRange(dateRange) && { createdAt: { - $gte: dateRangeLookup[dateRange], + $gte: getDateForRange(dateRange), }, }), }; @@ -386,9 +378,9 @@ class MongoChecksRepository implements IChecksRepository { findSummaryByTeamId = async (teamId: string, dateRange: string) => { const baseMatch = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), - ...(dateRangeLookup[dateRange] && { + ...(getDateForRange(dateRange) && { createdAt: { - $gte: dateRangeLookup[dateRange], + $gte: getDateForRange(dateRange), }, }), }; diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts index 89688afec..7633fcf7c 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts @@ -3,16 +3,10 @@ import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoCo import type { GeoChecksQueryResult, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js"; import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js"; import mongoose from "mongoose"; +import { getDateForRange } from "@/utils/dataUtils.js"; const SERVICE_NAME = "GeoChecksRepository"; -const dateRangeLookup: Record = { - recent: new Date(Date.now() - 60 * 60 * 1000), - day: new Date(Date.now() - 24 * 60 * 60 * 1000), - week: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - month: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), -}; - class MongoGeoChecksRepository implements IGeoChecksRepository { static SERVICE_NAME = SERVICE_NAME; @@ -116,9 +110,9 @@ class MongoGeoChecksRepository implements IGeoChecksRepository { try { const matchStage: Record = { "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), - ...(dateRangeLookup[dateRange] && { + ...(getDateForRange(dateRange) && { createdAt: { - $gte: dateRangeLookup[dateRange], + $gte: getDateForRange(dateRange), }, }), }; diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 6559a5c57..db44bdbb2 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -3,21 +3,13 @@ import type { Monitor } from "@/types/monitor.js"; import type { MonitorStatusResponse } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ParseBoolean } from "@/utils/utils.js"; +import { getDateForRange } from "@/utils/dataUtils.js"; import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; import type { Incident } from "@/types/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; import type { ILogger } from "@/utils/logger.js"; -const dateRangeLookup: Record = { - recent: new Date(new Date().setHours(new Date().getHours() - 2)), - hour: new Date(new Date().setHours(new Date().getHours() - 1)), - day: new Date(new Date().setDate(new Date().getDate() - 1)), - week: new Date(new Date().setDate(new Date().getDate() - 7)), - month: new Date(new Date().setMonth(new Date().getMonth() - 1)), - all: undefined, -}; - class IncidentService { static SERVICE_NAME = SERVICE_NAME; @@ -177,7 +169,7 @@ class IncidentService { throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getIncidentsByTeam", status: 400 }); } - const startDate = dateRangeLookup[dateRange]; + const startDate = getDateForRange(dateRange); const parsedPage = Number.isFinite(parseInt(page)) ? parseInt(page) : 0; const parsedRowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index f8cb8cef0..f9d0fd86e 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -145,7 +145,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue { pauseJob = async (monitor: any) => { const result = await this.scheduler.pauseJob(monitor.id); if (result === false) { - throw new Error("Failed to resume monitor"); + throw new Error("Failed to pause monitor"); } await this.scheduler.pauseJob(`${monitor.id}-geo`); this.logger.debug({ @@ -160,10 +160,9 @@ class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to resume monitor"); } - const geoResult = await this.scheduler.resumeJob(`${monitor.id}-geo`); - if (geoResult === false) { - throw new Error("Failed to resume geo check for monitor"); - } + + await this.scheduler.resumeJob(`${monitor.id}-geo`); + this.logger.debug({ message: `Resumed monitor ${monitor.id}`, service: SERVICE_NAME, diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index b981365e9..125e6013d 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,5 +1,30 @@ import type { GroupedCheck, NormalizedCheck, NormalizedUptimeCheck, HasResponseTime } from "@/types/index.js"; +/** + * Get a Date object for a given date range string. + * Returns a date in the past based on the range, or undefined for 'all'. + * This is calculated fresh on each call to ensure accuracy. + */ +export const getDateForRange = (dateRange: string): Date | undefined => { + const now = Date.now(); + switch (dateRange) { + case "recent": + return new Date(now - 2 * 60 * 60 * 1000); // 2 hours + case "hour": + return new Date(now - 60 * 60 * 1000); // 1 hour + case "day": + return new Date(now - 24 * 60 * 60 * 1000); // 1 day + case "week": + return new Date(now - 7 * 24 * 60 * 60 * 1000); // 7 days + case "month": + return new Date(now - 30 * 24 * 60 * 60 * 1000); // 30 days + case "all": + return undefined; + default: + return undefined; + } +}; + const calculatePercentile = (arr: T[], percentile: number): number => { const sorted = arr.slice().sort((a, b) => a.responseTime - b.responseTime); const index = (percentile / 100) * (sorted.length - 1); From cc1d63f91aea6d6924212128f11ad1f8f8324499 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 20:14:31 +0000 Subject: [PATCH 22/28] remove comment --- server/src/utils/dataUtils.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index 125e6013d..3c86c911e 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,10 +1,5 @@ import type { GroupedCheck, NormalizedCheck, NormalizedUptimeCheck, HasResponseTime } from "@/types/index.js"; -/** - * Get a Date object for a given date range string. - * Returns a date in the past based on the range, or undefined for 'all'. - * This is calculated fresh on each call to ensure accuracy. - */ export const getDateForRange = (dateRange: string): Date | undefined => { const now = Date.now(); switch (dateRange) { From 1bb2dd323a20b5bab60827c142540ac521a8b84d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 20:17:34 +0000 Subject: [PATCH 23/28] remove comment --- server/src/service/business/geoChecksService.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index ff0f0f31c..c2fabdcfa 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -45,12 +45,6 @@ class GeoChecksService implements IGeoChecksService { return GeoChecksService.SERVICE_NAME; } - /** - * Build a geo-distributed check for a monitor - * 1. Create measurement request with GlobalPing API - * 2. Poll for results (with 30s timeout) - * 3. Transform and return GeoCheck document - */ async buildGeoCheck(monitor: Monitor): Promise { try { if (!monitor.url) { From 64c0c93c3e59c4c769b9fcacc586a23806767d5d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 21:14:17 +0000 Subject: [PATCH 24/28] chart --- client/package-lock.json | 664 +++++++++++++++++- client/package.json | 3 + .../src/Components/monitors/GeoChecksMap.tsx | 151 ++++ client/src/Components/monitors/index.tsx | 1 + client/src/Pages/Uptime/Details/index.tsx | 2 + 5 files changed, 797 insertions(+), 24 deletions(-) create mode 100644 client/src/Components/monitors/GeoChecksMap.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 038814543..338de1e26 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,6 +17,7 @@ "@mui/x-charts": "7.29.1", "@mui/x-date-pickers": "7.29.4", "@reduxjs/toolkit": "2.7.0", + "@types/maplibre-gl": "^1.13.2", "axios": "^1.7.4", "dayjs": "1.11.13", "flag-icons": "7.3.2", @@ -25,6 +26,7 @@ "i18next": "25.4.2", "joi": "17.13.3", "lucide-react": "^0.562.0", + "maplibre-gl": "^5.19.0", "mui-color-input": "^7.0.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", @@ -33,6 +35,7 @@ "react-hook-form": "^7.63.0", "react-i18next": "^15.4.0", "react-icons": "5.5.0", + "react-map-gl": "^8.1.0", "react-redux": "9.2.0", "react-router": "^6.23.0", "react-router-dom": "^6.23.1", @@ -80,7 +83,6 @@ "node_modules/@babel/core": { "version": "7.29.0", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -351,7 +353,6 @@ "node_modules/@emotion/react": { "version": "11.14.0", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -389,7 +390,6 @@ "node_modules/@emotion/styled": { "version": "11.14.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -434,6 +434,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -450,6 +451,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -466,6 +468,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -482,6 +485,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -498,6 +502,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -514,6 +519,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -530,6 +536,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -546,6 +553,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -562,6 +570,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -578,6 +587,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -594,6 +604,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -610,6 +621,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,6 +638,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -642,6 +655,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -658,6 +672,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -674,6 +689,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -688,6 +704,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -704,6 +721,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -720,6 +738,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -736,6 +755,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -752,6 +772,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -768,6 +789,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -784,6 +806,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -800,6 +823,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -816,6 +840,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -832,6 +857,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -997,6 +1023,115 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.5.0.tgz", + "integrity": "sha512-55sPMCtWAZY7r7ftU2at1SsBJfhJyIE5X16Tbl+uFcnTuiCxEuh6839iq/hgjLM8zUEjXdRu2V30bfsYCNB1Qg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.6.tgz", + "integrity": "sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.7", "license": "MIT", @@ -1050,7 +1185,6 @@ "node_modules/@mui/material": { "version": "7.3.7", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.7", @@ -1155,7 +1289,6 @@ "node_modules/@mui/system": { "version": "7.3.7", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.7", @@ -1533,6 +1666,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1546,6 +1680,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1559,6 +1694,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1572,6 +1708,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1585,6 +1722,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1598,6 +1736,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1611,6 +1750,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1624,6 +1764,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1637,6 +1778,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1650,6 +1792,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1663,6 +1806,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1676,6 +1820,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1689,6 +1834,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1702,6 +1848,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1715,6 +1862,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1728,6 +1876,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1741,6 +1890,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1752,6 +1902,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1765,6 +1916,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1778,6 +1930,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1791,6 +1944,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1804,6 +1958,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1817,6 +1972,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1830,6 +1986,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1843,6 +2000,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2175,11 +2333,25 @@ "version": "1.0.8", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/maplibre-gl": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@types/maplibre-gl/-/maplibre-gl-1.13.2.tgz", + "integrity": "sha512-IC1RBMhKXpGDpiFsEwt17c/hbff0GCS/VmzqmrY6G+kyy2wfv2e7BoSQRAfqrvhBQPCoO8yc0SNCi5HkmCcVqw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.5.2", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -2194,8 +2366,8 @@ }, "node_modules/@types/react": { "version": "18.3.27", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2216,6 +2388,15 @@ "@types/react": "*" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "license": "MIT" @@ -2225,6 +2406,66 @@ "dev": true, "license": "ISC" }, + "node_modules/@vis.gl/react-mapbox": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.0.tgz", + "integrity": "sha512-FwvH822oxEjWYOr+pP2L8hpv+7cZB2UsQbHHHT0ryrkvvqzmTgt7qHDhamv0EobKw86e1I+B4ojENdJ5G5BkyQ==", + "license": "MIT", + "peerDependencies": { + "mapbox-gl": ">=3.5.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.0.tgz", + "integrity": "sha512-PkAK/gp3mUfhCLhUuc+4gc3PN9zCtVGxTF2hB6R5R5yYUw+hdg84OZ770U5MU4tPMTCG6fbduExuIW6RRKN6qQ==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^19.2.1" + }, + "peerDependencies": { + "maplibre-gl": ">=4.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "maplibre-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "dev": true, @@ -2248,7 +2489,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2321,6 +2561,15 @@ "version": "2.0.1", "license": "Python-2.0" }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "dev": true, @@ -2445,6 +2694,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -2553,7 +2811,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2568,6 +2825,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", + "dependencies": { + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" + } + }, + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2" + } + }, "node_modules/call-bind": { "version": "1.0.8", "dev": true, @@ -2966,8 +3242,7 @@ }, "node_modules/dayjs": { "version": "1.11.13", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -3085,6 +3360,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.283", "license": "ISC" @@ -3285,6 +3566,7 @@ }, "node_modules/esbuild": { "version": "0.25.12", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3343,7 +3625,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3559,6 +3840,18 @@ "version": "4.0.7", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -3591,6 +3884,7 @@ }, "node_modules/fdir": { "version": "6.5.0", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3724,6 +4018,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3829,6 +4124,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "dev": true, @@ -3845,6 +4152,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "dev": true, @@ -4049,7 +4371,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -4256,6 +4577,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -4378,6 +4708,18 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-regex": { "version": "1.2.1", "dev": true, @@ -4515,6 +4857,15 @@ "dev": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "dev": true, @@ -4585,6 +4936,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "license": "MIT", @@ -4609,6 +4966,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -4786,6 +5149,43 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/maplibre-gl": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", + "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^5.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.4.1", + "@maplibre/mlt": "^1.1.6", + "@maplibre/vt-pbf": "^4.2.1", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -4861,6 +5261,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -4887,6 +5296,12 @@ } } }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -4902,6 +5317,7 @@ }, "node_modules/nanoid": { "version": "3.3.11", + "dev": true, "funding": [ { "type": "github", @@ -5185,6 +5601,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -5222,6 +5650,7 @@ }, "node_modules/postcss": { "version": "8.5.6", + "dev": true, "funding": [ { "type": "opencollective", @@ -5246,6 +5675,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -5306,6 +5741,12 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "license": "MIT" @@ -5337,6 +5778,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/raf-schd": { "version": "4.0.3", "license": "MIT" @@ -5354,7 +5801,6 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5366,7 +5812,6 @@ "node_modules/react-hook-form": { "version": "7.71.1", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5413,6 +5858,30 @@ "version": "19.2.4", "license": "MIT" }, + "node_modules/react-map-gl": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz", + "integrity": "sha512-vDx/QXR3Tb+8/ap/z6gdMjJQ8ZEyaZf6+uMSPz7jhWF5VZeIsKsGfPvwHVPPwGF43Ryn+YR4bd09uEFNR5OPdg==", + "license": "MIT", + "dependencies": { + "@vis.gl/react-mapbox": "8.1.0", + "@vis.gl/react-maplibre": "8.1.0" + }, + "peerDependencies": { + "mapbox-gl": ">=1.13.0", + "maplibre-gl": ">=1.13.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + }, + "maplibre-gl": { + "optional": true + } + } + }, "node_modules/react-redux": { "version": "9.2.0", "license": "MIT", @@ -5542,8 +6011,7 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-persist": { "version": "6.0.0", @@ -5628,6 +6096,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5681,8 +6158,8 @@ }, "node_modules/rollup": { "version": "4.57.1", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5744,6 +6221,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "dev": true, @@ -5850,6 +6333,21 @@ "node": ">= 0.4" } }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -5988,6 +6486,41 @@ "tslib": "^2.0.3" } }, + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "license": "BSD-3-Clause", @@ -5997,11 +6530,49 @@ }, "node_modules/source-map-js": { "version": "1.2.1", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -6184,6 +6755,15 @@ "version": "4.2.0", "license": "MIT" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -6238,6 +6818,7 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6250,6 +6831,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6361,9 +6948,8 @@ }, "node_modules/typescript": { "version": "5.9.3", - "devOptional": true, + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6372,6 +6958,21 @@ "node": ">=14.17" } }, + "node_modules/typewise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" + } + }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "dev": true, @@ -6391,9 +6992,24 @@ }, "node_modules/undici-types": { "version": "7.12.0", - "devOptional": true, + "dev": true, "license": "MIT" }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "funding": [ @@ -6466,8 +7082,8 @@ }, "node_modules/vite": { "version": "6.3.6", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6750,7 +7366,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/client/package.json b/client/package.json index f0b34dbbc..dd585636b 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "@mui/x-charts": "7.29.1", "@mui/x-date-pickers": "7.29.4", "@reduxjs/toolkit": "2.7.0", + "@types/maplibre-gl": "^1.13.2", "axios": "^1.7.4", "dayjs": "1.11.13", "flag-icons": "7.3.2", @@ -33,6 +34,7 @@ "i18next": "25.4.2", "joi": "17.13.3", "lucide-react": "^0.562.0", + "maplibre-gl": "^5.19.0", "mui-color-input": "^7.0.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", @@ -41,6 +43,7 @@ "react-hook-form": "^7.63.0", "react-i18next": "^15.4.0", "react-icons": "5.5.0", + "react-map-gl": "^8.1.0", "react-redux": "9.2.0", "react-router": "^6.23.0", "react-router-dom": "^6.23.1", diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx new file mode 100644 index 000000000..b76ced718 --- /dev/null +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -0,0 +1,151 @@ +import { useRef, useEffect, useState } from "react"; +import { Box, Typography, Stack } from "@mui/material"; +import Map, { Marker, Popup } from "react-map-gl/maplibre"; +import type { MapRef } from "react-map-gl/maplibre"; +import type { FlatGeoCheck } from "@/Types/GeoCheck"; +import "maplibre-gl/dist/maplibre-gl.css"; + +interface GeoChecksMapProps { + geoChecks: FlatGeoCheck[]; +} + +export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { + const mapRef = useRef(null); + const [selectedCheck, setSelectedCheck] = useState(null); + + useEffect(() => { + if (geoChecks.length === 0 || !mapRef.current) return; + + const bounds = geoChecks.reduce( + (acc, check) => { + return { + minLng: Math.min(acc.minLng, check.location.longitude), + maxLng: Math.max(acc.maxLng, check.location.longitude), + minLat: Math.min(acc.minLat, check.location.latitude), + maxLat: Math.max(acc.maxLat, check.location.latitude), + }; + }, + { + minLng: Infinity, + maxLng: -Infinity, + minLat: Infinity, + maxLat: -Infinity, + } + ); + + if (bounds.minLng !== Infinity) { + mapRef.current.fitBounds( + [ + [bounds.minLng, bounds.minLat], + [bounds.maxLng, bounds.maxLat], + ], + { padding: 50, duration: 1000 } + ); + } + }, [geoChecks]); + + const getMarkerColor = (status: boolean): string => { + return status ? "#4caf50" : "#f44336"; + }; + + const formatResponseTime = (timing: number): string => { + return `${timing.toFixed(0)}ms`; + }; + + return ( +
+ + {geoChecks.map((check, index) => ( + { + e.originalEvent.stopPropagation(); + setSelectedCheck(check); + }} + > +
+ + ))} + + {selectedCheck && ( + setSelectedCheck(null)} + closeOnClick={false} + > + + + {selectedCheck.location.city}, {selectedCheck.location.country} + + + + + Status: + {" "} + {selectedCheck.status ? "Up" : "Down"} + + + + Status Code: + {" "} + {selectedCheck.statusCode} + + + + Response Time: + {" "} + {formatResponseTime(selectedCheck.timings.total)} + + + {new Date(selectedCheck.createdAt).toLocaleString()} + + + + + )} + +
+ ); +}; diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx index d06a374a3..efba79a08 100644 --- a/client/src/Components/monitors/index.tsx +++ b/client/src/Components/monitors/index.tsx @@ -2,6 +2,7 @@ export * from "./ControlsFilter"; export * from "./MonitorStatBoxes"; export * from "./HeaderMonitorControls"; export * from "./HeaderGeoTabs"; +export * from "./GeoChecksMap"; export * from "./charts/HistogramStatus"; export * from "./charts/RadialAvgResponse"; export * from "./charts/HistogramDetails"; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 14225a825..300117bce 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -7,6 +7,7 @@ import { HistogramDetails, HeaderMonitorControls, HeaderGeoTabs, + GeoChecksMap, } from "@/Components/monitors"; import { TrendingUp, AlertTriangle } from "lucide-react"; import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; @@ -247,6 +248,7 @@ const UptimeDetailsPage = () => { rowsPerPage={geoRowsPerPage} setRowsPerPage={setGeoRowsPerPage} /> + )} From b68214fc47154f56ccbd8f9b96ed45de020e214a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 21:20:45 +0000 Subject: [PATCH 25/28] dark/light mode --- client/src/Components/monitors/GeoChecksMap.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx index b76ced718..7ee1a1b4a 100644 --- a/client/src/Components/monitors/GeoChecksMap.tsx +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, useState } from "react"; -import { Box, Typography, Stack } from "@mui/material"; +import { Box, Typography, Stack, useTheme } from "@mui/material"; import Map, { Marker, Popup } from "react-map-gl/maplibre"; import type { MapRef } from "react-map-gl/maplibre"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; @@ -12,6 +12,8 @@ interface GeoChecksMapProps { export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { const mapRef = useRef(null); const [selectedCheck, setSelectedCheck] = useState(null); + const theme = useTheme(); + const isDarkMode = theme.palette.mode === "dark"; useEffect(() => { if (geoChecks.length === 0 || !mapRef.current) return; @@ -45,7 +47,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { }, [geoChecks]); const getMarkerColor = (status: boolean): string => { - return status ? "#4caf50" : "#f44336"; + return status ? theme.palette.success.main : theme.palette.error.main; }; const formatResponseTime = (timing: number): string => { @@ -64,7 +66,11 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { zoom: 1.5, }} style={{ height: "100%", width: "100%" }} - mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" + mapStyle={ + isDarkMode + ? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" + : "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" + } > {geoChecks.map((check, index) => ( Date: Wed, 25 Feb 2026 21:44:09 +0000 Subject: [PATCH 26/28] move css styles --- .../src/Components/monitors/GeoChecksMap.tsx | 215 ++++++++++-------- client/src/Types/env.d.ts | 5 + 2 files changed, 128 insertions(+), 92 deletions(-) diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx index 7ee1a1b4a..09d5bb15d 100644 --- a/client/src/Components/monitors/GeoChecksMap.tsx +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, useState } from "react"; -import { Box, Typography, Stack, useTheme } from "@mui/material"; +import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material"; import Map, { Marker, Popup } from "react-map-gl/maplibre"; import type { MapRef } from "react-map-gl/maplibre"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; @@ -15,6 +15,21 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { const theme = useTheme(); const isDarkMode = theme.palette.mode === "dark"; + const mapPopupStyles = ( + + ); + useEffect(() => { if (geoChecks.length === 0 || !mapRef.current) return; @@ -55,103 +70,119 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { }; return ( -
- + {mapPopupStyles} + - {geoChecks.map((check, index) => ( - { - e.originalEvent.stopPropagation(); - setSelectedCheck(check); - }} - > -
+ {geoChecks.map((check, index) => ( + { + e.originalEvent.stopPropagation(); + setSelectedCheck(check); }} - /> - - ))} + > +
+ + ))} - {selectedCheck && ( - setSelectedCheck(null)} - closeOnClick={false} - > - - setSelectedCheck(null)} + closeOnClick={false} + > + - {selectedCheck.location.city}, {selectedCheck.location.country} - - - - - Status: - {" "} - {selectedCheck.status ? "Up" : "Down"} - - - - Status Code: - {" "} - {selectedCheck.statusCode} - - - - Response Time: - {" "} - {formatResponseTime(selectedCheck.timings.total)} - - {new Date(selectedCheck.createdAt).toLocaleString()} + {selectedCheck.location.city}, {selectedCheck.location.country} - - - - )} - -
+ + + + Status: + {" "} + {selectedCheck.status ? "Up" : "Down"} + + + + Status Code: + {" "} + {selectedCheck.statusCode} + + + + Response Time: + {" "} + {formatResponseTime(selectedCheck.timings.total)} + + + {new Date(selectedCheck.createdAt).toLocaleString()} + + + + + )} + + + ); }; diff --git a/client/src/Types/env.d.ts b/client/src/Types/env.d.ts index cd714db75..c269d0f63 100644 --- a/client/src/Types/env.d.ts +++ b/client/src/Types/env.d.ts @@ -17,3 +17,8 @@ declare module "*.svg?react" { >; export default ReactComponent; } + +declare module "*.css" { + const content: string; + export default content; +} From 78d1451621742668e94ad7063cdce5398896fd07 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 21:49:01 +0000 Subject: [PATCH 27/28] hide attribution --- client/src/Components/monitors/GeoChecksMap.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx index 09d5bb15d..c74cd668f 100644 --- a/client/src/Components/monitors/GeoChecksMap.tsx +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -24,7 +24,13 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { boxShadow: "none !important", }, ".maplibregl-popup-tip": { - display: "none !important", + display: "none", + }, + ".maplibregl-ctrl-attrib": { + display: "none", + }, + ".maplibregl-ctrl-logo": { + display: "none", }, }} /> From 7960f1e82c906c3274e9ee0e8104968ee6513c51 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 25 Feb 2026 21:58:48 +0000 Subject: [PATCH 28/28] popup close button --- client/src/Components/monitors/GeoChecksMap.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx index c74cd668f..3f9429853 100644 --- a/client/src/Components/monitors/GeoChecksMap.tsx +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -32,6 +32,15 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { ".maplibregl-ctrl-logo": { display: "none", }, + ".maplibregl-popup-close-button": { + color: theme.palette.text.primary, + background: "transparent", + fontSize: "20px", + padding: "4px", + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + }, }} /> );