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 });