Compare commits

...
Author SHA1 Message Date
gorkem-bwl 3b858de2b5 feat: add buffer lookup and location results update to services
## Changes
- Add findInBuffer() to BufferService for looking up checks still in the write buffer
- Add updateLocationResults() to IChecksRepository interface and MongoChecksRepository
- Update GlobalpingService to return GlobalpingCheckResult[] instead of MonitorStatusResponse[]

## Benefits
Non-destructive service additions that prepare the backfill pipeline.
Existing check-writing and Globalping flows are unchanged.
2026-02-20 01:31:37 -05:00
gorkem-bwl d25ce9e498 feat: add LocationResult type and locationResults to Check model
## Changes
- Add LocationResult interface to server check types
- Add locationResults optional field to Check interface
- Add GlobalpingCheckResult interface to network types
- Add locationResultSchema subdocument and locationResults field to Mongoose Check schema
- Fix formatting in pre-existing files (SettingsGlobalping, Settings index, UptimeMonitorsTable, monitorService)

## Benefits
Non-destructive addition of the data model needed to store per-location
Globalping results inside a single Check document. No existing behaviour
is changed.
2026-02-20 01:31:31 -05:00
gorkem-bwl b7c9e2710a fix: address Globalping code review findings
## Changes

### Critical fixes
- Fix Switch `isChecked` → `checked` prop in CreateMonitor Globalping toggle
- Merge double `getDBSettings()` calls in globalpingService into single
  `getClientAndLocations()` method to eliminate redundant DB reads per check cycle
- Add in-flight lock (Set) to prevent overlapping Globalping checks for the same monitor
- Add hostname validation before calling Globalping API to prevent invalid targets

### Dead code removal
- Remove unused `locations` field from server Monitor type, Mongoose model,
  Joi validation, and repository mappers
- Remove unused `locations` field from client Monitor type
- Remove dead `getLimits()` method from globalpingService
- Remove dead `locations` translation keys from en.json
- Remove dead `monitorType`, `groupedUpChecks`, `groupedDownChecks` fields
  from findLocationChecksByMonitorId return value

### Medium/low fixes
- Make globalping/locations API fetch conditional on `monitor.globalpingEnabled`
  in Details page to avoid unnecessary requests
- Add useEffect to reset selectedLocationTab when location keys change
  to prevent out-of-bounds tab index
- Add `globalpingEnabled: false` to default case in useMonitorForm
- Replace console.log with comment in CreateMonitor onError handler
- Add aria-label to Globe icon for accessibility
- Add warning log when Globalping buildCheck returns undefined
- Fix step numbering in SuperSimpleQueueHelper (was duplicated Step 4)

## Benefits
- Eliminates one DB round-trip per monitor check cycle
- Prevents race conditions with overlapping Globalping checks
- Validates hostnames before external API calls
- Removes ~30 lines of dead code across server and client
- Improves accessibility with proper aria labels
2026-02-19 13:28:38 -05:00
gorkem-bwl bfdf28a2cc fix: show globe icon based on globalpingEnabled instead of locations array
The globe icon in the monitor list was checking row.locations.length,
but locations are no longer stored per-monitor. Updated to check
row.globalpingEnabled and show a "Global monitoring enabled" tooltip.
2026-02-19 12:14:52 -05:00
gorkem-bwl 4ca24c794e feat: add per-monitor Globalping toggle
Add a globalpingEnabled boolean to the Monitor model so users can
opt-in to global location checks per monitor during creation/edit.

## Changes
- Add globalpingEnabled field to server types, Mongoose model,
  Joi validation, and repository mappers
- Add globalpingEnabled to client Monitor type, Zod schemas for
  HTTP and ping monitors, and form defaults
- Add "Global monitoring" ConfigBox with Switch toggle to the
  monitor creation/edit form, visible for HTTP and ping types
- Queue helper and monitorService now check globalpingEnabled
  before running Globalping checks or fetching location data
- Add translation keys for the new form section

## Benefits
- Users can selectively enable Globalping on monitors that need it
  rather than running location checks on every HTTP/ping monitor
- Reduces unnecessary API usage and keeps the UI clean for monitors
  that only need local checks
2026-02-19 12:11:56 -05:00
gorkem-bwl 7f9d2f275e fix: prevent settings save from deleting API keys
When saving settings, empty sensitive fields (pagespeedApiKey,
globalpingApiKey, systemEmailPassword) were sent as "" to the server,
which treated them as $unset operations — deleting the stored keys.

Now the form omits these fields from the payload when they're already
set and haven't been explicitly reset by the user.
2026-02-19 11:04:21 -05:00
gorkem-bwl 1966134647 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
2026-02-19 10:58:18 -05:00
gorkem-bwl 7646d11ea0 fix: align radio buttons with label text and increase rate limits font size
- Remove default Radio padding and use explicit margin-right for
  consistent alignment with the first line of label text
- Use RadioGroup gap for consistent spacing between options
- Switch rate limits text from body2 to default (body1) to match
  other settings section descriptions
2026-02-19 10:34:22 -05:00
gorkem-bwl c10d9be0f0 fix: align radio buttons and fix rate limits box dark mode contrast
- Add top margin to radio buttons so they align with the first line
  of the multi-line labels
- Remove custom background color from rate limits box that was
  unreadable in dark mode
- Use default text color for bullet points instead of text.secondary
- Keep only the example note in text.secondary with italic style
2026-02-19 10:31:55 -05:00
gorkem-bwl 27c3a9e12b feat: add location tier presets (3, 6, 15) to Globalping settings
## Changes

### Server
- Expand LOCATION_MAP to support city/country-level locations for the
  15-location tier (US East/West, Toronto, London, Frankfurt, Amsterdam,
  Warsaw, Tokyo, Singapore, Mumbai, Sydney, Brazil, Santiago, Johannesburg,
  Dubai)
- Add globalpingLocationsTier field to Settings type, Mongoose model,
  repository mapper, and Joi validation (valid: 3, 6, 15, default: 6)
- Update getGlobalpingLocations endpoint to return locations based on
  the configured tier from settings
- Pass settingsService to MonitorController for tier lookup

### Client
- Add tier selector (radio buttons) to SettingsGlobalping component
  with descriptions for each option:
  - 3 locations: North America, Europe, Asia
  - 6 locations: all continents
  - 15 locations: city-level global coverage
- Add rate limits info box explaining credit usage per tier:
  - Without token: 250 tests/hour
  - With free token: 500 tests/hour
  - GitHub sponsors: higher limits
- Add all translation keys for tier labels, descriptions, and rate info

## Benefits
- Admins can choose the right balance of coverage vs API usage
- Rate limit info helps users decide whether they need authentication
2026-02-19 10:31:15 -05:00
gorkem-bwl 134c2ebbf4 fix: map globalpingApiKey in settings repository toEntity
The MongoSettingsRepository.toEntity method was missing the
globalpingApiKey field, causing GlobalpingService to always
report the API key as not configured.
2026-02-19 10:19:30 -05:00
gorkem-bwl e39b44db6c feat: add multi-location uptime monitoring via Globalping
## Changes

### Server
- Add `locations` field to Monitor model and types (up to 5 locations per monitor)
- Add `location` field to Check metadata for per-location tracking
- Add `globalpingApiKey` to AppSettings for API key management
- Create GlobalpingService that wraps the Globalping SDK to run distributed
  HTTP/ping checks from multiple global locations (NA, EU, AS, SA, OC, AF)
- Extend SuperSimpleQueueHelper with fire-and-forget Globalping checks that
  run alongside the existing local check without affecting the main pipeline
- Add location-aware check queries with aggregation pipeline grouping by
  location in MongoChecksRepository
- Filter location checks from main uptime aggregation to preserve local check
  as source of truth for status/incidents
- Extend monitor details endpoint to return per-location check data
- Add GET /globalping/locations endpoint for available location options
- Add globalpingKeySet to settings response
- Add Joi validation for locations and globalpingApiKey fields

### Client
- Add locations multi-select (Autocomplete with chips) to monitor creation
  form, shown only for HTTP and ping monitor types
- Add SettingsGlobalping component for API key management (matching existing
  PageSpeed pattern with set/reset flow)
- Add per-location breakdown section to uptime details page with tabs,
  avg response time, uptime percentage, and response time charts
- Add globe icon with tooltip to monitor table for monitors with locations
- Add Zod validation for locations field in HTTP and ping schemas
- Add all translation keys for globalping settings, locations form, and
  location details sections

## Benefits
- Users gain visibility into geographic availability and latency data
- Local check remains the source of truth, Globalping runs alongside
- Supports up to 5 locations per monitor with 6 continent-level options
- Graceful degradation when API key is not configured or rate limited
2026-02-19 00:45:14 -05:00
36 changed files with 1536 additions and 48 deletions

No files matched your search

+3
View File
@@ -37,6 +37,7 @@ export const useMonitorForm = ({
matchMethod: data?.matchMethod || "", matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "", expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "", jsonPath: data?.jsonPath || "",
globalpingEnabled: data?.globalpingEnabled || false,
}; };
break; break;
case "ping": case "ping":
@@ -44,6 +45,7 @@ export const useMonitorForm = ({
...base, ...base,
type: "ping", type: "ping",
url: data?.url || "", url: data?.url || "",
globalpingEnabled: data?.globalpingEnabled || false,
}; };
break; break;
case "port": case "port":
@@ -100,6 +102,7 @@ export const useMonitorForm = ({
matchMethod: "", matchMethod: "",
expectedValue: "", expectedValue: "",
jsonPath: "", jsonPath: "",
globalpingEnabled: false,
}; };
} }
+2
View File
@@ -29,6 +29,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
}, },
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "", pagespeedApiKey: "",
globalpingApiKey: "",
globalpingLocationsTier: data?.globalpingLocationsTier ?? 6,
systemEmailPassword: "", systemEmailPassword: "",
}; };
+23 -3
View File
@@ -146,7 +146,6 @@ const CreateMonitorPage = () => {
const { data: notifications } = useGet<Notification[]>("/notifications/team"); const { data: notifications } = useGet<Notification[]>("/notifications/team");
const { data: games } = useGet<GamesMap>("/monitors/games"); const { data: games } = useGet<GamesMap>("/monitors/games");
const { schema, defaults } = useMonitorForm({ const { schema, defaults } = useMonitorForm({
data: existingMonitor ?? null, data: existingMonitor ?? null,
defaultType, defaultType,
@@ -223,8 +222,8 @@ const CreateMonitorPage = () => {
} }
}; };
const onError = (errors: unknown) => { const onError = (_errors: unknown) => {
console.log(errors); // Validation errors are shown inline via react-hook-form field errors
}; };
return ( return (
@@ -489,6 +488,27 @@ const CreateMonitorPage = () => {
} }
/> />
{/* Global monitoring - only for http and ping types */}
{(watchedType === "http" || watchedType === "ping") && (
<ConfigBox
title={t("pages.createMonitor.form.globalping.title")}
subtitle={t("pages.createMonitor.form.globalping.description")}
rightContent={
<Controller
name="globalpingEnabled"
control={control}
render={({ field }) => (
<Switch
id="globalpingEnabled"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
}
/>
)}
{/* Alert Thresholds - only for hardware type */} {/* Alert Thresholds - only for hardware type */}
{generalSettingsConfig.showSecret && ( {generalSettingsConfig.showSecret && (
<ConfigBox <ConfigBox
@@ -0,0 +1,197 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import Radio from "@mui/material/Radio";
import ConfigBox from "@/Components/v1/ConfigBox/index.jsx";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
import { PasswordEndAdornment } from "@/Components/v1/Inputs/TextInput/Adornments/index.jsx";
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useTranslation } from "react-i18next";
import { Controller } from "react-hook-form";
const SettingsGlobalping = ({
isAdmin,
HEADING_SX,
isGlobalpingKeySet,
globalpingKeyHasBeenReset,
setGlobalpingKeyHasBeenReset,
defaults,
control,
setValue,
}) => {
const { t } = useTranslation();
const theme = useTheme();
if (!isAdmin) {
return null;
}
return (
<ConfigBox>
<Box>
<Typography
component="h1"
variant="h2"
>
{t("pages.settings.globalpingSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("pages.settings.globalpingSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
{(isGlobalpingKeySet === false || globalpingKeyHasBeenReset === true) && (
<Controller
name="globalpingApiKey"
control={control}
defaultValue={defaults.globalpingApiKey}
render={({ field, fieldState }) => (
<TextInput
{...field}
label={t("pages.settings.globalpingSettings.labelApiKey")}
type={"password"}
optionalLabel={t("pages.settings.globalpingSettings.optionalLabel")}
endAdornment={<PasswordEndAdornment />}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
)}
{isGlobalpingKeySet === true && globalpingKeyHasBeenReset === false && (
<Box>
<Typography>
{t("pages.settings.globalpingSettings.labelApiKeySet")}
</Typography>
<Button
onClick={() => {
setValue("globalpingApiKey", "");
setGlobalpingKeyHasBeenReset(true);
}}
variant="contained"
color="error"
sx={{ mt: theme.spacing(4) }}
>
{t("reset")}
</Button>
</Box>
)}
<Box>
<Typography
variant="h2"
sx={{ mb: theme.spacing(2) }}
>
{t("pages.settings.globalpingSettings.locationsTierTitle")}
</Typography>
<Typography sx={{ mb: theme.spacing(8), color: theme.palette.text.secondary }}>
{t("pages.settings.globalpingSettings.locationsTierDescription")}
</Typography>
<Controller
name="globalpingLocationsTier"
control={control}
defaultValue={defaults.globalpingLocationsTier}
render={({ field }) => (
<RadioGroup
value={String(field.value ?? 6)}
onChange={(e) => field.onChange(Number(e.target.value))}
sx={{ gap: theme.spacing(4) }}
>
{[
{ value: "3", labelKey: "tier3Label", descKey: "tier3Description" },
{ value: "6", labelKey: "tier6Label", descKey: "tier6Description" },
{ value: "15", labelKey: "tier15Label", descKey: "tier15Description" },
].map((tier) => (
<FormControlLabel
key={tier.value}
value={tier.value}
control={<Radio sx={{ p: 0, mr: theme.spacing(4) }} />}
label={
<Box>
<Typography
fontWeight={500}
lineHeight={1.5}
>
{t(`pages.settings.globalpingSettings.${tier.labelKey}`)}
</Typography>
<Typography
variant="body2"
color="text.secondary"
>
{t(`pages.settings.globalpingSettings.${tier.descKey}`)}
</Typography>
</Box>
}
sx={{ alignItems: "flex-start", m: 0 }}
/>
))}
</RadioGroup>
)}
/>
</Box>
<Box
sx={{
p: theme.spacing(8),
borderRadius: theme.spacing(2),
border: `1px solid ${theme.palette.primary.lowContrast}`,
}}
>
<Typography
variant="h2"
sx={{ mb: theme.spacing(4) }}
>
{t("pages.settings.globalpingSettings.rateLimitsTitle")}
</Typography>
<Typography sx={{ mb: theme.spacing(4) }}>
{t("pages.settings.globalpingSettings.rateLimitsDescription")}
</Typography>
<Box
component="ul"
sx={{ m: 0, pl: theme.spacing(8) }}
>
<Typography
component="li"
sx={{ mb: theme.spacing(2) }}
>
{t("pages.settings.globalpingSettings.rateLimitNoAuth")}
</Typography>
<Typography
component="li"
sx={{ mb: theme.spacing(2) }}
>
{t("pages.settings.globalpingSettings.rateLimitAuth")}
</Typography>
<Typography component="li">
{t("pages.settings.globalpingSettings.rateLimitSponsor")}
</Typography>
</Box>
<Typography
color="text.secondary"
sx={{ mt: theme.spacing(4), fontStyle: "italic" }}
>
{t("pages.settings.globalpingSettings.rateLimitsNote")}
</Typography>
</Box>
</Stack>
</ConfigBox>
);
};
SettingsGlobalping.propTypes = {
isAdmin: PropTypes.bool,
HEADING_SX: PropTypes.object,
isGlobalpingKeySet: PropTypes.bool,
globalpingKeyHasBeenReset: PropTypes.bool,
setGlobalpingKeyHasBeenReset: PropTypes.func,
defaults: PropTypes.object,
control: PropTypes.object,
setValue: PropTypes.func,
};
export default SettingsGlobalping;
+36
View File
@@ -5,6 +5,7 @@ import SettingsTimeZone from "./SettingsTimeZone.jsx";
import SettingsUI from "./SettingsUI.jsx"; import SettingsUI from "./SettingsUI.jsx";
import SettingsURL from "./SettingsURL.jsx"; import SettingsURL from "./SettingsURL.jsx";
import SettingsPagespeed from "./SettingsPagespeed.jsx"; import SettingsPagespeed from "./SettingsPagespeed.jsx";
import SettingsGlobalping from "./SettingsGlobalping.jsx";
import SettingsDemoMonitors from "./SettingsDemoMonitors.jsx"; import SettingsDemoMonitors from "./SettingsDemoMonitors.jsx";
import SettingsAbout from "./SettingsAbout.jsx"; import SettingsAbout from "./SettingsAbout.jsx";
import SettingsEmail from "./SettingsEmail.jsx"; import SettingsEmail from "./SettingsEmail.jsx";
@@ -34,6 +35,10 @@ const Settings = () => {
fetchedSettings?.pagespeedKeySet ?? false fetchedSettings?.pagespeedKeySet ?? false
); );
const [apiKeyHasBeenReset, setApiKeyHasBeenReset] = useState(false); const [apiKeyHasBeenReset, setApiKeyHasBeenReset] = useState(false);
const [isGlobalpingKeySet, setIsGlobalpingKeySet] = useState(
fetchedSettings?.globalpingKeySet ?? false
);
const [globalpingKeyHasBeenReset, setGlobalpingKeyHasBeenReset] = useState(false);
const [isEmailPasswordSet, setIsEmailPasswordSet] = useState( const [isEmailPasswordSet, setIsEmailPasswordSet] = useState(
fetchedSettings?.emailPasswordSet ?? false fetchedSettings?.emailPasswordSet ?? false
); );
@@ -63,6 +68,7 @@ const Settings = () => {
useEffect(() => { useEffect(() => {
if (fetchedSettings) { if (fetchedSettings) {
setIsApiKeySet(fetchedSettings?.pagespeedKeySet); setIsApiKeySet(fetchedSettings?.pagespeedKeySet);
setIsGlobalpingKeySet(fetchedSettings?.globalpingKeySet);
setIsEmailPasswordSet(fetchedSettings?.emailPasswordSet); setIsEmailPasswordSet(fetchedSettings?.emailPasswordSet);
} }
}, [fetchedSettings]); }, [fetchedSettings]);
@@ -74,10 +80,14 @@ const Settings = () => {
if (response?.data) { if (response?.data) {
const data = response.data; const data = response.data;
setIsApiKeySet(data.pagespeedKeySet); setIsApiKeySet(data.pagespeedKeySet);
setIsGlobalpingKeySet(data.globalpingKeySet);
setIsEmailPasswordSet(data.emailPasswordSet); setIsEmailPasswordSet(data.emailPasswordSet);
if (data.pagespeedKeySet === true) { if (data.pagespeedKeySet === true) {
setApiKeyHasBeenReset(false); setApiKeyHasBeenReset(false);
} }
if (data.globalpingKeySet === true) {
setGlobalpingKeyHasBeenReset(false);
}
if (data.emailPasswordSet === true) { if (data.emailPasswordSet === true) {
setEmailPasswordHasBeenReset(false); setEmailPasswordHasBeenReset(false);
} }
@@ -102,6 +112,22 @@ const Settings = () => {
}) })
); );
// Don't send empty sensitive fields when the key is already set and
// hasn't been explicitly reset — otherwise the backend $unsets them
if (isApiKeySet && !apiKeyHasBeenReset && !toSubmit.pagespeedApiKey) {
delete toSubmit.pagespeedApiKey;
}
if (isGlobalpingKeySet && !globalpingKeyHasBeenReset && !toSubmit.globalpingApiKey) {
delete toSubmit.globalpingApiKey;
}
if (
isEmailPasswordSet &&
!emailPasswordHasBeenReset &&
!toSubmit.systemEmailPassword
) {
delete toSubmit.systemEmailPassword;
}
saveSettings(toSubmit); saveSettings(toSubmit);
}; };
@@ -134,6 +160,16 @@ const Settings = () => {
defaults={defaults} defaults={defaults}
setValue={setValue} setValue={setValue}
/> />
<SettingsGlobalping
isAdmin={isAdmin}
HEADING_SX={HEADING_SX}
isGlobalpingKeySet={isGlobalpingKeySet}
globalpingKeyHasBeenReset={globalpingKeyHasBeenReset}
setGlobalpingKeyHasBeenReset={setGlobalpingKeyHasBeenReset}
control={control}
defaults={defaults}
setValue={setValue}
/>
<SettingsURL <SettingsURL
HEADING_SX={HEADING_SX} HEADING_SX={HEADING_SX}
control={control} control={control}
+88 -2
View File
@@ -1,6 +1,10 @@
import { BasePage } from "@/Components/v2/design-elements"; import { BasePage } from "@/Components/v2/design-elements";
import { HeaderTimeRange } from "@/Components/v2/common"; import { HeaderTimeRange } from "@/Components/v2/common";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import { import {
HistogramStatus, HistogramStatus,
RadialAvgResponse, RadialAvgResponse,
@@ -13,11 +17,11 @@ import { MonitorStatBoxes } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useGet } from "@/Hooks/UseApi"; import { useGet } from "@/Hooks/UseApi";
import type { MonitorDetailsResponse } from "@/Types/Monitor"; import type { MonitorDetailsResponse, LocationCheckData } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check"; import type { ChecksResponse } from "@/Types/Check";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
@@ -38,6 +42,7 @@ const UptimeDetailsPage = () => {
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(5); const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent"); const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocationTab, setSelectedLocationTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => { const monitorDetailsUrl = useMemo(() => {
if (!monitorId) { if (!monitorId) {
@@ -108,6 +113,30 @@ const UptimeDetailsPage = () => {
const checks = checksData?.checks ?? []; const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0; const checksCount = checksData?.checksCount ?? 0;
const globalpingLocationsUrl = monitor?.globalpingEnabled
? "/monitors/globalping/locations"
: null;
const { data: globalpingData } = useGet<{
locations: { id: string; label: string }[];
labels: Record<string, string>;
}>(globalpingLocationsUrl);
const locationLabels = globalpingData?.labels ?? {};
const locationChecks = monitorData?.locationChecks;
const locationKeys = useMemo(
() => (locationChecks ? Object.keys(locationChecks).sort() : []),
[locationChecks]
);
// Reset tab index when location keys change to prevent out-of-bounds
useEffect(() => {
setSelectedLocationTab(0);
}, [locationKeys.length]);
const selectedLocation = locationKeys[selectedLocationTab] ?? null;
const selectedLocationData: LocationCheckData | null =
selectedLocation && locationChecks ? locationChecks[selectedLocation] : null;
return ( return (
<BasePage> <BasePage>
<HeaderMonitorControls <HeaderMonitorControls
@@ -152,6 +181,63 @@ const UptimeDetailsPage = () => {
checks={monitorData?.groupedChecks || []} checks={monitorData?.groupedChecks || []}
range={dateRange} range={dateRange}
/> />
{locationKeys.length > 0 && (
<Box>
<Typography
variant="h2"
sx={{ mb: theme.spacing(4) }}
>
{t("pages.uptime.details.locations.title")}
</Typography>
<Tabs
value={selectedLocationTab}
onChange={(_e, newVal) => setSelectedLocationTab(newVal)}
sx={{ mb: theme.spacing(4) }}
>
{locationKeys.map((loc) => (
<Tab
key={loc}
label={locationLabels[loc] ?? loc}
/>
))}
</Tabs>
{selectedLocationData && (
<Stack gap={theme.spacing(4)}>
<Stack
direction="row"
gap={theme.spacing(8)}
>
<Box>
<Typography
variant="body2"
color="text.secondary"
>
{t("pages.uptime.details.locations.avgResponseTime")}
</Typography>
<Typography variant="h3">
{Math.round(selectedLocationData.avgResponseTime)} ms
</Typography>
</Box>
<Box>
<Typography
variant="body2"
color="text.secondary"
>
{t("pages.uptime.details.locations.uptime")}
</Typography>
<Typography variant="h3">
{(selectedLocationData.uptimePercentage * 100).toFixed(1)}%
</Typography>
</Box>
</Stack>
<HistogramDetails
checks={selectedLocationData.groupedChecks || []}
range={dateRange}
/>
</Stack>
)}
</Box>
)}
<ChecksTable <ChecksTable
checks={checks} checks={checks}
count={checksCount} count={checksCount}
@@ -9,7 +9,8 @@ import {
} from "@/Components/v2/design-elements"; } from "@/Components/v2/design-elements";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common"; import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common";
import { ActionsMenu } from "@/Components/v2/actions-menu"; import { ActionsMenu } from "@/Components/v2/actions-menu";
import { ArrowDown, ArrowUp } from "lucide-react"; import { ArrowDown, ArrowUp, Globe } from "lucide-react";
import Tooltip from "@mui/material/Tooltip";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
@@ -167,7 +168,23 @@ export const MonitorTable = ({
), ),
render: (row) => { render: (row) => {
return row?.name; return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
{row?.name}
{row.globalpingEnabled && (
<Tooltip title={t("pages.uptime.table.headers.globalpingEnabled")}>
<Globe
size={14}
aria-label={t("pages.uptime.table.headers.globalpingEnabled")}
/>
</Tooltip>
)}
</Stack>
);
}, },
}, },
{ {
+8
View File
@@ -58,6 +58,7 @@ export interface Monitor {
selectedDisks: string[]; selectedDisks: string[];
gameId?: string; gameId?: string;
group: string | null; group: string | null;
globalpingEnabled: boolean;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -97,6 +98,12 @@ export interface MonitorStats {
updatedAt: string; updatedAt: string;
} }
export interface LocationCheckData {
groupedChecks: GroupedCheck[];
uptimePercentage: number;
avgResponseTime: number;
}
export interface MonitorData { export interface MonitorData {
monitor: Monitor; monitor: Monitor;
groupedChecks: GroupedCheck[]; groupedChecks: GroupedCheck[];
@@ -104,6 +111,7 @@ export interface MonitorData {
groupedDownChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[];
groupedAvgResponseTime: number; groupedAvgResponseTime: number;
groupedUptimePercentage: number; groupedUptimePercentage: number;
locationChecks?: Record<string, LocationCheckData>;
} }
export interface MonitorDetailsResponse { export interface MonitorDetailsResponse {
+2
View File
@@ -21,6 +21,7 @@ export interface Settings {
systemEmailRequireTLS: boolean; systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean; systemEmailRejectUnauthorized: boolean;
showURL: boolean; showURL: boolean;
globalpingLocationsTier?: number;
singleton: boolean; singleton: boolean;
globalThresholds?: SettingsThresholds; globalThresholds?: SettingsThresholds;
createdAt: string; createdAt: string;
@@ -29,6 +30,7 @@ export interface Settings {
export interface AppSettingsResponse { export interface AppSettingsResponse {
pagespeedKeySet: boolean; pagespeedKeySet: boolean;
globalpingKeySet: boolean;
emailPasswordSet: boolean; emailPasswordSet: boolean;
settings: Settings; settings: Settings;
} }
+2
View File
@@ -31,12 +31,14 @@ const httpSchema = baseSchema.extend({
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
expectedValue: z.string().optional(), expectedValue: z.string().optional(),
jsonPath: z.string().optional(), jsonPath: z.string().optional(),
globalpingEnabled: z.boolean(),
}); });
// Ping monitor schema // Ping monitor schema
const pingSchema = baseSchema.extend({ const pingSchema = baseSchema.extend({
type: z.literal("ping"), type: z.literal("ping"),
url: z.string().min(1, "Host is required"), url: z.string().min(1, "Host is required"),
globalpingEnabled: z.boolean(),
}); });
// Port monitor schema // Port monitor schema
+2
View File
@@ -34,6 +34,8 @@ export const settingsSchema = z.object({
.min(1, "Please enter a value") .min(1, "Please enter a value")
.max(365, "Maximum 365 days"), .max(365, "Maximum 365 days"),
pagespeedApiKey: optionalString(), pagespeedApiKey: optionalString(),
globalpingApiKey: optionalString(),
globalpingLocationsTier: z.coerce.number().optional(),
systemEmailHost: z.preprocess( systemEmailHost: z.preprocess(
(val) => (val) =>
val === "" || val === null || val === undefined ? undefined : String(val).trim(), val === "" || val === null || val === undefined ? undefined : String(val).trim(),
+40 -1
View File
@@ -445,6 +445,15 @@
}, },
"title": "Check frequency" "title": "Check frequency"
}, },
"globalping": {
"title": "Global monitoring",
"description": "Run checks from multiple locations around the world using the Globalping network. Configure the API key and location count in settings.",
"option": {
"enabled": {
"label": "Enable Globalping"
}
}
},
"general": { "general": {
"option": { "option": {
"container": { "container": {
@@ -950,6 +959,27 @@
"labelApiKey": "PageSpeed API key", "labelApiKey": "PageSpeed API key",
"title": "Google PageSpeed API key" "title": "Google PageSpeed API key"
}, },
"globalpingSettings": {
"description": "Configure Globalping to run uptime checks from multiple geographic regions worldwide.",
"labelApiKeySet": "API key is set. Click Reset to change it.",
"labelApiKey": "Globalping API key",
"optionalLabel": "(Optional)",
"title": "Globalping",
"locationsTierTitle": "Global locations",
"locationsTierDescription": "Choose how many probe locations are available when creating monitors.",
"tier3Label": "3 locations — core regions",
"tier3Description": "North America, Europe, Asia",
"tier6Label": "6 locations — all continents",
"tier6Description": "North America, Europe, Asia, South America, Oceania, Africa",
"tier15Label": "15 locations — city level",
"tier15Description": "US East, US West, Canada, UK, Germany, Netherlands, Poland, Japan, Singapore, India, Australia, Brazil, Chile, South Africa, UAE",
"rateLimitsTitle": "Rate limits",
"rateLimitsDescription": "Globalping limits how many tests you can run per hour. Each location check on each monitor uses 1 credit per interval.",
"rateLimitNoAuth": "Without a token: 250 tests per hour (no authentication needed)",
"rateLimitAuth": "With a free token: 500 tests per hour (sign in at dash.globalping.io)",
"rateLimitSponsor": "GitHub sponsors: higher limits based on sponsorship tier + hosting probes",
"rateLimitsNote": "For example, 3 monitors with 5 locations each, checked every minute, uses 15 credits per minute (900/hour). Choose your tier accordingly."
},
"saveButtonLabel": "Save", "saveButtonLabel": "Save",
"statsSettings": { "statsSettings": {
"clearAllStatsButton": "Clear all stats", "clearAllStatsButton": "Clear all stats",
@@ -1106,6 +1136,14 @@
} }
}, },
"uptime": { "uptime": {
"details": {
"locations": {
"title": "Location breakdown",
"label": "Location",
"avgResponseTime": "Avg response time",
"uptime": "Uptime"
}
},
"filters": { "filters": {
"search": { "search": {
"placeholder": "Search monitors..." "placeholder": "Search monitors..."
@@ -1113,7 +1151,8 @@
}, },
"table": { "table": {
"headers": { "headers": {
"responseTime": "Response time" "responseTime": "Response time",
"globalpingEnabled": "Global monitoring enabled"
} }
}, },
"fallback": { "fallback": {
+580 -17
View File
@@ -22,6 +22,7 @@
"express": "^4.19.2", "express": "^4.19.2",
"express-rate-limit": "8.0.1", "express-rate-limit": "8.0.1",
"gamedig": "^5.3.1", "gamedig": "^5.3.1",
"globalping": "^0.2.0",
"got": "14.4.7", "got": "14.4.7",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^8.0.0", "helmet": "^8.0.0",
@@ -874,7 +875,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
@@ -1504,7 +1504,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -1549,7 +1548,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2261,6 +2259,132 @@
"@hapi/hoek": "^9.0.0" "@hapi/hoek": "^9.0.0"
} }
}, },
"node_modules/@hey-api/client-fetch": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.10.2.tgz",
"integrity": "sha512-AGiFYDx+y8VT1wlQ3EbzzZtfU8EfV+hLLRTtr8Y/tjYZaxIECwJagVZf24YzNbtEBXONFV50bwcU1wLVGXe1ow==",
"deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"@hey-api/openapi-ts": "< 2"
}
},
"node_modules/@hey-api/codegen-core": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.0.tgz",
"integrity": "sha512-HglL4B4QwpzocE+c8qDU6XK8zMf8W8Pcv0RpFDYxHuYALWLTnpDUuEsglC7NQ4vC1maoXsBpMbmwpco0N4QviA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hey-api/types": "0.1.3",
"ansi-colors": "4.1.3",
"c12": "3.3.3",
"color-support": "1.1.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@hey-api/json-schema-ref-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.0.tgz",
"integrity": "sha512-3tQJ8N2egHXZjQWUeceoWrl88APWjo7gRrQ/L4HWJKnh6HowczCv7yNNFeSusPoWGV6HGdoFiCvq6UsLkrwKhg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jsdevtools/ono": "7.1.3",
"@types/json-schema": "7.0.15",
"js-yaml": "4.1.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
}
},
"node_modules/@hey-api/openapi-ts": {
"version": "0.92.4",
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.92.4.tgz",
"integrity": "sha512-RA3wnL7Odr5xczuS3xpvnPClgJ/K8jivK3hvD8J0m5GBuvJFkZ1A1xp+6Ve1G0BV8p4LwxwgN1Qhb+4BFsLfMg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hey-api/codegen-core": "0.7.0",
"@hey-api/json-schema-ref-parser": "1.3.0",
"@hey-api/shared": "0.2.0",
"@hey-api/types": "0.1.3",
"ansi-colors": "4.1.3",
"color-support": "1.1.3",
"commander": "14.0.3"
},
"bin": {
"openapi-ts": "bin/run.js"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@hey-api/openapi-ts/node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
}
},
"node_modules/@hey-api/shared": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.0.tgz",
"integrity": "sha512-t7C+65ES12OqAE5k6DB/y5nDuTjydtqdxf/Qe4zflVn2AzGs7hO/7KjXvGXZYnpNVF7QISAcj0LEObASU9I53Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hey-api/codegen-core": "0.7.0",
"@hey-api/json-schema-ref-parser": "1.3.0",
"@hey-api/types": "0.1.3",
"ansi-colors": "4.1.3",
"cross-spawn": "7.0.6",
"open": "11.0.0",
"semver": "7.7.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/hey-api"
},
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@hey-api/types": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.3.tgz",
"integrity": "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": ">=5.5.3"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3223,6 +3347,13 @@
"url": "https://opencollective.com/js-sdsl" "url": "https://opencollective.com/js-sdsl"
} }
}, },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT",
"peer": true
},
"node_modules/@mongodb-js/saslprep": { "node_modules/@mongodb-js/saslprep": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
@@ -4229,6 +4360,7 @@
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"license": "ISC", "license": "ISC",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=10.13.0" "node": ">=10.13.0"
} }
@@ -4405,7 +4537,6 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^5.0.0",
@@ -4506,7 +4637,6 @@
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": { "node_modules/@types/jsonwebtoken": {
@@ -4566,7 +4696,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -5053,7 +5182,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -5632,7 +5760,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001751",
@@ -5739,6 +5866,22 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
} }
}, },
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"run-applescript": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/busboy": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -5759,6 +5902,78 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/c12": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz",
"integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^5.0.0",
"confbox": "^0.2.2",
"defu": "^6.1.4",
"dotenv": "^17.2.3",
"exsolve": "^1.0.8",
"giget": "^2.0.0",
"jiti": "^2.6.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.0.0",
"pkg-types": "^2.3.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "*"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/c12/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c12/node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/c12/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c8": { "node_modules/c8": {
"version": "10.1.3", "version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
@@ -6023,6 +6238,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/cjs-module-lexer": { "node_modules/cjs-module-lexer": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
@@ -6220,6 +6445,16 @@
"simple-swizzle": "^0.2.2" "simple-swizzle": "^0.2.2"
} }
}, },
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"peer": true,
"bin": {
"color-support": "bin.js"
}
},
"node_modules/colord": { "node_modules/colord": {
"version": "2.9.3", "version": "2.9.3",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
@@ -6357,6 +6592,23 @@
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
}, },
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"license": "MIT",
"peer": true
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -6575,7 +6827,6 @@
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssnano-preset-default": "^7.0.9", "cssnano-preset-default": "^7.0.9",
"lilconfig": "^3.1.3" "lilconfig": "^3.1.3"
@@ -6821,6 +7072,36 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bundle-name": "^4.1.0",
"default-browser-id": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-browser-id": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": { "node_modules/defer-to-connect": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -6830,6 +7111,26 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT",
"peer": true
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -6857,6 +7158,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT",
"peer": true
},
"node_modules/destroy": { "node_modules/destroy": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -7330,7 +7638,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -7692,7 +7999,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -7767,6 +8073,13 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT",
"peer": true
},
"node_modules/extend": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8395,6 +8708,24 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
} }
}, },
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"license": "MIT",
"peer": true,
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.6.0",
"pathe": "^2.0.3"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -8452,6 +8783,15 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/globalping": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/globalping/-/globalping-0.2.0.tgz",
"integrity": "sha512-rxyvqXF/oz5yBGJHR58TEsCkcbc4WQVmdKC+uZ8LXfIt4KwTdTvbzkfwhhjuyUmuQr8U/b8m/ZicdrA8ZIroKw==",
"license": "MIT",
"dependencies": {
"@hey-api/client-fetch": "^0.10.0"
}
},
"node_modules/globals": { "node_modules/globals": {
"version": "15.15.0", "version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
@@ -8947,6 +9287,22 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"license": "MIT",
"peer": true,
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -8987,6 +9343,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-in-ssh": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
"integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-docker": "^3.0.0"
},
"bin": {
"is-inside-container": "cli.js"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-json": { "node_modules/is-json": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz",
@@ -9020,6 +9408,22 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-wsl": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-inside-container": "^1.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": { "node_modules/isarray": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
@@ -9341,7 +9745,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.2.0", "@jest/core": "30.2.0",
"@jest/types": "30.2.0", "@jest/types": "30.2.0",
@@ -9929,6 +10332,16 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jmespath": { "node_modules/jmespath": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
@@ -11611,6 +12024,13 @@
} }
} }
}, },
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT",
"peer": true
},
"node_modules/node-fetch/node_modules/tr46": { "node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -11780,6 +12200,31 @@
"integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nypm": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
"integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"citty": "^0.2.0",
"pathe": "^2.0.3",
"tinyexec": "^1.0.2"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": ">=18"
}
},
"node_modules/nypm/node_modules/citty": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
"license": "MIT",
"peer": true
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -11801,6 +12246,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"license": "MIT",
"peer": true
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -11856,6 +12308,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/open": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
"integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
"license": "MIT",
"peer": true,
"dependencies": {
"default-browser": "^5.4.0",
"define-lazy-prop": "^3.0.0",
"is-in-ssh": "^1.0.0",
"is-inside-container": "^1.0.0",
"powershell-utils": "^0.1.0",
"wsl-utils": "^0.3.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -12080,6 +12553,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT",
"peer": true
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT",
"peer": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -12199,6 +12686,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"license": "MIT",
"peer": true,
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/plimit-lit": { "node_modules/plimit-lit": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
@@ -12231,7 +12730,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -12825,6 +13323,19 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
"integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -13088,6 +13599,17 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -13280,6 +13802,19 @@
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -14179,6 +14714,7 @@
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@trysound/sax": "0.2.0", "@trysound/sax": "0.2.0",
"commander": "^7.2.0", "commander": "^7.2.0",
@@ -14205,6 +14741,7 @@
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -14215,6 +14752,7 @@
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"mdn-data": "2.0.30", "mdn-data": "2.0.30",
"source-map-js": "^1.0.1" "source-map-js": "^1.0.1"
@@ -14228,7 +14766,8 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"license": "CC0-1.0", "license": "CC0-1.0",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/swagger-ui-dist": { "node_modules/swagger-ui-dist": {
"version": "5.30.0", "version": "5.30.0",
@@ -14365,6 +14904,16 @@
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tldts": { "node_modules/tldts": {
"version": "6.1.86", "version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
@@ -14513,7 +15062,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -14674,9 +15222,7 @@
"version": "5.9.2", "version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -15306,6 +15852,23 @@
} }
} }
}, },
"node_modules/wsl-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
"integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-wsl": "^3.1.0",
"powershell-utils": "^0.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+1
View File
@@ -37,6 +37,7 @@
"express": "^4.19.2", "express": "^4.19.2",
"express-rate-limit": "8.0.1", "express-rate-limit": "8.0.1",
"gamedig": "^5.3.1", "gamedig": "^5.3.1",
"globalping": "^0.2.0",
"got": "14.4.7", "got": "14.4.7",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^8.0.0", "helmet": "^8.0.0",
+1 -1
View File
@@ -29,7 +29,7 @@ export interface InitializedControllers {
export const initializeControllers = (services: InitializedServices): InitializedControllers => { export const initializeControllers = (services: InitializedServices): InitializedControllers => {
return { return {
authController: new AuthController(services.userService), authController: new AuthController(services.userService),
monitorController: new MonitorController(services.monitorService), monitorController: new MonitorController(services.monitorService, services.settingsService),
settingsController: new SettingsController(services.settingsService, services.emailService), settingsController: new SettingsController(services.settingsService, services.emailService),
checkController: new CheckController(services.checkService), checkController: new CheckController(services.checkService),
inviteController: new InviteController(services.inviteService), inviteController: new InviteController(services.inviteService),
+4
View File
@@ -14,6 +14,7 @@ import {
INotificationsService, INotificationsService,
} from "@/service/index.js"; } from "@/service/index.js";
import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import GlobalpingService from "../service/infrastructure/globalpingService.js";
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/business/userService.js"; import UserService from "../service/business/userService.js";
import CheckService from "../service/business/checkService.js"; import CheckService from "../service/business/checkService.js";
@@ -192,6 +193,8 @@ export const initializeServices = async ({
notificationMessageBuilder notificationMessageBuilder
); );
const globalpingService = new GlobalpingService({ settingsService, logger });
const superSimpleQueueHelper = new SuperSimpleQueueHelper({ const superSimpleQueueHelper = new SuperSimpleQueueHelper({
logger, logger,
networkService, networkService,
@@ -200,6 +203,7 @@ export const initializeServices = async ({
checkService, checkService,
buffer: bufferService, buffer: bufferService,
incidentService, incidentService,
globalpingService,
maintenanceWindowsRepository, maintenanceWindowsRepository,
monitorsRepository, monitorsRepository,
teamsRepository, teamsRepository,
+20 -1
View File
@@ -26,15 +26,19 @@ import {
} from "./controllerUtils.js"; } from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/service/index.js"; import { IMonitorService } from "@/service/index.js";
import { getGlobalpingLocationsByTier, LOCATION_LABELS } from "@/service/infrastructure/globalpingService.js";
import type { ISettingsService } from "@/service/system/settingsService.js";
const SERVICE_NAME = "monitorController"; const SERVICE_NAME = "monitorController";
class MonitorController { class MonitorController {
static SERVICE_NAME = SERVICE_NAME; static SERVICE_NAME = SERVICE_NAME;
private monitorService: IMonitorService; private monitorService: IMonitorService;
private settingsService: ISettingsService;
constructor(monitorService: IMonitorService) { constructor(monitorService: IMonitorService, settingsService: ISettingsService) {
this.monitorService = monitorService; this.monitorService = monitorService;
this.settingsService = settingsService;
} }
get serviceName() { get serviceName() {
@@ -387,6 +391,21 @@ class MonitorController {
} }
}; };
getGlobalpingLocations = async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await this.settingsService.getDBSettings();
const tier = settings.globalpingLocationsTier ?? 6;
const locations = getGlobalpingLocationsByTier(tier);
return res.status(200).json({
success: true,
msg: "Globalping locations retrieved successfully",
data: { locations, labels: LOCATION_LABELS },
});
} catch (error) {
next(error);
}
};
getGroupsByTeamId = async (req: Request, res: Response, next: NextFunction) => { getGroupsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const teamId = req?.user?.teamId; const teamId = req?.user?.teamId;
+18 -9
View File
@@ -21,8 +21,9 @@ class SettingsController {
const sanitizedSettings = { ...dbSettings }; const sanitizedSettings = { ...dbSettings };
delete sanitizedSettings.version; delete sanitizedSettings.version;
delete sanitizedSettings.jwtSecret; delete sanitizedSettings.jwtSecret;
const returnSettings = { const returnSettings: any = {
pagespeedKeySet: false, pagespeedKeySet: false,
globalpingKeySet: false,
emailPasswordSet: false, emailPasswordSet: false,
settings: null, settings: null,
}; };
@@ -31,6 +32,10 @@ class SettingsController {
returnSettings.pagespeedKeySet = true; returnSettings.pagespeedKeySet = true;
delete sanitizedSettings.pagespeedApiKey; delete sanitizedSettings.pagespeedApiKey;
} }
if (typeof sanitizedSettings.globalpingApiKey !== "undefined") {
returnSettings.globalpingKeySet = true;
delete sanitizedSettings.globalpingApiKey;
}
if (typeof sanitizedSettings.systemEmailPassword !== "undefined") { if (typeof sanitizedSettings.systemEmailPassword !== "undefined") {
returnSettings.emailPasswordSet = true; returnSettings.emailPasswordSet = true;
delete sanitizedSettings.systemEmailPassword; delete sanitizedSettings.systemEmailPassword;
@@ -55,15 +60,19 @@ class SettingsController {
}; };
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => { 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 updatedSettings = await this.settingsService.updateDbSettings(req.body);
const returnSettings = this.buildAppSettings(updatedSettings); const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
msg: "App settings updated successfully", msg: "App settings updated successfully",
data: returnSettings, data: returnSettings,
}); });
} catch (error) {
next(error);
}
}; };
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => { sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
+2
View File
@@ -23,6 +23,8 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
language: { type: String, default: "gb" }, language: { type: String, default: "gb" },
jwtSecret: { type: String }, jwtSecret: { type: String },
pagespeedApiKey: { type: String }, pagespeedApiKey: { type: String },
globalpingApiKey: { type: String },
globalpingLocationsTier: { type: Number, default: 6 },
systemEmailHost: { type: String }, systemEmailHost: { type: String },
systemEmailPort: { type: Number }, systemEmailPort: { type: Number },
systemEmailAddress: { type: String }, systemEmailAddress: { type: String },
+21
View File
@@ -13,12 +13,14 @@ import type {
CheckNetworkInterfaceInfo, CheckNetworkInterfaceInfo,
GotTimings, GotTimings,
ILighthouseAudit, ILighthouseAudit,
LocationResult,
} from "@/types/check.js"; } from "@/types/check.js";
type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & { type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
monitorId: Types.ObjectId; monitorId: Types.ObjectId;
teamId: Types.ObjectId; teamId: Types.ObjectId;
type: MonitorType; type: MonitorType;
location?: string;
}; };
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & { type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
@@ -175,6 +177,17 @@ const auditsSchema = new Schema<CheckAudits>(
{ _id: false } { _id: false }
); );
const locationResultSchema = new Schema<LocationResult>(
{
location: { type: String, required: true },
status: { type: Boolean, required: true },
responseTime: { type: Number, required: true },
statusCode: { type: Number, required: true },
message: { type: String, default: "" },
},
{ _id: false }
);
const metadataSchema = new Schema<CheckMetadataDocument>( const metadataSchema = new Schema<CheckMetadataDocument>(
{ {
monitorId: { monitorId: {
@@ -197,6 +210,9 @@ const metadataSchema = new Schema<CheckMetadataDocument>(
required: true, required: true,
index: true, index: true,
}, },
location: {
type: String,
},
}, },
{ _id: false } { _id: false }
); );
@@ -281,6 +297,10 @@ const CheckSchema = new Schema<CheckDocument>(
type: auditsSchema, type: auditsSchema,
default: undefined, default: undefined,
}, },
locationResults: {
type: [locationResultSchema],
default: undefined,
},
}, },
{ {
timestamps: true, timestamps: true,
@@ -299,6 +319,7 @@ CheckSchema.index({ createdAt: 1 });
CheckSchema.index({ "metadata.teamId": 1, createdAt: -1 }); CheckSchema.index({ "metadata.teamId": 1, createdAt: -1 });
CheckSchema.index({ "metadata.monitorId": 1, "metadata.type": 1, createdAt: -1 }); CheckSchema.index({ "metadata.monitorId": 1, "metadata.type": 1, createdAt: -1 });
CheckSchema.index({ "metadata.teamId": 1, status: 1, createdAt: -1 }); CheckSchema.index({ "metadata.teamId": 1, status: 1, createdAt: -1 });
CheckSchema.index({ "metadata.monitorId": 1, "metadata.location": 1, createdAt: -1 });
const CheckModel = model<CheckDocument>("Check", CheckSchema); const CheckModel = model<CheckDocument>("Check", CheckSchema);
+4
View File
@@ -160,6 +160,10 @@ const MonitorSchema = new Schema<MonitorDocument>(
gameId: { gameId: {
type: String, type: String,
}, },
globalpingEnabled: {
type: Boolean,
default: false,
},
group: { group: {
type: String, type: String,
trim: true, trim: true,
@@ -2,6 +2,7 @@ import type {
Check, Check,
ChecksQueryResult, ChecksQueryResult,
ChecksSummary, ChecksSummary,
LocationResult,
MonitorType, MonitorType,
PageSpeedChecksResult, PageSpeedChecksResult,
HardwareChecksResult, HardwareChecksResult,
@@ -35,7 +36,9 @@ export interface IChecksRepository {
options?: { type?: MonitorType } options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>; ): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
findSummaryByTeamId(teamId: string, dateRange: string): Promise<ChecksSummary>; findSummaryByTeamId(teamId: string, dateRange: string): Promise<ChecksSummary>;
findLocationChecksByMonitorId(monitorId: string, startDate: Date, endDate: Date, dateString: string): Promise<Record<string, UptimeChecksResult>>;
// update // update
updateLocationResults(checkId: string, locationResults: LocationResult[]): Promise<void>;
//delete //delete
deleteByMonitorId(monitorId: string): Promise<number>; deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>; deleteByTeamId(teamId: string): Promise<number>;
@@ -11,6 +11,7 @@ import type {
CheckMetadata, CheckMetadata,
CheckNetworkInterfaceInfo, CheckNetworkInterfaceInfo,
GotTimings, GotTimings,
LocationResult,
MonitorType, MonitorType,
} from "@/types/index.js"; } from "@/types/index.js";
import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js"; import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js";
@@ -169,6 +170,7 @@ class MongoChecksRepository implements IChecksRepository {
monitorId: toStringId(metadata.monitorId), monitorId: toStringId(metadata.monitorId),
teamId: toStringId(metadata.teamId), teamId: toStringId(metadata.teamId),
type: metadata.type, type: metadata.type,
...(metadata.location ? { location: metadata.location } : {}),
}); });
return { return {
@@ -219,6 +221,7 @@ class MongoChecksRepository implements IChecksRepository {
monitorId: new mongoose.Types.ObjectId(metadata.monitorId), monitorId: new mongoose.Types.ObjectId(metadata.monitorId),
teamId: new mongoose.Types.ObjectId(metadata.teamId), teamId: new mongoose.Types.ObjectId(metadata.teamId),
type: metadata.type, type: metadata.type,
...(metadata.location ? { location: metadata.location } : {}),
}, },
...rest, ...rest,
} as unknown as CheckDocument; } as unknown as CheckDocument;
@@ -404,6 +407,10 @@ class MongoChecksRepository implements IChecksRepository {
}; };
}; };
updateLocationResults = async (checkId: string, locationResults: LocationResult[]): Promise<void> => {
await CheckModel.updateOne({ _id: new mongoose.Types.ObjectId(checkId) }, { $set: { locationResults } });
};
deleteByMonitorId = async (monitorId: string): Promise<number> => { deleteByMonitorId = async (monitorId: string): Promise<number> => {
const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) }); const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) });
return result.deletedCount; return result.deletedCount;
@@ -420,6 +427,59 @@ class MongoChecksRepository implements IChecksRepository {
return result.deletedCount ?? 0; return result.deletedCount ?? 0;
}; };
findLocationChecksByMonitorId = async (monitorId: string, startDate: Date, endDate: Date, dateString: string): Promise<Record<string, any>> => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
const matchStage = {
"metadata.monitorId": monitorObjectId,
"metadata.location": { $exists: true, $ne: null },
createdAt: { $gte: startDate, $lte: endDate },
};
const results = await CheckModel.aggregate([
{ $match: matchStage },
{ $sort: { createdAt: 1 } },
{
$group: {
_id: {
location: "$metadata.location",
bucket: { $dateToString: { format: dateString, date: "$createdAt" } },
},
avgResponseTime: { $avg: "$responseTime" },
totalChecks: { $sum: 1 },
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
},
},
{ $sort: { "_id.bucket": 1 } },
{
$group: {
_id: "$_id.location",
groupedChecks: {
$push: {
bucketDate: "$_id.bucket",
avgResponseTime: "$avgResponseTime",
totalChecks: "$totalChecks",
},
},
totalUpChecks: { $sum: "$upChecks" },
totalAllChecks: { $sum: "$totalChecks" },
overallAvgResponseTime: { $avg: "$avgResponseTime" },
},
},
]);
const locationChecks: Record<string, any> = {};
for (const loc of results) {
const uptimePercentage = loc.totalAllChecks > 0 ? loc.totalUpChecks / loc.totalAllChecks : 0;
locationChecks[loc._id] = {
groupedChecks: loc.groupedChecks,
uptimePercentage,
avgResponseTime: loc.overallAvgResponseTime ?? 0,
};
}
return locationChecks;
};
private findUptimeDateRangeChecks = async ( private findUptimeDateRangeChecks = async (
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">, monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId, monitorObjectId: mongoose.Types.ObjectId,
@@ -427,9 +487,10 @@ class MongoChecksRepository implements IChecksRepository {
endDate: Date, endDate: Date,
dateString: string dateString: string
) => { ) => {
const matchStage = { const matchStage: Record<string, any> = {
"metadata.monitorId": monitorObjectId, "metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate }, createdAt: { $gte: startDate, $lte: endDate },
"metadata.location": { $exists: false },
}; };
const [result] = await CheckModel.aggregate([ const [result] = await CheckModel.aggregate([
{ $match: matchStage }, { $match: matchStage },
@@ -343,6 +343,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
selectedDisks: doc.selectedDisks ?? [], selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
globalpingEnabled: doc.globalpingEnabled ?? false,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt), createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt), updatedAt: toDateString(doc.updatedAt),
@@ -398,6 +399,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
selectedDisks: doc.selectedDisks ?? [], selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
globalpingEnabled: doc.globalpingEnabled ?? false,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt), createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt), updatedAt: toDateString(doc.updatedAt),
@@ -38,6 +38,8 @@ class MongoSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false, systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true, systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true,
showURL: doc.showURL ?? false, showURL: doc.showURL ?? false,
globalpingApiKey: doc.globalpingApiKey ?? undefined,
globalpingLocationsTier: doc.globalpingLocationsTier ?? 6,
singleton: doc.singleton, singleton: doc.singleton,
version: doc.version ?? 1, version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined, globalThresholds: doc.globalThresholds ?? undefined,
@@ -81,10 +83,6 @@ class MongoSettingsRepository implements ISettingsRepository {
delete update.$unset; delete update.$unset;
} }
await AppSettingsModel.findOneAndUpdate({}, update, {
upsert: true,
});
const updatedSettings = await AppSettingsModel.findOneAndUpdate({}, update, { const updatedSettings = await AppSettingsModel.findOneAndUpdate({}, update, {
upsert: true, upsert: true,
new: true, new: true,
+3
View File
@@ -21,6 +21,9 @@ class MonitorRoutes {
this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId); this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId);
this.router.get("/team/groups", this.monitorController.getGroupsByTeamId); this.router.get("/team/groups", this.monitorController.getGroupsByTeamId);
// Globalping locations
this.router.get("/globalping/locations", this.monitorController.getGlobalpingLocations);
// Uptime routes // Uptime routes
this.router.get("/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById); this.router.get("/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById);
+2 -1
View File
@@ -37,7 +37,7 @@ class CheckService {
}; };
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => { buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse; const { monitorId, teamId, type, status, responseTime, code, message, payload, timings, location } = statusResponse;
const check: Partial<Check> = { const check: Partial<Check> = {
id: new Types.ObjectId().toString(), id: new Types.ObjectId().toString(),
@@ -46,6 +46,7 @@ class CheckService {
monitorId, monitorId,
teamId, teamId,
type, type,
...(location ? { location } : {}),
}, },
status, status,
statusCode: code, statusCode: code,
@@ -6,6 +6,7 @@ import type {
UptimeDetailsResult, UptimeDetailsResult,
HardwareDetailsResult, HardwareDetailsResult,
PageSpeedDetailsResult, PageSpeedDetailsResult,
LocationCheckData,
GamesMap, GamesMap,
} from "@/types/monitor.js"; } from "@/types/monitor.js";
import type { import type {
@@ -222,6 +223,15 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 }); throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
} }
// Fetch location checks if Globalping is enabled for this monitor
let locationChecks: Record<string, LocationCheckData> | undefined;
if (monitor.globalpingEnabled && (monitor.type === "http" || monitor.type === "ping")) {
const locData = await this.checksRepository.findLocationChecksByMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey));
if (Object.keys(locData).length > 0) {
locationChecks = locData;
}
}
return { return {
monitorData: { monitorData: {
monitor, monitor,
@@ -230,6 +240,7 @@ export class MonitorService implements IMonitorService {
groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100), groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100),
groupedAvgResponseTime: checksData.avgResponseTime, groupedAvgResponseTime: checksData.avgResponseTime,
groupedUptimePercentage: checksData.uptimePercentage, groupedUptimePercentage: checksData.uptimePercentage,
...(locationChecks ? { locationChecks } : {}),
}, },
monitorStats, monitorStats,
}; };
@@ -4,6 +4,7 @@ import { AppError } from "@/utils/AppError.js";
import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js";
import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js"; import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js";
import IncidentService from "@/service/business/incidentService.js"; import IncidentService from "@/service/business/incidentService.js";
import type GlobalpingService from "@/service/infrastructure/globalpingService.js";
import { import {
IMaintenanceWindowsRepository, IMaintenanceWindowsRepository,
IMonitorsRepository, IMonitorsRepository,
@@ -37,6 +38,7 @@ class SuperSimpleQueueHelper {
private checkService: any; private checkService: any;
private buffer: any; private buffer: any;
private incidentService: IncidentService; private incidentService: IncidentService;
private globalpingService: GlobalpingService;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository; private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
private monitorsRepository: IMonitorsRepository; private monitorsRepository: IMonitorsRepository;
private teamsRepository: ITeamsRepository; private teamsRepository: ITeamsRepository;
@@ -52,6 +54,7 @@ class SuperSimpleQueueHelper {
checkService, checkService,
buffer, buffer,
incidentService, incidentService,
globalpingService,
maintenanceWindowsRepository, maintenanceWindowsRepository,
monitorsRepository, monitorsRepository,
teamsRepository, teamsRepository,
@@ -66,6 +69,7 @@ class SuperSimpleQueueHelper {
checkService: any; checkService: any;
buffer: any; buffer: any;
incidentService: IncidentService; incidentService: IncidentService;
globalpingService: GlobalpingService;
maintenanceWindowsRepository: IMaintenanceWindowsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository;
monitorsRepository: IMonitorsRepository; monitorsRepository: IMonitorsRepository;
teamsRepository: ITeamsRepository; teamsRepository: ITeamsRepository;
@@ -80,6 +84,7 @@ class SuperSimpleQueueHelper {
this.buffer = buffer; this.buffer = buffer;
this.notificationsService = notificationsService; this.notificationsService = notificationsService;
this.incidentService = incidentService; this.incidentService = incidentService;
this.globalpingService = globalpingService;
this.maintenanceWindowsRepository = maintenanceWindowsRepository; this.maintenanceWindowsRepository = maintenanceWindowsRepository;
this.monitorsRepository = monitorsRepository; this.monitorsRepository = monitorsRepository;
this.teamsRepository = teamsRepository; this.teamsRepository = teamsRepository;
@@ -122,7 +127,34 @@ class SuperSimpleQueueHelper {
throw new Error("No network response"); throw new Error("No network response");
} }
// Step 3. Build check // Step 2b. Run Globalping checks (fire-and-forget, doesn't affect local check pipeline)
if (monitor.globalpingEnabled && (monitor.type === "http" || monitor.type === "ping")) {
this.globalpingService
.runChecks(monitor)
.then((locationStatuses) => {
for (const locStatus of locationStatuses) {
const locCheck = this.checkService.buildCheck(locStatus);
if (locCheck) {
this.buffer.addToBuffer(locCheck);
} else {
this.logger.warn({
message: `Failed to build Globalping check for monitor ${monitorId}, location: ${locStatus.location}`,
service: SERVICE_NAME,
method: "getMonitorJob",
});
}
}
})
.catch((err: any) => {
this.logger.warn({
message: `Globalping checks failed for monitor ${monitorId}: ${err.message}`,
service: SERVICE_NAME,
method: "getMonitorJob",
});
});
}
// Step 3. Build check
const check = await this.checkService.buildCheck(status); const check = await this.checkService.buildCheck(status);
if (!check) { if (!check) {
this.logger.warn({ this.logger.warn({
@@ -133,15 +165,15 @@ class SuperSimpleQueueHelper {
}); });
return; return;
} }
// Step 4 Add check to buffer // Step 4. Add check to buffer
this.buffer.addToBuffer(check); this.buffer.addToBuffer(check);
// Step 4. Update monitor status // Step 5. Update monitor status
const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); const statusChangeResult = await this.statusService.updateMonitorStatus(status, check);
// Step 5. Get decisions // Step 6. Get decisions
const decision = this.evaluateMonitorAction(statusChangeResult); const decision = this.evaluateMonitorAction(statusChangeResult);
// Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) // Step 7. Handle notifications (best effort, continue even in event of failure, don't wait)
if (decision.shouldSendNotification) { if (decision.shouldSendNotification) {
this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => { this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => {
this.logger.error({ this.logger.error({
@@ -154,7 +186,7 @@ class SuperSimpleQueueHelper {
}); });
} }
// Step 7. Handle incidents (best effort, don't wait) // Step 8. Handle incidents (best effort, don't wait)
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => { this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: any) => {
this.logger.warn({ this.logger.warn({
message: error.message, message: error.message,
@@ -5,6 +5,7 @@ const SERVICE_NAME = "BufferService";
export interface IBufferService { export interface IBufferService {
addToBuffer(check: Check): void; addToBuffer(check: Check): void;
removeCheckFromBuffer(check: Check): boolean; removeCheckFromBuffer(check: Check): boolean;
findInBuffer(checkId: string): Check | undefined;
scheduleNextFlush(): void; scheduleNextFlush(): void;
flushBuffer(): Promise<void>; flushBuffer(): Promise<void>;
} }
@@ -49,6 +50,10 @@ class BufferService implements IBufferService {
} }
} }
findInBuffer(checkId: string): Check | undefined {
return this.buffer.find((c: Check) => c.id?.toString() === checkId);
}
removeCheckFromBuffer(checkToRemove: Check) { removeCheckFromBuffer(checkToRemove: Check) {
try { try {
if (!checkToRemove) { if (!checkToRemove) {
@@ -0,0 +1,302 @@
import { Globalping } from "globalping";
import type { ISettingsService } from "@/service/system/settingsService.js";
import type { Monitor } from "@/types/monitor.js";
import type { GlobalpingCheckResult } from "@/types/network.js";
const SERVICE_NAME = "GlobalpingService";
export interface GlobalpingLocationOption {
id: string;
label: string;
}
// Maps location IDs to Globalping API location objects
const LOCATION_MAP: Record<string, Record<string, string>> = {
"north-america": { continent: "NA" },
europe: { continent: "EU" },
asia: { continent: "AS" },
"south-america": { continent: "SA" },
oceania: { continent: "OC" },
africa: { continent: "AF" },
"us-east": { country: "US", state: "VA" },
"us-west": { country: "US", state: "CA" },
canada: { country: "CA", city: "Toronto" },
uk: { country: "GB", city: "London" },
germany: { country: "DE", city: "Frankfurt" },
netherlands: { country: "NL", city: "Amsterdam" },
poland: { country: "PL", city: "Warsaw" },
japan: { country: "JP", city: "Tokyo" },
singapore: { country: "SG", city: "Singapore" },
india: { country: "IN", city: "Mumbai" },
australia: { country: "AU", city: "Sydney" },
brazil: { country: "BR" },
chile: { country: "CL", city: "Santiago" },
"south-africa": { country: "ZA", city: "Johannesburg" },
uae: { country: "AE", city: "Dubai" },
};
const LOCATIONS_TIER_3: GlobalpingLocationOption[] = [
{ id: "north-america", label: "North America" },
{ id: "europe", label: "Europe" },
{ id: "asia", label: "Asia" },
];
const LOCATIONS_TIER_6: GlobalpingLocationOption[] = [
...LOCATIONS_TIER_3,
{ id: "south-america", label: "South America" },
{ id: "oceania", label: "Oceania" },
{ id: "africa", label: "Africa" },
];
const LOCATIONS_TIER_15: GlobalpingLocationOption[] = [
{ id: "us-east", label: "US East (Virginia)" },
{ id: "us-west", label: "US West (California)" },
{ id: "canada", label: "Canada (Toronto)" },
{ id: "uk", label: "UK (London)" },
{ id: "germany", label: "Germany (Frankfurt)" },
{ id: "netherlands", label: "Netherlands (Amsterdam)" },
{ id: "poland", label: "Poland (Warsaw)" },
{ id: "japan", label: "Japan (Tokyo)" },
{ id: "singapore", label: "Singapore" },
{ id: "india", label: "India (Mumbai)" },
{ id: "australia", label: "Australia (Sydney)" },
{ id: "brazil", label: "Brazil" },
{ id: "chile", label: "Chile (Santiago)" },
{ id: "south-africa", label: "South Africa (Johannesburg)" },
{ id: "uae", label: "UAE (Dubai)" },
];
const LOCATIONS_BY_TIER: Record<number, GlobalpingLocationOption[]> = {
3: LOCATIONS_TIER_3,
6: LOCATIONS_TIER_6,
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;
}
}
const HOSTNAME_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
export const getGlobalpingLocationsByTier = (tier: number): GlobalpingLocationOption[] => {
return LOCATIONS_BY_TIER[tier] ?? LOCATIONS_TIER_6;
};
class GlobalpingService {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
private settingsService: ISettingsService;
private client: Globalping<false> | null = null;
private lastApiKey: string | undefined;
private inFlightChecks = new Set<string>();
constructor({ settingsService, logger }: { settingsService: ISettingsService; logger: any }) {
this.settingsService = settingsService;
this.logger = logger;
}
get serviceName() {
return GlobalpingService.SERVICE_NAME;
}
/**
* Reads settings once, returns both the SDK client and configured location IDs.
* Returns null client if the API key is not configured.
*/
private async getClientAndLocations(): Promise<{
client: Globalping<false> | null;
locationIds: string[];
}> {
const dbSettings = await this.settingsService.getDBSettings();
const apiKey = dbSettings.globalpingApiKey;
if (!apiKey) {
return { client: null, locationIds: [] };
}
if (!this.client || this.lastApiKey !== apiKey) {
this.client = new Globalping({ auth: apiKey, throwApiErrors: false, timeout: 30000 });
this.lastApiKey = apiKey;
}
const tier = dbSettings.globalpingLocationsTier ?? 6;
const locationIds = getGlobalpingLocationsByTier(tier).map((loc) => loc.id);
return { client: this.client, locationIds };
}
async runChecks(monitor: Monitor): Promise<GlobalpingCheckResult[]> {
if (monitor.type !== "http" && monitor.type !== "ping") {
return [];
}
// Prevent overlapping checks for the same monitor
if (this.inFlightChecks.has(monitor.id)) {
return [];
}
this.inFlightChecks.add(monitor.id);
try {
return await this.executeChecks(monitor);
} finally {
this.inFlightChecks.delete(monitor.id);
}
}
private async executeChecks(monitor: Monitor): Promise<GlobalpingCheckResult[]> {
const { client, locationIds } = await this.getClientAndLocations();
if (!client || locationIds.length === 0) {
return [];
}
const measurementType = monitor.type === "ping" ? "ping" : "http";
const monitorUrl = monitor.url ?? "";
let target: string;
try {
if (measurementType === "http") {
const url = new URL(monitorUrl);
target = url.hostname;
} else {
target = monitorUrl.replace(/^https?:\/\//, "").split("/")[0] ?? monitorUrl;
}
} catch {
target = monitorUrl;
}
if (!target || !HOSTNAME_REGEX.test(target)) {
this.logger.warn({
message: `Invalid target hostname for Globalping: "${target}"`,
service: SERVICE_NAME,
method: "executeChecks",
monitorId: monitor.id,
});
return [];
}
const locations = locationIds.map((loc) => LOCATION_MAP[loc]).filter(Boolean);
if (locations.length === 0) {
return [];
}
try {
const createResult =
measurementType === "http"
? await client.createMeasurement({
type: "http" as const,
target,
locations: locations as any,
measurementOptions: { request: { method: "HEAD" }, protocol: "HTTPS" },
})
: await client.createMeasurement({
type: "ping" as const,
target,
locations: locations as any,
});
if (!createResult.ok) {
this.logger.warn({
message: "Failed to create Globalping measurement",
service: SERVICE_NAME,
method: "executeChecks",
details: JSON.stringify(createResult.data),
});
return [];
}
const measurementId = (createResult.data as any).id;
const awaitResult = await client.awaitMeasurement(measurementId);
if (!awaitResult.ok) {
this.logger.warn({
message: "Failed to await Globalping measurement",
service: SERVICE_NAME,
method: "executeChecks",
details: JSON.stringify(awaitResult.data),
});
return [];
}
const measurement = awaitResult.data as any;
const results: GlobalpingCheckResult[] = [];
for (const item of measurement.results || []) {
const probe = item.probe;
const result = item.result;
const locationId = this.resolveLocationId(probe?.continent, probe?.country, locationIds);
if (measurementType === "http") {
results.push({
location: locationId,
status: result.statusCode >= 200 && result.statusCode < 400,
statusCode: result.statusCode || 0,
message: result.statusCodeName || result.rawOutput || "",
responseTime: result.timings?.total ?? 0,
});
} else {
// Ping: loss < 100% is considered up (consistent with local ping checks)
const stats = result.stats;
results.push({
location: locationId,
status: stats?.loss !== undefined ? stats.loss < 100 : false,
statusCode: stats?.loss === 0 ? 200 : 5000,
message: stats?.loss === 0 ? "Ping successful" : `Packet loss: ${stats?.loss}%`,
responseTime: stats?.avg ?? 0,
});
}
}
return results;
} catch (error: any) {
this.logger.warn({
message: `Globalping check failed: ${error.message}`,
service: SERVICE_NAME,
method: "executeChecks",
stack: error.stack,
});
return [];
}
}
/**
* 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";
}
// 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";
}
}
export { LOCATION_LABELS };
export default GlobalpingService;
+10
View File
@@ -6,6 +6,15 @@ export interface CheckMetadata {
monitorId: string; monitorId: string;
teamId: string; teamId: string;
type: MonitorType; type: MonitorType;
location?: string;
}
export interface LocationResult {
location: string;
status: boolean;
responseTime: number;
statusCode: number;
message: string;
} }
export interface CheckCpuInfo { export interface CheckCpuInfo {
@@ -113,6 +122,7 @@ export interface Check {
seo?: number; seo?: number;
performance?: number; performance?: number;
audits?: CheckAudits; audits?: CheckAudits;
locationResults?: LocationResult[];
__v: number; __v: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
+8
View File
@@ -43,6 +43,7 @@ export interface Monitor {
selectedDisks: string[]; selectedDisks: string[];
gameId?: string; gameId?: string;
group: string | null; group: string | null;
globalpingEnabled: boolean;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -64,6 +65,12 @@ export interface MonitorsWithChecksByTeamIdResult {
monitors: Monitor[]; monitors: Monitor[];
} }
export interface LocationCheckData {
groupedChecks: import("./check.js").GroupedCheck[];
uptimePercentage: number;
avgResponseTime: number;
}
export interface UptimeDetailsResult { export interface UptimeDetailsResult {
monitorData: { monitorData: {
monitor: Monitor; monitor: Monitor;
@@ -72,6 +79,7 @@ export interface UptimeDetailsResult {
groupedDownChecks: import("./check.js").GroupedCheck[]; groupedDownChecks: import("./check.js").GroupedCheck[];
groupedAvgResponseTime: number; groupedAvgResponseTime: number;
groupedUptimePercentage: number; groupedUptimePercentage: number;
locationChecks?: Record<string, LocationCheckData>;
}; };
monitorStats: import("./monitorStats.js").MonitorStats | null; monitorStats: import("./monitorStats.js").MonitorStats | null;
} }
+9
View File
@@ -34,6 +34,15 @@ export interface MonitorStatusResponse<T = any> {
matchMethod?: MonitorMatchMethod; matchMethod?: MonitorMatchMethod;
expectedValue?: string; expectedValue?: string;
extracted?: unknown; extracted?: unknown;
location?: string;
}
export interface GlobalpingCheckResult {
location: string;
status: boolean;
responseTime: number;
statusCode: number;
message: string;
} }
export interface PingStatusPayload { export interface PingStatusPayload {
+2
View File
@@ -24,6 +24,8 @@ export interface Settings {
systemEmailRequireTLS: boolean; systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean; systemEmailRejectUnauthorized: boolean;
showURL: boolean; showURL: boolean;
globalpingApiKey?: string;
globalpingLocationsTier?: number;
singleton: boolean; singleton: boolean;
version: number; version: number;
globalThresholds?: SettingsThresholds; globalThresholds?: SettingsThresholds;
+4
View File
@@ -172,6 +172,7 @@ const createMonitorBodyValidation = joi.object({
gameId: joi.string().allow(""), gameId: joi.string().allow(""),
selectedDisks: joi.array().items(joi.string()).optional(), selectedDisks: joi.array().items(joi.string()).optional(),
group: joi.string().max(50).trim().allow(null, "").optional(), group: joi.string().max(50).trim().allow(null, "").optional(),
globalpingEnabled: joi.boolean().optional(),
}); });
const createMonitorsBodyValidation = joi.array().items( const createMonitorsBodyValidation = joi.array().items(
@@ -203,6 +204,7 @@ const editMonitorBodyValidation = joi
gameId: joi.string().allow(""), gameId: joi.string().allow(""),
selectedDisks: joi.array().items(joi.string()).optional(), selectedDisks: joi.array().items(joi.string()).optional(),
group: joi.string().max(50).trim().allow(null, "").optional(), group: joi.string().max(50).trim().allow(null, "").optional(),
globalpingEnabled: joi.boolean().optional(),
}) })
.options({ stripUnknown: true }); .options({ stripUnknown: true });
@@ -428,6 +430,8 @@ const updateAppSettingsBodyValidation = joi.object({
systemEmailRequireTLS: joi.boolean(), systemEmailRequireTLS: joi.boolean(),
systemEmailRejectUnauthorized: joi.boolean(), systemEmailRejectUnauthorized: joi.boolean(),
globalpingApiKey: joi.string().allow(""),
globalpingLocationsTier: joi.number().valid(3, 6, 15).optional(),
globalThresholds: joi globalThresholds: joi
.object({ .object({
cpu: joi.number().min(1).max(100).allow(""), cpu: joi.number().min(1).max(100).allow(""),