Compare commits

...
Author SHA1 Message Date
Alex Holliday 35c022df0f geo job 2026-02-23 00:00:45 +00:00
Alex Holliday 19fc85ae2d front end 2026-02-22 23:25:04 +00:00
Alex Holliday b4f8fa7d72 aggregation 2026-02-22 21:15:02 +00:00
Alex Holliday ef5629d8e4 remove unused 2026-02-22 20:35:13 +00:00
Alex Holliday 4018125224 initial commit 2026-02-22 20:28:42 +00:00
15 changed files with 511 additions and 67 deletions

No files matched your search

+3
View File
@@ -14,6 +14,9 @@ const getBaseDefaults = (data?: Monitor | null) => ({
notifications: data?.notifications || [], notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5, statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60, statusWindowThreshold: data?.statusWindowThreshold || 60,
geoCheckEnabled: data?.geoCheckEnabled || false,
geoCheckLocations: data?.geoCheckLocations || [],
geoCheckInterval: data?.geoCheckInterval || 300000,
}); });
export const useMonitorForm = ({ export const useMonitorForm = ({
+85
View File
@@ -30,6 +30,7 @@ import {
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
import { useMonitorForm } from "@/Hooks/useMonitorForm"; import { useMonitorForm } from "@/Hooks/useMonitorForm";
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor"; import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
import { GeoCheckLocations } from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification"; import type { Notification } from "@/Types/Notification";
import type { MonitorFormData } from "@/Validation/monitor"; import type { MonitorFormData } from "@/Validation/monitor";
@@ -165,6 +166,7 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType; const watchedType = watch("type") as MonitorType;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
useEffect(() => { useEffect(() => {
clearErrors(); clearErrors();
@@ -823,6 +825,89 @@ const CreateMonitorPage = () => {
/> />
)} )}
{/* Geo-distributed Checks Configuration */}
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
subtitle={t("pages.createMonitor.form.geoChecks.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="geoCheckEnabled"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t("pages.createMonitor.form.geoChecks.option.enabled.label")}
</Typography>
</Stack>
)}
/>
{watchGeoCheckEnabled && (
<Stack spacing={theme.spacing(8)}>
<Controller
name="geoCheckLocations"
control={control}
render={({ field }) => {
const locationOptions = GeoCheckLocations.map((loc) => ({
id: loc,
name: t(
`pages.createMonitor.form.geoChecks.option.locations.options.${loc}`
),
}));
const selectedLocations = locationOptions.filter((loc) =>
(field.value ?? []).includes(loc.id)
);
return (
<Autocomplete
multiple={true}
options={locationOptions}
value={selectedLocations}
getOptionLabel={(option) => option.name}
onChange={(_, 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"
)}
/>
);
}}
/>
<Controller
name="geoCheckInterval"
control={control}
render={({ field }) => (
<Select
value={field.value ?? 300000}
onChange={(e) => field.onChange(Number(e.target.value))}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.interval.label"
)}
>
<MenuItem value={300000}>5 min</MenuItem>
<MenuItem value={600000}>10 min</MenuItem>
<MenuItem value={900000}>15 min</MenuItem>
<MenuItem value={1800000}>30 min</MenuItem>
</Select>
)}
/>
</Stack>
)}
</Stack>
}
/>
)}
<Stack <Stack
direction="row" direction="row"
justifyContent="flex-end" justifyContent="flex-end"
+9
View File
@@ -160,6 +160,14 @@ export interface PageSpeedGroupedCheck {
totalChecks: number; totalChecks: number;
} }
export interface GroupedGeoCheck {
bucketDate: string;
continent: string;
avgResponseTime: number;
totalChecks: number;
uptimePercentage: number;
}
export interface LatestCheck { export interface LatestCheck {
status: boolean; status: boolean;
responseTime: number; responseTime: number;
@@ -236,6 +244,7 @@ export interface UptimeChecksResult {
groupedDownChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[];
uptimePercentage: number; uptimePercentage: number;
avgResponseTime: number; avgResponseTime: number;
groupedGeoChecks?: GroupedGeoCheck[];
} }
export interface ChecksSummary { export interface ChecksSummary {
+6
View File
@@ -25,6 +25,9 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export const GeoCheckLocations = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoCheckLocation = (typeof GeoCheckLocations)[number];
export interface Monitor { export interface Monitor {
id: string; id: string;
userId: string; userId: string;
@@ -59,6 +62,9 @@ export interface Monitor {
selectedDisks: string[]; selectedDisks: string[];
gameId?: string; gameId?: string;
group: string | null; group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoCheckLocation[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
+3
View File
@@ -20,6 +20,9 @@ const baseSchema = z.object({
.number({ message: "Threshold percentage is required" }) .number({ message: "Threshold percentage is required" })
.min(1, "Incident percentage must be at least 1") .min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100"), .max(100, "Incident percentage must be at most 100"),
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(["EU", "NA", "AS", "SA", "AF", "OC"])).optional(),
geoCheckInterval: z.number().min(5, "Interval must be at least 5 minutes").optional(),
}); });
// HTTP monitor schema // HTTP monitor schema
+25
View File
@@ -431,6 +431,31 @@
}, },
"title": "Advanced settings" "title": "Advanced settings"
}, },
"geoChecks": {
"title": "Geo-distributed checks",
"description": "Run checks from multiple geographic locations to monitor global availability and performance.",
"option": {
"enabled": {
"label": "Enable geo-distributed checks"
},
"locations": {
"label": "Locations",
"placeholder": "Select locations",
"options": {
"EU": "Europe",
"NA": "North America",
"AS": "Asia",
"SA": "South America",
"AF": "Africa",
"OC": "Oceania"
}
},
"interval": {
"label": "Check interval (minutes)",
"helper": "Minimum 5 minutes between geo checks"
}
}
},
"frequency": { "frequency": {
"description": "How often do you want to check the status of this monitor?", "description": "How often do you want to check the status of this monitor?",
"option": { "option": {
+34
View File
@@ -11,6 +11,8 @@ import type {
CheckMemoryInfo, CheckMemoryInfo,
CheckMetadata, CheckMetadata,
CheckNetworkInterfaceInfo, CheckNetworkInterfaceInfo,
GeoCheckResult,
GeoCheckTimings,
GotTimings, GotTimings,
ILighthouseAudit, ILighthouseAudit,
} from "@/types/check.js"; } from "@/types/check.js";
@@ -175,6 +177,34 @@ const auditsSchema = new Schema<CheckAudits>(
{ _id: false } { _id: false }
); );
const geoCheckTimingsSchema = new Schema<GeoCheckTimings>(
{
total: { type: Number, required: true },
dns: { type: Number },
tcp: { type: Number },
tls: { type: Number },
firstByte: { type: Number },
download: { type: Number },
},
{ _id: false }
);
const geoCheckResultSchema = new Schema<GeoCheckResult>(
{
continent: { type: String, required: true },
region: { type: String },
country: { type: String, required: true },
state: { type: String },
city: { type: String, required: true },
longitude: { type: Number, required: true },
latitude: { type: Number, required: true },
status: { type: Boolean, required: true },
statusCode: { type: Number, required: true },
timings: { type: geoCheckTimingsSchema, required: true },
},
{ _id: false }
);
const metadataSchema = new Schema<CheckMetadataDocument>( const metadataSchema = new Schema<CheckMetadataDocument>(
{ {
monitorId: { monitorId: {
@@ -281,6 +311,10 @@ const CheckSchema = new Schema<CheckDocument>(
type: auditsSchema, type: auditsSchema,
default: undefined, default: undefined,
}, },
geoResults: {
type: [geoCheckResultSchema],
default: undefined,
},
}, },
{ {
timestamps: true, timestamps: true,
+15 -2
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types, type UpdateQuery } from "mongoose"; import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; import { MonitorTypes, MonitorStatuses, GeoCheckLocations } from "@/types/monitor.js";
type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Date }; type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Date };
@@ -169,6 +169,19 @@ const MonitorSchema = new Schema<MonitorDocument>(
return value && value.trim() ? value.trim() : null; return value && value.trim() ? value.trim() : null;
}, },
}, },
geoCheckEnabled: {
type: Boolean,
default: false,
},
geoCheckLocations: {
type: [String],
enum: GeoCheckLocations,
default: [],
},
geoCheckInterval: {
type: Number,
default: 300000,
},
recentChecks: { recentChecks: {
type: [checkSnapshotSchema], type: [checkSnapshotSchema],
default: [], default: [],
@@ -10,6 +10,8 @@ import type {
CheckMemoryInfo, CheckMemoryInfo,
CheckMetadata, CheckMetadata,
CheckNetworkInterfaceInfo, CheckNetworkInterfaceInfo,
GeoCheckResult,
GeoCheckTimings,
GotTimings, GotTimings,
MonitorType, MonitorType,
} from "@/types/index.js"; } from "@/types/index.js";
@@ -165,6 +167,33 @@ class MongoChecksRepository implements IChecksRepository {
}; };
}; };
const mapGeoTimings = (timings?: GeoCheckTimings): GeoCheckTimings => ({
total: timings?.total ?? 0,
dns: timings?.dns,
tcp: timings?.tcp,
tls: timings?.tls,
firstByte: timings?.firstByte,
download: timings?.download,
});
const mapGeoResults = (geoResults?: GeoCheckResult[]): GeoCheckResult[] | undefined => {
if (!geoResults?.length) {
return undefined;
}
return geoResults.map((result) => ({
continent: result.continent ?? "",
region: result.region,
country: result.country ?? "",
state: result.state,
city: result.city ?? "",
longitude: result.longitude ?? 0,
latitude: result.latitude ?? 0,
status: result.status ?? false,
statusCode: result.statusCode ?? 0,
timings: mapGeoTimings(result.timings),
}));
};
const mapMetadata = (metadata: CheckDocument["metadata"]): CheckMetadata => ({ const mapMetadata = (metadata: CheckDocument["metadata"]): CheckMetadata => ({
monitorId: toStringId(metadata.monitorId), monitorId: toStringId(metadata.monitorId),
teamId: toStringId(metadata.teamId), teamId: toStringId(metadata.teamId),
@@ -194,6 +223,7 @@ class MongoChecksRepository implements IChecksRepository {
seo: doc.seo, seo: doc.seo,
performance: doc.performance, performance: doc.performance,
audits: mapAudits(doc.audits), audits: mapAudits(doc.audits),
geoResults: mapGeoResults(doc.geoResults),
__v: doc.__v ?? 0, __v: doc.__v ?? 0,
createdAt: toDateString(doc.createdAt), createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt), updatedAt: toDateString(doc.updatedAt),
@@ -427,85 +457,131 @@ class MongoChecksRepository implements IChecksRepository {
endDate: Date, endDate: Date,
dateString: string dateString: string
) => { ) => {
const matchStage = { // Match stage for primary checks (exclude geo checks)
const primaryCheckMatchStage = {
"metadata.monitorId": monitorObjectId, "metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate }, createdAt: { $gte: startDate, $lte: endDate },
geoResults: { $exists: false },
}; };
const [result] = await CheckModel.aggregate([
{ $match: matchStage }, // Match stage for geo checks only
{ $sort: { createdAt: 1 } }, const geoCheckMatchStage = {
{ "metadata.monitorId": monitorObjectId,
$facet: { createdAt: { $gte: startDate, $lte: endDate },
uptimePercentage: [ geoResults: { $exists: true, $ne: null },
{ };
$group: {
_id: null, // Run two separate aggregations - one for primary checks, one for geo checks
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, // TODO, only do this if the monitor has geochecks enabled, otherwise we only need one aggregation
totalChecks: { $sum: 1 }, const [primaryResult, geoResults] = await Promise.all([
}, CheckModel.aggregate([
}, { $match: primaryCheckMatchStage },
{ { $sort: { createdAt: 1 } },
$project: { {
_id: 0, $facet: {
percentage: { uptimePercentage: [
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }], {
$group: {
_id: null,
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
totalChecks: { $sum: 1 },
}, },
}, },
}, {
], $project: {
groupedAvgResponseTime: [ _id: 0,
{ percentage: {
$group: { $cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
_id: null, },
avgResponseTime: { $avg: "$responseTime" },
},
},
],
groupedChecks: [
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
}, },
avgResponseTime: { $avg: "$responseTime" },
totalChecks: { $sum: 1 },
}, },
}, ],
{ $sort: { _id: 1 } }, groupedAvgResponseTime: [
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, {
], $group: {
groupedUpChecks: [ _id: null,
{ $match: { status: true } }, avgResponseTime: { $avg: "$responseTime" },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
}, },
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
}, },
}, ],
{ $sort: { _id: 1 } }, groupedChecks: [
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, {
], $group: {
groupedDownChecks: [ _id: {
{ $match: { status: false } }, $dateToString: { format: dateString, date: "$createdAt" },
{ },
$group: { avgResponseTime: { $avg: "$responseTime" },
_id: { totalChecks: { $sum: 1 },
$dateToString: { format: dateString, date: "$createdAt" },
}, },
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
}, },
}, { $sort: { _id: 1 } },
{ $sort: { _id: 1 } }, { $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } }, ],
], groupedUpChecks: [
{ $match: { status: true } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
groupedDownChecks: [
{ $match: { status: false } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
},
}, },
}, ]),
CheckModel.aggregate([
{ $match: geoCheckMatchStage },
{ $sort: { createdAt: 1 } },
{ $unwind: "$geoResults" },
{
$group: {
_id: {
date: { $dateToString: { format: dateString, date: "$createdAt" } },
continent: "$geoResults.continent",
},
avgResponseTime: { $avg: "$geoResults.timings.total" },
totalChecks: { $sum: 1 },
upChecks: { $sum: { $cond: [{ $eq: ["$geoResults.status", true] }, 1, 0] } },
},
},
{
$project: {
_id: 0,
bucketDate: "$_id.date",
continent: "$_id.continent",
avgResponseTime: 1,
totalChecks: 1,
uptimePercentage: {
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
},
},
},
{ $sort: { bucketDate: 1, continent: 1 } },
]),
]); ]);
const [result] = primaryResult;
const groupedGeoChecks = geoResults ?? [];
const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0; const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0;
const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0; const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0;
@@ -516,6 +592,7 @@ class MongoChecksRepository implements IChecksRepository {
groupedDownChecks: result?.groupedDownChecks ?? [], groupedDownChecks: result?.groupedDownChecks ?? [],
uptimePercentage, uptimePercentage,
avgResponseTime, avgResponseTime,
groupedGeoChecks,
}; };
}; };
@@ -353,6 +353,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
selectedDisks: doc.selectedDisks ?? [], selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 5,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt), createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt), updatedAt: toDateString(doc.updatedAt),
@@ -408,6 +411,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
selectedDisks: doc.selectedDisks ?? [], selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 5,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt), createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt), updatedAt: toDateString(doc.updatedAt),
@@ -97,6 +97,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
this.scheduler.start(); this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); 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()); this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
const monitors = await this.monitorsRepository.findAll(); const monitors = await this.monitorsRepository.findAll();
if (!monitors) { if (!monitors) {
@@ -131,14 +132,28 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
active: monitor.isActive, active: monitor.isActive,
data: monitor, data: monitor,
}); });
// Add geo check job if enabled
if (monitor.geoCheckEnabled && monitor.type === "http") {
this.scheduler.addJob({
id: `${monitorId}-geo`,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
}
}; };
deleteJob = async (monitor: any) => { deleteJob = async (monitor: any) => {
this.scheduler.removeJob(monitor.id); this.scheduler.removeJob(monitor.id);
this.scheduler.removeJob(`${monitor.id}-geo`);
}; };
pauseJob = async (monitor: any) => { pauseJob = async (monitor: any) => {
const result = await this.scheduler.pauseJob(monitor.id); const result = await this.scheduler.pauseJob(monitor.id);
// Also pause geo check job if it exists
await this.scheduler.pauseJob(`${monitor.id}-geo`);
if (result === false) { if (result === false) {
throw new Error("Failed to resume monitor"); throw new Error("Failed to resume monitor");
} }
@@ -151,6 +166,8 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
resumeJob = async (monitor: any) => { resumeJob = async (monitor: any) => {
const result = await this.scheduler.resumeJob(monitor.id); const result = await this.scheduler.resumeJob(monitor.id);
// Also resume geo check job if it exists
await this.scheduler.resumeJob(`${monitor.id}-geo`);
if (result === false) { if (result === false) {
throw new Error("Failed to resume monitor"); throw new Error("Failed to resume monitor");
} }
@@ -163,6 +180,29 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
updateJob = async (monitor: any) => { updateJob = async (monitor: any) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
// Handle geo check job
const geoJobId = `${monitor.id}-geo`;
const geoJobExists = await this.scheduler.getJob(geoJobId);
if (monitor.geoCheckEnabled && monitor.type === "http") {
if (geoJobExists) {
// Update existing geo job
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, data: monitor, active: monitor.isActive });
} else {
// Add new geo job
this.scheduler.addJob({
id: geoJobId,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
}
} else if (geoJobExists) {
// Remove geo job if it exists but is no longer enabled
this.scheduler.removeJob(geoJobId);
}
}; };
shutdown = async () => { shutdown = async () => {
@@ -176,6 +176,98 @@ class SuperSimpleQueueHelper {
}; };
}; };
getGeoCheckJob = () => {
return async (monitor: Monitor) => {
try {
const monitorId = monitor.id;
const teamId = monitor.teamId;
if (!monitorId) {
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" });
}
// Safety checks
if (!monitor.geoCheckEnabled || monitor.type !== "http") {
this.logger.debug({
message: `Geo check skipped for monitor ${monitorId} - not enabled or not HTTP type`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
});
return;
}
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
this.logger.warn({
message: `Geo check skipped for monitor ${monitorId} - no locations configured`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
});
return;
}
// Check for maintenance window
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId);
if (maintenanceWindowActive) {
this.logger.debug({
message: `Geo check skipped for monitor ${monitorId} - in maintenance window`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
});
return;
}
this.logger.debug({
message: `Starting geo check for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
});
// TODO: Implement GlobalPing API integration
// Step 1: Create measurement via GlobalPing API
// const measurementId = await this.globalPingService.createMeasurement(
// monitor.url,
// monitor.geoCheckLocations
// );
// Step 2: Poll for results (blocks 2-30s, but doesn't affect primary monitor checks)
// const geoResults = await this.globalPingService.pollForResults(measurementId);
// Step 3: Create check document with only geo results
// const check = {
// metadata: {
// monitorId: monitor.id,
// teamId: monitor.teamId,
// type: monitor.type,
// },
// status: true,
// responseTime: 0,
// statusCode: 200,
// message: "Geo check",
// geoResults,
// ack: false,
// };
// Step 4: Save check
// await this.checksRepository.create([check]);
this.logger.info({
message: `Geo check completed for monitor ${monitorId}`,
service: SERVICE_NAME,
method: "getGeoCheckJob",
});
} catch (error: any) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "getGeoCheckJob",
details: error,
stack: error.stack,
});
throw error;
}
};
};
getCleanupOrphanedJob = () => { getCleanupOrphanedJob = () => {
return async () => { return async () => {
try { try {
+32
View File
@@ -90,6 +90,28 @@ export interface ILighthouseAudit {
numericUnit?: string; numericUnit?: string;
} }
export interface GeoCheckTimings {
total: number;
dns?: number;
tcp?: number;
tls?: number;
firstByte?: number;
download?: number;
}
export interface GeoCheckResult {
continent: string;
region?: string;
country: string;
state?: string;
city: string;
longitude: number;
latitude: number;
status: boolean;
statusCode: number;
timings: GeoCheckTimings;
}
export interface Check { export interface Check {
id: string; id: string;
metadata: CheckMetadata; metadata: CheckMetadata;
@@ -113,6 +135,7 @@ export interface Check {
seo?: number; seo?: number;
performance?: number; performance?: number;
audits?: CheckAudits; audits?: CheckAudits;
geoResults?: GeoCheckResult[];
__v: number; __v: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -179,6 +202,14 @@ export interface PageSpeedGroupedCheck {
totalChecks: number; totalChecks: number;
} }
export interface GroupedGeoCheck {
bucketDate: string;
continent: string;
avgResponseTime: number;
totalChecks: number;
uptimePercentage: number;
}
export interface UptimeChecksResult { export interface UptimeChecksResult {
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">; monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
groupedChecks: GroupedCheck[]; groupedChecks: GroupedCheck[];
@@ -186,6 +217,7 @@ export interface UptimeChecksResult {
groupedDownChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[];
uptimePercentage: number; uptimePercentage: number;
avgResponseTime: number; avgResponseTime: number;
groupedGeoChecks?: GroupedGeoCheck[];
} }
export interface ChecksSummary { export interface ChecksSummary {
+6
View File
@@ -7,6 +7,9 @@ export type MonitorType = (typeof MonitorTypes)[number];
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const; export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
export type MonitorStatus = (typeof MonitorStatuses)[number]; export type MonitorStatus = (typeof MonitorStatuses)[number];
export const GeoCheckLocations = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoCheckLocation = (typeof GeoCheckLocations)[number];
export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export interface Monitor { export interface Monitor {
@@ -43,6 +46,9 @@ export interface Monitor {
selectedDisks: string[]; selectedDisks: string[];
gameId?: string; gameId?: string;
group: string | null; group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoCheckLocation[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
+13
View File
@@ -1,5 +1,6 @@
import joi, { type CustomHelpers } from "joi"; import joi, { type CustomHelpers } from "joi";
import { type UserRole, UserRoles } from "@/types/user.js"; import { type UserRole, UserRoles } from "@/types/user.js";
import { GeoCheckLocations } from "@/types/monitor.js";
//**************************************** //****************************************
// Custom Validators // Custom Validators
@@ -172,6 +173,12 @@ const createMonitorBodyValidation = joi.object({
gameId: joi.string().allow(""), gameId: joi.string().allow(""),
selectedDisks: joi.array().items(joi.string()).optional(), selectedDisks: joi.array().items(joi.string()).optional(),
group: joi.string().max(50).trim().allow(null, "").optional(), group: joi.string().max(50).trim().allow(null, "").optional(),
geoCheckEnabled: joi.boolean().optional(),
geoCheckLocations: joi
.array()
.items(joi.string().valid(...GeoCheckLocations))
.optional(),
geoCheckInterval: joi.number().min(300000).optional(),
}); });
const createMonitorsBodyValidation = joi.array().items( const createMonitorsBodyValidation = joi.array().items(
@@ -203,6 +210,12 @@ const editMonitorBodyValidation = joi
gameId: joi.string().allow(""), gameId: joi.string().allow(""),
selectedDisks: joi.array().items(joi.string()).optional(), selectedDisks: joi.array().items(joi.string()).optional(),
group: joi.string().max(50).trim().allow(null, "").optional(), group: joi.string().max(50).trim().allow(null, "").optional(),
geoCheckEnabled: joi.boolean().optional(),
geoCheckLocations: joi
.array()
.items(joi.string().valid(...GeoCheckLocations))
.optional(),
geoCheckInterval: joi.number().min(5).optional(),
}) })
.options({ stripUnknown: true }); .options({ stripUnknown: true });