resolve conflicts

This commit is contained in:
Alex Holliday committed 2026-02-26 17:32:11 +00:00
commit dfeba1e4fb
48 files changed
+2921 -217

No files matched your search

+640 -24
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -25,6 +25,7 @@
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
@@ -33,6 +34,7 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
@@ -41,6 +43,7 @@
"react-hook-form": "^7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
"react-map-gl": "^8.1.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
@@ -5,6 +5,7 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import type { MonitorType } from "@/Types/Monitor";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "docker", "game", "grpc"];
const typeDisplayNames: Record<string, string> = {
@@ -38,6 +39,7 @@ export const ControlsFilter = ({
onClearFilters: () => void;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
@@ -96,7 +98,7 @@ export const ControlsFilter = ({
variant="contained"
onClick={onClearFilters}
>
Clear Filters
{t("common.buttons.clearFilters")}
</Button>
)}
</Stack>
@@ -0,0 +1,203 @@
import { useRef, useEffect, useState } from "react";
import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
}
export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const mapRef = useRef<MapRef>(null);
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const mapPopupStyles = (
<GlobalStyles
styles={{
".maplibregl-popup-content": {
background: "transparent !important",
padding: "0 !important",
boxShadow: "none !important",
},
".maplibregl-popup-tip": {
display: "none",
},
".maplibregl-ctrl-attrib": {
display: "none",
},
".maplibregl-ctrl-logo": {
display: "none",
},
".maplibregl-popup-close-button": {
color: theme.palette.text.primary,
background: "transparent",
fontSize: "20px",
padding: "4px",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
}}
/>
);
useEffect(() => {
if (geoChecks.length === 0 || !mapRef.current) return;
const bounds = geoChecks.reduce(
(acc, check) => {
return {
minLng: Math.min(acc.minLng, check.location.longitude),
maxLng: Math.max(acc.maxLng, check.location.longitude),
minLat: Math.min(acc.minLat, check.location.latitude),
maxLat: Math.max(acc.maxLat, check.location.latitude),
};
},
{
minLng: Infinity,
maxLng: -Infinity,
minLat: Infinity,
maxLat: -Infinity,
}
);
if (bounds.minLng !== Infinity) {
mapRef.current.fitBounds(
[
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
],
{ padding: 50, duration: 1000 }
);
}
}, [geoChecks]);
const getMarkerColor = (status: boolean): string => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
<Box
height={500}
width={"100%"}
borderRadius={theme.shape.borderRadius}
overflow={"hidden"}
>
<Map
ref={mapRef}
initialViewState={{
longitude: 0,
latitude: 20,
zoom: 1.5,
}}
style={{ height: "100%", width: "100%" }}
mapStyle={
isDarkMode
? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
}
>
{geoChecks.map((check, index) => (
<Marker
key={`${check.id}-${index}`}
longitude={check.location.longitude}
latitude={check.location.latitude}
anchor="bottom"
onClick={(e: any) => {
e.originalEvent.stopPropagation();
setSelectedCheck(check);
}}
>
<div
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: getMarkerColor(check.status),
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
cursor: "pointer",
}}
/>
</Marker>
))}
{selectedCheck && (
<Popup
longitude={selectedCheck.location.longitude}
latitude={selectedCheck.location.latitude}
anchor="top"
onClose={() => setSelectedCheck(null)}
closeOnClick={false}
>
<Box
sx={{
minWidth: 200,
p: theme.spacing(4),
bgcolor: theme.palette.background.paper,
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: 1,
}}
>
<Typography
variant="subtitle1"
fontWeight="bold"
gutterBottom
>
{selectedCheck.location.city}, {selectedCheck.location.country}
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status:
</Typography>{" "}
{selectedCheck.status ? "Up" : "Down"}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Response Time:
</Typography>{" "}
{formatResponseTime(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{new Date(selectedCheck.createdAt).toLocaleString()}
</Typography>
</Stack>
</Box>
</Popup>
)}
</Map>
</Box>
</>
);
};
@@ -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 (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"flex-start"}
>
<Tabs
value={selectedLocation}
onChange={handleChange}
>
{locations.map((location) => (
<Tab
key={location}
label={t(
`pages.createMonitor.form.geoChecks.option.locations.options.${location}`
)}
value={location}
/>
))}
</Tabs>
</Stack>
);
};
+2
View File
@@ -1,6 +1,8 @@
export * from "./ControlsFilter";
export * from "./MonitorStatBoxes";
export * from "./HeaderMonitorControls";
export * from "./HeaderGeoTabs";
export * from "./GeoChecksMap";
export * from "./charts/HistogramStatus";
export * from "./charts/RadialAvgResponse";
export * from "./charts/HistogramDetails";
+1 -1
View File
@@ -17,7 +17,7 @@ const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
};
export const useGet = <T>(
url: string | null,
url: string | null | undefined,
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration
) => {
+3
View File
@@ -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 = ({
+136
View File
@@ -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 {
@@ -192,6 +193,7 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
useEffect(() => {
clearErrors();
@@ -881,6 +883,140 @@ const CreateMonitorPage = () => {
/>
)}
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
subtitle={t("pages.createMonitor.form.geoChecks.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="geoCheckEnabled"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t("pages.createMonitor.form.geoChecks.option.enabled.label")}
</Typography>
</Stack>
)}
/>
{watchGeoCheckEnabled && (
<Stack spacing={theme.spacing(8)}>
<Controller
name="geoCheckLocations"
control={control}
render={({ field }) => {
// 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 (
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={locationOptions}
value={selectedLocations}
getOptionLabel={(option) => 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 && (
<Stack
flex={1}
width="100%"
>
{selectedLocations.map((location, index) => (
<Stack
direction="row"
alignItems="center"
key={location.id}
width="100%"
>
<Typography flexGrow={1}>{location.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== location.id
)
);
}}
aria-label="Remove location"
>
<Trash2 size={16} />
</IconButton>
{index < selectedLocations.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
<Controller
name="geoCheckInterval"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value ?? 300000}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.interval.label"
)}
>
<MenuItem value={300000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fiveMinutes"
)}
</MenuItem>
<MenuItem value={600000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.tenMinutes"
)}
</MenuItem>
<MenuItem value={900000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fifteenMinutes"
)}
</MenuItem>
<MenuItem value={1800000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.thirtyMinutes"
)}
</MenuItem>
</Select>
)}
/>
</Stack>
)}
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(2);
const [activeTab, setActiveTab] = useState<number>(1);
return (
<BasePage>
<Tabs
@@ -20,7 +20,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
},
{
id: "date",
content: t("pages.checks.table.headers.dateTime"),
content: t("common.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
@@ -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<FlatGeoCheck>[] = [
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
const status = row.status ? "up" : "down";
return <StatusLabel status={status} />;
},
},
{
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<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
return (
<Box>
<Table
headers={headers}
data={geoChecks}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
+94 -3
View File
@@ -6,9 +6,12 @@ import {
RadialAvgResponse,
HistogramDetails,
HeaderMonitorControls,
HeaderGeoTabs,
GeoChecksMap,
} 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 +22,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 +46,10 @@ const UptimeDetailsPage = () => {
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
@@ -56,7 +68,7 @@ const UptimeDetailsPage = () => {
} = useGet<MonitorDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
{ refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false }
);
const monitorData = monitorDetailsData?.monitorData;
@@ -71,7 +83,11 @@ const UptimeDetailsPage = () => {
return `/monitors/certificate/${monitorId}`;
}, [monitorId, monitor?.type]);
const { data: certificateData } = useGet<CertificateResponse>(certificateUrl);
const { data: certificateData } = useGet<CertificateResponse>(
certificateUrl,
{},
{ revalidateOnFocus: false }
);
const certificateExpiry = useMemo(() => {
if (!certificateData?.certificateDate) {
@@ -102,9 +118,58 @@ const UptimeDetailsPage = () => {
const { data: checksData, isLoading: checksIsLoading } = useGet<ChecksResponse>(
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<GeoChecksResult>(
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<FlatGeoChecksResponse>(
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 +192,7 @@ const UptimeDetailsPage = () => {
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
@@ -160,6 +226,31 @@ const UptimeDetailsPage = () => {
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
{monitor?.geoCheckEnabled && (
<>
<Typography variant="h1">Location breakdown</Typography>
<HeaderGeoTabs
geoCheckEnabled={monitor?.geoCheckEnabled ?? false}
locations={geoLocations}
selectedLocation={selectedLocation}
onLocationChange={setSelectedLocation}
/>
<HistogramDetails
checks={geoGroupedChecks}
range={dateRange}
/>
<GeoChecksTable
geoChecks={geoChecksForTable}
count={geoChecksCount}
page={geoPage}
setPage={setGeoPage}
rowsPerPage={geoRowsPerPage}
setRowsPerPage={setGeoRowsPerPage}
/>
<GeoChecksMap geoChecks={geoChecksForTable} />
</>
)}
</BasePage>
);
};
+3 -3
View File
@@ -42,11 +42,11 @@ const UptimeMonitorsPage = () => {
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: "up" -> true, "down" -> false
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
+80
View File
@@ -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;
}
+5
View File
@@ -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",
@@ -61,6 +63,9 @@ export interface Monitor {
gameId?: string;
grpcServiceName?: string;
group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
+5
View File
@@ -17,3 +17,8 @@ declare module "*.svg?react" {
>;
export default ReactComponent;
}
declare module "*.css" {
const content: string;
export default content;
}
+7
View File
@@ -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
+36 -5
View File
@@ -45,7 +45,8 @@
"removeMonitors": "Remove monitors",
"sendTestEmail": "Send test email",
"exportToJSON": "Export to JSON",
"importFromJSON": "Import from JSON"
"importFromJSON": "Import from JSON",
"clearFilters": "Clear filters"
},
"charts": {
"labels": {
@@ -115,7 +116,8 @@
"type": "Type",
"url": "Url",
"interval": "Interval",
"active": "Active"
"active": "Active",
"responseTime": "Response time"
}
}
},
@@ -372,8 +374,8 @@
"table": {
"empty": "No down checks in this time range",
"headers": {
"dateTime": "Date & time",
"statusCode": "Status code"
"statusCode": "Status code",
"location": "Location"
}
}
},
@@ -558,6 +560,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.",
@@ -627,7 +659,6 @@
},
"filters": {
"allMonitors": "All monitors",
"clearFilters": "Clear filters",
"monitor": "Monitor",
"resolutionType": "Resolution type",
"resolutionTypes": {
+3
View File
@@ -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),
+3
View File
@@ -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<string, any>, 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<string, any>, 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());
+28 -35
View File
@@ -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";
@@ -48,6 +50,7 @@ import * as protoLoader from "@grpc/proto-loader";
import {
MongoMonitorsRepository,
MongoChecksRepository,
MongoGeoChecksRepository,
MongoMonitorStatsRepository,
MongoStatusPagesRepository,
MongoUsersRepository,
@@ -59,6 +62,7 @@ import {
MongoMaintenanceWindowsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
@@ -83,12 +87,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;
@@ -96,6 +101,7 @@ export type InitializedServices = {
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -128,6 +134,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();
@@ -138,40 +145,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,
grpc,
protoLoader,
});
const networkService = new NetworkService(axios, got, https, jmespath, GameDig, ping, logger, Docker, net, settingsService, grpc, protoLoader);
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);
@@ -196,13 +188,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,
@@ -210,13 +202,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({
@@ -251,6 +241,7 @@ export const initializeServices = async ({
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -269,6 +260,7 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
userService,
checkService,
geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
@@ -282,6 +274,7 @@ export const initializeServices = async ({
// Repositories
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
@@ -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;
@@ -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);
+116
View File
@@ -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<GeoCheckMetadata, "monitorId" | "teamId"> & {
monitorId: Types.ObjectId;
teamId: Types.ObjectId;
type: MonitorType;
};
type GeoCheckDocumentBase = Omit<GeoCheck, "id" | "metadata" | "expiry" | "createdAt" | "updatedAt" | "results"> & {
metadata: GeoCheckMetadataDocument;
results: GeoCheckResult[];
expiry: Date;
createdAt: Date;
updatedAt: Date;
__v: number;
};
export interface GeoCheckDocument extends GeoCheckDocumentBase {
_id: Types.ObjectId;
}
const geoCheckMetadataSchema = new Schema<GeoCheckMetadataDocument>(
{
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<GeoCheckTimings>(
{
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<GeoCheckLocation>(
{
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<GeoCheckResult>(
{
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<GeoCheckDocument>(
{
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<GeoCheckDocument>("GeoCheck", GeoCheckSchema);
export type { GeoCheckMetadataDocument };
export { GeoCheckModel };
export default GeoCheckModel;
+12
View File
@@ -173,6 +173,18 @@ const MonitorSchema = new Schema<MonitorDocument>(
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: [],
+3
View File
@@ -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";
@@ -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<string, Date | undefined> = {
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<string, Check[]>;
type DateRange = { start: Date; end: Date };
type HardwareUpChecks = { totalChecks: number };
@@ -248,9 +240,9 @@ class MongoChecksRepository implements IChecksRepository {
const matchStage: Record<string, any> = {
"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<string, any> = {
"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),
},
}),
};
@@ -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<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]>;
findByMonitorId(
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult>;
findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]>;
findGroupedByMonitorIdAndDateRange(
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]>;
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
}
@@ -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<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
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<FlatGeoChecksQueryResult> => {
try {
const matchStage: Record<string, any> = {
"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<GeoCheck[]> => {
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<GroupedGeoCheck[]> => {
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<number> => {
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<number> => {
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<number> => {
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;
+3
View File
@@ -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";
@@ -355,6 +355,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
grpcServiceName: doc.grpcServiceName ?? 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),
};
@@ -411,6 +414,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
grpcServiceName: doc.grpcServiceName ?? 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),
};
+22
View File
@@ -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;
+3
View File
@@ -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);
+3 -11
View File
@@ -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;
@@ -0,0 +1,192 @@
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<GeoCheck | null>;
createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>;
getGeoChecksByMonitor(args: { monitorId: string; query: any; teamId: string }): Promise<any>;
}
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;
}
async buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null> {
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;
+16 -29
View File
@@ -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<string, Date | undefined> = {
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;
@@ -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<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<PageSpeedDetailsResult>;
getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continents?: GeoContinent[] }): Promise<any>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
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;
@@ -316,6 +323,40 @@ export class MonitorService implements IMonitorService {
monitorStats,
};
};
getGeoChecksByMonitorId = async ({
teamId,
monitorId,
dateRange,
continents,
}: {
teamId: string;
monitorId: string;
dateRange: string;
continents?: GeoContinent[];
}): Promise<any> => {
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<Monitor> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
@@ -439,6 +480,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;
};
@@ -450,6 +499,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) {
@@ -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 () => {
@@ -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<void>;
getCleanupOrphanedJob(): () => Promise<void>;
getGeoCheckJob(): (monitor: Monitor) => Promise<void>;
isInMaintenanceWindow(monitorId: string, teamId: string): Promise<boolean>;
}
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:
@@ -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<void>;
flushGeoBuffer(): Promise<void>;
}
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 = [];
}
}
}
@@ -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<string | null>;
pollForResults(measurementId: string, timeoutMs?: number): Promise<GeoCheckResult[]>;
}
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<string | null> {
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<GlobalPingMeasurementResponse>(`${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<GeoCheckResult[]> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
try {
const response = await got.get<GlobalPingMeasurementResponse>(`${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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
export default GlobalPingService;
@@ -1,11 +1,14 @@
import { HTTPError, RequestError } from "got";
import type { Got, Response } from "got";
import type { Monitor, MonitorStatusResponse, GrpcStatusPayload } from "@/types/index.js";
import type { AxiosStatic } from "axios";
import { AppError } from "@/utils/AppError.js";
import path from "path";
import { fileURLToPath } from "url";
import CacheableLookup from "cacheable-lookup";
import { ISettingsService } from "../system/settingsService.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "NetworkService";
type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
@@ -50,16 +53,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 grpc: any;
private protoLoader: any;
@@ -117,35 +120,20 @@ class NetworkService implements INetworkService {
};
};
constructor({
axios,
got,
https,
jmespath,
GameDig,
ping,
logger,
http,
Docker,
net,
settingsService,
grpc,
protoLoader,
}: {
axios: any;
got: Got;
https: any;
jmespath: any;
GameDig: any;
ping: any;
logger: any;
http: any;
Docker: any;
net: any;
settingsService: any;
grpc: any;
protoLoader: any;
}) {
constructor(
axios: AxiosStatic,
got: Got,
https: any,
jmespath: any,
GameDig: any,
ping: any,
logger: ILogger,
Docker: any,
net: any,
settingsService: ISettingsService,
grpc: any,
protoLoader: any
) {
this.TYPE_PING = "ping";
this.TYPE_HTTP = "http";
this.TYPE_PAGESPEED = "pagespeed";
@@ -449,7 +437,6 @@ class NetworkService implements INetworkService {
const docker = new this.Docker({
socketPath: "/var/run/docker.sock",
handleError: true, // Enable error handling
});
const dockerResponse = this.buildStatusResponse({
@@ -545,6 +532,10 @@ class NetworkService implements INetworkService {
private async requestPort(monitor: Monitor): Promise<MonitorStatusResponse> {
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(
@@ -611,9 +602,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,
+72
View File
@@ -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<MonitorType, "hardware" | "pagespeed">;
groupedGeoChecks: GroupedGeoCheck[];
}
+1
View File
@@ -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";
+5
View File
@@ -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", "grpc", "unknown"] as const;
export type MonitorType = (typeof MonitorTypes)[number];
@@ -44,6 +46,9 @@ export interface Monitor {
gameId?: string;
grpcServiceName?: string;
group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
+20
View File
@@ -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 = <T extends HasResponseTime>(arr: T[], percentile: number): number => {
const sorted = arr.slice().sort((a, b) => a.responseTime - b.responseTime);
const index = (percentile / 100) * (sorted.length - 1);
+15
View File
@@ -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({});
@@ -173,6 +175,12 @@ const createMonitorBodyValidation = joi.object({
grpcServiceName: joi.string().allow("").default(""),
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(
@@ -205,6 +213,12 @@ const editMonitorBodyValidation = joi
grpcServiceName: 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 });
@@ -312,6 +326,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({