Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c022df0f | ||
|
|
19fc85ae2d | ||
|
|
b4f8fa7d72 | ||
|
|
ef5629d8e4 | ||
|
|
4018125224 | ||
|
|
2d023de568 | ||
|
|
f2c88cfd23 | ||
|
|
910c675a49 | ||
|
|
882e91f6a7 | ||
|
|
06be65ec52 | ||
|
|
ea54736a39 | ||
|
|
da38f2ee76 | ||
|
|
83d56f1ff6 | ||
|
|
f94afb63c6 | ||
|
|
673f47983a | ||
|
|
b708e83cce |
No files matched your search
@@ -12,7 +12,7 @@ import {
|
||||
import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { Palette } from "@mui/material/styles";
|
||||
type PaletteColorKey = Extract<
|
||||
keyof Palette,
|
||||
@@ -52,7 +52,7 @@ export const HistogramPageSpeedDetails = ({
|
||||
checks,
|
||||
range,
|
||||
}: {
|
||||
checks: CheckSnapshot[];
|
||||
checks: PageSpeedGroupedCheck[];
|
||||
range: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
@@ -73,7 +73,7 @@ export const HistogramPageSpeedDetails = ({
|
||||
>
|
||||
<AreaChart data={checks}>
|
||||
<XAxis
|
||||
dataKey={"createdAt"}
|
||||
dataKey={"bucketDate"}
|
||||
tick={(props) => (
|
||||
<XTick
|
||||
{...props}
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
|
||||
import { useMonitorForm } from "@/Hooks/useMonitorForm";
|
||||
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
|
||||
import { GeoCheckLocations } from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MonitorFormData } from "@/Validation/monitor";
|
||||
|
||||
@@ -165,6 +166,7 @@ const CreateMonitorPage = () => {
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
|
||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||
|
||||
useEffect(() => {
|
||||
clearErrors();
|
||||
@@ -823,6 +825,89 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Geo-distributed Checks Configuration */}
|
||||
{watchedType === "http" && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.geoChecks.title")}
|
||||
subtitle={t("pages.createMonitor.form.geoChecks.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="geoCheckEnabled"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
<Typography>
|
||||
{t("pages.createMonitor.form.geoChecks.option.enabled.label")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
{watchGeoCheckEnabled && (
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="geoCheckLocations"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const locationOptions = GeoCheckLocations.map((loc) => ({
|
||||
id: loc,
|
||||
name: t(
|
||||
`pages.createMonitor.form.geoChecks.option.locations.options.${loc}`
|
||||
),
|
||||
}));
|
||||
const selectedLocations = locationOptions.filter((loc) =>
|
||||
(field.value ?? []).includes(loc.id)
|
||||
);
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple={true}
|
||||
options={locationOptions}
|
||||
value={selectedLocations}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_, newValue: typeof locationOptions) => {
|
||||
field.onChange(newValue.map((loc) => loc.id));
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.geoChecks.option.locations.label"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="geoCheckInterval"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value ?? 300000}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.geoChecks.option.interval.label"
|
||||
)}
|
||||
>
|
||||
<MenuItem value={300000}>5 min</MenuItem>
|
||||
<MenuItem value={600000}>10 min</MenuItem>
|
||||
<MenuItem value={900000}>15 min</MenuItem>
|
||||
<MenuItem value={1800000}>30 min</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BasePage } from "@/Components/design-elements";
|
||||
import { HeaderTimeRange } from "@/Components/common";
|
||||
import type { PageSpeedDetailsResponse } from "@/Types/Monitor";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import {
|
||||
@@ -13,23 +14,36 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
const PageSpeedDetails = () => {
|
||||
const { monitorId } = useParams();
|
||||
const isAdmin = useIsAdmin();
|
||||
const theme = useTheme();
|
||||
const [dateRange, setDateRange] = useState<string>("day");
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
if (!monitorId) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.append("dateRange", dateRange);
|
||||
return `/monitors/pagespeed/details/${monitorId}?${params.toString()}`;
|
||||
}, [monitorId, dateRange]);
|
||||
|
||||
const {
|
||||
data: monitorData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGet<PageSpeedDetailsResponse>(
|
||||
monitorId ? `/monitors/pagespeed/details/${monitorId}?dateRange=day` : null,
|
||||
monitorDetailsUrl,
|
||||
{},
|
||||
{ keepPreviousData: true, refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
const monitor = monitorData?.monitor;
|
||||
const monitor = monitorData?.monitorData?.monitor;
|
||||
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
|
||||
const monitorStats = monitorData?.monitorStats || null;
|
||||
|
||||
return (
|
||||
@@ -47,9 +61,15 @@ const PageSpeedDetails = () => {
|
||||
monitor={monitor}
|
||||
monitorStats={monitorStats}
|
||||
/>
|
||||
<HeaderTimeRange
|
||||
isLoading={isLoading}
|
||||
hasDateRange={true}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
<HistogramPageSpeedDetails
|
||||
checks={monitor?.recentChecks || []}
|
||||
range="day"
|
||||
checks={groupedChecks}
|
||||
range={dateRange}
|
||||
/>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import DummyChart from "@/Pages/Settings/DummyChart";
|
||||
import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { useSettingsForm } from "@/Hooks/useSettingsForm";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
|
||||
import type { SettingsFormData } from "@/Validation/settings";
|
||||
@@ -44,10 +45,13 @@ export const SettingsPage = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { toastError } = useToast();
|
||||
// Local state for demo monitors dialog
|
||||
const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false);
|
||||
const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost();
|
||||
const { deleteFn: deleteAllMonitors, loading: isDeletingAllMonitors } = useDelete();
|
||||
// Import monitors functionality
|
||||
const { post: importMonitors, loading: isImportingMonitors } = usePost();
|
||||
|
||||
// Fetch settings data from API
|
||||
const { data: fetchedSettings } = useGet<SettingsResponse>("/settings");
|
||||
@@ -202,6 +206,36 @@ export const SettingsPage = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type !== "application/json") {
|
||||
toastError("Please select a valid JSON file");
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const monitors = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(monitors)) {
|
||||
toastError("Invalid file format: expected an array of monitors");
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
await importMonitors("/monitors/import/json", { monitors });
|
||||
|
||||
event.target.value = "";
|
||||
} catch (error) {
|
||||
toastError("Error parsing JSON file. Please check the file format.");
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: SettingsFormData) => {
|
||||
// Don't send pagespeedApiKey if it's already set and user hasn't clicked reset
|
||||
const dataToSend = { ...data };
|
||||
@@ -866,17 +900,34 @@ export const SettingsPage = () => {
|
||||
{/* Export Monitors - Admin Only */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.exportMonitors.title")}
|
||||
subtitle={t("pages.settings.form.exportMonitors.description")}
|
||||
title={t("pages.settings.form.importExportMonitors.title")}
|
||||
subtitle={t("pages.settings.form.importExportMonitors.description")}
|
||||
rightContent={
|
||||
<Box>
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
>
|
||||
<input
|
||||
id="monitor-import-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => document.getElementById("monitor-import-input")?.click()}
|
||||
disabled={isImportingMonitors}
|
||||
>
|
||||
{t("common.buttons.importFromJSON")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleExportMonitors}
|
||||
>
|
||||
{t("common.buttons.exportToJSON")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -151,6 +151,23 @@ export interface GroupedCheck {
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface PageSpeedGroupedCheck {
|
||||
bucketDate: string;
|
||||
performance: number;
|
||||
accessibility: number;
|
||||
bestPractices: number;
|
||||
seo: number;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface GroupedGeoCheck {
|
||||
bucketDate: string;
|
||||
continent: string;
|
||||
avgResponseTime: number;
|
||||
totalChecks: number;
|
||||
uptimePercentage: number;
|
||||
}
|
||||
|
||||
export interface LatestCheck {
|
||||
status: boolean;
|
||||
responseTime: number;
|
||||
@@ -227,6 +244,7 @@ export interface UptimeChecksResult {
|
||||
groupedDownChecks: GroupedCheck[];
|
||||
uptimePercentage: number;
|
||||
avgResponseTime: number;
|
||||
groupedGeoChecks?: GroupedGeoCheck[];
|
||||
}
|
||||
|
||||
export interface ChecksSummary {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
|
||||
export const MonitorTypes = [
|
||||
"http",
|
||||
@@ -24,6 +25,9 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
||||
|
||||
export const GeoCheckLocations = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
|
||||
export type GeoCheckLocation = (typeof GeoCheckLocations)[number];
|
||||
|
||||
export interface Monitor {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -58,6 +62,9 @@ export interface Monitor {
|
||||
selectedDisks: string[];
|
||||
gameId?: string;
|
||||
group: string | null;
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoCheckLocation[];
|
||||
geoCheckInterval?: number;
|
||||
recentChecks: CheckSnapshot[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -112,7 +119,10 @@ export interface MonitorDetailsResponse {
|
||||
}
|
||||
|
||||
export interface PageSpeedDetailsResponse {
|
||||
monitor: MonitorWithChecks;
|
||||
monitorData: {
|
||||
monitor: Monitor;
|
||||
groupedChecks: PageSpeedGroupedCheck[];
|
||||
};
|
||||
monitorStats: MonitorStats | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ 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(["EU", "NA", "AS", "SA", "AF", "OC"])).optional(),
|
||||
geoCheckInterval: z.number().min(5, "Interval must be at least 5 minutes").optional(),
|
||||
});
|
||||
|
||||
// HTTP monitor schema
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"addDemo": "Add demo monitors",
|
||||
"removeMonitors": "Remove monitors",
|
||||
"sendTestEmail": "Send test email",
|
||||
"exportToJSON": "Export to JSON"
|
||||
"exportToJSON": "Export to JSON",
|
||||
"importFromJSON": "Import from JSON"
|
||||
},
|
||||
"charts": {
|
||||
"labels": {
|
||||
@@ -430,6 +431,31 @@
|
||||
},
|
||||
"title": "Advanced settings"
|
||||
},
|
||||
"geoChecks": {
|
||||
"title": "Geo-distributed checks",
|
||||
"description": "Run checks from multiple geographic locations to monitor global availability and performance.",
|
||||
"option": {
|
||||
"enabled": {
|
||||
"label": "Enable geo-distributed checks"
|
||||
},
|
||||
"locations": {
|
||||
"label": "Locations",
|
||||
"placeholder": "Select locations",
|
||||
"options": {
|
||||
"EU": "Europe",
|
||||
"NA": "North America",
|
||||
"AS": "Asia",
|
||||
"SA": "South America",
|
||||
"AF": "Africa",
|
||||
"OC": "Oceania"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
"label": "Check interval (minutes)",
|
||||
"helper": "Minimum 5 minutes between geo checks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"frequency": {
|
||||
"description": "How often do you want to check the status of this monitor?",
|
||||
"option": {
|
||||
@@ -1086,8 +1112,11 @@
|
||||
}
|
||||
},
|
||||
"exportMonitors": {
|
||||
"title": "Export monitors",
|
||||
"description": "Export your monitors data as a JSON file for backup or transfer."
|
||||
"title": "Export monitors"
|
||||
},
|
||||
"importExportMonitors": {
|
||||
"title": "Import / Export monitors",
|
||||
"description": "Import or export your monitors data as a JSON file for backup or transfer."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
parseMonitorTypeFilter,
|
||||
parseSortOrder,
|
||||
requireTeamId,
|
||||
requireUserId,
|
||||
} from "./controllerUtils.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { IMonitorService } from "@/service/index.js";
|
||||
@@ -172,22 +173,23 @@ class MonitorController {
|
||||
}
|
||||
};
|
||||
|
||||
createBulkMonitors = async (req: Request, res: Response, next: NextFunction) => {
|
||||
importMonitorsFromJSON = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
throw new AppError({ message: "User ID is required", status: 400 });
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
const userId = requireUserId(req?.user?.id);
|
||||
|
||||
const { monitors } = req.body;
|
||||
|
||||
if (!monitors || !Array.isArray(monitors)) {
|
||||
throw new AppError({ message: "Invalid request: monitors array is required", status: 400 });
|
||||
}
|
||||
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
|
||||
const data = req.body;
|
||||
const monitors = await this.monitorService.createBulkMonitors(data, userId, teamId);
|
||||
const result = await this.monitorService.importMonitorsFromJSON({ teamId, userId, monitors });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Bulk monitors created successfully",
|
||||
data: monitors,
|
||||
msg: `Successfully imported ${result.imported} monitor(s)`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
CheckMemoryInfo,
|
||||
CheckMetadata,
|
||||
CheckNetworkInterfaceInfo,
|
||||
GeoCheckResult,
|
||||
GeoCheckTimings,
|
||||
GotTimings,
|
||||
ILighthouseAudit,
|
||||
} from "@/types/check.js";
|
||||
@@ -175,6 +177,34 @@ const auditsSchema = new Schema<CheckAudits>(
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const geoCheckTimingsSchema = new Schema<GeoCheckTimings>(
|
||||
{
|
||||
total: { type: Number, required: true },
|
||||
dns: { type: Number },
|
||||
tcp: { type: Number },
|
||||
tls: { type: Number },
|
||||
firstByte: { type: Number },
|
||||
download: { type: Number },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const geoCheckResultSchema = new Schema<GeoCheckResult>(
|
||||
{
|
||||
continent: { type: String, required: true },
|
||||
region: { type: String },
|
||||
country: { type: String, required: true },
|
||||
state: { type: String },
|
||||
city: { type: String, required: true },
|
||||
longitude: { type: Number, required: true },
|
||||
latitude: { type: Number, required: true },
|
||||
status: { type: Boolean, required: true },
|
||||
statusCode: { type: Number, required: true },
|
||||
timings: { type: geoCheckTimingsSchema, required: true },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const metadataSchema = new Schema<CheckMetadataDocument>(
|
||||
{
|
||||
monitorId: {
|
||||
@@ -281,6 +311,10 @@ const CheckSchema = new Schema<CheckDocument>(
|
||||
type: auditsSchema,
|
||||
default: undefined,
|
||||
},
|
||||
geoResults: {
|
||||
type: [geoCheckResultSchema],
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema, model, Types, type UpdateQuery } from "mongoose";
|
||||
import { Schema, model, Types } from "mongoose";
|
||||
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
|
||||
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
|
||||
import { MonitorTypes, MonitorStatuses, GeoCheckLocations } from "@/types/monitor.js";
|
||||
|
||||
type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Date };
|
||||
|
||||
@@ -169,6 +169,19 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
return value && value.trim() ? value.trim() : null;
|
||||
},
|
||||
},
|
||||
geoCheckEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
geoCheckLocations: {
|
||||
type: [String],
|
||||
enum: GeoCheckLocations,
|
||||
default: [],
|
||||
},
|
||||
geoCheckInterval: {
|
||||
type: Number,
|
||||
default: 300000,
|
||||
},
|
||||
recentChecks: {
|
||||
type: [checkSnapshotSchema],
|
||||
default: [],
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
CheckMemoryInfo,
|
||||
CheckMetadata,
|
||||
CheckNetworkInterfaceInfo,
|
||||
GeoCheckResult,
|
||||
GeoCheckTimings,
|
||||
GotTimings,
|
||||
MonitorType,
|
||||
} from "@/types/index.js";
|
||||
@@ -165,6 +167,33 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
};
|
||||
};
|
||||
|
||||
const mapGeoTimings = (timings?: GeoCheckTimings): GeoCheckTimings => ({
|
||||
total: timings?.total ?? 0,
|
||||
dns: timings?.dns,
|
||||
tcp: timings?.tcp,
|
||||
tls: timings?.tls,
|
||||
firstByte: timings?.firstByte,
|
||||
download: timings?.download,
|
||||
});
|
||||
|
||||
const mapGeoResults = (geoResults?: GeoCheckResult[]): GeoCheckResult[] | undefined => {
|
||||
if (!geoResults?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return geoResults.map((result) => ({
|
||||
continent: result.continent ?? "",
|
||||
region: result.region,
|
||||
country: result.country ?? "",
|
||||
state: result.state,
|
||||
city: result.city ?? "",
|
||||
longitude: result.longitude ?? 0,
|
||||
latitude: result.latitude ?? 0,
|
||||
status: result.status ?? false,
|
||||
statusCode: result.statusCode ?? 0,
|
||||
timings: mapGeoTimings(result.timings),
|
||||
}));
|
||||
};
|
||||
|
||||
const mapMetadata = (metadata: CheckDocument["metadata"]): CheckMetadata => ({
|
||||
monitorId: toStringId(metadata.monitorId),
|
||||
teamId: toStringId(metadata.teamId),
|
||||
@@ -194,6 +223,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
seo: doc.seo,
|
||||
performance: doc.performance,
|
||||
audits: mapAudits(doc.audits),
|
||||
geoResults: mapGeoResults(doc.geoResults),
|
||||
__v: doc.__v ?? 0,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
@@ -378,7 +408,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
|
||||
}
|
||||
if (options?.type === "pagespeed") {
|
||||
return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate);
|
||||
return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
|
||||
}
|
||||
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString);
|
||||
};
|
||||
@@ -427,85 +457,131 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
endDate: Date,
|
||||
dateString: string
|
||||
) => {
|
||||
const matchStage = {
|
||||
// Match stage for primary checks (exclude geo checks)
|
||||
const primaryCheckMatchStage = {
|
||||
"metadata.monitorId": monitorObjectId,
|
||||
createdAt: { $gte: startDate, $lte: endDate },
|
||||
geoResults: { $exists: false },
|
||||
};
|
||||
const [result] = await CheckModel.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $sort: { createdAt: 1 } },
|
||||
{
|
||||
$facet: {
|
||||
uptimePercentage: [
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
|
||||
totalChecks: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
percentage: {
|
||||
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
|
||||
|
||||
// Match stage for geo checks only
|
||||
const geoCheckMatchStage = {
|
||||
"metadata.monitorId": monitorObjectId,
|
||||
createdAt: { $gte: startDate, $lte: endDate },
|
||||
geoResults: { $exists: true, $ne: null },
|
||||
};
|
||||
|
||||
// Run two separate aggregations - one for primary checks, one for geo checks
|
||||
// TODO, only do this if the monitor has geochecks enabled, otherwise we only need one aggregation
|
||||
const [primaryResult, geoResults] = await Promise.all([
|
||||
CheckModel.aggregate([
|
||||
{ $match: primaryCheckMatchStage },
|
||||
{ $sort: { createdAt: 1 } },
|
||||
{
|
||||
$facet: {
|
||||
uptimePercentage: [
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
|
||||
totalChecks: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
groupedAvgResponseTime: [
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
},
|
||||
},
|
||||
],
|
||||
groupedChecks: [
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: dateString, date: "$createdAt" },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
percentage: {
|
||||
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
|
||||
},
|
||||
},
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
totalChecks: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
|
||||
],
|
||||
groupedUpChecks: [
|
||||
{ $match: { status: true } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: dateString, date: "$createdAt" },
|
||||
],
|
||||
groupedAvgResponseTime: [
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
},
|
||||
totalChecks: { $sum: 1 },
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
|
||||
],
|
||||
groupedDownChecks: [
|
||||
{ $match: { status: false } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: dateString, date: "$createdAt" },
|
||||
],
|
||||
groupedChecks: [
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: dateString, date: "$createdAt" },
|
||||
},
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
totalChecks: { $sum: 1 },
|
||||
},
|
||||
totalChecks: { $sum: 1 },
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
|
||||
],
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
|
||||
],
|
||||
groupedUpChecks: [
|
||||
{ $match: { status: true } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: dateString, date: "$createdAt" },
|
||||
},
|
||||
totalChecks: { $sum: 1 },
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
|
||||
],
|
||||
groupedDownChecks: [
|
||||
{ $match: { status: false } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: dateString, date: "$createdAt" },
|
||||
},
|
||||
totalChecks: { $sum: 1 },
|
||||
avgResponseTime: { $avg: "$responseTime" },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
CheckModel.aggregate([
|
||||
{ $match: geoCheckMatchStage },
|
||||
{ $sort: { createdAt: 1 } },
|
||||
{ $unwind: "$geoResults" },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
date: { $dateToString: { format: dateString, date: "$createdAt" } },
|
||||
continent: "$geoResults.continent",
|
||||
},
|
||||
avgResponseTime: { $avg: "$geoResults.timings.total" },
|
||||
totalChecks: { $sum: 1 },
|
||||
upChecks: { $sum: { $cond: [{ $eq: ["$geoResults.status", true] }, 1, 0] } },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
bucketDate: "$_id.date",
|
||||
continent: "$_id.continent",
|
||||
avgResponseTime: 1,
|
||||
totalChecks: 1,
|
||||
uptimePercentage: {
|
||||
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $sort: { bucketDate: 1, continent: 1 } },
|
||||
]),
|
||||
]);
|
||||
|
||||
const [result] = primaryResult;
|
||||
const groupedGeoChecks = geoResults ?? [];
|
||||
|
||||
const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0;
|
||||
const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0;
|
||||
|
||||
@@ -516,6 +592,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
groupedDownChecks: result?.groupedDownChecks ?? [],
|
||||
uptimePercentage,
|
||||
avgResponseTime,
|
||||
groupedGeoChecks,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -572,16 +649,50 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
};
|
||||
};
|
||||
|
||||
private findPageSpeedDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date) => {
|
||||
private findPageSpeedDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => {
|
||||
const matchStage = {
|
||||
"metadata.monitorId": monitorObjectId,
|
||||
createdAt: { $gte: startDate, $lte: endDate },
|
||||
};
|
||||
|
||||
const checks = await CheckModel.find(matchStage).sort({ createdAt: -1 }).limit(25).lean();
|
||||
const [result] = await CheckModel.aggregate([
|
||||
{ $match: matchStage },
|
||||
{ $sort: { createdAt: 1 } },
|
||||
{
|
||||
$facet: {
|
||||
groupedChecks: [
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: dateString, date: "$createdAt" },
|
||||
},
|
||||
avgPerformance: { $avg: "$performance" },
|
||||
avgAccessibility: { $avg: "$accessibility" },
|
||||
avgBestPractices: { $avg: "$bestPractices" },
|
||||
avgSeo: { $avg: "$seo" },
|
||||
totalChecks: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{
|
||||
$project: {
|
||||
bucketDate: "$_id",
|
||||
performance: "$avgPerformance",
|
||||
accessibility: "$avgAccessibility",
|
||||
bestPractices: "$avgBestPractices",
|
||||
seo: "$avgSeo",
|
||||
totalChecks: 1,
|
||||
_id: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
monitorType: "pagespeed" as const,
|
||||
checks: this.mapDocuments(checks),
|
||||
groupedChecks: result?.groupedChecks ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface SummaryConfig {
|
||||
export interface IMonitorsRepository {
|
||||
// create
|
||||
create(monitor: Monitor, teamId: string, userId: string): Promise<Monitor | null>;
|
||||
createBulkMonitors(monitors: Monitor[]): Promise<Monitor[]>;
|
||||
createMonitors(monitors: Monitor[]): Promise<Monitor[]>;
|
||||
// single fetch
|
||||
findById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||
|
||||
|
||||
@@ -12,13 +12,23 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return this.toEntity(saved);
|
||||
};
|
||||
|
||||
createBulkMonitors = async (monitors: Monitor[]): Promise<Monitor[]> => {
|
||||
createMonitors = async (monitors: Monitor[]): Promise<Monitor[]> => {
|
||||
if (!monitors.length) {
|
||||
return [];
|
||||
}
|
||||
const payload = monitors.map((monitor) => ({ ...monitor, notifications: undefined }));
|
||||
const inserted = await MonitorModel.insertMany(payload, { ordered: false });
|
||||
return this.mapDocuments(inserted);
|
||||
try {
|
||||
const inserted = await MonitorModel.insertMany(payload, { ordered: false });
|
||||
return this.mapDocuments(inserted);
|
||||
} catch (error: any) {
|
||||
if (error.name === "MongoBulkWriteError" || error.name === "BulkWriteError") {
|
||||
const insertedDocs = error.insertedDocs || [];
|
||||
if (insertedDocs.length > 0) {
|
||||
return this.mapDocuments(insertedDocs);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
findById = async (monitorId: string, teamId: string): Promise<Monitor> => {
|
||||
@@ -343,6 +353,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
selectedDisks: doc.selectedDisks ?? [],
|
||||
gameId: doc.gameId ?? undefined,
|
||||
group: doc.group ?? null,
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
geoCheckInterval: doc.geoCheckInterval ?? 5,
|
||||
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
@@ -398,6 +411,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
selectedDisks: doc.selectedDisks ?? [],
|
||||
gameId: doc.gameId ?? undefined,
|
||||
group: doc.group ?? null,
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
geoCheckInterval: doc.geoCheckInterval ?? 5,
|
||||
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
|
||||
@@ -26,6 +26,7 @@ class MonitorRoutes {
|
||||
|
||||
// Hardware routes
|
||||
this.router.get("/hardware/details/:monitorId", this.monitorController.getHardwareDetailsById);
|
||||
|
||||
// PageSpeed routes
|
||||
this.router.get("/pagespeed/details/:monitorId", this.monitorController.getPageSpeedDetailsById);
|
||||
|
||||
@@ -44,7 +45,8 @@ class MonitorRoutes {
|
||||
// Other static routes
|
||||
this.router.post("/demo", isAllowed(["admin", "superadmin"]), this.monitorController.addDemoMonitors);
|
||||
this.router.get("/export/json", isAllowed(["admin", "superadmin"]), this.monitorController.exportMonitorsToJSON);
|
||||
this.router.post("/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors);
|
||||
this.router.post("/import/json", isAllowed(["admin", "superadmin"]), this.monitorController.importMonitorsFromJSON);
|
||||
|
||||
this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.monitorController.sendTestEmail);
|
||||
this.router.get("/games", this.monitorController.getAllGames);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface IMonitorService {
|
||||
|
||||
// create
|
||||
createMonitor(teamId: string, userId: string, body: Monitor): Promise<void>;
|
||||
createBulkMonitors(monitors: Array<Monitor>, userId: string, teamId: string): Promise<Monitor[] | null>;
|
||||
createMonitors(monitors: Array<Monitor>, userId: string, teamId: string): Promise<Monitor[] | null>;
|
||||
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
|
||||
|
||||
// read
|
||||
@@ -72,6 +72,7 @@ export interface IMonitorService {
|
||||
// other
|
||||
sendTestEmail(args: { to: string }): Promise<string>;
|
||||
exportMonitorsToJSON(args: { teamId: string }): Promise<Monitor[]>;
|
||||
importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: any[] }): Promise<{ imported: number; errors: string[] }>;
|
||||
}
|
||||
|
||||
export class MonitorService implements IMonitorService {
|
||||
@@ -157,10 +158,10 @@ export class MonitorService implements IMonitorService {
|
||||
this.jobQueue.addJob(monitor.id, monitor);
|
||||
};
|
||||
|
||||
createBulkMonitors = async (monitors: Array<Monitor>, userId: string, teamId: string): Promise<Monitor[] | null> => {
|
||||
const createdMonitors = await this.monitorsRepository.createBulkMonitors(monitors);
|
||||
createMonitors = async (monitors: Array<Monitor>, userId: string, teamId: string): Promise<Monitor[] | null> => {
|
||||
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
|
||||
if (!monitors || monitors.length === 0) {
|
||||
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createBulkMonitors" });
|
||||
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
|
||||
}
|
||||
|
||||
await Promise.all(createdMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
|
||||
@@ -184,7 +185,7 @@ export class MonitorService implements IMonitorService {
|
||||
interval: 60000,
|
||||
};
|
||||
});
|
||||
const demoMonitors = await this.monitorsRepository.createBulkMonitors(monitors);
|
||||
const demoMonitors = await this.monitorsRepository.createMonitors(monitors);
|
||||
|
||||
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
|
||||
return demoMonitors;
|
||||
@@ -307,9 +308,9 @@ export class MonitorService implements IMonitorService {
|
||||
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
|
||||
|
||||
return {
|
||||
monitor: {
|
||||
...monitor,
|
||||
checks: checksData.checks,
|
||||
monitorData: {
|
||||
monitor,
|
||||
groupedChecks: checksData.groupedChecks,
|
||||
},
|
||||
monitorStats,
|
||||
};
|
||||
@@ -486,4 +487,41 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
return monitors;
|
||||
};
|
||||
|
||||
importMonitorsFromJSON = async ({
|
||||
teamId,
|
||||
userId,
|
||||
monitors,
|
||||
}: {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
monitors: any[];
|
||||
}): Promise<{ imported: number; errors: string[] }> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const cleanedMonitors = monitors.map((monitor) => {
|
||||
const cleanData = { ...monitor };
|
||||
delete cleanData.id;
|
||||
delete cleanData._id;
|
||||
delete cleanData.createdAt;
|
||||
delete cleanData.updatedAt;
|
||||
delete cleanData.recentChecks;
|
||||
// Monitors must belong to current team
|
||||
cleanData.teamId = teamId;
|
||||
return cleanData;
|
||||
});
|
||||
|
||||
const createdMonitors = await this.createMonitors(cleanedMonitors, userId, teamId);
|
||||
|
||||
if (!createdMonitors || createdMonitors.length === 0) {
|
||||
throw new AppError({
|
||||
message: "Failed to import any monitors. Please check the file format and try again.",
|
||||
service: SERVICE_NAME,
|
||||
method: "importMonitorsFromJSON",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return { imported: createdMonitors.length, errors };
|
||||
};
|
||||
}
|
||||
@@ -97,6 +97,7 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
this.scheduler.start();
|
||||
|
||||
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
|
||||
this.scheduler.addTemplate("geo-check-job", this.helper.getGeoCheckJob());
|
||||
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
|
||||
const monitors = await this.monitorsRepository.findAll();
|
||||
if (!monitors) {
|
||||
@@ -131,14 +132,28 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
active: monitor.isActive,
|
||||
data: monitor,
|
||||
});
|
||||
|
||||
// Add geo check job if enabled
|
||||
if (monitor.geoCheckEnabled && monitor.type === "http") {
|
||||
this.scheduler.addJob({
|
||||
id: `${monitorId}-geo`,
|
||||
template: "geo-check-job",
|
||||
repeat: monitor.geoCheckInterval,
|
||||
active: monitor.isActive,
|
||||
data: monitor,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
deleteJob = async (monitor: any) => {
|
||||
this.scheduler.removeJob(monitor.id);
|
||||
this.scheduler.removeJob(`${monitor.id}-geo`);
|
||||
};
|
||||
|
||||
pauseJob = async (monitor: any) => {
|
||||
const result = await this.scheduler.pauseJob(monitor.id);
|
||||
// Also pause geo check job if it exists
|
||||
await this.scheduler.pauseJob(`${monitor.id}-geo`);
|
||||
if (result === false) {
|
||||
throw new Error("Failed to resume monitor");
|
||||
}
|
||||
@@ -151,6 +166,8 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
|
||||
resumeJob = async (monitor: any) => {
|
||||
const result = await this.scheduler.resumeJob(monitor.id);
|
||||
// Also resume geo check job if it exists
|
||||
await this.scheduler.resumeJob(`${monitor.id}-geo`);
|
||||
if (result === false) {
|
||||
throw new Error("Failed to resume monitor");
|
||||
}
|
||||
@@ -163,6 +180,29 @@ class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
|
||||
updateJob = async (monitor: any) => {
|
||||
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
|
||||
|
||||
// Handle geo check job
|
||||
const geoJobId = `${monitor.id}-geo`;
|
||||
const geoJobExists = await this.scheduler.getJob(geoJobId);
|
||||
|
||||
if (monitor.geoCheckEnabled && monitor.type === "http") {
|
||||
if (geoJobExists) {
|
||||
// Update existing geo job
|
||||
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, data: monitor, active: monitor.isActive });
|
||||
} else {
|
||||
// Add new geo job
|
||||
this.scheduler.addJob({
|
||||
id: geoJobId,
|
||||
template: "geo-check-job",
|
||||
repeat: monitor.geoCheckInterval,
|
||||
active: monitor.isActive,
|
||||
data: monitor,
|
||||
});
|
||||
}
|
||||
} else if (geoJobExists) {
|
||||
// Remove geo job if it exists but is no longer enabled
|
||||
this.scheduler.removeJob(geoJobId);
|
||||
}
|
||||
};
|
||||
|
||||
shutdown = async () => {
|
||||
|
||||
@@ -176,6 +176,98 @@ class SuperSimpleQueueHelper {
|
||||
};
|
||||
};
|
||||
|
||||
getGeoCheckJob = () => {
|
||||
return async (monitor: Monitor) => {
|
||||
try {
|
||||
const monitorId = monitor.id;
|
||||
const teamId = monitor.teamId;
|
||||
|
||||
if (!monitorId) {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getGeoCheckJob" });
|
||||
}
|
||||
|
||||
// Safety checks
|
||||
if (!monitor.geoCheckEnabled || monitor.type !== "http") {
|
||||
this.logger.debug({
|
||||
message: `Geo check skipped for monitor ${monitorId} - not enabled or not HTTP type`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
|
||||
this.logger.warn({
|
||||
message: `Geo check skipped for monitor ${monitorId} - no locations configured`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for maintenance window
|
||||
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId);
|
||||
if (maintenanceWindowActive) {
|
||||
this.logger.debug({
|
||||
message: `Geo check skipped for monitor ${monitorId} - in maintenance window`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
message: `Starting geo check for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
});
|
||||
|
||||
// TODO: Implement GlobalPing API integration
|
||||
// Step 1: Create measurement via GlobalPing API
|
||||
// const measurementId = await this.globalPingService.createMeasurement(
|
||||
// monitor.url,
|
||||
// monitor.geoCheckLocations
|
||||
// );
|
||||
|
||||
// Step 2: Poll for results (blocks 2-30s, but doesn't affect primary monitor checks)
|
||||
// const geoResults = await this.globalPingService.pollForResults(measurementId);
|
||||
|
||||
// Step 3: Create check document with only geo results
|
||||
// const check = {
|
||||
// metadata: {
|
||||
// monitorId: monitor.id,
|
||||
// teamId: monitor.teamId,
|
||||
// type: monitor.type,
|
||||
// },
|
||||
// status: true,
|
||||
// responseTime: 0,
|
||||
// statusCode: 200,
|
||||
// message: "Geo check",
|
||||
// geoResults,
|
||||
// ack: false,
|
||||
// };
|
||||
|
||||
// Step 4: Save check
|
||||
// await this.checksRepository.create([check]);
|
||||
|
||||
this.logger.info({
|
||||
message: `Geo check completed for monitor ${monitorId}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error({
|
||||
message: error.message,
|
||||
service: SERVICE_NAME,
|
||||
method: "getGeoCheckJob",
|
||||
details: error,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getCleanupOrphanedJob = () => {
|
||||
return async () => {
|
||||
try {
|
||||
|
||||
@@ -90,6 +90,28 @@ export interface ILighthouseAudit {
|
||||
numericUnit?: string;
|
||||
}
|
||||
|
||||
export interface GeoCheckTimings {
|
||||
total: number;
|
||||
dns?: number;
|
||||
tcp?: number;
|
||||
tls?: number;
|
||||
firstByte?: number;
|
||||
download?: number;
|
||||
}
|
||||
|
||||
export interface GeoCheckResult {
|
||||
continent: string;
|
||||
region?: string;
|
||||
country: string;
|
||||
state?: string;
|
||||
city: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
status: boolean;
|
||||
statusCode: number;
|
||||
timings: GeoCheckTimings;
|
||||
}
|
||||
|
||||
export interface Check {
|
||||
id: string;
|
||||
metadata: CheckMetadata;
|
||||
@@ -113,6 +135,7 @@ export interface Check {
|
||||
seo?: number;
|
||||
performance?: number;
|
||||
audits?: CheckAudits;
|
||||
geoResults?: GeoCheckResult[];
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -124,7 +147,7 @@ export interface ChecksQueryResult {
|
||||
|
||||
export interface PageSpeedChecksResult {
|
||||
monitorType: "pagespeed";
|
||||
checks: Check[];
|
||||
groupedChecks: PageSpeedGroupedCheck[];
|
||||
}
|
||||
|
||||
export interface HardwareChecksResult {
|
||||
@@ -170,6 +193,23 @@ export interface GroupedCheck {
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface PageSpeedGroupedCheck {
|
||||
bucketDate: string;
|
||||
performance: number;
|
||||
accessibility: number;
|
||||
bestPractices: number;
|
||||
seo: number;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface GroupedGeoCheck {
|
||||
bucketDate: string;
|
||||
continent: string;
|
||||
avgResponseTime: number;
|
||||
totalChecks: number;
|
||||
uptimePercentage: number;
|
||||
}
|
||||
|
||||
export interface UptimeChecksResult {
|
||||
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
|
||||
groupedChecks: GroupedCheck[];
|
||||
@@ -177,6 +217,7 @@ export interface UptimeChecksResult {
|
||||
groupedDownChecks: GroupedCheck[];
|
||||
uptimePercentage: number;
|
||||
avgResponseTime: number;
|
||||
groupedGeoChecks?: GroupedGeoCheck[];
|
||||
}
|
||||
|
||||
export interface ChecksSummary {
|
||||
|
||||
@@ -7,6 +7,9 @@ export type MonitorType = (typeof MonitorTypes)[number];
|
||||
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
|
||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
export const GeoCheckLocations = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
|
||||
export type GeoCheckLocation = (typeof GeoCheckLocations)[number];
|
||||
|
||||
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
||||
|
||||
export interface Monitor {
|
||||
@@ -43,6 +46,9 @@ export interface Monitor {
|
||||
selectedDisks: string[];
|
||||
gameId?: string;
|
||||
group: string | null;
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoCheckLocation[];
|
||||
geoCheckInterval?: number;
|
||||
recentChecks: CheckSnapshot[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -125,8 +131,9 @@ export interface HardwareDetailsResult {
|
||||
}
|
||||
|
||||
export interface PageSpeedDetailsResult {
|
||||
monitor: Monitor & {
|
||||
checks: import("./check.js").Check[];
|
||||
monitorData: {
|
||||
monitor: Monitor;
|
||||
groupedChecks: import("./check.js").PageSpeedGroupedCheck[];
|
||||
};
|
||||
monitorStats: import("./monitorStats.js").MonitorStats | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import joi, { type CustomHelpers } from "joi";
|
||||
import { type UserRole, UserRoles } from "@/types/user.js";
|
||||
import { GeoCheckLocations } from "@/types/monitor.js";
|
||||
|
||||
//****************************************
|
||||
// Custom Validators
|
||||
@@ -172,6 +173,12 @@ const createMonitorBodyValidation = joi.object({
|
||||
gameId: joi.string().allow(""),
|
||||
selectedDisks: joi.array().items(joi.string()).optional(),
|
||||
group: joi.string().max(50).trim().allow(null, "").optional(),
|
||||
geoCheckEnabled: joi.boolean().optional(),
|
||||
geoCheckLocations: joi
|
||||
.array()
|
||||
.items(joi.string().valid(...GeoCheckLocations))
|
||||
.optional(),
|
||||
geoCheckInterval: joi.number().min(300000).optional(),
|
||||
});
|
||||
|
||||
const createMonitorsBodyValidation = joi.array().items(
|
||||
@@ -203,6 +210,12 @@ const editMonitorBodyValidation = joi
|
||||
gameId: joi.string().allow(""),
|
||||
selectedDisks: joi.array().items(joi.string()).optional(),
|
||||
group: joi.string().max(50).trim().allow(null, "").optional(),
|
||||
geoCheckEnabled: joi.boolean().optional(),
|
||||
geoCheckLocations: joi
|
||||
.array()
|
||||
.items(joi.string().valid(...GeoCheckLocations))
|
||||
.optional(),
|
||||
geoCheckInterval: joi.number().min(5).optional(),
|
||||
})
|
||||
.options({ stripUnknown: true });
|
||||
|
||||
|
||||
Reference in new issue
Block a user