diff --git a/client/src/Components/monitors/HeaderGeoTabs.tsx b/client/src/Components/monitors/HeaderGeoTabs.tsx
new file mode 100644
index 000000000..7351078d4
--- /dev/null
+++ b/client/src/Components/monitors/HeaderGeoTabs.tsx
@@ -0,0 +1,54 @@
+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);
+ };
+
+ 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/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/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts
index 633fcb5c9..14d6bbd68 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 && }
+
+ ))}
+
+ )}
+
+ );
+ }}
+ />
+ (
+
+ )}
+ />
+
+ )}
+
+ }
+ />
+ )}
+
{
const { t } = useTranslation();
- const [activeTab, setActiveTab] = useState(2);
+ const [activeTab, setActiveTab] = useState(1);
return (
{
},
{
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..31ad6a3dd
--- /dev/null
+++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx
@@ -0,0 +1,107 @@
+import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
+import Box from "@mui/material/Box";
+import type { Header } from "@/Components/design-elements";
+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";
+
+const getHeaders = (t: Function, uiTimezone: string) => {
+ const headers: Header[] = [
+ {
+ id: "status",
+ content: t("common.table.headers.status"),
+ render: (row) => {
+ const status = row.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) => {
+ return row.statusCode || "N/A";
+ },
+ },
+ {
+ id: "location",
+ content: t("pages.checks.table.headers.location"),
+ render: (row) => {
+ const location = row.location;
+ if (!location) return "N/A";
+ const { continent, country, city } = location;
+ return `${continent} - ${country}, ${city}`;
+ },
+ },
+ {
+ id: "responseTime",
+ content: t("common.table.headers.responseTime"),
+ render: (row) => {
+ if (!row.timings?.total) return "N/A";
+ return prettyMilliseconds(row.timings.total, { compact: true });
+ },
+ },
+ ];
+ return headers;
+};
+
+export const GeoChecksTable = ({
+ geoChecks,
+ count,
+ page,
+ setPage,
+ rowsPerPage,
+ setRowsPerPage,
+}: {
+ geoChecks: FlatGeoCheck[];
+ 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 23f9a2196..14225a825 100644
--- a/client/src/Pages/Uptime/Details/index.tsx
+++ b/client/src/Pages/Uptime/Details/index.tsx
@@ -6,9 +6,11 @@ import {
RadialAvgResponse,
HistogramDetails,
HeaderMonitorControls,
+ HeaderGeoTabs,
} 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";
@@ -19,9 +21,15 @@ 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,
+ FlatGeoChecksResponse,
+ 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";
@@ -37,7 +45,10 @@ 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");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
@@ -56,7 +67,7 @@ const UptimeDetailsPage = () => {
} = useGet(
monitorDetailsUrl,
{},
- { refreshInterval: 10000, keepPreviousData: true }
+ { refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false }
);
const monitorData = monitorDetailsData?.monitorData;
@@ -71,7 +82,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) {
@@ -102,9 +117,58 @@ const UptimeDetailsPage = () => {
const { data: checksData, isLoading: checksIsLoading } = useGet(
checksUrl,
{},
- { keepPreviousData: true }
+ { keepPreviousData: true, revalidateOnFocus: false }
);
+ const geoChecksUrl = useMemo(() => {
+ if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) {
+ return null;
+ }
+ 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, selectedLocation]);
+
+ const { data: geoGroupedData } = useGet(
+ geoChecksUrl,
+ {},
+ { keepPreviousData: true, revalidateOnFocus: false }
+ );
+
+ 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));
+ return `/geo-checks/${monitorId}?${params.toString()}`;
+ }, [
+ monitorId,
+ monitor?.type,
+ monitor?.geoCheckEnabled,
+ dateRange,
+ geoPage,
+ geoRowsPerPage,
+ ]);
+
+ const { data: geoChecksTableData } = useGet(
+ geoChecksTableUrl,
+ {},
+ { keepPreviousData: true, revalidateOnFocus: false }
+ );
+
+ const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
+ const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
+
+ const geoLocations = monitor?.geoCheckLocations;
+
const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0;
@@ -127,6 +191,7 @@ const UptimeDetailsPage = () => {
dateRange={dateRange}
setDateRange={setDateRange}
/>
+
{
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
+
+ {monitor?.geoCheckEnabled && (
+ <>
+ Location breakdown
+
+
+
+ >
+ )}
);
};
diff --git a/client/src/Types/GeoCheck.ts b/client/src/Types/GeoCheck.ts
new file mode 100644
index 000000000..ec46eeac1
--- /dev/null
+++ b/client/src/Types/GeoCheck.ts
@@ -0,0 +1,80 @@
+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 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;
+ avgResponseTime: number;
+ totalChecks: number;
+ uptimePercentage: number;
+}
+
+export interface GeoChecksResult {
+ monitorType: string;
+ groupedGeoChecks: GroupedGeoCheck[];
+}
+
+export interface GeoChecksResponse {
+ geoChecks: GeoCheck[];
+ geoChecksCount: number;
+}
+
+export interface FlatGeoChecksResponse {
+ geoChecks: FlatGeoCheck[];
+ geoChecksCount: number;
+}
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/client/src/locales/en.json b/client/src/locales/en.json
index b3f3bbcf5..dcfa68281 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -116,7 +116,8 @@
"type": "Type",
"url": "Url",
"interval": "Interval",
- "active": "Active"
+ "active": "Active",
+ "responseTime": "Response time"
}
}
},
@@ -373,8 +374,8 @@
"table": {
"empty": "No down checks in this time range",
"headers": {
- "dateTime": "Date & time",
- "statusCode": "Status code"
+ "statusCode": "Status code",
+ "location": "Location"
}
}
},
@@ -552,6 +553,36 @@
}
}
},
+ "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",
+ "value": {
+ "fiveMinutes": "5 minutes",
+ "tenMinutes": "10 minutes",
+ "fifteenMinutes": "15 minutes",
+ "thirtyMinutes": "30 minutes"
+ }
+ }
+ }
+ },
"url": {
"title": "Monitor IP/URL on Status Page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
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 ecb49ab40..ce2472588 100644
--- a/server/src/config/services.ts
+++ b/server/src/config/services.ts
@@ -17,6 +17,8 @@ import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/S
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/business/userService.js";
import CheckService from "../service/business/checkService.js";
+import GeoChecksService from "../service/business/geoChecksService.js";
+import GlobalPingService from "../service/infrastructure/globalPingService.js";
import DiagnosticService from "../service/business/diagnosticService.js";
import InviteService from "../service/business/inviteService.js";
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
@@ -46,6 +48,7 @@ import jmespath from "jmespath";
import {
MongoMonitorsRepository,
MongoChecksRepository,
+ MongoGeoChecksRepository,
MongoMonitorStatsRepository,
MongoStatusPagesRepository,
MongoUsersRepository,
@@ -57,6 +60,7 @@ import {
MongoMaintenanceWindowsRepository,
IMonitorsRepository,
IChecksRepository,
+ IGeoChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
@@ -81,12 +85,13 @@ export type InitializedServices = {
jobQueue: any;
userService: any;
checkService: any;
+ geoChecksService: any;
diagnosticService: any;
inviteService: any;
maintenanceWindowService: any;
monitorService: any;
incidentService: any;
- logger: any;
+ logger: ILogger;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
notificationMessageBuilder: INotificationMessageBuilder;
@@ -94,6 +99,7 @@ export type InitializedServices = {
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
+ geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -126,6 +132,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();
@@ -136,38 +143,25 @@ 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({
+ const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
+
+ const checkService = new CheckService(monitorsRepository, logger, checksRepository);
+
+ const globalPingService = new GlobalPingService(logger);
+
+ const geoChecksService = new GeoChecksService({
logger,
- incidentsRepository,
+ geoChecksRepository,
+ globalPingService,
monitorsRepository,
- usersRepository,
- notificationMessageBuilder,
});
- const checkService = new CheckService({
- monitorsRepository,
- logger,
- checksRepository,
- });
-
- const bufferService = new BufferService({ logger, checkService, settingsService });
+ const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
@@ -192,13 +186,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,
@@ -206,13 +200,11 @@ export const initializeServices = async ({
monitorStatsRepository,
checksRepository,
incidentsRepository,
- });
+ geoChecksService,
+ geoChecksRepository
+ );
- const superSimpleQueue = await SuperSimpleQueue.create({
- logger,
- helper: superSimpleQueueHelper,
- monitorsRepository,
- });
+ const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository);
// Business services
const userService = new UserService({
@@ -247,6 +239,7 @@ export const initializeServices = async ({
games,
monitorsRepository,
checksRepository,
+ geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -265,6 +258,7 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
userService,
checkService,
+ geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
@@ -278,6 +272,7 @@ export const initializeServices = async ({
// Repositories
monitorsRepository,
checksRepository,
+ geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
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/controllers/monitorController.ts b/server/src/controllers/monitorController.ts
index 8bd131dfc..a11e3fc65 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 {
@@ -134,6 +135,38 @@ class MonitorController {
}
};
+ getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ 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");
+ 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,
+ continents,
+ });
+
+ 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/db/models/GeoCheck.ts b/server/src/db/models/GeoCheck.ts
new file mode 100644
index 000000000..8c0749f00
--- /dev/null
+++ b/server/src/db/models/GeoCheck.ts
@@ -0,0 +1,116 @@
+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.teamId": 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/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/IGeoChecksRepository.ts b/server/src/repositories/geo-checks/IGeoChecksRepository.ts
new file mode 100644
index 000000000..fe4beb796
--- /dev/null
+++ b/server/src/repositories/geo-checks/IGeoChecksRepository.ts
@@ -0,0 +1,35 @@
+import type { GeoCheck, GroupedGeoCheck } 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(
+ monitorId: string,
+ sortOrder: string,
+ dateRange: string,
+ page: number,
+ rowsPerPage: number,
+ continents?: GeoContinent[]
+ ): Promise;
+ findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise;
+ findGroupedByMonitorIdAndDateRange(
+ monitorId: string,
+ startDate: Date,
+ endDate: Date,
+ dateFormat: string,
+ continents?: GeoContinent[]
+ ): 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
new file mode 100644
index 000000000..7633fcf7c
--- /dev/null
+++ b/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts
@@ -0,0 +1,386 @@
+import { IGeoChecksRepository } 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";
+import { getDateForRange } from "@/utils/dataUtils.js";
+
+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),
+ };
+ };
+
+ createGeoChecks = async (geoChecks: Omit[]): Promise => {
+ try {
+ 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: `Failed to createGeoChecks: ${error.message}`,
+ service: SERVICE_NAME,
+ method: "createGeoChecks",
+ stack: error.stack,
+ });
+ throw error;
+ }
+ };
+
+ findByMonitorId = async (
+ monitorId: string,
+ sortOrder: string,
+ dateRange: string,
+ page: number,
+ rowsPerPage: number,
+ continents?: GeoContinent[]
+ ): Promise => {
+ try {
+ const matchStage: Record = {
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ ...(getDateForRange(dateRange) && {
+ createdAt: {
+ $gte: getDateForRange(dateRange),
+ },
+ }),
+ };
+
+ const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
+
+ let skip = 0;
+ if (page && rowsPerPage) {
+ skip = page * rowsPerPage;
+ }
+
+ if (continents && continents.length > 0) {
+ const pipeline: any[] = [
+ { $match: matchStage },
+ { $unwind: "$results" },
+ {
+ $match: {
+ "results.location.continent": { $in: continents },
+ },
+ },
+ ];
+ } else {
+ 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}`,
+ 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({
+ "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,
+ continents?: GeoContinent[]
+ ): 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",
+ },
+ // Filter by continent if specified
+ ...(continents && continents.length > 0
+ ? [
+ {
+ $match: {
+ "results.location.continent": { $in: continents },
+ },
+ },
+ ]
+ : []),
+ // 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;
+ }
+ };
+
+ 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 };
+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/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),
};
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/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
new file mode 100644
index 000000000..ff0f0f31c
--- /dev/null
+++ b/server/src/service/business/geoChecksService.ts
@@ -0,0 +1,198 @@
+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 { AppError } from "@/utils/AppError.js";
+
+const SERVICE_NAME = "GeoChecksService";
+
+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 {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private logger: ILogger;
+ private geoChecksRepository: IGeoChecksRepository;
+ private globalPingService: IGlobalPingService;
+ private monitorsRepository: IMonitorsRepository;
+
+ 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() {
+ 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) {
+ this.logger.warn({
+ message: "Monitor missing URL for geo check",
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id },
+ });
+ return null;
+ }
+
+ if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
+ this.logger.warn({
+ message: "Monitor missing geo check locations",
+ service: SERVICE_NAME,
+ method: "buildGeoCheck",
+ details: { monitorId: monitor.id },
+ });
+ return null;
+ }
+
+ // 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: "buildGeoCheck",
+ details: { monitorId: monitor.id },
+ });
+ return null;
+ }
+
+ // 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: "buildGeoCheck",
+ details: { monitorId: monitor.id, measurementId },
+ });
+ return null;
+ }
+
+ // Step 3: Build GeoCheck document
+ const geoCheck = this.createGeoCheckDocument(monitor, results);
+
+ this.logger.debug({
+ message: `Geo check completed for monitor ${monitor.id}`,
+ service: SERVICE_NAME,
+ 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: "buildGeoCheck",
+ details: { monitorId: monitor.id, error: error.message },
+ stack: error.stack,
+ });
+ return null;
+ }
+ }
+
+ private createGeoCheckDocument(monitor: Monitor, results: GeoCheckResult[]): GeoCheck {
+ const now = new Date();
+ const ttl = 90 * 24 * 60 * 60 * 1000; // 90 days in ms
+ const expiryDate = new Date(now.getTime() + ttl);
+
+ 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(),
+ };
+ }
+
+ 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 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, continents);
+
+ return result;
+ };
+}
+
+export default GeoChecksService;
diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts
index 9b3344f11..db44bdbb2 100644
--- a/server/src/service/business/incidentService.ts
+++ b/server/src/service/business/incidentService.ts
@@ -3,42 +3,29 @@ 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";
-
-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,
-};
+import type { ILogger } from "@/utils/logger.js";
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 +138,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 +147,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "resolveIncident",
message: error.message,
- details: incidentId,
+ details: { id: incidentId },
stack: error.stack,
});
throw error;
@@ -182,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;
@@ -207,7 +194,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "getIncidentsByTeam",
message: error.message,
- details: teamId,
+ details: { teamId },
stack: error.stack,
});
throw error;
@@ -229,7 +216,7 @@ class IncidentService {
service: SERVICE_NAME,
method: "getIncidentSummary",
message: error.message,
- details: teamId,
+ details: { teamId },
stack: error.stack,
});
throw error;
@@ -250,7 +237,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..662e5775a 100644
--- a/server/src/service/business/monitorService.ts
+++ b/server/src/service/business/monitorService.ts
@@ -8,8 +8,10 @@ import type {
PageSpeedDetailsResult,
GamesMap,
} from "@/types/monitor.js";
+import type { GeoContinent } from "@/types/geoCheck.js";
import type {
IChecksRepository,
+ IGeoChecksRepository,
IIncidentsRepository,
IMonitorsRepository,
IMonitorStatsRepository,
@@ -36,6 +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; continents?: GeoContinent[] }): Promise;
getMonitorById(args: { teamId: string; monitorId: string }): Promise;
getMonitorsByTeamId(args: {
teamId: string;
@@ -84,6 +87,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 +99,7 @@ export class MonitorService implements IMonitorService {
games,
monitorsRepository,
checksRepository,
+ geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -105,6 +110,7 @@ export class MonitorService implements IMonitorService {
games: any;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
+ geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
@@ -115,6 +121,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 +322,40 @@ export class MonitorService implements IMonitorService {
monitorStats,
};
};
+
+ getGeoChecksByMonitorId = async ({
+ teamId,
+ monitorId,
+ dateRange,
+ continents,
+ }: {
+ teamId: string;
+ monitorId: string;
+ dateRange: string;
+ continents?: GeoContinent[];
+ }): 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),
+ continents
+ );
+
+ return { groupedGeoChecks };
+ };
+
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
@@ -438,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;
};
@@ -449,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/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts
index 1947549c9..f9d0fd86e 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;
}
@@ -97,6 +89,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) {
@@ -112,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,
@@ -131,17 +124,30 @@ 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) => {
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({
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -154,6 +160,9 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to resume monitor");
}
+
+ await this.scheduler.resumeJob(`${monitor.id}-geo`);
+
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -163,6 +172,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 () => {
diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts
index 4d9876f2f..454b52b0d 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,
@@ -11,7 +12,18 @@ import {
IMonitorStatsRepository,
IChecksRepository,
IIncidentsRepository,
+ IGeoChecksRepository,
} 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;
@@ -27,7 +39,7 @@ export interface MonitorActionDecision {
};
}
-class SuperSimpleQueueHelper {
+class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
@@ -35,7 +47,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;
@@ -43,36 +55,26 @@ class SuperSimpleQueueHelper {
private monitorStatsRepository: IMonitorStatsRepository;
private checksRepository: IChecksRepository;
private incidentsRepository: IIncidentsRepository;
+ private geoChecksService: IGeoChecksService;
+ private geoChecksRepository: IGeoChecksRepository;
- constructor({
- logger,
- networkService,
- statusService,
- notificationsService,
- checkService,
- buffer,
- incidentService,
- maintenanceWindowsRepository,
- monitorsRepository,
- teamsRepository,
- monitorStatsRepository,
- checksRepository,
- incidentsRepository,
- }: {
- 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;
- }) {
+ 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,
+ geoChecksRepository: IGeoChecksRepository
+ ) {
this.logger = logger;
this.networkService = networkService;
this.statusService = statusService;
@@ -86,6 +88,8 @@ class SuperSimpleQueueHelper {
this.monitorStatsRepository = monitorStatsRepository;
this.checksRepository = checksRepository;
this.incidentsRepository = incidentsRepository;
+ this.geoChecksService = geoChecksService;
+ this.geoChecksRepository = geoChecksRepository;
}
get serviceName() {
@@ -240,6 +244,16 @@ class SuperSimpleQueueHelper {
});
}
+ // 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,
@@ -257,6 +271,81 @@ 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: 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}`,
+ 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:
diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts
index c38fd52a4..a56ae4bd8 100755
--- a/server/src/service/infrastructure/bufferService.ts
+++ b/server/src/service/infrastructure/bufferService.ts
@@ -1,29 +1,38 @@
import type { Check } from "@/types/index.js";
-
+import type { GeoCheck } from "@/types/index.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 {
addToBuffer(check: Check): void;
+ addGeoCheckToBuffer(geoCheck: GeoCheck): void;
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: ILogger;
private SERVICE_NAME: string;
private buffer: any[];
+ private geoBuffer: any[];
private bufferTimer: NodeJS.Timeout | null = null;
private checksService: any;
+ private geoChecksService: IGeoChecksService;
- constructor({ logger, checkService, settingsService }: { logger: any; checkService: 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;
+ 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 +58,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) {
@@ -94,6 +116,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}`,
@@ -110,18 +133,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);
+ 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 = [];
+ 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
new file mode 100644
index 000000000..1d49c6679
--- /dev/null
+++ b/server/src/service/infrastructure/globalPingService.ts
@@ -0,0 +1,210 @@
+import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js";
+import type { ILogger } from "@/utils/logger.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: ILogger;
+
+ constructor(logger: ILogger) {
+ this.logger = logger;
+ }
+
+ get serviceName() {
+ return GlobalPingService.SERVICE_NAME;
+ }
+
+ 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: cleanTarget,
+ 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,
+ });
+ 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;
diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts
index 92d269808..958bbdfd8 100644
--- a/server/src/service/infrastructure/networkService.ts
+++ b/server/src/service/infrastructure/networkService.ts
@@ -1,8 +1,12 @@
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";
+import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "NetworkService";
type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>;
@@ -46,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,
@@ -111,31 +115,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: AxiosStatic,
+ 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";
@@ -434,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({
@@ -530,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(
@@ -596,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,
diff --git a/server/src/types/geoCheck.ts b/server/src/types/geoCheck.ts
new file mode 100644
index 000000000..548c4c65f
--- /dev/null
+++ b/server/src/types/geoCheck.ts
@@ -0,0 +1,72 @@
+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 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;
+ 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/utils/dataUtils.ts b/server/src/utils/dataUtils.ts
index b981365e9..3c86c911e 100755
--- a/server/src/utils/dataUtils.ts
+++ b/server/src/utils/dataUtils.ts
@@ -1,5 +1,25 @@
import type { GroupedCheck, NormalizedCheck, NormalizedUptimeCheck, HasResponseTime } from "@/types/index.js";
+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);
diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts
index 912767587..4002c97e6 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
@@ -112,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({});
@@ -172,6 +174,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 +211,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 });
@@ -310,6 +324,7 @@ const getChecksQueryValidation = joi.object({
page: joi.number(),
rowsPerPage: joi.number(),
status: joi.boolean(),
+ continent: joi.alternatives().try(joi.string().valid(...GeoContinents), joi.array().items(joi.string().valid(...GeoContinents))),
});
const getTeamChecksQueryValidation = joi.object({