refactor: switch Globalping to dynamic tier-based location lookup
## Changes - Remove per-monitor location selection — locations are now determined globally by the tier setting in admin settings (3, 6, or 15 locations) - GlobalpingService reads tier from settings on each check run, so changing the tier in settings immediately affects all HTTP/ping monitors - Add resolveLocationId() to map Globalping probe results back to configured location IDs for proper grouping - Export LOCATION_LABELS for human-readable location names on the client - Remove unused locations field from Zod schemas and form defaults - Remove per-monitor location ConfigBox from CreateMonitor page - Fix settingsController.updateAppSettings missing try/catch - Fix MongoSettingsRepository duplicate findOneAndUpdate call - Use typed LocationCheckData instead of Record<string, any> - Remove unused REVERSE_LOCATION_MAP from globalpingService ## Benefits - Changing the location tier in settings takes effect immediately for all monitors without needing to edit each one individually - Cleaner separation of concerns — location configuration lives in settings, not scattered across monitors - Human-readable location labels shown in uptime details tabs
This commit is contained in:
1 parent
7646d11ea0
commit
1966134647
10 files changed
+92
-113
No files matched your search
@@ -37,7 +37,6 @@ export const useMonitorForm = ({
|
||||
matchMethod: data?.matchMethod || "",
|
||||
expectedValue: data?.expectedValue || "",
|
||||
jsonPath: data?.jsonPath || "",
|
||||
locations: data?.locations || [],
|
||||
};
|
||||
break;
|
||||
case "ping":
|
||||
@@ -45,7 +44,6 @@ export const useMonitorForm = ({
|
||||
...base,
|
||||
type: "ping",
|
||||
url: data?.url || "",
|
||||
locations: data?.locations || [],
|
||||
};
|
||||
break;
|
||||
case "port":
|
||||
|
||||
@@ -27,18 +27,12 @@ import {
|
||||
SliderWithLabel,
|
||||
Dialog,
|
||||
} from "@/Components/v2/inputs";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
|
||||
import { useMonitorForm } from "@/Hooks/useMonitorForm";
|
||||
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MonitorFormData } from "@/Validation/monitor";
|
||||
|
||||
interface GlobalpingLocation {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GeneralSettingsConfig {
|
||||
urlLabel: string;
|
||||
urlPlaceholder: string;
|
||||
@@ -152,10 +146,6 @@ const CreateMonitorPage = () => {
|
||||
|
||||
const { data: notifications } = useGet<Notification[]>("/notifications/team");
|
||||
const { data: games } = useGet<GamesMap>("/monitors/games");
|
||||
const { data: globalpingLocations } = useGet<GlobalpingLocation[]>(
|
||||
"/monitors/globalping/locations"
|
||||
);
|
||||
|
||||
const { schema, defaults } = useMonitorForm({
|
||||
data: existingMonitor ?? null,
|
||||
defaultType,
|
||||
@@ -498,56 +488,6 @@ const CreateMonitorPage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Global locations - only for http and ping types */}
|
||||
{(watchedType === "http" || watchedType === "ping") && globalpingLocations && globalpingLocations.length > 0 && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.locations.title")}
|
||||
subtitle={t("pages.createMonitor.form.locations.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="locations"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const locationOptions = (globalpingLocations ?? []).map((loc) => ({
|
||||
...loc,
|
||||
name: loc.label,
|
||||
}));
|
||||
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) => {
|
||||
const ids = newValue.map((loc) => loc.id);
|
||||
if (ids.length <= 5) {
|
||||
field.onChange(ids);
|
||||
}
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={option.id}
|
||||
label={option.name}
|
||||
size="small"
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Alert Thresholds - only for hardware type */}
|
||||
{generalSettingsConfig.showSecret && (
|
||||
<ConfigBox
|
||||
|
||||
@@ -113,6 +113,12 @@ const UptimeDetailsPage = () => {
|
||||
const checks = checksData?.checks ?? [];
|
||||
const checksCount = checksData?.checksCount ?? 0;
|
||||
|
||||
const { data: globalpingData } = useGet<{
|
||||
locations: { id: string; label: string }[];
|
||||
labels: Record<string, string>;
|
||||
}>("/monitors/globalping/locations");
|
||||
const locationLabels = globalpingData?.labels ?? {};
|
||||
|
||||
const locationChecks = monitorData?.locationChecks;
|
||||
const locationKeys = useMemo(
|
||||
() => (locationChecks ? Object.keys(locationChecks).sort() : []),
|
||||
@@ -182,7 +188,7 @@ const UptimeDetailsPage = () => {
|
||||
{locationKeys.map((loc) => (
|
||||
<Tab
|
||||
key={loc}
|
||||
label={loc}
|
||||
label={locationLabels[loc] ?? loc}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
@@ -31,14 +31,12 @@ const httpSchema = baseSchema.extend({
|
||||
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
|
||||
expectedValue: z.string().optional(),
|
||||
jsonPath: z.string().optional(),
|
||||
locations: z.array(z.string()).max(5).optional(),
|
||||
});
|
||||
|
||||
// Ping monitor schema
|
||||
const pingSchema = baseSchema.extend({
|
||||
type: z.literal("ping"),
|
||||
url: z.string().min(1, "Host is required"),
|
||||
locations: z.array(z.string()).max(5).optional(),
|
||||
});
|
||||
|
||||
// Port monitor schema
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "./controllerUtils.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { IMonitorService } from "@/service/index.js";
|
||||
import { getGlobalpingLocationsByTier } from "@/service/infrastructure/globalpingService.js";
|
||||
import { getGlobalpingLocationsByTier, LOCATION_LABELS } from "@/service/infrastructure/globalpingService.js";
|
||||
import type { ISettingsService } from "@/service/system/settingsService.js";
|
||||
|
||||
const SERVICE_NAME = "monitorController";
|
||||
@@ -399,7 +399,7 @@ class MonitorController {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Globalping locations retrieved successfully",
|
||||
data: locations,
|
||||
data: { locations, labels: LOCATION_LABELS },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -60,15 +60,19 @@ class SettingsController {
|
||||
};
|
||||
|
||||
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => {
|
||||
await updateAppSettingsBodyValidation.validateAsync(req.body);
|
||||
try {
|
||||
await updateAppSettingsBodyValidation.validateAsync(req.body);
|
||||
|
||||
const updatedSettings = await this.settingsService.updateDbSettings(req.body);
|
||||
const returnSettings = this.buildAppSettings(updatedSettings);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "App settings updated successfully",
|
||||
data: returnSettings,
|
||||
});
|
||||
const updatedSettings = await this.settingsService.updateDbSettings(req.body);
|
||||
const returnSettings = this.buildAppSettings(updatedSettings);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "App settings updated successfully",
|
||||
data: returnSettings,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -83,10 +83,6 @@ class MongoSettingsRepository implements ISettingsRepository {
|
||||
delete update.$unset;
|
||||
}
|
||||
|
||||
await AppSettingsModel.findOneAndUpdate({}, update, {
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
const updatedSettings = await AppSettingsModel.findOneAndUpdate({}, update, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
UptimeDetailsResult,
|
||||
HardwareDetailsResult,
|
||||
PageSpeedDetailsResult,
|
||||
LocationCheckData,
|
||||
GamesMap,
|
||||
} from "@/types/monitor.js";
|
||||
import type {
|
||||
@@ -222,9 +223,9 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
|
||||
}
|
||||
|
||||
// Fetch location checks if monitor has locations configured
|
||||
let locationChecks: Record<string, any> | undefined;
|
||||
if (monitor.locations && monitor.locations.length > 0) {
|
||||
// Fetch location checks if this is an HTTP or ping monitor
|
||||
let locationChecks: Record<string, LocationCheckData> | undefined;
|
||||
if (monitor.type === "http" || monitor.type === "ping") {
|
||||
const locData = await this.checksRepository.findLocationChecksByMonitorId(
|
||||
monitor.id,
|
||||
start,
|
||||
|
||||
@@ -128,7 +128,7 @@ class SuperSimpleQueueHelper {
|
||||
}
|
||||
|
||||
// Step 2b. Run Globalping checks (fire-and-forget, doesn't affect local check pipeline)
|
||||
if (monitor.locations && monitor.locations.length > 0 && (monitor.type === "http" || monitor.type === "ping")) {
|
||||
if (monitor.type === "http" || monitor.type === "ping") {
|
||||
this.globalpingService
|
||||
.runChecks(monitor)
|
||||
.then((locationStatuses) => {
|
||||
|
||||
@@ -5,20 +5,23 @@ import type { MonitorStatusResponse } from "@/types/network.js";
|
||||
|
||||
const SERVICE_NAME = "GlobalpingService";
|
||||
|
||||
interface GlobalpingLocationOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Maps location IDs to Globalping API location objects
|
||||
const LOCATION_MAP: Record<string, Record<string, string>> = {
|
||||
// Continent-level (used for 3 and 6 tier)
|
||||
"north-america": { continent: "NA" },
|
||||
europe: { continent: "EU" },
|
||||
asia: { continent: "AS" },
|
||||
"south-america": { continent: "SA" },
|
||||
oceania: { continent: "OC" },
|
||||
africa: { continent: "AF" },
|
||||
// City-level (used for 15 tier)
|
||||
"us-east": { country: "US", state: "VA" },
|
||||
"us-west": { country: "US", state: "CA" },
|
||||
canada: { country: "CA", city: "Toronto" },
|
||||
"uk": { country: "GB", city: "London" },
|
||||
uk: { country: "GB", city: "London" },
|
||||
germany: { country: "DE", city: "Frankfurt" },
|
||||
netherlands: { country: "NL", city: "Amsterdam" },
|
||||
poland: { country: "PL", city: "Warsaw" },
|
||||
@@ -32,29 +35,19 @@ const LOCATION_MAP: Record<string, Record<string, string>> = {
|
||||
uae: { country: "AE", city: "Dubai" },
|
||||
};
|
||||
|
||||
interface GlobalpingLocationOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 3 locations: core regions
|
||||
const LOCATIONS_TIER_3: GlobalpingLocationOption[] = [
|
||||
{ id: "north-america", label: "North America" },
|
||||
{ id: "europe", label: "Europe" },
|
||||
{ id: "asia", label: "Asia" },
|
||||
];
|
||||
|
||||
// 6 locations: all continents
|
||||
const LOCATIONS_TIER_6: GlobalpingLocationOption[] = [
|
||||
{ id: "north-america", label: "North America" },
|
||||
{ id: "europe", label: "Europe" },
|
||||
{ id: "asia", label: "Asia" },
|
||||
...LOCATIONS_TIER_3,
|
||||
{ id: "south-america", label: "South America" },
|
||||
{ id: "oceania", label: "Oceania" },
|
||||
{ id: "africa", label: "Africa" },
|
||||
];
|
||||
|
||||
// 15 locations: city/country level
|
||||
const LOCATIONS_TIER_15: GlobalpingLocationOption[] = [
|
||||
{ id: "us-east", label: "US East (Virginia)" },
|
||||
{ id: "us-west", label: "US West (California)" },
|
||||
@@ -79,6 +72,14 @@ const LOCATIONS_BY_TIER: Record<number, GlobalpingLocationOption[]> = {
|
||||
15: LOCATIONS_TIER_15,
|
||||
};
|
||||
|
||||
// Build a lookup from location ID to human-readable label
|
||||
const LOCATION_LABELS: Record<string, string> = {};
|
||||
for (const tier of Object.values(LOCATIONS_BY_TIER)) {
|
||||
for (const loc of tier) {
|
||||
LOCATION_LABELS[loc.id] = loc.label;
|
||||
}
|
||||
}
|
||||
|
||||
export const getGlobalpingLocationsByTier = (tier: number): GlobalpingLocationOption[] => {
|
||||
return LOCATIONS_BY_TIER[tier] ?? LOCATIONS_TIER_6;
|
||||
};
|
||||
@@ -117,25 +118,34 @@ class GlobalpingService {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current tier's location IDs from settings.
|
||||
* This is the source of truth for which locations to check.
|
||||
*/
|
||||
private async getTierLocationIds(): Promise<string[]> {
|
||||
const dbSettings = await this.settingsService.getDBSettings();
|
||||
const tier = dbSettings.globalpingLocationsTier ?? 6;
|
||||
return getGlobalpingLocationsByTier(tier).map((loc) => loc.id);
|
||||
}
|
||||
|
||||
async runChecks(monitor: Monitor): Promise<MonitorStatusResponse[]> {
|
||||
if (!monitor.locations || monitor.locations.length === 0) {
|
||||
if (monitor.type !== "http" && monitor.type !== "ping") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const client = await this.getClient();
|
||||
if (!client) {
|
||||
this.logger.warn({
|
||||
message: "Globalping API key not configured, skipping location checks",
|
||||
service: SERVICE_NAME,
|
||||
method: "runChecks",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const locationIds = await this.getTierLocationIds();
|
||||
if (locationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const measurementType = monitor.type === "ping" ? "ping" : "http";
|
||||
const monitorUrl = monitor.url ?? "";
|
||||
|
||||
// Parse target from monitor URL
|
||||
let target: string;
|
||||
try {
|
||||
if (measurementType === "http") {
|
||||
@@ -148,7 +158,7 @@ class GlobalpingService {
|
||||
target = monitorUrl;
|
||||
}
|
||||
|
||||
const locations = monitor.locations
|
||||
const locations = locationIds
|
||||
.map((loc) => LOCATION_MAP[loc])
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -199,7 +209,7 @@ class GlobalpingService {
|
||||
for (const item of measurement.results || []) {
|
||||
const probe = item.probe;
|
||||
const result = item.result;
|
||||
const locationLabel = this.getLocationLabel(probe?.continent, probe?.country);
|
||||
const locationId = this.resolveLocationId(probe?.continent, probe?.country, locationIds);
|
||||
|
||||
if (measurementType === "http") {
|
||||
results.push({
|
||||
@@ -210,10 +220,9 @@ class GlobalpingService {
|
||||
code: result.statusCode || 0,
|
||||
message: result.statusCodeName || result.rawOutput || "",
|
||||
responseTime: result.timings?.total ?? 0,
|
||||
location: locationLabel,
|
||||
location: locationId,
|
||||
});
|
||||
} else {
|
||||
// Ping
|
||||
const stats = result.stats;
|
||||
results.push({
|
||||
monitorId: monitor.id,
|
||||
@@ -223,7 +232,7 @@ class GlobalpingService {
|
||||
code: stats?.loss === 0 ? 200 : 5000,
|
||||
message: stats?.loss === 0 ? "Ping successful" : `Packet loss: ${stats?.loss}%`,
|
||||
responseTime: stats?.avg ?? 0,
|
||||
location: locationLabel,
|
||||
location: locationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -240,11 +249,37 @@ class GlobalpingService {
|
||||
}
|
||||
}
|
||||
|
||||
private getLocationLabel(continent?: string, country?: string): string {
|
||||
if (continent && country) {
|
||||
return `${continent}-${country}`;
|
||||
/**
|
||||
* Maps a probe's continent/country back to a configured location ID.
|
||||
* For city-level tiers, matches by country code.
|
||||
* For continent-level tiers, matches by continent code.
|
||||
*/
|
||||
private resolveLocationId(continent?: string, country?: string, configuredIds?: string[]): string {
|
||||
if (!configuredIds || configuredIds.length === 0) {
|
||||
return continent && country ? `${continent}-${country}` : continent || country || "unknown";
|
||||
}
|
||||
return continent || country || "unknown";
|
||||
|
||||
// Try country-level match first (for tier 15)
|
||||
if (country) {
|
||||
for (const id of configuredIds) {
|
||||
const loc = LOCATION_MAP[id];
|
||||
if (loc?.country === country) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to continent-level match (for tier 3/6)
|
||||
if (continent) {
|
||||
for (const id of configuredIds) {
|
||||
const loc = LOCATION_MAP[id];
|
||||
if (loc?.continent === continent) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return continent && country ? `${continent}-${country}` : continent || country || "unknown";
|
||||
}
|
||||
|
||||
async getLimits(): Promise<any | null> {
|
||||
@@ -265,4 +300,5 @@ class GlobalpingService {
|
||||
}
|
||||
}
|
||||
|
||||
export { LOCATION_LABELS };
|
||||
export default GlobalpingService;
|
||||
Reference in new issue
Block a user