diff --git a/Common/Server/Services/MonitorService.ts b/Common/Server/Services/MonitorService.ts index f18c718fed..74d2367c9c 100644 --- a/Common/Server/Services/MonitorService.ts +++ b/Common/Server/Services/MonitorService.ts @@ -135,6 +135,15 @@ export class Service extends DatabaseService { monitorDestination = `${monitorDestination} @${firstStep.data.dnsMonitor.hostname}`; } } + + // For External Status Page monitors, use the statusPageUrl + if ( + monitorType === MonitorType.ExternalStatusPage && + firstStep?.data?.externalStatusPageMonitor + ) { + monitorDestination = + firstStep.data.externalStatusPageMonitor.statusPageUrl || ""; + } } } diff --git a/Common/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts b/Common/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts new file mode 100644 index 0000000000..1b9e27ad81 --- /dev/null +++ b/Common/Server/Utils/Monitor/Criteria/ExternalStatusPageMonitorCriteria.ts @@ -0,0 +1,163 @@ +import DataToProcess from "../DataToProcess"; +import CompareCriteria from "./CompareCriteria"; +import { + CheckOn, + CriteriaFilter, + FilterType, +} from "../../../../Types/Monitor/CriteriaFilter"; +import ExternalStatusPageMonitorResponse from "../../../../Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse"; +import ProbeMonitorResponse from "../../../../Types/Probe/ProbeMonitorResponse"; +import EvaluateOverTime from "./EvaluateOverTime"; +import CaptureSpan from "../../Telemetry/CaptureSpan"; +import logger from "../../Logger"; + +export default class ExternalStatusPageMonitorCriteria { + @CaptureSpan() + public static async isMonitorInstanceCriteriaFilterMet(input: { + dataToProcess: DataToProcess; + criteriaFilter: CriteriaFilter; + }): Promise { + let threshold: number | string | undefined | null = + input.criteriaFilter.value; + + const dataToProcess: ProbeMonitorResponse = + input.dataToProcess as ProbeMonitorResponse; + + const externalStatusPageResponse: + | ExternalStatusPageMonitorResponse + | undefined = dataToProcess.externalStatusPageResponse; + + let overTimeValue: Array | number | boolean | undefined = + undefined; + + if ( + input.criteriaFilter.evaluateOverTime && + input.criteriaFilter.evaluateOverTimeOptions + ) { + try { + overTimeValue = await EvaluateOverTime.getValueOverTime({ + projectId: (input.dataToProcess as ProbeMonitorResponse).projectId, + monitorId: input.dataToProcess.monitorId!, + evaluateOverTimeOptions: input.criteriaFilter.evaluateOverTimeOptions, + metricType: input.criteriaFilter.checkOn, + }); + + if (Array.isArray(overTimeValue) && overTimeValue.length === 0) { + overTimeValue = undefined; + } + } catch (err) { + logger.error( + `Error in getting over time value for ${input.criteriaFilter.checkOn}`, + ); + logger.error(err); + overTimeValue = undefined; + } + } + + // Check if external status page is online + if ( + input.criteriaFilter.checkOn === CheckOn.ExternalStatusPageIsOnline + ) { + const currentIsOnline: boolean | Array = + (overTimeValue as Array) || + (input.dataToProcess as ProbeMonitorResponse).isOnline; + + return CompareCriteria.compareCriteriaBoolean({ + value: currentIsOnline, + criteriaFilter: input.criteriaFilter, + }); + } + + // Check external status page response time + if ( + input.criteriaFilter.checkOn === CheckOn.ExternalStatusPageResponseTime + ) { + threshold = CompareCriteria.convertToNumber(threshold); + + if (threshold === null || threshold === undefined) { + return null; + } + + const currentResponseTime: number | Array = + (overTimeValue as Array) || + externalStatusPageResponse?.responseTimeInMs || + (input.dataToProcess as ProbeMonitorResponse).responseTimeInMs; + + if (currentResponseTime === null || currentResponseTime === undefined) { + return null; + } + + return CompareCriteria.compareCriteriaNumbers({ + value: currentResponseTime, + threshold: threshold as number, + criteriaFilter: input.criteriaFilter, + }); + } + + // Check overall status + if ( + input.criteriaFilter.checkOn === CheckOn.ExternalStatusPageOverallStatus + ) { + if (!externalStatusPageResponse?.overallStatus) { + return null; + } + + return CompareCriteria.compareCriteriaStrings({ + value: externalStatusPageResponse.overallStatus, + threshold: String(threshold), + criteriaFilter: input.criteriaFilter, + }); + } + + // Check component status + if ( + input.criteriaFilter.checkOn === + CheckOn.ExternalStatusPageComponentStatus + ) { + if ( + !externalStatusPageResponse?.componentStatuses || + externalStatusPageResponse.componentStatuses.length === 0 + ) { + return null; + } + + // Check if any component status matches the criteria + for (const component of externalStatusPageResponse.componentStatuses) { + const result: string | null = CompareCriteria.compareCriteriaStrings({ + value: component.status, + threshold: String(threshold), + criteriaFilter: input.criteriaFilter, + }); + + if (result) { + return `Component "${component.name}": ${result}`; + } + } + + return null; + } + + // Check active incidents count + if ( + input.criteriaFilter.checkOn === + CheckOn.ExternalStatusPageActiveIncidents + ) { + threshold = CompareCriteria.convertToNumber(threshold); + + if (threshold === null || threshold === undefined) { + return null; + } + + const activeIncidents: number = + externalStatusPageResponse?.activeIncidentCount || 0; + + return CompareCriteria.compareCriteriaNumbers({ + value: activeIncidents, + threshold: threshold as number, + criteriaFilter: input.criteriaFilter, + }); + } + + return null; + } +} diff --git a/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts b/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts index 0f437d5632..d2b9de0ac9 100644 --- a/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +++ b/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts @@ -14,6 +14,7 @@ import ExceptionMonitorCriteria from "./Criteria/ExceptionMonitorCriteria"; import SnmpMonitorCriteria from "./Criteria/SnmpMonitorCriteria"; import DnsMonitorCriteria from "./Criteria/DnsMonitorCriteria"; import DomainMonitorCriteria from "./Criteria/DomainMonitorCriteria"; +import ExternalStatusPageMonitorCriteria from "./Criteria/ExternalStatusPageMonitorCriteria"; import MonitorCriteriaMessageBuilder from "./MonitorCriteriaMessageBuilder"; import MonitorCriteriaDataExtractor from "./MonitorCriteriaDataExtractor"; import MonitorCriteriaMessageFormatter from "./MonitorCriteriaMessageFormatter"; @@ -519,6 +520,20 @@ ${contextBlock} } } + if (input.monitor.monitorType === MonitorType.ExternalStatusPage) { + const externalStatusPageResult: string | null = + await ExternalStatusPageMonitorCriteria.isMonitorInstanceCriteriaFilterMet( + { + dataToProcess: input.dataToProcess, + criteriaFilter: input.criteriaFilter, + }, + ); + + if (externalStatusPageResult) { + return externalStatusPageResult; + } + } + return null; } diff --git a/Common/Server/Utils/Monitor/MonitorTemplateUtil.ts b/Common/Server/Utils/Monitor/MonitorTemplateUtil.ts index 1fd9782eea..1539876aa7 100644 --- a/Common/Server/Utils/Monitor/MonitorTemplateUtil.ts +++ b/Common/Server/Utils/Monitor/MonitorTemplateUtil.ts @@ -18,6 +18,9 @@ import DnsMonitorResponse, { DnsRecordResponse, } from "../../../Types/Monitor/DnsMonitor/DnsMonitorResponse"; import DomainMonitorResponse from "../../../Types/Monitor/DomainMonitor/DomainMonitorResponse"; +import ExternalStatusPageMonitorResponse, { + ExternalStatusPageComponentStatus, +} from "../../../Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse"; import Typeof from "../../../Types/Typeof"; import VMUtil from "../VM/VMAPI"; import DataToProcess from "./DataToProcess"; @@ -298,6 +301,36 @@ export default class MonitorTemplateUtil { dnssec: domainResponse?.dnssec, } as JSONObject; } + + if (data.monitorType === MonitorType.ExternalStatusPage) { + const externalStatusPageResponse: + | ExternalStatusPageMonitorResponse + | undefined = (data.dataToProcess as ProbeMonitorResponse) + .externalStatusPageResponse; + + storageMap = { + isOnline: (data.dataToProcess as ProbeMonitorResponse).isOnline, + responseTimeInMs: externalStatusPageResponse?.responseTimeInMs, + failureCause: externalStatusPageResponse?.failureCause, + overallStatus: externalStatusPageResponse?.overallStatus, + activeIncidentCount: + externalStatusPageResponse?.activeIncidentCount, + } as JSONObject; + + // Add component statuses + if (externalStatusPageResponse?.componentStatuses) { + storageMap["componentStatuses"] = + externalStatusPageResponse.componentStatuses.map( + (component: ExternalStatusPageComponentStatus) => { + return { + name: component.name, + status: component.status, + description: component.description, + }; + }, + ); + } + } } catch (err) { logger.error(err); } diff --git a/Common/Types/Monitor/CriteriaFilter.ts b/Common/Types/Monitor/CriteriaFilter.ts index 1fbca4cf24..aed1420922 100644 --- a/Common/Types/Monitor/CriteriaFilter.ts +++ b/Common/Types/Monitor/CriteriaFilter.ts @@ -74,6 +74,13 @@ export enum CheckOn { DomainNameServer = "Domain Name Server", DomainStatusCode = "Domain Status Code", DomainIsExpired = "Domain Is Expired", + + // External Status Page monitors. + ExternalStatusPageIsOnline = "External Status Page Is Online", + ExternalStatusPageOverallStatus = "External Status Page Overall Status", + ExternalStatusPageComponentStatus = "External Status Page Component Status", + ExternalStatusPageActiveIncidents = "External Status Page Active Incidents", + ExternalStatusPageResponseTime = "External Status Page Response Time (in ms)", } export interface ServerMonitorOptions { @@ -159,7 +166,8 @@ export class CriteriaFilterUtil { checkOn === CheckOn.IsOnline || checkOn === CheckOn.SnmpIsOnline || checkOn === CheckOn.DnsIsOnline || - checkOn === CheckOn.DomainIsExpired + checkOn === CheckOn.DomainIsExpired || + checkOn === CheckOn.ExternalStatusPageIsOnline ) { return false; } @@ -229,7 +237,9 @@ export class CriteriaFilterUtil { checkOn === CheckOn.SnmpResponseTime || checkOn === CheckOn.SnmpIsOnline || checkOn === CheckOn.DnsResponseTime || - checkOn === CheckOn.DnsIsOnline + checkOn === CheckOn.DnsIsOnline || + checkOn === CheckOn.ExternalStatusPageResponseTime || + checkOn === CheckOn.ExternalStatusPageIsOnline ); } } diff --git a/Common/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse.ts b/Common/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse.ts new file mode 100644 index 0000000000..b790f12d02 --- /dev/null +++ b/Common/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse.ts @@ -0,0 +1,16 @@ +export interface ExternalStatusPageComponentStatus { + name: string; + status: string; + description?: string | undefined; +} + +export default interface ExternalStatusPageMonitorResponse { + isOnline: boolean; + overallStatus: string; + componentStatuses: Array; + activeIncidentCount: number; + responseTimeInMs: number; + failureCause: string; + rawBody?: string | undefined; + isTimeout?: boolean | undefined; +} diff --git a/Common/Types/Monitor/ExternalStatusPageProviderType.ts b/Common/Types/Monitor/ExternalStatusPageProviderType.ts new file mode 100644 index 0000000000..fc8646201f --- /dev/null +++ b/Common/Types/Monitor/ExternalStatusPageProviderType.ts @@ -0,0 +1,8 @@ +enum ExternalStatusPageProviderType { + AtlassianStatuspage = "Atlassian Statuspage", + RSS = "RSS", + Atom = "Atom", + Auto = "Auto", +} + +export default ExternalStatusPageProviderType; diff --git a/Common/Types/Monitor/MonitorCriteriaInstance.ts b/Common/Types/Monitor/MonitorCriteriaInstance.ts index 05f02679b9..a00609803a 100644 --- a/Common/Types/Monitor/MonitorCriteriaInstance.ts +++ b/Common/Types/Monitor/MonitorCriteriaInstance.ts @@ -448,10 +448,35 @@ export default class MonitorCriteriaInstance extends DatabaseProperty { return monitorCriteriaInstance; } - return null; - } + if (arg.monitorType === MonitorType.ExternalStatusPage) { + const monitorCriteriaInstance: MonitorCriteriaInstance = + new MonitorCriteriaInstance(); - public static getDefaultOfflineMonitorCriteriaInstance(arg: { + monitorCriteriaInstance.data = { + id: ObjectID.generate().toString(), + monitorStatusId: arg.monitorStatusId, + filterCondition: FilterCondition.All, + filters: [ + { + checkOn: CheckOn.ExternalStatusPageIsOnline, + filterType: FilterType.True, + value: undefined, + }, + ], + incidents: [], + alerts: [], + createAlerts: false, + changeMonitorStatus: true, + createIncidents: false, + name: `Check if ${arg.monitorName} is online`, + description: `This criteria checks if the ${arg.monitorName} external status page is reachable`, + }; + + return monitorCriteriaInstance; + } + + return null; + }(arg: { monitorType: MonitorType; monitorStatusId: ObjectID; incidentSeverityId: ObjectID; @@ -629,9 +654,48 @@ export default class MonitorCriteriaInstance extends DatabaseProperty { }; } + if (arg.monitorType === MonitorType.ExternalStatusPage) { + monitorCriteriaInstance.data = { + id: ObjectID.generate().toString(), + monitorStatusId: arg.monitorStatusId, + filterCondition: FilterCondition.Any, + filters: [ + { + checkOn: CheckOn.ExternalStatusPageIsOnline, + filterType: FilterType.False, + value: undefined, + }, + ], + incidents: [ + { + title: `${arg.monitorName} is offline`, + description: `${arg.monitorName} external status page is currently unreachable.`, + incidentSeverityId: arg.incidentSeverityId, + autoResolveIncident: true, + id: ObjectID.generate().toString(), + onCallPolicyIds: [], + }, + ], + changeMonitorStatus: true, + createIncidents: true, + createAlerts: false, + alerts: [ + { + title: `${arg.monitorName} is offline`, + description: `${arg.monitorName} external status page is currently unreachable.`, + alertSeverityId: arg.alertSeverityId, + autoResolveAlert: true, + id: ObjectID.generate().toString(), + onCallPolicyIds: [], + }, + ], + name: `Check if ${arg.monitorName} is offline`, + description: `This criteria checks if the ${arg.monitorName} external status page is unreachable`, + }; + } + if ( arg.monitorType === MonitorType.API || - arg.monitorType === MonitorType.Website ) { monitorCriteriaInstance.data = { id: ObjectID.generate().toString(), diff --git a/Common/Types/Monitor/MonitorStep.ts b/Common/Types/Monitor/MonitorStep.ts index eb9298f0f3..ae6e8504e3 100644 --- a/Common/Types/Monitor/MonitorStep.ts +++ b/Common/Types/Monitor/MonitorStep.ts @@ -35,6 +35,9 @@ import MonitorStepDnsMonitor, { import MonitorStepDomainMonitor, { MonitorStepDomainMonitorUtil, } from "./MonitorStepDomainMonitor"; +import MonitorStepExternalStatusPageMonitor, { + MonitorStepExternalStatusPageMonitorUtil, +} from "./MonitorStepExternalStatusPageMonitor"; import Zod, { ZodSchema } from "../../Utils/Schema/Zod"; export interface MonitorStepType { @@ -84,6 +87,9 @@ export interface MonitorStepType { // Domain monitor domainMonitor?: MonitorStepDomainMonitor | undefined; + + // External Status Page monitor + externalStatusPageMonitor?: MonitorStepExternalStatusPageMonitor | undefined; } export default class MonitorStep extends DatabaseProperty { @@ -112,6 +118,7 @@ export default class MonitorStep extends DatabaseProperty { snmpMonitor: undefined, dnsMonitor: undefined, domainMonitor: undefined, + externalStatusPageMonitor: undefined, }; } @@ -145,6 +152,7 @@ export default class MonitorStep extends DatabaseProperty { snmpMonitor: undefined, dnsMonitor: undefined, domainMonitor: undefined, + externalStatusPageMonitor: undefined, }; return monitorStep; @@ -252,6 +260,13 @@ export default class MonitorStep extends DatabaseProperty { return this; } + public setExternalStatusPageMonitor( + externalStatusPageMonitor: MonitorStepExternalStatusPageMonitor, + ): MonitorStep { + this.data!.externalStatusPageMonitor = externalStatusPageMonitor; + return this; + } + public setCustomCode(customCode: string): MonitorStep { this.data!.customCode = customCode; return this; @@ -380,6 +395,16 @@ export default class MonitorStep extends DatabaseProperty { } } + if (monitorType === MonitorType.ExternalStatusPage) { + if (!value.data.externalStatusPageMonitor) { + return "External status page configuration is required"; + } + + if (!value.data.externalStatusPageMonitor.statusPageUrl) { + return "Status page URL is required"; + } + } + return null; } @@ -431,6 +456,11 @@ export default class MonitorStep extends DatabaseProperty { domainMonitor: this.data.domainMonitor ? MonitorStepDomainMonitorUtil.toJSON(this.data.domainMonitor) : undefined, + externalStatusPageMonitor: this.data.externalStatusPageMonitor + ? MonitorStepExternalStatusPageMonitorUtil.toJSON( + this.data.externalStatusPageMonitor, + ) + : undefined, }, }); } @@ -542,6 +572,9 @@ export default class MonitorStep extends DatabaseProperty { domainMonitor: json["domainMonitor"] ? (json["domainMonitor"] as JSONObject) : undefined, + externalStatusPageMonitor: json["externalStatusPageMonitor"] + ? (json["externalStatusPageMonitor"] as JSONObject) + : undefined, }) as any; return monitorStep; @@ -569,6 +602,7 @@ export default class MonitorStep extends DatabaseProperty { snmpMonitor: Zod.any().optional(), dnsMonitor: Zod.any().optional(), domainMonitor: Zod.any().optional(), + externalStatusPageMonitor: Zod.any().optional(), }).openapi({ type: "object", example: { diff --git a/Common/Types/Monitor/MonitorStepExternalStatusPageMonitor.ts b/Common/Types/Monitor/MonitorStepExternalStatusPageMonitor.ts new file mode 100644 index 0000000000..5627e76b12 --- /dev/null +++ b/Common/Types/Monitor/MonitorStepExternalStatusPageMonitor.ts @@ -0,0 +1,48 @@ +import { JSONObject } from "../JSON"; +import ExternalStatusPageProviderType from "./ExternalStatusPageProviderType"; + +export default interface MonitorStepExternalStatusPageMonitor { + statusPageUrl: string; + provider: ExternalStatusPageProviderType; + componentName?: string | undefined; // optional: filter to a specific component + timeout: number; + retries: number; +} + +export class MonitorStepExternalStatusPageMonitorUtil { + public static getDefault(): MonitorStepExternalStatusPageMonitor { + return { + statusPageUrl: "", + provider: ExternalStatusPageProviderType.Auto, + componentName: undefined, + timeout: 10000, + retries: 3, + }; + } + + public static fromJSON( + json: JSONObject, + ): MonitorStepExternalStatusPageMonitor { + return { + statusPageUrl: (json["statusPageUrl"] as string) || "", + provider: + (json["provider"] as ExternalStatusPageProviderType) || + ExternalStatusPageProviderType.Auto, + componentName: (json["componentName"] as string) || undefined, + timeout: (json["timeout"] as number) || 10000, + retries: (json["retries"] as number) || 3, + }; + } + + public static toJSON( + monitor: MonitorStepExternalStatusPageMonitor, + ): JSONObject { + return { + statusPageUrl: monitor.statusPageUrl, + provider: monitor.provider, + componentName: monitor.componentName, + timeout: monitor.timeout, + retries: monitor.retries, + }; + } +} diff --git a/Common/Types/Monitor/MonitorType.ts b/Common/Types/Monitor/MonitorType.ts index 6c66b5974d..faad5be0ec 100644 --- a/Common/Types/Monitor/MonitorType.ts +++ b/Common/Types/Monitor/MonitorType.ts @@ -32,6 +32,9 @@ enum MonitorType { // Domain registration monitoring Domain = "Domain", + + // External status page monitoring + ExternalStatusPage = "External Status Page", } export default MonitorType; @@ -62,6 +65,7 @@ export class MonitorTypeHelper { MonitorType.DNS, MonitorType.SSLCertificate, MonitorType.Domain, + MonitorType.ExternalStatusPage, ], }, { @@ -250,6 +254,13 @@ export class MonitorTypeHelper { "This monitor type lets you monitor domain registration health — expiry dates, registrar info, nameserver delegation, and WHOIS status.", icon: IconProp.Globe, }, + { + monitorType: MonitorType.ExternalStatusPage, + title: "External Status Page", + description: + "This monitor type lets you monitor third-party status pages (e.g. AWS, GCP, Azure, GitHub, Cloudflare) and alert when their services degrade.", + icon: IconProp.ExternalLink, + }, ]; return monitorTypeProps; @@ -297,7 +308,8 @@ export class MonitorTypeHelper { monitorType === MonitorType.CustomJavaScriptCode || monitorType === MonitorType.SNMP || monitorType === MonitorType.DNS || - monitorType === MonitorType.Domain; + monitorType === MonitorType.Domain || + monitorType === MonitorType.ExternalStatusPage; return isProbeableMonitor; } @@ -321,6 +333,7 @@ export class MonitorTypeHelper { MonitorType.SNMP, MonitorType.DNS, MonitorType.Domain, + MonitorType.ExternalStatusPage, ]; } @@ -355,7 +368,8 @@ export class MonitorTypeHelper { monitorType === MonitorType.CustomJavaScriptCode || monitorType === MonitorType.SNMP || monitorType === MonitorType.DNS || - monitorType === MonitorType.Domain + monitorType === MonitorType.Domain || + monitorType === MonitorType.ExternalStatusPage ) { return true; } diff --git a/Common/Types/Probe/ProbeMonitorResponse.ts b/Common/Types/Probe/ProbeMonitorResponse.ts index 1c5b984c65..0062810875 100644 --- a/Common/Types/Probe/ProbeMonitorResponse.ts +++ b/Common/Types/Probe/ProbeMonitorResponse.ts @@ -9,6 +9,7 @@ import SyntheticMonitorResponse from "../Monitor/SyntheticMonitors/SyntheticMoni import SnmpMonitorResponse from "../Monitor/SnmpMonitor/SnmpMonitorResponse"; import DnsMonitorResponse from "../Monitor/DnsMonitor/DnsMonitorResponse"; import DomainMonitorResponse from "../Monitor/DomainMonitor/DomainMonitorResponse"; +import ExternalStatusPageMonitorResponse from "../Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse"; import MonitorEvaluationSummary from "../Monitor/MonitorEvaluationSummary"; import ObjectID from "../ObjectID"; import Port from "../Port"; @@ -34,6 +35,7 @@ export default interface ProbeMonitorResponse { snmpResponse?: SnmpMonitorResponse | undefined; dnsResponse?: DnsMonitorResponse | undefined; domainResponse?: DomainMonitorResponse | undefined; + externalStatusPageResponse?: ExternalStatusPageMonitorResponse | undefined; monitoredAt: Date; isTimeout?: boolean | undefined; ingestedAt?: Date | undefined; diff --git a/Dashboard/src/Components/Form/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorStepForm.tsx b/Dashboard/src/Components/Form/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorStepForm.tsx new file mode 100644 index 0000000000..b50438da0a --- /dev/null +++ b/Dashboard/src/Components/Form/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorStepForm.tsx @@ -0,0 +1,157 @@ +import React, { FunctionComponent, ReactElement, useState } from "react"; +import MonitorStepExternalStatusPageMonitor from "Common/Types/Monitor/MonitorStepExternalStatusPageMonitor"; +import ExternalStatusPageProviderType from "Common/Types/Monitor/ExternalStatusPageProviderType"; +import Input, { InputType } from "Common/UI/Components/Input/Input"; +import Dropdown, { + DropdownOption, + DropdownValue, +} from "Common/UI/Components/Dropdown/Dropdown"; +import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel"; +import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button"; +import DropdownUtil from "Common/UI/Utils/Dropdown"; + +export interface ComponentProps { + monitorStepExternalStatusPageMonitor: MonitorStepExternalStatusPageMonitor; + onChange: (value: MonitorStepExternalStatusPageMonitor) => void; +} + +const ExternalStatusPageMonitorStepForm: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [showAdvancedOptions, setShowAdvancedOptions] = + useState(false); + + const providerOptions: Array = + DropdownUtil.getDropdownOptionsFromEnum(ExternalStatusPageProviderType); + + return ( +
+
+ + { + props.onChange({ + ...props.monitorStepExternalStatusPageMonitor, + statusPageUrl: value, + }); + }} + /> +
+ +
+ + { + return ( + option.value === + props.monitorStepExternalStatusPageMonitor.provider + ); + })} + onChange={(value: DropdownValue | Array | null) => { + props.onChange({ + ...props.monitorStepExternalStatusPageMonitor, + provider: value as ExternalStatusPageProviderType, + }); + }} + /> +
+ +
+ + { + props.onChange({ + ...props.monitorStepExternalStatusPageMonitor, + componentName: value || undefined, + }); + }} + /> +
+ + {!showAdvancedOptions && ( +
+
+ )} + + {showAdvancedOptions && ( +
+

Advanced Options

+ +
+ + { + props.onChange({ + ...props.monitorStepExternalStatusPageMonitor, + timeout: parseInt(value) || 10000, + }); + }} + /> +
+ +
+ + { + props.onChange({ + ...props.monitorStepExternalStatusPageMonitor, + retries: parseInt(value) || 3, + }); + }} + /> +
+
+ )} +
+ ); +}; + +export default ExternalStatusPageMonitorStepForm; diff --git a/Dashboard/src/Components/Form/Monitor/MonitorStep.tsx b/Dashboard/src/Components/Form/Monitor/MonitorStep.tsx index 65be64856c..6d5a1595d3 100644 --- a/Dashboard/src/Components/Form/Monitor/MonitorStep.tsx +++ b/Dashboard/src/Components/Form/Monitor/MonitorStep.tsx @@ -83,6 +83,10 @@ import DomainMonitorStepForm from "./DomainMonitor/DomainMonitorStepForm"; import MonitorStepDomainMonitor, { MonitorStepDomainMonitorUtil, } from "Common/Types/Monitor/MonitorStepDomainMonitor"; +import ExternalStatusPageMonitorStepForm from "./ExternalStatusPageMonitor/ExternalStatusPageMonitorStepForm"; +import MonitorStepExternalStatusPageMonitor, { + MonitorStepExternalStatusPageMonitorUtil, +} from "Common/Types/Monitor/MonitorStepExternalStatusPageMonitor"; export interface ComponentProps { monitorStatusDropdownOptions: Array; @@ -834,6 +838,24 @@ return { )} + {props.monitorType === MonitorType.ExternalStatusPage && ( + + { + monitorStep.setExternalStatusPageMonitor(value); + props.onChange?.(MonitorStep.clone(monitorStep)); + }} + /> + + )} + {/* Code Monitor Section */} {isCodeMonitor && ( = ( }, }, ]; + } else if (props.monitorType === MonitorType.ExternalStatusPage) { + fields = [ + { + key: "externalStatusPageMonitor", + title: "Status Page URL", + description: "The URL of the external status page being monitored.", + fieldType: FieldType.Element, + placeholder: "No data entered", + getElement: (item: MonitorStepType): ReactElement => { + const externalStatusPageMonitor: any = item.externalStatusPageMonitor; + return

{externalStatusPageMonitor?.statusPageUrl || "-"}

; + }, + }, + { + key: "externalStatusPageMonitor", + title: "Provider", + description: "The provider type for this status page.", + fieldType: FieldType.Element, + placeholder: "Auto", + getElement: (item: MonitorStepType): ReactElement => { + const externalStatusPageMonitor: any = item.externalStatusPageMonitor; + return

{externalStatusPageMonitor?.provider || "Auto"}

; + }, + }, + { + key: "externalStatusPageMonitor", + title: "Component Name Filter", + description: + "If set, only this specific component will be monitored.", + fieldType: FieldType.Element, + placeholder: "All components", + getElement: (item: MonitorStepType): ReactElement => { + const externalStatusPageMonitor: any = item.externalStatusPageMonitor; + return ( +

+ {externalStatusPageMonitor?.componentName || "All components"} +

+ ); + }, + }, + ]; } else if (props.monitorType === MonitorType.Logs) { logFields = []; diff --git a/Dashboard/src/Components/Monitor/SummaryView/ExternalStatusPageMonitorView.tsx b/Dashboard/src/Components/Monitor/SummaryView/ExternalStatusPageMonitorView.tsx new file mode 100644 index 0000000000..1dbf8c6e24 --- /dev/null +++ b/Dashboard/src/Components/Monitor/SummaryView/ExternalStatusPageMonitorView.tsx @@ -0,0 +1,136 @@ +import OneUptimeDate from "Common/Types/Date"; +import ProbeMonitorResponse from "Common/Types/Probe/ProbeMonitorResponse"; +import ExternalStatusPageMonitorResponse, { + ExternalStatusPageComponentStatus, +} from "Common/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse"; +import InfoCard from "Common/UI/Components/InfoCard/InfoCard"; +import React, { FunctionComponent, ReactElement } from "react"; + +export interface ComponentProps { + probeMonitorResponse: ProbeMonitorResponse; + probeName?: string | undefined; +} + +const ExternalStatusPageMonitorView: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const externalStatusPageResponse: + | ExternalStatusPageMonitorResponse + | undefined = props.probeMonitorResponse?.externalStatusPageResponse; + + let responseTimeInMs: number = + externalStatusPageResponse?.responseTimeInMs || 0; + + if (responseTimeInMs > 0) { + responseTimeInMs = Math.round(responseTimeInMs); + } + + return ( +
+
+ + + + + +
+ +
+ +
+ + {props.probeMonitorResponse.failureCause && ( +
+ +
+ )} + + {/* Component Statuses Section */} + {externalStatusPageResponse?.componentStatuses && + externalStatusPageResponse.componentStatuses.length > 0 && ( +
+

+ Component Statuses +

+
+ + + + + + + + + + {externalStatusPageResponse.componentStatuses.map( + ( + component: ExternalStatusPageComponentStatus, + index: number, + ) => { + return ( + + + + + + ); + }, + )} + +
+ Component + + Status + + Description +
+ {component.name} + + {component.status} + + {component.description || "-"} +
+
+
+ )} +
+ ); +}; + +export default ExternalStatusPageMonitorView; diff --git a/Dashboard/src/Components/Monitor/SummaryView/SummaryInfo.tsx b/Dashboard/src/Components/Monitor/SummaryView/SummaryInfo.tsx index c1abb41801..468b1785ea 100644 --- a/Dashboard/src/Components/Monitor/SummaryView/SummaryInfo.tsx +++ b/Dashboard/src/Components/Monitor/SummaryView/SummaryInfo.tsx @@ -9,6 +9,7 @@ import WebsiteMonitorSummaryView from "./WebsiteMonitorView"; import SnmpMonitorView from "./SnmpMonitorView"; import DnsMonitorView from "./DnsMonitorView"; import DomainMonitorView from "./DomainMonitorView"; +import ExternalStatusPageMonitorView from "./ExternalStatusPageMonitorView"; import IncomingMonitorRequest from "Common/Types/Monitor/IncomingMonitor/IncomingMonitorRequest"; import IncomingEmailMonitorRequest from "Common/Types/Monitor/IncomingEmailMonitor/IncomingEmailMonitorRequest"; import MonitorType, { @@ -141,6 +142,15 @@ const SummaryInfo: FunctionComponent = ( ); } + if (props.monitorType === MonitorType.ExternalStatusPage) { + summaryComponent = ( + + ); + } + return (
{summaryComponent} diff --git a/Dashboard/src/Utils/Form/Monitor/CriteriaFilter.ts b/Dashboard/src/Utils/Form/Monitor/CriteriaFilter.ts index 97a1438225..084942288c 100644 --- a/Dashboard/src/Utils/Form/Monitor/CriteriaFilter.ts +++ b/Dashboard/src/Utils/Form/Monitor/CriteriaFilter.ts @@ -307,6 +307,18 @@ export default class CriteriaFilterUtil { }); } + if (monitorType === MonitorType.ExternalStatusPage) { + options = options.filter((i: DropdownOption) => { + return ( + i.value === CheckOn.ExternalStatusPageIsOnline || + i.value === CheckOn.ExternalStatusPageOverallStatus || + i.value === CheckOn.ExternalStatusPageComponentStatus || + i.value === CheckOn.ExternalStatusPageActiveIncidents || + i.value === CheckOn.ExternalStatusPageResponseTime + ); + }); + } + return options; } @@ -616,6 +628,42 @@ export default class CriteriaFilterUtil { }); } + if (checkOn === CheckOn.ExternalStatusPageIsOnline) { + options = options.filter((i: DropdownOption) => { + return i.value === FilterType.True || i.value === FilterType.False; + }); + } + + if ( + checkOn === CheckOn.ExternalStatusPageResponseTime || + checkOn === CheckOn.ExternalStatusPageActiveIncidents + ) { + options = options.filter((i: DropdownOption) => { + return ( + i.value === FilterType.GreaterThan || + i.value === FilterType.LessThan || + i.value === FilterType.LessThanOrEqualTo || + i.value === FilterType.GreaterThanOrEqualTo + ); + }); + } + + if ( + checkOn === CheckOn.ExternalStatusPageOverallStatus || + checkOn === CheckOn.ExternalStatusPageComponentStatus + ) { + options = options.filter((i: DropdownOption) => { + return ( + i.value === FilterType.Contains || + i.value === FilterType.NotContains || + i.value === FilterType.EqualTo || + i.value === FilterType.NotEqualTo || + i.value === FilterType.StartsWith || + i.value === FilterType.EndsWith + ); + }); + } + return options; } @@ -771,6 +819,22 @@ export default class CriteriaFilterUtil { return "clientTransferProhibited"; } + if (checkOn === CheckOn.ExternalStatusPageResponseTime) { + return "5000"; + } + + if (checkOn === CheckOn.ExternalStatusPageOverallStatus) { + return "operational"; + } + + if (checkOn === CheckOn.ExternalStatusPageComponentStatus) { + return "operational"; + } + + if (checkOn === CheckOn.ExternalStatusPageActiveIncidents) { + return "0"; + } + return ""; } } diff --git a/Docs/Services/Docs/Content/monitor/external-status-page-monitor.md b/Docs/Services/Docs/Content/monitor/external-status-page-monitor.md new file mode 100644 index 0000000000..74a7156cd5 --- /dev/null +++ b/Docs/Services/Docs/Content/monitor/external-status-page-monitor.md @@ -0,0 +1,127 @@ +# External Status Page Monitor + +External Status Page monitoring allows you to monitor third-party status pages and get alerted when services you depend on experience outages or degraded performance. OneUptime periodically checks external status pages (such as AWS, GCP, Azure, GitHub, and more) and evaluates their status. + +## Overview + +External Status Page monitors check the health of services you rely on by querying their public status pages. This enables you to: + +- Monitor the availability of third-party services your application depends on +- Get alerted when upstream providers experience outages +- Track individual component statuses (e.g., "AWS EC2 us-east-1") +- Detect degraded performance before it impacts your users +- Correlate your own incidents with upstream provider issues + +## Supported Providers + +OneUptime supports monitoring status pages via the following methods: + +| Provider Type | Description | +|---|---| +| **Auto** (default) | Automatically detects the status page format | +| **Atlassian Statuspage** | Status pages powered by Atlassian Statuspage (JSON API) | +| **RSS** | Status pages that provide an RSS feed | +| **Atom** | Status pages that provide an Atom feed | + +### Auto-Detection + +When set to **Auto**, OneUptime will attempt to detect the status page format automatically: + +1. First, it tries the Atlassian Statuspage JSON API (`/api/v2/status.json` and `/api/v2/components.json`) +2. If that fails, it attempts to parse the page as an RSS or Atom feed +3. As a final fallback, it performs a basic HTTP reachability check + +## Creating an External Status Page Monitor + +1. Go to **Monitors** in the OneUptime Dashboard +2. Click **Create Monitor** +3. Select **External Status Page** as the monitor type +4. Enter the status page URL you want to monitor +5. Optionally select a specific provider type (or leave as Auto) +6. Optionally enter a component name to filter monitoring to a specific component +7. Configure monitoring criteria as needed + +## Configuration Options + +### Status Page URL + +Enter the URL of the external status page you want to monitor. For Atlassian Statuspage-powered sites, this is typically the root URL (e.g., `https://status.example.com`). For RSS/Atom feeds, enter the feed URL directly. + +### Provider Type + +Select the provider type for the status page. Use **Auto** (default) to let OneUptime detect the format automatically, or specify a specific provider type if you know it. + +### Component Name Filter + +If the status page reports on multiple components, you can optionally specify a component name to monitor only that specific component. For example, to monitor only AWS EC2 in us-east-1, you would enter `EC2 us-east-1` (the exact component name as shown on the status page). + +When no component name is specified, the overall status of the status page is monitored. + +### Advanced Options + +#### Timeout + +The maximum time (in milliseconds) to wait for a response from the status page. Default is 10000ms (10 seconds). + +#### Retries + +The number of times to retry the request if it fails. Default is 3 retries. + +## Monitoring Criteria + +You can configure criteria to determine when the external service is considered online, degraded, or offline based on: + +- **Is Online** – Whether the status page is reachable and returning status data +- **Overall Status** – The overall status indicator of the status page (e.g., "operational", "major_outage") +- **Component Status** – The status of a specific component (when using component name filter) +- **Active Incidents** – The number of currently active incidents reported on the status page +- **Response Time** – How long it takes to fetch the status page data + +## Popular Status Page URLs + +Here is a curated list of popular service status page URLs you can monitor: + +| Service | Status Page URL | +|---|---| +| AWS | `https://health.aws.amazon.com/health/status` | +| Google Cloud Platform | `https://status.cloud.google.com` | +| Microsoft Azure | `https://status.azure.com` | +| GitHub | `https://www.githubstatus.com` | +| Cloudflare | `https://www.cloudflarestatus.com` | +| Datadog | `https://status.datadoghq.com` | +| PagerDuty | `https://status.pagerduty.com` | +| Twilio | `https://status.twilio.com` | +| Stripe | `https://status.stripe.com` | +| Slack | `https://status.slack.com` | +| Atlassian (Jira, Confluence) | `https://status.atlassian.com` | +| Vercel | `https://www.vercel-status.com` | +| Netlify | `https://www.netlifystatus.com` | +| DigitalOcean | `https://status.digitalocean.com` | +| Heroku | `https://status.heroku.com` | +| MongoDB Atlas | `https://status.cloud.mongodb.com` | +| Fastly | `https://status.fastly.com` | +| New Relic | `https://status.newrelic.com` | +| Sentry | `https://status.sentry.io` | +| CircleCI | `https://status.circleci.com` | + +> **Note:** Many of these use Atlassian Statuspage, so the **Auto** provider type will detect them automatically. + +## Incident & Alert Templating + +When creating incidents or alerts from External Status Page monitors, you can use the following template variables: + +| Variable | Description | +|---|---| +| `{{isOnline}}` | Whether the status page is online (true/false) | +| `{{responseTimeInMs}}` | Response time in milliseconds | +| `{{failureCause}}` | Reason for failure, if any | +| `{{overallStatus}}` | The overall status indicator value | +| `{{activeIncidentCount}}` | Number of active incidents | +| `{{componentStatuses}}` | JSON array of component statuses | + +## Best Practices + +- **Use Auto provider type** unless you know the exact format — Auto detection works well for most status pages +- **Monitor specific components** if you only depend on certain services (e.g., a specific AWS region) +- **Set up incident correlation** — when your monitors detect issues and the upstream status page also shows problems, it helps identify root causes faster +- **Combine with other monitors** — pair External Status Page monitors with your own API/Website monitors for comprehensive visibility diff --git a/Docs/Services/Docs/Utils/Nav.ts b/Docs/Services/Docs/Utils/Nav.ts index 257b1fad8f..94d549a170 100644 --- a/Docs/Services/Docs/Utils/Nav.ts +++ b/Docs/Services/Docs/Utils/Nav.ts @@ -182,6 +182,10 @@ const DocsNav: NavGroup[] = [ title: "Incoming Email Monitor", url: "/docs/monitor/incoming-email-monitor", }, + { + title: "External Status Page Monitor", + url: "/docs/monitor/external-status-page-monitor", + }, { title: "JavaScript Expressions", url: "/docs/monitor/javascript-expression", diff --git a/Probe/Utils/Monitors/Monitor.ts b/Probe/Utils/Monitors/Monitor.ts index 1661cece87..43c3acb35a 100644 --- a/Probe/Utils/Monitors/Monitor.ts +++ b/Probe/Utils/Monitors/Monitor.ts @@ -19,6 +19,9 @@ import MonitorStepDnsMonitor from "Common/Types/Monitor/MonitorStepDnsMonitor"; import DomainMonitorUtil from "./MonitorTypes/DomainMonitor"; import DomainMonitorResponse from "Common/Types/Monitor/DomainMonitor/DomainMonitorResponse"; import MonitorStepDomainMonitor from "Common/Types/Monitor/MonitorStepDomainMonitor"; +import ExternalStatusPageMonitorUtil from "./MonitorTypes/ExternalStatusPageMonitor"; +import ExternalStatusPageMonitorResponse from "Common/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse"; +import MonitorStepExternalStatusPageMonitor from "Common/Types/Monitor/MonitorStepExternalStatusPageMonitor"; import HTTPMethod from "Common/Types/API/HTTPMethod"; import URL from "Common/Types/API/URL"; import OneUptimeDate from "Common/Types/Date"; @@ -581,6 +584,39 @@ export default class MonitorUtil { result.domainResponse = response; } + if (monitorType === MonitorType.ExternalStatusPage) { + if (!monitorStep.data?.externalStatusPageMonitor) { + result.failureCause = + "External status page configuration not specified"; + return result; + } + + const externalStatusPageConfig: MonitorStepExternalStatusPageMonitor = + monitorStep.data.externalStatusPageMonitor; + + if (!externalStatusPageConfig.statusPageUrl) { + result.failureCause = "Status page URL not specified"; + return result; + } + + const response: ExternalStatusPageMonitorResponse | null = + await ExternalStatusPageMonitorUtil.fetch(externalStatusPageConfig, { + retry: PROBE_MONITOR_RETRY_LIMIT, + monitorId: monitorId, + timeout: externalStatusPageConfig.timeout || 10000, + }); + + if (!response) { + return null; + } + + result.isOnline = response.isOnline; + result.isTimeout = response.isTimeout; + result.responseTimeInMs = response.responseTimeInMs; + result.failureCause = response.failureCause; + result.externalStatusPageResponse = response; + } + // update the monitoredAt time to the current time. result.monitoredAt = OneUptimeDate.getCurrentDate(); diff --git a/Probe/Utils/Monitors/MonitorTypes/ExternalStatusPageMonitor.ts b/Probe/Utils/Monitors/MonitorTypes/ExternalStatusPageMonitor.ts new file mode 100644 index 0000000000..ed0f3577d3 --- /dev/null +++ b/Probe/Utils/Monitors/MonitorTypes/ExternalStatusPageMonitor.ts @@ -0,0 +1,507 @@ +import OnlineCheck from "../../OnlineCheck"; +import logger from "Common/Server/Utils/Logger"; +import ObjectID from "Common/Types/ObjectID"; +import Sleep from "Common/Types/Sleep"; +import MonitorStepExternalStatusPageMonitor from "Common/Types/Monitor/MonitorStepExternalStatusPageMonitor"; +import ExternalStatusPageMonitorResponse, { + ExternalStatusPageComponentStatus, +} from "Common/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse"; +import ExternalStatusPageProviderType from "Common/Types/Monitor/ExternalStatusPageProviderType"; +import axios, { AxiosResponse } from "axios"; +import { XMLParser } from "fast-xml-parser"; + +export interface ExternalStatusPageQueryOptions { + timeout?: number | undefined; + retry?: number | undefined; + currentRetryCount?: number | undefined; + monitorId?: ObjectID | undefined; + isOnlineCheckRequest?: boolean | undefined; +} + +interface AtlassianStatusResponse { + page?: { + id?: string; + name?: string; + url?: string; + }; + status?: { + indicator?: string; + description?: string; + }; +} + +interface AtlassianComponent { + id?: string; + name?: string; + status?: string; + description?: string; + group_id?: string | null; +} + +interface AtlassianComponentsResponse { + components?: Array; +} + +export default class ExternalStatusPageMonitorUtil { + public static async fetch( + config: MonitorStepExternalStatusPageMonitor, + options?: ExternalStatusPageQueryOptions, + ): Promise { + if (!options) { + options = {}; + } + + if (options?.currentRetryCount === undefined) { + options.currentRetryCount = 1; + } + + logger.debug( + `External Status Page Query: ${options?.monitorId?.toString()} ${config.statusPageUrl} - Retry: ${options?.currentRetryCount}`, + ); + + const startTime: [number, number] = process.hrtime(); + + try { + let response: ExternalStatusPageMonitorResponse | null = null; + + const provider: ExternalStatusPageProviderType = config.provider; + + if (provider === ExternalStatusPageProviderType.Auto) { + // Auto-detect: try Atlassian first, then fall back to RSS/Atom + response = await ExternalStatusPageMonitorUtil.tryAtlassianStatuspage( + config, + options, + ); + + if (!response) { + response = await ExternalStatusPageMonitorUtil.tryRssAtomFeed( + config, + options, + ); + } + } else if ( + provider === ExternalStatusPageProviderType.AtlassianStatuspage + ) { + response = await ExternalStatusPageMonitorUtil.tryAtlassianStatuspage( + config, + options, + ); + } else if ( + provider === ExternalStatusPageProviderType.RSS || + provider === ExternalStatusPageProviderType.Atom + ) { + response = await ExternalStatusPageMonitorUtil.tryRssAtomFeed( + config, + options, + ); + } + + if (!response) { + // If all methods fail, just check if the URL is reachable + response = await ExternalStatusPageMonitorUtil.tryBasicHttpCheck( + config, + options, + ); + } + + const endTime: [number, number] = process.hrtime(startTime); + const responseTimeInMs: number = Math.ceil( + (endTime[0] * 1000000000 + endTime[1]) / 1000000, + ); + + if (response) { + response.responseTimeInMs = responseTimeInMs; + + // Filter by component name if specified + if (config.componentName && response.componentStatuses.length > 0) { + const filterName: string = config.componentName.toLowerCase(); + response.componentStatuses = response.componentStatuses.filter( + (c: ExternalStatusPageComponentStatus) => { + return c.name.toLowerCase().includes(filterName); + }, + ); + } + } + + logger.debug( + `External Status Page Query success: ${options?.monitorId?.toString()} ${config.statusPageUrl} - Response Time: ${responseTimeInMs}ms`, + ); + + return response; + } catch (err: unknown) { + logger.debug( + `External Status Page Query error: ${options?.monitorId?.toString()} ${config.statusPageUrl}`, + ); + logger.debug(err); + + if (!options) { + options = {}; + } + + if (!options.currentRetryCount) { + options.currentRetryCount = 0; + } + + if (options.currentRetryCount < (options.retry || config.retries || 3)) { + options.currentRetryCount++; + await Sleep.sleep(1000); + return await ExternalStatusPageMonitorUtil.fetch(config, options); + } + + // Check if the probe is online + if (!options.isOnlineCheckRequest) { + if (!(await OnlineCheck.canProbeMonitorWebsiteMonitors())) { + logger.error( + `ExternalStatusPageMonitor - Probe is not online. Cannot fetch ${options?.monitorId?.toString()} ${config.statusPageUrl} - ERROR: ${err}`, + ); + return null; + } + } + + const endTime: [number, number] = process.hrtime(startTime); + const responseTimeInMs: number = Math.ceil( + (endTime[0] * 1000000000 + endTime[1]) / 1000000, + ); + + const isTimeout: boolean = + (err as Error).message?.toLowerCase().includes("timeout") || + (err as Error).message?.toLowerCase().includes("timed out") || + (err as Error).message?.toLowerCase().includes("etimeout") || + (err as Error).message?.toLowerCase().includes("econnaborted"); + + if (isTimeout) { + return { + isOnline: false, + isTimeout: true, + overallStatus: "unknown", + componentStatuses: [], + activeIncidentCount: 0, + responseTimeInMs: responseTimeInMs, + failureCause: + "Request was tried " + + options.currentRetryCount + + " times and it timed out.", + }; + } + + return { + isOnline: false, + isTimeout: false, + overallStatus: "unknown", + componentStatuses: [], + activeIncidentCount: 0, + responseTimeInMs: responseTimeInMs, + failureCause: (err as Error).message || (err as Error).toString(), + }; + } + } + + private static async tryAtlassianStatuspage( + config: MonitorStepExternalStatusPageMonitor, + options: ExternalStatusPageQueryOptions, + ): Promise { + try { + let baseUrl: string = config.statusPageUrl.replace(/\/+$/, ""); + + // Fetch status + const statusUrl: string = `${baseUrl}/api/v2/status.json`; + const statusResponse: AxiosResponse = await axios.get(statusUrl, { + timeout: config.timeout || options.timeout || 10000, + headers: { + Accept: "application/json", + "User-Agent": "OneUptime-Probe/1.0", + }, + validateStatus: (status: number) => { + return status < 500; + }, + }); + + if ( + statusResponse.status === 404 || + statusResponse.status === 403 || + statusResponse.status === 401 + ) { + return null; + } + + const statusData: AtlassianStatusResponse = + statusResponse.data as AtlassianStatusResponse; + + if (!statusData?.status?.indicator) { + return null; + } + + // Fetch components + const componentStatuses: Array = []; + try { + const componentsUrl: string = `${baseUrl}/api/v2/components.json`; + const componentsResponse: AxiosResponse = await axios.get( + componentsUrl, + { + timeout: config.timeout || options.timeout || 10000, + headers: { + Accept: "application/json", + "User-Agent": "OneUptime-Probe/1.0", + }, + }, + ); + + const componentsData: AtlassianComponentsResponse = + componentsResponse.data as AtlassianComponentsResponse; + + if (componentsData?.components) { + for (const component of componentsData.components) { + // Skip group headers (components with no group_id that are groups themselves) + if (component.name && component.status) { + componentStatuses.push({ + name: component.name, + status: component.status, + description: component.description || undefined, + }); + } + } + } + } catch (err) { + logger.debug( + `Failed to fetch Atlassian components for ${baseUrl}: ${err}`, + ); + // Continue without component data + } + + const overallStatus: string = + statusData.status.description || statusData.status.indicator || ""; + + return { + isOnline: true, + overallStatus: overallStatus, + componentStatuses: componentStatuses, + activeIncidentCount: 0, // Could be enhanced later with /api/v2/incidents/unresolved.json + responseTimeInMs: 0, // Will be overwritten by caller + failureCause: "", + rawBody: JSON.stringify(statusData), + }; + } catch (err) { + logger.debug( + `Atlassian Statuspage API failed for ${config.statusPageUrl}: ${err}`, + ); + return null; + } + } + + private static async tryRssAtomFeed( + config: MonitorStepExternalStatusPageMonitor, + options: ExternalStatusPageQueryOptions, + ): Promise { + try { + const feedUrl: string = config.statusPageUrl.replace(/\/+$/, ""); + + const response: AxiosResponse = await axios.get(feedUrl, { + timeout: config.timeout || options.timeout || 10000, + headers: { + Accept: + "application/rss+xml, application/atom+xml, application/xml, text/xml", + "User-Agent": "OneUptime-Probe/1.0", + }, + responseType: "text", + }); + + const body: string = response.data as string; + + if (!body || typeof body !== "string") { + return null; + } + + // Check if it looks like XML + const trimmed: string = body.trim(); + if (!trimmed.startsWith("<")) { + return null; + } + + const parser: XMLParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + }); + + const parsed: Record = parser.parse(body) as Record< + string, + unknown + >; + + // Try RSS format + if (parsed["rss"]) { + return ExternalStatusPageMonitorUtil.parseRssFeed( + parsed["rss"] as Record, + body, + ); + } + + // Try Atom format + if (parsed["feed"]) { + return ExternalStatusPageMonitorUtil.parseAtomFeed( + parsed["feed"] as Record, + body, + ); + } + + return null; + } catch (err) { + logger.debug( + `RSS/Atom feed parsing failed for ${config.statusPageUrl}: ${err}`, + ); + return null; + } + } + + private static parseRssFeed( + rss: Record, + rawBody: string, + ): ExternalStatusPageMonitorResponse { + const channel: Record = + (rss["channel"] as Record) || {}; + const items: Array> = Array.isArray( + channel["item"], + ) + ? (channel["item"] as Array>) + : channel["item"] + ? [channel["item"] as Record] + : []; + + // Count active incidents (recent items — items published in the last 24 hours) + const now: Date = new Date(); + let activeIncidentCount: number = 0; + const componentStatuses: Array = []; + + for (const item of items) { + const pubDate: string = (item["pubDate"] as string) || ""; + if (pubDate) { + const itemDate: Date = new Date(pubDate); + const hoursDiff: number = + (now.getTime() - itemDate.getTime()) / (1000 * 60 * 60); + if (hoursDiff <= 24) { + activeIncidentCount++; + componentStatuses.push({ + name: (item["title"] as string) || "Unknown", + status: "incident", + description: (item["description"] as string) || undefined, + }); + } + } + } + + const overallStatus: string = + activeIncidentCount > 0 ? "degraded" : "operational"; + + return { + isOnline: true, + overallStatus: overallStatus, + componentStatuses: componentStatuses, + activeIncidentCount: activeIncidentCount, + responseTimeInMs: 0, + failureCause: "", + rawBody: rawBody, + }; + } + + private static parseAtomFeed( + feed: Record, + rawBody: string, + ): ExternalStatusPageMonitorResponse { + const entries: Array> = Array.isArray( + feed["entry"], + ) + ? (feed["entry"] as Array>) + : feed["entry"] + ? [feed["entry"] as Record] + : []; + + // Count active incidents (recent entries — entries updated in the last 24 hours) + const now: Date = new Date(); + let activeIncidentCount: number = 0; + const componentStatuses: Array = []; + + for (const entry of entries) { + const updated: string = + (entry["updated"] as string) || (entry["published"] as string) || ""; + if (updated) { + const entryDate: Date = new Date(updated); + const hoursDiff: number = + (now.getTime() - entryDate.getTime()) / (1000 * 60 * 60); + if (hoursDiff <= 24) { + activeIncidentCount++; + const title: string | Record = + (entry["title"] as string | Record) || "Unknown"; + const titleText: string = + typeof title === "string" + ? title + : (title["#text"] as string) || "Unknown"; + const content: string | Record = + (entry["content"] as string | Record) || + (entry["summary"] as string | Record) || + ""; + const contentText: string = + typeof content === "string" + ? content + : (content["#text"] as string) || ""; + + componentStatuses.push({ + name: titleText, + status: "incident", + description: contentText || undefined, + }); + } + } + } + + const overallStatus: string = + activeIncidentCount > 0 ? "degraded" : "operational"; + + return { + isOnline: true, + overallStatus: overallStatus, + componentStatuses: componentStatuses, + activeIncidentCount: activeIncidentCount, + responseTimeInMs: 0, + failureCause: "", + rawBody: rawBody, + }; + } + + private static async tryBasicHttpCheck( + config: MonitorStepExternalStatusPageMonitor, + options: ExternalStatusPageQueryOptions, + ): Promise { + try { + const response: AxiosResponse = await axios.get(config.statusPageUrl, { + timeout: config.timeout || options.timeout || 10000, + headers: { + "User-Agent": "OneUptime-Probe/1.0", + }, + validateStatus: () => { + return true; + }, + }); + + const isOnline: boolean = response.status >= 200 && response.status < 400; + + return { + isOnline: isOnline, + overallStatus: isOnline ? "reachable" : "unreachable", + componentStatuses: [], + activeIncidentCount: 0, + responseTimeInMs: 0, + failureCause: isOnline + ? "" + : `HTTP status ${response.status}`, + }; + } catch (err) { + return { + isOnline: false, + overallStatus: "unreachable", + componentStatuses: [], + activeIncidentCount: 0, + responseTimeInMs: 0, + failureCause: (err as Error).message || (err as Error).toString(), + }; + } + } +} diff --git a/Probe/package.json b/Probe/package.json index 765f670ab6..fc4731ef47 100644 --- a/Probe/package.json +++ b/Probe/package.json @@ -25,6 +25,7 @@ "axios": "^1.13.1", "Common": "file:../Common", "ejs": "^3.1.10", + "fast-xml-parser": "^5.2.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "net-snmp": "^3.26.1", diff --git a/ProbeIngest/Utils/Monitor.ts b/ProbeIngest/Utils/Monitor.ts index 72820700c4..d1af392730 100644 --- a/ProbeIngest/Utils/Monitor.ts +++ b/ProbeIngest/Utils/Monitor.ts @@ -257,6 +257,31 @@ export default class MonitorUtil { } } + if (monitorType === MonitorType.ExternalStatusPage) { + for (const monitorStep of monitorSteps?.data?.monitorStepsInstanceArray || + []) { + // Handle External Status Page URL secrets + if ( + monitorStep.data?.externalStatusPageMonitor?.statusPageUrl && + this.hasSecrets( + monitorStep.data.externalStatusPageMonitor.statusPageUrl, + ) + ) { + if (!isSecretsLoaded) { + monitorSecrets = await MonitorUtil.loadMonitorSecrets(monitorId); + isSecretsLoaded = true; + } + + monitorStep.data.externalStatusPageMonitor.statusPageUrl = + (await MonitorUtil.fillSecretsInStringOrJSON({ + secrets: monitorSecrets, + populateSecretsIn: + monitorStep.data.externalStatusPageMonitor.statusPageUrl, + })) as string; + } + } + } + return monitorSteps; }