@oneuptime/common 11.6.1 → 11.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/AnalyticsModels/Index.ts +4 -0
- package/Models/AnalyticsModels/KubernetesCostAllocation.ts +842 -0
- package/Models/AnalyticsModels/NetworkFlow.ts +54 -0
- package/Models/AnalyticsModels/SloHistory.ts +240 -0
- package/Models/DatabaseModels/Index.ts +20 -0
- package/Models/DatabaseModels/NetworkDevice.ts +453 -6
- package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +7 -6
- package/Models/DatabaseModels/NetworkEndpoint.ts +753 -0
- package/Models/DatabaseModels/NetworkSite.ts +1155 -0
- package/Models/DatabaseModels/NetworkSiteAssignmentRule.ts +517 -0
- package/Models/DatabaseModels/NetworkSiteLink.ts +611 -0
- package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +520 -0
- package/Models/DatabaseModels/NetworkSiteType.ts +499 -0
- package/Models/DatabaseModels/ServiceLevelObjective.ts +1106 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.ts +762 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.ts +444 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.ts +443 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.ts +359 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.ts +48 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.ts +65 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.ts +88 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.ts +391 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
- package/Server/Services/AnalyticsDatabaseService.ts +25 -9
- package/Server/Services/Index.ts +24 -0
- package/Server/Services/KubernetesCostAllocationService.ts +14 -0
- package/Server/Services/MetricService.ts +21 -2
- package/Server/Services/MonitorService.ts +22 -0
- package/Server/Services/NetworkDeviceService.ts +438 -2
- package/Server/Services/NetworkEndpointService.ts +414 -0
- package/Server/Services/NetworkSiteAssignmentRuleService.ts +98 -0
- package/Server/Services/NetworkSiteLinkService.ts +10 -0
- package/Server/Services/NetworkSiteService.ts +1254 -0
- package/Server/Services/NetworkSiteStatusTimelineService.ts +10 -0
- package/Server/Services/NetworkSiteTypeService.ts +10 -0
- package/Server/Services/ProjectService.ts +122 -2
- package/Server/Services/ServiceLevelObjectiveBurnRateRuleService.ts +457 -0
- package/Server/Services/ServiceLevelObjectiveOwnerTeamService.ts +10 -0
- package/Server/Services/ServiceLevelObjectiveOwnerUserService.ts +10 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +677 -0
- package/Server/Services/SloHistoryService.ts +101 -0
- package/Server/Services/UserNotificationSettingService.ts +37 -0
- package/Server/Types/AnalyticsDatabase/AggregateBy.ts +9 -0
- package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +15 -0
- package/Server/Utils/AnalyticsDatabase/Statement.ts +39 -3
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +87 -48
- package/Server/Utils/Memory.ts +9 -1
- package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +190 -12
- package/Server/Utils/Monitor/Data/OuiVendors.json +6920 -0
- package/Server/Utils/Monitor/DnsResolutionCache.ts +129 -0
- package/Server/Utils/Monitor/MonitorResource.ts +4 -30
- package/Server/Utils/Monitor/NetworkDeviceHydrationUtil.ts +214 -39
- package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +343 -0
- package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +251 -0
- package/Server/Utils/Monitor/NetworkInventoryUtil.ts +176 -142
- package/Server/Utils/Monitor/OuiLookupUtil.ts +49 -0
- package/Server/Utils/Monitor/SnmpInterfaceRateUtil.ts +17 -13
- package/Server/Utils/WhatsAppTemplateUtil.ts +7 -3
- package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +135 -0
- package/Tests/Models/NetworkSiteHierarchy.test.ts +134 -0
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +239 -0
- package/Tests/Server/Services/KubernetesCostAllocationAggregate.test.ts +338 -0
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +142 -0
- package/Tests/Server/Services/NetworkDeviceSiteAssignment.test.ts +484 -0
- package/Tests/Server/Services/NetworkEndpointServiceUpsert.test.ts +686 -0
- package/Tests/Server/Services/NetworkSiteAssignmentRuleService.test.ts +185 -0
- package/Tests/Server/Services/NetworkSiteService.test.ts +1354 -0
- package/Tests/Server/Services/ServiceLevelObjectiveBurnRateRuleService.test.ts +1313 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +1988 -0
- package/Tests/Server/Utils/AnalyticsDatabase/Statement.test.ts +22 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +404 -0
- package/Tests/Server/Utils/Encryption.test.ts +70 -0
- package/Tests/Server/Utils/FileAttachmentMarkdownUtil.test.ts +225 -0
- package/Tests/Server/Utils/JsonWebToken.test.ts +175 -0
- package/Tests/Server/Utils/LocalFile.test.ts +258 -0
- package/Tests/Server/Utils/Memory.test.ts +213 -0
- package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +30 -0
- package/Tests/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.test.ts +385 -0
- package/Tests/Server/Utils/Monitor/DnsResolutionCache.test.ts +194 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceHydrationUtil.test.ts +155 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +600 -0
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +151 -124
- package/Tests/Server/Utils/Monitor/SnmpInterfaceRateUtil.test.ts +496 -0
- package/Tests/Server/Utils/MonitorCriteriaDataExtractor.test.ts +175 -0
- package/Tests/Server/Utils/MonitorCriteriaMessageFormatter.test.ts +282 -0
- package/Tests/Server/Utils/OuiLookupUtil.test.ts +59 -0
- package/Tests/Server/Utils/SeriesResourceLabels.test.ts +117 -0
- package/Tests/Server/Utils/StackTraceParser.test.ts +370 -0
- package/Tests/Server/Utils/Stream.test.ts +87 -0
- package/Tests/Server/Utils/ValidateGlobalProviderProjectTeams.test.ts +221 -0
- package/Tests/Server/Utils/WhatsAppTemplateUtil.test.ts +199 -0
- package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +33 -0
- package/Tests/Types/Email.test.ts +198 -0
- package/Tests/Types/Kubernetes/KubernetesObjectParser.test.ts +177 -0
- package/Tests/Types/Log/LogQueryToFilter.test.ts +111 -0
- package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +141 -1
- package/Tests/Types/Monitor/MonitorType.test.ts +59 -1
- package/Tests/Types/Monitor/NetworkDeviceAlertPack.test.ts +97 -0
- package/Tests/Types/Monitor/SnmpEndpointDiscovery.test.ts +151 -0
- package/Tests/Types/Monitor/SnmpVendorTemplate.test.ts +110 -6
- package/Tests/Types/NetworkSite/DefaultNetworkSiteType.test.ts +129 -0
- package/Tests/Types/SerializableObjectDictionary.test.ts +166 -0
- package/Tests/Types/Time/RangeStartAndEndDateTime.test.ts +98 -0
- package/Tests/Types/WebsiteRequest.test.ts +230 -0
- package/Tests/UI/Components/EntityFilterRoundTrip.test.tsx +103 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableUrlState.test.tsx +612 -0
- package/Tests/UI/Components/ModelTable/FilterDataToQuery.test.ts +275 -0
- package/Tests/UI/Utils/LogExport.test.ts +159 -0
- package/Tests/UI/Utils/Navigation.test.ts +183 -0
- package/Tests/UI/Utils/TableFilterUrlState.test.ts +539 -0
- package/Tests/UI/Utils/TableViewUrlState.test.ts +272 -0
- package/Tests/Utils/Base64.test.ts +51 -0
- package/Tests/Utils/Boolean.test.ts +76 -0
- package/Tests/Utils/Crypto.test.ts +48 -0
- package/Tests/Utils/Dashboard/DashboardSloComponent.test.ts +259 -0
- package/Tests/Utils/Dashboard/ModelQueryVariableInterpolation.test.ts +162 -0
- package/Tests/Utils/Enum.test.ts +54 -0
- package/Tests/Utils/IpCanonicalUtil.test.ts +85 -0
- package/Tests/Utils/Metrics/MetricSeriesFingerprint.test.ts +160 -0
- package/Tests/Utils/Monitor/EndpointAttachmentUtil.test.ts +836 -0
- package/Tests/Utils/Monitor/MonitorMetricType.test.ts +218 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +447 -0
- package/Tests/Utils/NetworkSite/CidrMatchUtil.test.ts +440 -0
- package/Tests/Utils/NetworkSite/MaterializedPathUtil.test.ts +205 -0
- package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +221 -0
- package/Tests/Utils/NetworkSite/SiteUptimeUtil.test.ts +283 -0
- package/Tests/Utils/ObjectUtil.test.ts +30 -0
- package/Tests/Utils/Slo/SloStatusColor.test.ts +89 -0
- package/Tests/Utils/Slo/SloUtil.test.ts +1980 -0
- package/Tests/Utils/Slo/SloWidgetFormat.test.ts +605 -0
- package/Tests/Utils/TechStack.test.ts +50 -0
- package/Tests/Utils/Traces/CriticalPath.test.ts +178 -0
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +10 -0
- package/Types/BaseDatabase/EqualTo.ts +9 -1
- package/Types/BaseDatabase/EqualToOrNull.ts +9 -1
- package/Types/BaseDatabase/NotEqual.ts +9 -1
- package/Types/Dashboard/DashboardComponentType.ts +1 -0
- package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
- package/Types/Dashboard/DashboardComponents/DashboardSloComponent.ts +42 -0
- package/Types/Dashboard/DashboardTemplates.ts +214 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/Kubernetes/KubernetesCostIngest.ts +79 -0
- package/Types/Monitor/CriteriaFilter.ts +9 -0
- package/Types/Monitor/MonitorStep.ts +5 -8
- package/Types/Monitor/MonitorStepNetworkDeviceMonitor.ts +27 -5
- package/Types/Monitor/MonitorType.ts +15 -5
- package/Types/Monitor/SnmpMonitor/ArpEntry.ts +14 -0
- package/Types/Monitor/SnmpMonitor/FdbEntry.ts +18 -0
- package/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.ts +10 -3
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -5
- package/Types/Monitor/SnmpMonitor/SnmpMonitorResponse.ts +12 -0
- package/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.ts +231 -3
- package/Types/NetworkSite/DefaultNetworkSiteType.ts +20 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/Permission.ts +449 -0
- package/Types/ServiceLevelObjective/SliType.ts +6 -0
- package/Types/ServiceLevelObjective/SloMultiMonitorMode.ts +6 -0
- package/Types/ServiceLevelObjective/SloStatus.ts +9 -0
- package/Types/ServiceLevelObjective/SloWindowType.ts +6 -0
- package/Types/Telemetry/ServiceType.ts +1 -0
- package/UI/Components/Filters/EntityFilter.tsx +16 -0
- package/UI/Components/Filters/FilterViewer.tsx +22 -5
- package/UI/Components/List/List.tsx +8 -0
- package/UI/Components/ModelTable/BaseModelTable.tsx +309 -106
- package/UI/Components/ModelTable/FilterDataToQuery.ts +172 -0
- package/UI/Components/Navbar/NavBar.tsx +16 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +3 -3
- package/UI/Utils/Navigation.ts +10 -0
- package/UI/Utils/TableFilterUrlState.ts +204 -17
- package/UI/Utils/TableViewUrlState.ts +165 -0
- package/Utils/Dashboard/Components/DashboardSloComponent.ts +114 -0
- package/Utils/Dashboard/Components/Index.ts +7 -0
- package/Utils/IpCanonicalUtil.ts +134 -0
- package/Utils/Monitor/EndpointAttachmentUtil.ts +508 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +157 -5
- package/Utils/NetworkSite/CidrMatchUtil.ts +296 -0
- package/Utils/NetworkSite/MaterializedPathUtil.ts +115 -0
- package/Utils/NetworkSite/SiteStatusRollupUtil.ts +97 -0
- package/Utils/NetworkSite/SiteUptimeUtil.ts +111 -0
- package/Utils/Slo/SloStatusColor.ts +54 -0
- package/Utils/Slo/SloUtil.ts +637 -0
- package/Utils/Slo/SloWidgetFormat.ts +383 -0
- package/build/dist/Models/AnalyticsModels/Index.js +4 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +713 -0
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js +44 -0
- package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/SloHistory.js +215 -0
- package/build/dist/Models/AnalyticsModels/SloHistory.js.map +1 -0
- package/build/dist/Models/DatabaseModels/Index.js +20 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +458 -6
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +0 -6
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkEndpoint.js +779 -0
- package/build/dist/Models/DatabaseModels/NetworkEndpoint.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSite.js +1180 -0
- package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteAssignmentRule.js +534 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteAssignmentRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteLink.js +629 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteLink.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +538 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteType.js +522 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteType.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +1124 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.js +769 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.js +460 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.js +459 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.js +130 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.js +31 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.js +36 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.js +49 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.js +140 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +23 -6
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +24 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/KubernetesCostAllocationService.js +12 -0
- package/build/dist/Server/Services/KubernetesCostAllocationService.js.map +1 -0
- package/build/dist/Server/Services/MetricService.js +21 -3
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +19 -0
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceService.js +351 -1
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/NetworkEndpointService.js +319 -0
- package/build/dist/Server/Services/NetworkEndpointService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteAssignmentRuleService.js +90 -0
- package/build/dist/Server/Services/NetworkSiteAssignmentRuleService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteLinkService.js +9 -0
- package/build/dist/Server/Services/NetworkSiteLinkService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteService.js +1038 -0
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteStatusTimelineService.js +9 -0
- package/build/dist/Server/Services/NetworkSiteStatusTimelineService.js.map +1 -0
- package/build/dist/Server/Services/NetworkSiteTypeService.js +9 -0
- package/build/dist/Server/Services/NetworkSiteTypeService.js.map +1 -0
- package/build/dist/Server/Services/ProjectService.js +98 -2
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveBurnRateRuleService.js +358 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveBurnRateRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerTeamService.js +9 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerTeamService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerUserService.js +9 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerUserService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +582 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -0
- package/build/dist/Server/Services/SloHistoryService.js +77 -0
- package/build/dist/Server/Services/SloHistoryService.js.map +1 -0
- package/build/dist/Server/Services/UserNotificationSettingService.js +20 -0
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js +9 -0
- package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +15 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js +31 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +76 -48
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Memory.js +9 -1
- package/build/dist/Server/Utils/Memory.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +132 -10
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Data/OuiVendors.json +6920 -0
- package/build/dist/Server/Utils/Monitor/DnsResolutionCache.js +77 -0
- package/build/dist/Server/Utils/Monitor/DnsResolutionCache.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +21 -41
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js +152 -40
- package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +262 -0
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +201 -0
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +152 -120
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/OuiLookupUtil.js +44 -0
- package/build/dist/Server/Utils/Monitor/OuiLookupUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/SnmpInterfaceRateUtil.js +10 -10
- package/build/dist/Server/Utils/Monitor/SnmpInterfaceRateUtil.js.map +1 -1
- package/build/dist/Server/Utils/WhatsAppTemplateUtil.js +5 -0
- package/build/dist/Server/Utils/WhatsAppTemplateUtil.js.map +1 -1
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +10 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/BaseDatabase/EqualTo.js +9 -1
- package/build/dist/Types/BaseDatabase/EqualTo.js.map +1 -1
- package/build/dist/Types/BaseDatabase/EqualToOrNull.js +9 -1
- package/build/dist/Types/BaseDatabase/EqualToOrNull.js.map +1 -1
- package/build/dist/Types/BaseDatabase/NotEqual.js +9 -1
- package/build/dist/Types/BaseDatabase/NotEqual.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardSloComponent.js +23 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardSloComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardTemplates.js +205 -0
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Kubernetes/KubernetesCostIngest.js +21 -0
- package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -0
- package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
- package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStep.js +5 -5
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStepNetworkDeviceMonitor.js +8 -0
- package/build/dist/Types/Monitor/MonitorStepNetworkDeviceMonitor.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +15 -5
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/Monitor/SnmpMonitor/ArpEntry.js +2 -0
- package/build/dist/Types/Monitor/SnmpMonitor/ArpEntry.js.map +1 -0
- package/build/dist/Types/Monitor/SnmpMonitor/FdbEntry.js +2 -0
- package/build/dist/Types/Monitor/SnmpMonitor/FdbEntry.js.map +1 -0
- package/build/dist/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.js +9 -3
- package/build/dist/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.js.map +1 -1
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js +217 -3
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js.map +1 -1
- package/build/dist/Types/NetworkSite/DefaultNetworkSiteType.js +21 -0
- package/build/dist/Types/NetworkSite/DefaultNetworkSiteType.js.map +1 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/Permission.js +409 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/ServiceLevelObjective/SliType.js +7 -0
- package/build/dist/Types/ServiceLevelObjective/SliType.js.map +1 -0
- package/build/dist/Types/ServiceLevelObjective/SloMultiMonitorMode.js +7 -0
- package/build/dist/Types/ServiceLevelObjective/SloMultiMonitorMode.js.map +1 -0
- package/build/dist/Types/ServiceLevelObjective/SloStatus.js +10 -0
- package/build/dist/Types/ServiceLevelObjective/SloStatus.js.map +1 -0
- package/build/dist/Types/ServiceLevelObjective/SloWindowType.js +7 -0
- package/build/dist/Types/ServiceLevelObjective/SloWindowType.js.map +1 -0
- package/build/dist/Types/Telemetry/ServiceType.js +1 -0
- package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
- package/build/dist/UI/Components/Filters/EntityFilter.js +15 -0
- package/build/dist/UI/Components/Filters/EntityFilter.js.map +1 -1
- package/build/dist/UI/Components/Filters/FilterViewer.js +19 -4
- package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
- package/build/dist/UI/Components/List/List.js +1 -1
- package/build/dist/UI/Components/List/List.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +235 -89
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/FilterDataToQuery.js +96 -0
- package/build/dist/UI/Components/ModelTable/FilterDataToQuery.js.map +1 -0
- package/build/dist/UI/Components/Navbar/NavBar.js +14 -2
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +3 -2
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Utils/Navigation.js +9 -0
- package/build/dist/UI/Utils/Navigation.js.map +1 -1
- package/build/dist/UI/Utils/TableFilterUrlState.js +159 -15
- package/build/dist/UI/Utils/TableFilterUrlState.js.map +1 -1
- package/build/dist/UI/Utils/TableViewUrlState.js +103 -0
- package/build/dist/UI/Utils/TableViewUrlState.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardSloComponent.js +93 -0
- package/build/dist/Utils/Dashboard/Components/DashboardSloComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
- package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
- package/build/dist/Utils/IpCanonicalUtil.js +110 -0
- package/build/dist/Utils/IpCanonicalUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/EndpointAttachmentUtil.js +311 -0
- package/build/dist/Utils/Monitor/EndpointAttachmentUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +98 -5
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/NetworkSite/CidrMatchUtil.js +222 -0
- package/build/dist/Utils/NetworkSite/CidrMatchUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/MaterializedPathUtil.js +89 -0
- package/build/dist/Utils/NetworkSite/MaterializedPathUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +60 -0
- package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -0
- package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js +76 -0
- package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js.map +1 -0
- package/build/dist/Utils/Slo/SloStatusColor.js +18 -0
- package/build/dist/Utils/Slo/SloStatusColor.js.map +1 -0
- package/build/dist/Utils/Slo/SloUtil.js +393 -0
- package/build/dist/Utils/Slo/SloUtil.js.map +1 -0
- package/build/dist/Utils/Slo/SloWidgetFormat.js +174 -0
- package/build/dist/Utils/Slo/SloWidgetFormat.js.map +1 -0
- package/package.json +2 -2
- package/Types/Dashboard/DashboardComponents/Index.ts +0 -7
- package/build/dist/Types/Dashboard/DashboardComponents/Index.js +0 -8
- package/build/dist/Types/Dashboard/DashboardComponents/Index.js.map +0 -1
|
@@ -0,0 +1,1980 @@
|
|
|
1
|
+
import SloUtil, {
|
|
2
|
+
CalendarMonthWindow,
|
|
3
|
+
DowntimeInterval,
|
|
4
|
+
ErrorBudgetResult,
|
|
5
|
+
TimeSliResult,
|
|
6
|
+
} from "../../../Utils/Slo/SloUtil";
|
|
7
|
+
import UptimeUtil, { UptimeWindow } from "../../../Utils/Uptime/UptimeUtil";
|
|
8
|
+
import { Green, Red } from "../../../Types/BrandColors";
|
|
9
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
10
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
11
|
+
import MonitorStatus from "../../../Models/DatabaseModels/MonitorStatus";
|
|
12
|
+
import MonitorStatusTimeline from "../../../Models/DatabaseModels/MonitorStatusTimeline";
|
|
13
|
+
import SloMultiMonitorMode from "../../../Types/ServiceLevelObjective/SloMultiMonitorMode";
|
|
14
|
+
import SloStatus from "../../../Types/ServiceLevelObjective/SloStatus";
|
|
15
|
+
import UptimePrecision from "../../../Types/StatusPage/UptimePrecision";
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Every assertion is relative to a pinned "now" so the numbers are stable - the same
|
|
19
|
+
* discipline as UptimeUtil.test.ts (uptime math used to drift with wall clock time).
|
|
20
|
+
*/
|
|
21
|
+
const NOW: Date = new Date("2026-07-19T00:00:00.000Z");
|
|
22
|
+
|
|
23
|
+
const MONITOR_A_ID: ObjectID = new ObjectID(
|
|
24
|
+
"11111111-1111-4111-8111-111111111111",
|
|
25
|
+
);
|
|
26
|
+
const MONITOR_B_ID: ObjectID = new ObjectID(
|
|
27
|
+
"44444444-4444-4444-8444-444444444444",
|
|
28
|
+
);
|
|
29
|
+
const MONITOR_C_ID: ObjectID = new ObjectID(
|
|
30
|
+
"55555555-5555-4555-8555-555555555555",
|
|
31
|
+
);
|
|
32
|
+
const OFFLINE_STATUS_ID: ObjectID = new ObjectID(
|
|
33
|
+
"22222222-2222-4222-8222-222222222222",
|
|
34
|
+
);
|
|
35
|
+
const OPERATIONAL_STATUS_ID: ObjectID = new ObjectID(
|
|
36
|
+
"33333333-3333-4333-8333-333333333333",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const SECONDS_IN_HOUR: number = 3600;
|
|
40
|
+
const SECONDS_IN_DAY: number = 86400;
|
|
41
|
+
|
|
42
|
+
const offlineStatus: MonitorStatus = new MonitorStatus();
|
|
43
|
+
offlineStatus.id = OFFLINE_STATUS_ID;
|
|
44
|
+
offlineStatus.name = "Offline";
|
|
45
|
+
offlineStatus.priority = 2;
|
|
46
|
+
offlineStatus.color = Red;
|
|
47
|
+
|
|
48
|
+
const downtimeStatuses: Array<MonitorStatus> = [offlineStatus];
|
|
49
|
+
|
|
50
|
+
type CreateTimelineFunction = (data: {
|
|
51
|
+
statusId: ObjectID;
|
|
52
|
+
name: string;
|
|
53
|
+
priority: number;
|
|
54
|
+
startsAt: string;
|
|
55
|
+
endsAt?: string | undefined;
|
|
56
|
+
monitorId: ObjectID;
|
|
57
|
+
}) => MonitorStatusTimeline;
|
|
58
|
+
|
|
59
|
+
const createTimeline: CreateTimelineFunction = (data: {
|
|
60
|
+
statusId: ObjectID;
|
|
61
|
+
name: string;
|
|
62
|
+
priority: number;
|
|
63
|
+
startsAt: string;
|
|
64
|
+
endsAt?: string | undefined;
|
|
65
|
+
monitorId: ObjectID;
|
|
66
|
+
}): MonitorStatusTimeline => {
|
|
67
|
+
const monitorStatus: MonitorStatus = new MonitorStatus();
|
|
68
|
+
monitorStatus.id = data.statusId;
|
|
69
|
+
monitorStatus.name = data.name;
|
|
70
|
+
monitorStatus.priority = data.priority;
|
|
71
|
+
monitorStatus.color = data.name === "Offline" ? Red : Green;
|
|
72
|
+
|
|
73
|
+
const timeline: MonitorStatusTimeline = new MonitorStatusTimeline();
|
|
74
|
+
timeline.monitorId = data.monitorId;
|
|
75
|
+
timeline.monitorStatusId = data.statusId;
|
|
76
|
+
timeline.monitorStatus = monitorStatus;
|
|
77
|
+
timeline.startsAt = new Date(data.startsAt);
|
|
78
|
+
|
|
79
|
+
// endsAt is left unset for open rows, matching what the database rows look like.
|
|
80
|
+
if (data.endsAt) {
|
|
81
|
+
timeline.endsAt = new Date(data.endsAt);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return timeline;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type StatusTimelineFunction = (
|
|
88
|
+
monitorId: ObjectID,
|
|
89
|
+
startsAt: string,
|
|
90
|
+
endsAt?: string | undefined,
|
|
91
|
+
) => MonitorStatusTimeline;
|
|
92
|
+
|
|
93
|
+
const offline: StatusTimelineFunction = (
|
|
94
|
+
monitorId: ObjectID,
|
|
95
|
+
startsAt: string,
|
|
96
|
+
endsAt?: string | undefined,
|
|
97
|
+
): MonitorStatusTimeline => {
|
|
98
|
+
return createTimeline({
|
|
99
|
+
statusId: OFFLINE_STATUS_ID,
|
|
100
|
+
name: "Offline",
|
|
101
|
+
priority: 2,
|
|
102
|
+
startsAt,
|
|
103
|
+
endsAt,
|
|
104
|
+
monitorId,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const operational: StatusTimelineFunction = (
|
|
109
|
+
monitorId: ObjectID,
|
|
110
|
+
startsAt: string,
|
|
111
|
+
endsAt?: string | undefined,
|
|
112
|
+
): MonitorStatusTimeline => {
|
|
113
|
+
return createTimeline({
|
|
114
|
+
statusId: OPERATIONAL_STATUS_ID,
|
|
115
|
+
name: "Operational",
|
|
116
|
+
priority: 1,
|
|
117
|
+
startsAt,
|
|
118
|
+
endsAt,
|
|
119
|
+
monitorId,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
describe("SloUtil", () => {
|
|
124
|
+
beforeAll(() => {
|
|
125
|
+
jest.useFakeTimers({ now: NOW });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(() => {
|
|
129
|
+
jest.useRealTimers();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* The case the whole util exists for: Monitor A Offline 10:00-11:00 while Monitor B
|
|
134
|
+
* is Operational 10:30-12:00. Cross-monitor budget math must see the full hour of
|
|
135
|
+
* downtime; UptimeUtil's priority flatten (built for rendering one status-page bar)
|
|
136
|
+
* silently erases the 10:30-11:00 half of it.
|
|
137
|
+
*/
|
|
138
|
+
describe("the union correctness case: A Offline 10:00-11:00, B Operational 10:30-12:00", () => {
|
|
139
|
+
const window: UptimeWindow = {
|
|
140
|
+
startDate: new Date("2026-07-18T10:00:00.000Z"),
|
|
141
|
+
endDate: new Date("2026-07-18T13:00:00.000Z"),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const monitorATimelines: Array<MonitorStatusTimeline> = [
|
|
145
|
+
offline(
|
|
146
|
+
MONITOR_A_ID,
|
|
147
|
+
"2026-07-18T10:00:00.000Z",
|
|
148
|
+
"2026-07-18T11:00:00.000Z",
|
|
149
|
+
),
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const monitorBTimelines: Array<MonitorStatusTimeline> = [
|
|
153
|
+
operational(
|
|
154
|
+
MONITOR_B_ID,
|
|
155
|
+
"2026-07-18T10:30:00.000Z",
|
|
156
|
+
"2026-07-18T12:00:00.000Z",
|
|
157
|
+
),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
it("counts the full hour of downtime, not 30 minutes", () => {
|
|
161
|
+
expect(
|
|
162
|
+
SloUtil.getUnionDowntimeSeconds(
|
|
163
|
+
[
|
|
164
|
+
{ monitorId: MONITOR_A_ID, timelines: monitorATimelines },
|
|
165
|
+
{ monitorId: MONITOR_B_ID, timelines: monitorBTimelines },
|
|
166
|
+
],
|
|
167
|
+
downtimeStatuses,
|
|
168
|
+
window,
|
|
169
|
+
),
|
|
170
|
+
).toBe(SECONDS_IN_HOUR);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("documents the bug being avoided: the cross-monitor flatten yields only 1800s", () => {
|
|
174
|
+
/*
|
|
175
|
+
* This is why SloUtil does NOT feed multiple monitors into
|
|
176
|
+
* UptimeUtil.getNonOverlappingMonitorEvents / getTotalDowntimeInSeconds: B's
|
|
177
|
+
* Operational event ends later than A's Offline event, so the flatten truncates
|
|
178
|
+
* the Offline event at 10:30. If UptimeUtil's flatten ever changes, revisit
|
|
179
|
+
* SloUtil - this assertion pins the behaviour SloUtil works around.
|
|
180
|
+
*/
|
|
181
|
+
expect(
|
|
182
|
+
UptimeUtil.getTotalDowntimeInSeconds(
|
|
183
|
+
[...monitorATimelines, ...monitorBTimelines],
|
|
184
|
+
downtimeStatuses,
|
|
185
|
+
window,
|
|
186
|
+
).totalDowntimeInSeconds,
|
|
187
|
+
).toBe(SECONDS_IN_HOUR / 2);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("computes the AnyDown SLI over the shared window", () => {
|
|
191
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
192
|
+
perMonitorTimelines: [
|
|
193
|
+
{ monitorId: MONITOR_A_ID, timelines: monitorATimelines },
|
|
194
|
+
{ monitorId: MONITOR_B_ID, timelines: monitorBTimelines },
|
|
195
|
+
],
|
|
196
|
+
downtimeStatuses,
|
|
197
|
+
window,
|
|
198
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// earliest first event is 10:00 (= window start), window end is in the past.
|
|
202
|
+
expect(result.totalSeconds).toBe(3 * SECONDS_IN_HOUR);
|
|
203
|
+
expect(result.badSeconds).toBe(SECONDS_IN_HOUR);
|
|
204
|
+
expect(result.sliPercentage).toBeCloseTo((2 / 3) * 100, 10);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("MonitorSecondsAverage gives a different (per-monitor weighted) answer", () => {
|
|
208
|
+
const anyDown: TimeSliResult = SloUtil.computeTimeSli({
|
|
209
|
+
perMonitorTimelines: [
|
|
210
|
+
{ monitorId: MONITOR_A_ID, timelines: monitorATimelines },
|
|
211
|
+
{ monitorId: MONITOR_B_ID, timelines: monitorBTimelines },
|
|
212
|
+
],
|
|
213
|
+
downtimeStatuses,
|
|
214
|
+
window,
|
|
215
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const average: TimeSliResult = SloUtil.computeTimeSli({
|
|
219
|
+
perMonitorTimelines: [
|
|
220
|
+
{ monitorId: MONITOR_A_ID, timelines: monitorATimelines },
|
|
221
|
+
{ monitorId: MONITOR_B_ID, timelines: monitorBTimelines },
|
|
222
|
+
],
|
|
223
|
+
downtimeStatuses,
|
|
224
|
+
window,
|
|
225
|
+
mode: SloMultiMonitorMode.MonitorSecondsAverage,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/*
|
|
229
|
+
* Per-monitor windows (each clamped to its own first event, mirroring
|
|
230
|
+
* UptimeUtil.getTotalDowntimeInSeconds): A is 10:00-13:00 = 10800s with 3600s
|
|
231
|
+
* down; B is 10:30-13:00 = 9000s with 0s down.
|
|
232
|
+
*/
|
|
233
|
+
expect(average.badSeconds).toBe(SECONDS_IN_HOUR);
|
|
234
|
+
expect(average.totalSeconds).toBe(10800 + 9000);
|
|
235
|
+
expect(average.sliPercentage).toBeCloseTo((1 - 3600 / 19800) * 100, 10);
|
|
236
|
+
|
|
237
|
+
expect(average.sliPercentage).not.toBeCloseTo(anyDown.sliPercentage, 5);
|
|
238
|
+
expect(average.totalSeconds).not.toBe(anyDown.totalSeconds);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("getDowntimeIntervalsForMonitor", () => {
|
|
243
|
+
const window: UptimeWindow = {
|
|
244
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
245
|
+
endDate: new Date("2026-07-11T00:00:00.000Z"),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
it("returns only downtime intervals, clipped to the window", () => {
|
|
249
|
+
const timelines: Array<MonitorStatusTimeline> = [
|
|
250
|
+
// open row from before the window: end is imputed at the next row's start.
|
|
251
|
+
operational(MONITOR_A_ID, "2026-06-01T00:00:00.000Z"),
|
|
252
|
+
offline(
|
|
253
|
+
MONITOR_A_ID,
|
|
254
|
+
"2026-07-05T00:00:00.000Z",
|
|
255
|
+
"2026-07-05T01:00:00.000Z",
|
|
256
|
+
),
|
|
257
|
+
operational(MONITOR_A_ID, "2026-07-05T01:00:00.000Z"),
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const intervals: Array<DowntimeInterval> =
|
|
261
|
+
SloUtil.getDowntimeIntervalsForMonitor(
|
|
262
|
+
MONITOR_A_ID,
|
|
263
|
+
timelines,
|
|
264
|
+
downtimeStatuses,
|
|
265
|
+
window,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(intervals).toHaveLength(1);
|
|
269
|
+
expect(intervals[0]!.startDate.toISOString()).toBe(
|
|
270
|
+
"2026-07-05T00:00:00.000Z",
|
|
271
|
+
);
|
|
272
|
+
expect(intervals[0]!.endDate.toISOString()).toBe(
|
|
273
|
+
"2026-07-05T01:00:00.000Z",
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("caps an open downtime row at the window end", () => {
|
|
278
|
+
const openWindow: UptimeWindow = {
|
|
279
|
+
startDate: new Date("2026-07-18T10:00:00.000Z"),
|
|
280
|
+
endDate: new Date("2026-07-18T13:00:00.000Z"),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const intervals: Array<DowntimeInterval> =
|
|
284
|
+
SloUtil.getDowntimeIntervalsForMonitor(
|
|
285
|
+
MONITOR_A_ID,
|
|
286
|
+
[offline(MONITOR_A_ID, "2026-07-18T10:00:00.000Z")],
|
|
287
|
+
downtimeStatuses,
|
|
288
|
+
openWindow,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
expect(intervals).toHaveLength(1);
|
|
292
|
+
expect(intervals[0]!.endDate.toISOString()).toBe(
|
|
293
|
+
openWindow.endDate.toISOString(),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("ignores rows that belong to other monitors", () => {
|
|
298
|
+
const intervals: Array<DowntimeInterval> =
|
|
299
|
+
SloUtil.getDowntimeIntervalsForMonitor(
|
|
300
|
+
MONITOR_A_ID,
|
|
301
|
+
[
|
|
302
|
+
offline(
|
|
303
|
+
MONITOR_B_ID,
|
|
304
|
+
"2026-07-05T00:00:00.000Z",
|
|
305
|
+
"2026-07-06T00:00:00.000Z",
|
|
306
|
+
),
|
|
307
|
+
],
|
|
308
|
+
downtimeStatuses,
|
|
309
|
+
window,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(intervals).toHaveLength(0);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("getUnionDowntimeSeconds", () => {
|
|
317
|
+
const window: UptimeWindow = {
|
|
318
|
+
startDate: new Date("2026-07-18T10:00:00.000Z"),
|
|
319
|
+
endDate: new Date("2026-07-18T13:00:00.000Z"),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
it("computes simple downtime for a single monitor", () => {
|
|
323
|
+
expect(
|
|
324
|
+
SloUtil.getUnionDowntimeSeconds(
|
|
325
|
+
[
|
|
326
|
+
{
|
|
327
|
+
monitorId: MONITOR_A_ID,
|
|
328
|
+
timelines: [
|
|
329
|
+
offline(
|
|
330
|
+
MONITOR_A_ID,
|
|
331
|
+
"2026-07-18T10:00:00.000Z",
|
|
332
|
+
"2026-07-18T11:00:00.000Z",
|
|
333
|
+
),
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
downtimeStatuses,
|
|
338
|
+
window,
|
|
339
|
+
),
|
|
340
|
+
).toBe(SECONDS_IN_HOUR);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("merges overlapping and keeps disjoint downtime across three monitors", () => {
|
|
344
|
+
const perMonitor: Array<{
|
|
345
|
+
monitorId: ObjectID;
|
|
346
|
+
timelines: Array<MonitorStatusTimeline>;
|
|
347
|
+
}> = [
|
|
348
|
+
{
|
|
349
|
+
monitorId: MONITOR_A_ID,
|
|
350
|
+
timelines: [
|
|
351
|
+
offline(
|
|
352
|
+
MONITOR_A_ID,
|
|
353
|
+
"2026-07-18T10:00:00.000Z",
|
|
354
|
+
"2026-07-18T11:00:00.000Z",
|
|
355
|
+
),
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
monitorId: MONITOR_B_ID,
|
|
360
|
+
timelines: [
|
|
361
|
+
offline(
|
|
362
|
+
MONITOR_B_ID,
|
|
363
|
+
"2026-07-18T10:30:00.000Z",
|
|
364
|
+
"2026-07-18T11:30:00.000Z",
|
|
365
|
+
),
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
monitorId: MONITOR_C_ID,
|
|
370
|
+
timelines: [
|
|
371
|
+
offline(
|
|
372
|
+
MONITOR_C_ID,
|
|
373
|
+
"2026-07-18T12:00:00.000Z",
|
|
374
|
+
"2026-07-18T12:30:00.000Z",
|
|
375
|
+
),
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
// 10:00-11:30 merged (5400s) + 12:00-12:30 disjoint (1800s).
|
|
381
|
+
expect(
|
|
382
|
+
SloUtil.getUnionDowntimeSeconds(perMonitor, downtimeStatuses, window),
|
|
383
|
+
).toBe(5400 + 1800);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("merges adjacent intervals without double counting the boundary", () => {
|
|
387
|
+
const perMonitor: Array<{
|
|
388
|
+
monitorId: ObjectID;
|
|
389
|
+
timelines: Array<MonitorStatusTimeline>;
|
|
390
|
+
}> = [
|
|
391
|
+
{
|
|
392
|
+
monitorId: MONITOR_A_ID,
|
|
393
|
+
timelines: [
|
|
394
|
+
offline(
|
|
395
|
+
MONITOR_A_ID,
|
|
396
|
+
"2026-07-18T10:00:00.000Z",
|
|
397
|
+
"2026-07-18T10:30:00.000Z",
|
|
398
|
+
),
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
monitorId: MONITOR_B_ID,
|
|
403
|
+
timelines: [
|
|
404
|
+
offline(
|
|
405
|
+
MONITOR_B_ID,
|
|
406
|
+
"2026-07-18T10:30:00.000Z",
|
|
407
|
+
"2026-07-18T11:00:00.000Z",
|
|
408
|
+
),
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
expect(
|
|
414
|
+
SloUtil.getUnionDowntimeSeconds(perMonitor, downtimeStatuses, window),
|
|
415
|
+
).toBe(SECONDS_IN_HOUR);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("returns 0 when nothing is down", () => {
|
|
419
|
+
expect(
|
|
420
|
+
SloUtil.getUnionDowntimeSeconds(
|
|
421
|
+
[
|
|
422
|
+
{
|
|
423
|
+
monitorId: MONITOR_A_ID,
|
|
424
|
+
timelines: [
|
|
425
|
+
operational(MONITOR_A_ID, "2026-07-18T10:00:00.000Z"),
|
|
426
|
+
],
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
downtimeStatuses,
|
|
430
|
+
window,
|
|
431
|
+
),
|
|
432
|
+
).toBe(0);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("mergeIntervals", () => {
|
|
437
|
+
it("merges overlaps, keeps disjoint intervals and drops empty ones", () => {
|
|
438
|
+
const merged: Array<DowntimeInterval> = SloUtil.mergeIntervals([
|
|
439
|
+
{
|
|
440
|
+
startDate: new Date("2026-07-18T12:00:00.000Z"),
|
|
441
|
+
endDate: new Date("2026-07-18T12:30:00.000Z"),
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
startDate: new Date("2026-07-18T10:00:00.000Z"),
|
|
445
|
+
endDate: new Date("2026-07-18T11:00:00.000Z"),
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
startDate: new Date("2026-07-18T10:30:00.000Z"),
|
|
449
|
+
endDate: new Date("2026-07-18T10:45:00.000Z"),
|
|
450
|
+
},
|
|
451
|
+
// empty interval - must be dropped.
|
|
452
|
+
{
|
|
453
|
+
startDate: new Date("2026-07-18T09:00:00.000Z"),
|
|
454
|
+
endDate: new Date("2026-07-18T09:00:00.000Z"),
|
|
455
|
+
},
|
|
456
|
+
]);
|
|
457
|
+
|
|
458
|
+
expect(merged).toHaveLength(2);
|
|
459
|
+
expect(merged[0]!.startDate.toISOString()).toBe(
|
|
460
|
+
"2026-07-18T10:00:00.000Z",
|
|
461
|
+
);
|
|
462
|
+
expect(merged[0]!.endDate.toISOString()).toBe("2026-07-18T11:00:00.000Z");
|
|
463
|
+
expect(merged[1]!.startDate.toISOString()).toBe(
|
|
464
|
+
"2026-07-18T12:00:00.000Z",
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("computeTimeSli", () => {
|
|
470
|
+
it("computes a simple single-monitor SLI (AnyDown)", () => {
|
|
471
|
+
const window: UptimeWindow = {
|
|
472
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
473
|
+
endDate: new Date("2026-07-11T00:00:00.000Z"),
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const timelines: Array<MonitorStatusTimeline> = [
|
|
477
|
+
operational(MONITOR_A_ID, "2026-06-01T00:00:00.000Z"),
|
|
478
|
+
offline(
|
|
479
|
+
MONITOR_A_ID,
|
|
480
|
+
"2026-07-05T00:00:00.000Z",
|
|
481
|
+
"2026-07-05T01:00:00.000Z",
|
|
482
|
+
),
|
|
483
|
+
operational(MONITOR_A_ID, "2026-07-05T01:00:00.000Z"),
|
|
484
|
+
];
|
|
485
|
+
|
|
486
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
487
|
+
perMonitorTimelines: [
|
|
488
|
+
{ monitorId: MONITOR_A_ID, timelines: timelines },
|
|
489
|
+
],
|
|
490
|
+
downtimeStatuses,
|
|
491
|
+
window,
|
|
492
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// the monitor pre-dates the window, so the denominator is the full 10 days.
|
|
496
|
+
expect(result.totalSeconds).toBe(10 * SECONDS_IN_DAY);
|
|
497
|
+
expect(result.badSeconds).toBe(SECONDS_IN_HOUR);
|
|
498
|
+
expect(result.sliPercentage).toBeCloseTo(
|
|
499
|
+
((10 * SECONDS_IN_DAY - SECONDS_IN_HOUR) / (10 * SECONDS_IN_DAY)) * 100,
|
|
500
|
+
10,
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("clamps the denominator of a young SLO to its first event (AnyDown)", () => {
|
|
505
|
+
/*
|
|
506
|
+
* 18-day window but the monitor's first event is 17 July - two days before the
|
|
507
|
+
* pinned now. Without the clamp the 16 empty days would count as uptime and a
|
|
508
|
+
* monitor that was down half its life would report ~97%.
|
|
509
|
+
*/
|
|
510
|
+
const window: UptimeWindow = {
|
|
511
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
512
|
+
endDate: new Date("2026-07-19T00:00:00.000Z"),
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const timelines: Array<MonitorStatusTimeline> = [
|
|
516
|
+
offline(MONITOR_A_ID, "2026-07-17T00:00:00.000Z"),
|
|
517
|
+
operational(MONITOR_A_ID, "2026-07-18T00:00:00.000Z"),
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
521
|
+
perMonitorTimelines: [
|
|
522
|
+
{ monitorId: MONITOR_A_ID, timelines: timelines },
|
|
523
|
+
],
|
|
524
|
+
downtimeStatuses,
|
|
525
|
+
window,
|
|
526
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
expect(result.totalSeconds).toBe(2 * SECONDS_IN_DAY);
|
|
530
|
+
expect(result.badSeconds).toBe(SECONDS_IN_DAY);
|
|
531
|
+
expect(result.sliPercentage).toBeCloseTo(50, 10);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("anchors the AnyDown window at the EARLIEST first event across monitors", () => {
|
|
535
|
+
const window: UptimeWindow = {
|
|
536
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
537
|
+
endDate: new Date("2026-07-19T00:00:00.000Z"),
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
541
|
+
perMonitorTimelines: [
|
|
542
|
+
{
|
|
543
|
+
monitorId: MONITOR_A_ID,
|
|
544
|
+
timelines: [operational(MONITOR_A_ID, "2026-07-15T00:00:00.000Z")],
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
// young monitor, down since its first event.
|
|
548
|
+
monitorId: MONITOR_B_ID,
|
|
549
|
+
timelines: [offline(MONITOR_B_ID, "2026-07-17T00:00:00.000Z")],
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
downtimeStatuses,
|
|
553
|
+
window,
|
|
554
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// window is anchored at A's first event (15 July), not B's (17 July).
|
|
558
|
+
expect(result.totalSeconds).toBe(4 * SECONDS_IN_DAY);
|
|
559
|
+
expect(result.badSeconds).toBe(2 * SECONDS_IN_DAY);
|
|
560
|
+
expect(result.sliPercentage).toBeCloseTo(50, 10);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("clips the window end to now, not the future window end (AnyDown)", () => {
|
|
564
|
+
// window runs to 1 August but now is 19 July.
|
|
565
|
+
const window: UptimeWindow = {
|
|
566
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
567
|
+
endDate: new Date("2026-08-01T00:00:00.000Z"),
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
571
|
+
perMonitorTimelines: [
|
|
572
|
+
{
|
|
573
|
+
monitorId: MONITOR_A_ID,
|
|
574
|
+
timelines: [operational(MONITOR_A_ID, "2026-07-01T00:00:00.000Z")],
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
downtimeStatuses,
|
|
578
|
+
window,
|
|
579
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
expect(result.totalSeconds).toBe(18 * SECONDS_IN_DAY);
|
|
583
|
+
expect(result.sliPercentage).toBe(100);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it.each([
|
|
587
|
+
SloMultiMonitorMode.AnyDown,
|
|
588
|
+
SloMultiMonitorMode.MonitorSecondsAverage,
|
|
589
|
+
])(
|
|
590
|
+
"returns totalSeconds 0 and 100%% when there is no data at all (%s)",
|
|
591
|
+
(mode: SloMultiMonitorMode) => {
|
|
592
|
+
const window: UptimeWindow = {
|
|
593
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
594
|
+
endDate: new Date("2026-07-11T00:00:00.000Z"),
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
for (const perMonitorTimelines of [
|
|
598
|
+
[],
|
|
599
|
+
[
|
|
600
|
+
{ monitorId: MONITOR_A_ID, timelines: [] },
|
|
601
|
+
{ monitorId: MONITOR_B_ID, timelines: [] },
|
|
602
|
+
],
|
|
603
|
+
]) {
|
|
604
|
+
expect(
|
|
605
|
+
SloUtil.computeTimeSli({
|
|
606
|
+
perMonitorTimelines,
|
|
607
|
+
downtimeStatuses,
|
|
608
|
+
window,
|
|
609
|
+
mode,
|
|
610
|
+
}),
|
|
611
|
+
).toEqual({
|
|
612
|
+
badSeconds: 0,
|
|
613
|
+
totalSeconds: 0,
|
|
614
|
+
sliPercentage: 100,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
it("treats rows that never overlap the window as a fully-up full window (AnyDown)", () => {
|
|
621
|
+
/*
|
|
622
|
+
* Same semantics as UptimeUtil's production-incident test: data exists but none
|
|
623
|
+
* of it clips into the window, so the window reports 100% over its full length.
|
|
624
|
+
*/
|
|
625
|
+
const window: UptimeWindow = {
|
|
626
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
627
|
+
endDate: new Date("2026-07-11T00:00:00.000Z"),
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
631
|
+
perMonitorTimelines: [
|
|
632
|
+
{
|
|
633
|
+
monitorId: MONITOR_A_ID,
|
|
634
|
+
timelines: [
|
|
635
|
+
offline(
|
|
636
|
+
MONITOR_A_ID,
|
|
637
|
+
"2026-01-01T00:00:00.000Z",
|
|
638
|
+
"2026-01-02T00:00:00.000Z",
|
|
639
|
+
),
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
downtimeStatuses,
|
|
644
|
+
window,
|
|
645
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
expect(result.totalSeconds).toBe(10 * SECONDS_IN_DAY);
|
|
649
|
+
expect(result.badSeconds).toBe(0);
|
|
650
|
+
expect(result.sliPercentage).toBe(100);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("skips monitors with zero rows in MonitorSecondsAverage instead of diluting", () => {
|
|
654
|
+
const window: UptimeWindow = {
|
|
655
|
+
startDate: new Date("2026-07-17T00:00:00.000Z"),
|
|
656
|
+
endDate: new Date("2026-07-19T00:00:00.000Z"),
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const withEmptyMonitor: TimeSliResult = SloUtil.computeTimeSli({
|
|
660
|
+
perMonitorTimelines: [
|
|
661
|
+
{
|
|
662
|
+
monitorId: MONITOR_A_ID,
|
|
663
|
+
timelines: [offline(MONITOR_A_ID, "2026-07-17T00:00:00.000Z")],
|
|
664
|
+
},
|
|
665
|
+
{ monitorId: MONITOR_B_ID, timelines: [] },
|
|
666
|
+
],
|
|
667
|
+
downtimeStatuses,
|
|
668
|
+
window,
|
|
669
|
+
mode: SloMultiMonitorMode.MonitorSecondsAverage,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// monitor B contributes nothing: the SLI is A's alone - 0%.
|
|
673
|
+
expect(withEmptyMonitor.totalSeconds).toBe(2 * SECONDS_IN_DAY);
|
|
674
|
+
expect(withEmptyMonitor.badSeconds).toBe(2 * SECONDS_IN_DAY);
|
|
675
|
+
expect(withEmptyMonitor.sliPercentage).toBeCloseTo(0, 10);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
describe("getErrorBudget", () => {
|
|
680
|
+
it("computes the budget from bad/total seconds", () => {
|
|
681
|
+
// 99.9% target over ~11.6 days: 0.1% of 1,000,000s = 1000s of budget.
|
|
682
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
683
|
+
badSeconds: 400,
|
|
684
|
+
totalSeconds: 1000000,
|
|
685
|
+
targetPercentage: 99.9,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
expect(budget.budgetTotalSeconds).toBeCloseTo(1000, 6);
|
|
689
|
+
expect(budget.budgetConsumedSeconds).toBe(400);
|
|
690
|
+
expect(budget.budgetRemainingSeconds).toBeCloseTo(600, 6);
|
|
691
|
+
expect(budget.budgetRemainingPercentage).toBeCloseTo(60, 6);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("computes the budget from an SLI percentage", () => {
|
|
695
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
696
|
+
sliPercentage: 99.95,
|
|
697
|
+
totalSeconds: 1000000,
|
|
698
|
+
targetPercentage: 99.9,
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
expect(budget.budgetConsumedSeconds).toBeCloseTo(500, 6);
|
|
702
|
+
expect(budget.budgetRemainingSeconds).toBeCloseTo(500, 6);
|
|
703
|
+
expect(budget.budgetRemainingPercentage).toBeCloseTo(50, 6);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("goes SIGNED negative when over budget - never clamped here", () => {
|
|
707
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
708
|
+
badSeconds: 1500,
|
|
709
|
+
totalSeconds: 1000000,
|
|
710
|
+
targetPercentage: 99.9,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
expect(budget.budgetRemainingSeconds).toBeCloseTo(-500, 6);
|
|
714
|
+
expect(budget.budgetRemainingSeconds).toBeLessThan(0);
|
|
715
|
+
expect(budget.budgetRemainingPercentage).toBeCloseTo(-50, 6);
|
|
716
|
+
expect(budget.budgetRemainingPercentage).toBeLessThan(0);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("caps the remaining percentage at 100", () => {
|
|
720
|
+
// degenerate negative consumption must not report more than a full budget.
|
|
721
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
722
|
+
badSeconds: -10,
|
|
723
|
+
totalSeconds: 1000000,
|
|
724
|
+
targetPercentage: 99.9,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
expect(budget.budgetRemainingPercentage).toBe(100);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("reports a full budget when no time has elapsed", () => {
|
|
731
|
+
expect(
|
|
732
|
+
SloUtil.getErrorBudget({
|
|
733
|
+
badSeconds: 0,
|
|
734
|
+
totalSeconds: 0,
|
|
735
|
+
targetPercentage: 99.9,
|
|
736
|
+
}),
|
|
737
|
+
).toEqual({
|
|
738
|
+
budgetTotalSeconds: 0,
|
|
739
|
+
budgetConsumedSeconds: 0,
|
|
740
|
+
budgetRemainingSeconds: 0,
|
|
741
|
+
budgetRemainingPercentage: 100,
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it.each([100, 100.5, 0, -5])(
|
|
746
|
+
"rejects a target of %s%%",
|
|
747
|
+
(targetPercentage: number) => {
|
|
748
|
+
expect(() => {
|
|
749
|
+
return SloUtil.getErrorBudget({
|
|
750
|
+
badSeconds: 0,
|
|
751
|
+
totalSeconds: 1000,
|
|
752
|
+
targetPercentage,
|
|
753
|
+
});
|
|
754
|
+
}).toThrow(BadDataException);
|
|
755
|
+
},
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
it("rejects a call with neither sliPercentage nor badSeconds", () => {
|
|
759
|
+
expect(() => {
|
|
760
|
+
return SloUtil.getErrorBudget({
|
|
761
|
+
totalSeconds: 1000,
|
|
762
|
+
targetPercentage: 99.9,
|
|
763
|
+
});
|
|
764
|
+
}).toThrow(BadDataException);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe("computeBurnRate", () => {
|
|
769
|
+
it("computes the burn rate as badFraction / allowedBadFraction", () => {
|
|
770
|
+
// burning 1% of the period against a 0.1% allowance = 10x burn.
|
|
771
|
+
expect(
|
|
772
|
+
SloUtil.computeBurnRate({
|
|
773
|
+
badSeconds: 36,
|
|
774
|
+
totalSeconds: 3600,
|
|
775
|
+
targetPercentage: 99.9,
|
|
776
|
+
}),
|
|
777
|
+
).toBeCloseTo(10, 10);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("returns exactly 1 when burning at precisely the sustainable rate", () => {
|
|
781
|
+
expect(
|
|
782
|
+
SloUtil.computeBurnRate({
|
|
783
|
+
badSeconds: (1 - 99.9 / 100) * 3600,
|
|
784
|
+
totalSeconds: 3600,
|
|
785
|
+
targetPercentage: 99.9,
|
|
786
|
+
}),
|
|
787
|
+
).toBeCloseTo(1, 10);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("works with event counts the same as with seconds", () => {
|
|
791
|
+
// 1 bad request out of 1000 against a 99% target: 0.1% / 1% = 0.1x.
|
|
792
|
+
expect(
|
|
793
|
+
SloUtil.computeBurnRate({
|
|
794
|
+
badSeconds: 1,
|
|
795
|
+
totalSeconds: 1000,
|
|
796
|
+
targetPercentage: 99,
|
|
797
|
+
}),
|
|
798
|
+
).toBeCloseTo(0.1, 10);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("returns 0 for a no-data window", () => {
|
|
802
|
+
expect(
|
|
803
|
+
SloUtil.computeBurnRate({
|
|
804
|
+
badSeconds: 0,
|
|
805
|
+
totalSeconds: 0,
|
|
806
|
+
targetPercentage: 99.9,
|
|
807
|
+
}),
|
|
808
|
+
).toBe(0);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("returns 0 when nothing is bad", () => {
|
|
812
|
+
expect(
|
|
813
|
+
SloUtil.computeBurnRate({
|
|
814
|
+
badSeconds: 0,
|
|
815
|
+
totalSeconds: 3600,
|
|
816
|
+
targetPercentage: 99.9,
|
|
817
|
+
}),
|
|
818
|
+
).toBe(0);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it.each([100, 0])(
|
|
822
|
+
"rejects a target of %s%%",
|
|
823
|
+
(targetPercentage: number) => {
|
|
824
|
+
expect(() => {
|
|
825
|
+
return SloUtil.computeBurnRate({
|
|
826
|
+
badSeconds: 1,
|
|
827
|
+
totalSeconds: 100,
|
|
828
|
+
targetPercentage,
|
|
829
|
+
});
|
|
830
|
+
}).toThrow(BadDataException);
|
|
831
|
+
},
|
|
832
|
+
);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
describe("computeSloStatus hysteresis (threshold 20)", () => {
|
|
836
|
+
type StatusCase = [SloStatus, number, SloStatus];
|
|
837
|
+
|
|
838
|
+
const cases: Array<StatusCase> = [
|
|
839
|
+
// plain healthy.
|
|
840
|
+
[SloStatus.Healthy, 50, SloStatus.Healthy],
|
|
841
|
+
[SloStatus.Healthy, 20.001, SloStatus.Healthy],
|
|
842
|
+
// enter AtRisk at <= threshold.
|
|
843
|
+
[SloStatus.Healthy, 20, SloStatus.AtRisk],
|
|
844
|
+
[SloStatus.Healthy, 10, SloStatus.AtRisk],
|
|
845
|
+
// hysteresis: stays AtRisk in the band between threshold and threshold + 5...
|
|
846
|
+
[SloStatus.AtRisk, 21, SloStatus.AtRisk],
|
|
847
|
+
[SloStatus.AtRisk, 24.999, SloStatus.AtRisk],
|
|
848
|
+
// ...and exits only at >= threshold + 5.
|
|
849
|
+
[SloStatus.AtRisk, 25, SloStatus.Healthy],
|
|
850
|
+
[SloStatus.AtRisk, 30, SloStatus.Healthy],
|
|
851
|
+
// enter BudgetExhausted at <= 0 from any state.
|
|
852
|
+
[SloStatus.Healthy, 0, SloStatus.BudgetExhausted],
|
|
853
|
+
[SloStatus.AtRisk, -10, SloStatus.BudgetExhausted],
|
|
854
|
+
// hysteresis: stays exhausted below 2...
|
|
855
|
+
[SloStatus.BudgetExhausted, 1.5, SloStatus.BudgetExhausted],
|
|
856
|
+
// ...then exits into AtRisk (still under the at-risk threshold).
|
|
857
|
+
[SloStatus.BudgetExhausted, 2, SloStatus.AtRisk],
|
|
858
|
+
[SloStatus.BudgetExhausted, 30, SloStatus.Healthy],
|
|
859
|
+
// never emits Misconfigured/Paused - the caller owns those.
|
|
860
|
+
[SloStatus.Paused, 50, SloStatus.Healthy],
|
|
861
|
+
[SloStatus.Misconfigured, 1, SloStatus.AtRisk],
|
|
862
|
+
];
|
|
863
|
+
|
|
864
|
+
it.each(cases)(
|
|
865
|
+
"from %s with %s%% remaining -> %s",
|
|
866
|
+
(
|
|
867
|
+
currentStatus: SloStatus,
|
|
868
|
+
budgetRemainingPercentage: number,
|
|
869
|
+
expected: SloStatus,
|
|
870
|
+
) => {
|
|
871
|
+
expect(
|
|
872
|
+
SloUtil.computeSloStatus({
|
|
873
|
+
budgetRemainingPercentage,
|
|
874
|
+
currentStatus,
|
|
875
|
+
atRiskThresholdPercentage: 20,
|
|
876
|
+
}),
|
|
877
|
+
).toBe(expected);
|
|
878
|
+
},
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
it("does not flap on a value oscillating just above the threshold", () => {
|
|
882
|
+
// dips to 19 -> AtRisk; recovers to 22 -> must STAY AtRisk; back to 19 -> AtRisk.
|
|
883
|
+
let status: SloStatus = SloStatus.Healthy;
|
|
884
|
+
|
|
885
|
+
for (const remaining of [19, 22, 19, 23, 24.9]) {
|
|
886
|
+
status = SloUtil.computeSloStatus({
|
|
887
|
+
budgetRemainingPercentage: remaining,
|
|
888
|
+
currentStatus: status,
|
|
889
|
+
atRiskThresholdPercentage: 20,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
expect(status).toBe(SloStatus.AtRisk);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// only a real recovery clears it.
|
|
896
|
+
expect(
|
|
897
|
+
SloUtil.computeSloStatus({
|
|
898
|
+
budgetRemainingPercentage: 25,
|
|
899
|
+
currentStatus: status,
|
|
900
|
+
atRiskThresholdPercentage: 20,
|
|
901
|
+
}),
|
|
902
|
+
).toBe(SloStatus.Healthy);
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
describe("getCalendarMonthWindow", () => {
|
|
907
|
+
it("returns the full UTC month containing the instant", () => {
|
|
908
|
+
const window: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
909
|
+
at: new Date("2026-07-15T12:34:56.000Z"),
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
expect(window.startDate.toISOString()).toBe("2026-07-01T00:00:00.000Z");
|
|
913
|
+
expect(window.endDate.toISOString()).toBe("2026-08-01T00:00:00.000Z");
|
|
914
|
+
expect(window.totalSecondsInFullPeriod).toBe(31 * SECONDS_IN_DAY);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("handles month boundaries exactly", () => {
|
|
918
|
+
const firstInstantOfJuly: CalendarMonthWindow =
|
|
919
|
+
SloUtil.getCalendarMonthWindow({
|
|
920
|
+
at: new Date("2026-07-01T00:00:00.000Z"),
|
|
921
|
+
timezone: "UTC",
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
expect(firstInstantOfJuly.startDate.toISOString()).toBe(
|
|
925
|
+
"2026-07-01T00:00:00.000Z",
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
const lastInstantOfJune: CalendarMonthWindow =
|
|
929
|
+
SloUtil.getCalendarMonthWindow({
|
|
930
|
+
at: new Date("2026-06-30T23:59:59.999Z"),
|
|
931
|
+
timezone: "UTC",
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
expect(lastInstantOfJune.startDate.toISOString()).toBe(
|
|
935
|
+
"2026-06-01T00:00:00.000Z",
|
|
936
|
+
);
|
|
937
|
+
expect(lastInstantOfJune.endDate.toISOString()).toBe(
|
|
938
|
+
"2026-07-01T00:00:00.000Z",
|
|
939
|
+
);
|
|
940
|
+
expect(lastInstantOfJune.totalSecondsInFullPeriod).toBe(
|
|
941
|
+
30 * SECONDS_IN_DAY,
|
|
942
|
+
);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it("gets February right in normal and leap years", () => {
|
|
946
|
+
expect(
|
|
947
|
+
SloUtil.getCalendarMonthWindow({
|
|
948
|
+
at: new Date("2026-02-10T00:00:00.000Z"),
|
|
949
|
+
}).totalSecondsInFullPeriod,
|
|
950
|
+
).toBe(28 * SECONDS_IN_DAY);
|
|
951
|
+
|
|
952
|
+
expect(
|
|
953
|
+
SloUtil.getCalendarMonthWindow({
|
|
954
|
+
at: new Date("2028-02-10T00:00:00.000Z"),
|
|
955
|
+
}).totalSecondsInFullPeriod,
|
|
956
|
+
).toBe(29 * SECONDS_IN_DAY);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("resolves the month in the SLO's timezone, not UTC", () => {
|
|
960
|
+
// 1 July 02:00 UTC is still 30 June 22:00 in New York - the JUNE window.
|
|
961
|
+
const window: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
962
|
+
at: new Date("2026-07-01T02:00:00.000Z"),
|
|
963
|
+
timezone: "America/New_York",
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
expect(window.startDate.toISOString()).toBe("2026-06-01T04:00:00.000Z");
|
|
967
|
+
expect(window.endDate.toISOString()).toBe("2026-07-01T04:00:00.000Z");
|
|
968
|
+
expect(window.totalSecondsInFullPeriod).toBe(30 * SECONDS_IN_DAY);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("uses real elapsed seconds across a DST transition", () => {
|
|
972
|
+
// March 2026 in New York springs forward on 8 March: the month is 1h shorter.
|
|
973
|
+
const window: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
974
|
+
at: new Date("2026-03-15T12:00:00.000Z"),
|
|
975
|
+
timezone: "America/New_York",
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
expect(window.startDate.toISOString()).toBe("2026-03-01T05:00:00.000Z");
|
|
979
|
+
expect(window.endDate.toISOString()).toBe("2026-04-01T04:00:00.000Z");
|
|
980
|
+
expect(window.totalSecondsInFullPeriod).toBe(
|
|
981
|
+
31 * SECONDS_IN_DAY - SECONDS_IN_HOUR,
|
|
982
|
+
);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("rejects an unknown timezone", () => {
|
|
986
|
+
expect(() => {
|
|
987
|
+
return SloUtil.getCalendarMonthWindow({
|
|
988
|
+
at: NOW,
|
|
989
|
+
timezone: "Not/AZone",
|
|
990
|
+
});
|
|
991
|
+
}).toThrow(BadDataException);
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
describe("roundForDisplay", () => {
|
|
996
|
+
it("floors to the requested precision, like UptimeUtil", () => {
|
|
997
|
+
expect(SloUtil.roundForDisplay(99.999, UptimePrecision.TWO_DECIMAL)).toBe(
|
|
998
|
+
99.99,
|
|
999
|
+
);
|
|
1000
|
+
expect(
|
|
1001
|
+
SloUtil.roundForDisplay(66.6666, UptimePrecision.ONE_DECIMAL),
|
|
1002
|
+
).toBe(66.6);
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
describe("getEarliestEventStartDate", () => {
|
|
1007
|
+
const window: UptimeWindow = {
|
|
1008
|
+
startDate: new Date("2026-07-18T10:00:00.000Z"),
|
|
1009
|
+
endDate: new Date("2026-07-18T13:00:00.000Z"),
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
it("returns null when there are no monitors attached at all", () => {
|
|
1013
|
+
expect(SloUtil.getEarliestEventStartDate([], window)).toBeNull();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("returns null when every attached monitor has zero timeline rows", () => {
|
|
1017
|
+
expect(
|
|
1018
|
+
SloUtil.getEarliestEventStartDate(
|
|
1019
|
+
[
|
|
1020
|
+
{ monitorId: MONITOR_A_ID, timelines: [] },
|
|
1021
|
+
{ monitorId: MONITOR_B_ID, timelines: [] },
|
|
1022
|
+
],
|
|
1023
|
+
window,
|
|
1024
|
+
),
|
|
1025
|
+
).toBeNull();
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("returns null when rows exist but none of them overlap the window", () => {
|
|
1029
|
+
/*
|
|
1030
|
+
* January rows against a July window: nothing clips in, so this window can
|
|
1031
|
+
* see no data. Callers must read this as "cannot judge", NOT as "no
|
|
1032
|
+
* downtime" - the burn-rate evaluator refuses to evaluate a rule at all
|
|
1033
|
+
* when this is null.
|
|
1034
|
+
*/
|
|
1035
|
+
expect(
|
|
1036
|
+
SloUtil.getEarliestEventStartDate(
|
|
1037
|
+
[
|
|
1038
|
+
{
|
|
1039
|
+
monitorId: MONITOR_A_ID,
|
|
1040
|
+
timelines: [
|
|
1041
|
+
offline(
|
|
1042
|
+
MONITOR_A_ID,
|
|
1043
|
+
"2026-01-01T00:00:00.000Z",
|
|
1044
|
+
"2026-01-02T00:00:00.000Z",
|
|
1045
|
+
),
|
|
1046
|
+
],
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
window,
|
|
1050
|
+
),
|
|
1051
|
+
).toBeNull();
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("returns the earliest event start across ALL monitors, not just the first monitor in the array", () => {
|
|
1055
|
+
// the earliest row deliberately belongs to the SECOND entry of the array.
|
|
1056
|
+
const earliest: Date | null = SloUtil.getEarliestEventStartDate(
|
|
1057
|
+
[
|
|
1058
|
+
{
|
|
1059
|
+
monitorId: MONITOR_A_ID,
|
|
1060
|
+
timelines: [operational(MONITOR_A_ID, "2026-07-18T12:00:00.000Z")],
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
monitorId: MONITOR_B_ID,
|
|
1064
|
+
timelines: [
|
|
1065
|
+
offline(
|
|
1066
|
+
MONITOR_B_ID,
|
|
1067
|
+
"2026-07-18T10:15:00.000Z",
|
|
1068
|
+
"2026-07-18T10:45:00.000Z",
|
|
1069
|
+
),
|
|
1070
|
+
],
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
monitorId: MONITOR_C_ID,
|
|
1074
|
+
timelines: [operational(MONITOR_C_ID, "2026-07-18T11:00:00.000Z")],
|
|
1075
|
+
},
|
|
1076
|
+
],
|
|
1077
|
+
window,
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
expect(earliest!.toISOString()).toBe("2026-07-18T10:15:00.000Z");
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it("returns the earliest of several rows belonging to one monitor", () => {
|
|
1084
|
+
const earliest: Date | null = SloUtil.getEarliestEventStartDate(
|
|
1085
|
+
[
|
|
1086
|
+
{
|
|
1087
|
+
monitorId: MONITOR_A_ID,
|
|
1088
|
+
timelines: [
|
|
1089
|
+
// deliberately out of chronological order in the array.
|
|
1090
|
+
offline(
|
|
1091
|
+
MONITOR_A_ID,
|
|
1092
|
+
"2026-07-18T12:00:00.000Z",
|
|
1093
|
+
"2026-07-18T12:30:00.000Z",
|
|
1094
|
+
),
|
|
1095
|
+
operational(
|
|
1096
|
+
MONITOR_A_ID,
|
|
1097
|
+
"2026-07-18T10:30:00.000Z",
|
|
1098
|
+
"2026-07-18T12:00:00.000Z",
|
|
1099
|
+
),
|
|
1100
|
+
operational(MONITOR_A_ID, "2026-07-18T12:30:00.000Z"),
|
|
1101
|
+
],
|
|
1102
|
+
},
|
|
1103
|
+
],
|
|
1104
|
+
window,
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
expect(earliest!.toISOString()).toBe("2026-07-18T10:30:00.000Z");
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it("reports the CLIPPED window start for an event that began before the window, matching the SLI denominator's clamp exactly", () => {
|
|
1111
|
+
/*
|
|
1112
|
+
* The gate (this helper) and the clamp (computeTimeSli) must never drift:
|
|
1113
|
+
* both run through UptimeUtil.getMonitorEventsForId, so an event that
|
|
1114
|
+
* started before the window reports the WINDOW start, and the denominator
|
|
1115
|
+
* is therefore the full window.
|
|
1116
|
+
*/
|
|
1117
|
+
const perMonitorTimelines: Array<{
|
|
1118
|
+
monitorId: ObjectID;
|
|
1119
|
+
timelines: Array<MonitorStatusTimeline>;
|
|
1120
|
+
}> = [
|
|
1121
|
+
{
|
|
1122
|
+
monitorId: MONITOR_A_ID,
|
|
1123
|
+
timelines: [operational(MONITOR_A_ID, "2026-07-18T08:00:00.000Z")],
|
|
1124
|
+
},
|
|
1125
|
+
];
|
|
1126
|
+
|
|
1127
|
+
const earliest: Date | null = SloUtil.getEarliestEventStartDate(
|
|
1128
|
+
perMonitorTimelines,
|
|
1129
|
+
window,
|
|
1130
|
+
);
|
|
1131
|
+
|
|
1132
|
+
expect(earliest!.toISOString()).toBe("2026-07-18T10:00:00.000Z");
|
|
1133
|
+
expect(earliest!.getTime()).toBe(window.startDate.getTime());
|
|
1134
|
+
|
|
1135
|
+
const sli: TimeSliResult = SloUtil.computeTimeSli({
|
|
1136
|
+
perMonitorTimelines,
|
|
1137
|
+
downtimeStatuses,
|
|
1138
|
+
window,
|
|
1139
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// the clamp moved nothing: the full three-hour window is the denominator.
|
|
1143
|
+
expect(sli.totalSeconds).toBe(3 * SECONDS_IN_HOUR);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("never returns a date before the window start, even for a months-old open row", () => {
|
|
1147
|
+
const earliest: Date | null = SloUtil.getEarliestEventStartDate(
|
|
1148
|
+
[
|
|
1149
|
+
{
|
|
1150
|
+
monitorId: MONITOR_A_ID,
|
|
1151
|
+
timelines: [offline(MONITOR_A_ID, "2026-01-01T00:00:00.000Z")],
|
|
1152
|
+
},
|
|
1153
|
+
],
|
|
1154
|
+
window,
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
expect(earliest!.getTime()).toBeGreaterThanOrEqual(
|
|
1158
|
+
window.startDate.getTime(),
|
|
1159
|
+
);
|
|
1160
|
+
expect(earliest!.toISOString()).toBe("2026-07-18T10:00:00.000Z");
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
/*
|
|
1165
|
+
* THE REGRESSION: a monitor whose first event is 5 minutes old with 20 seconds
|
|
1166
|
+
* of downtime used to PAGE on a 60-minute burn rule.
|
|
1167
|
+
*
|
|
1168
|
+
* computeTimeSli clamps its denominator forward to the earliest observed
|
|
1169
|
+
* event. Inside a fixed-length burn window that silently turns the denominator
|
|
1170
|
+
* into the DATA AGE: 20s/3600s (5.6x burn, below a 14.4x page threshold)
|
|
1171
|
+
* becomes 20s/300s (66.7x, far above it). Both the long and the short window
|
|
1172
|
+
* then measure the same few minutes, both breach, and the multi-window rule -
|
|
1173
|
+
* whose entire purpose is to demand SUSTAINED evidence before paging - pages on
|
|
1174
|
+
* seconds of data. Every freshly created SLO + monitor pair paged on its first
|
|
1175
|
+
* blip.
|
|
1176
|
+
*
|
|
1177
|
+
* The fix is a gate, not a change to the clamp: EvaluateSlos.evaluateBurnRateRule
|
|
1178
|
+
* calls getEarliestEventStartDate and SKIPS the rule when the earliest observed
|
|
1179
|
+
* data is AFTER (now - longWindowInMinutes). These tests pin both halves - the
|
|
1180
|
+
* gate's answer, and the math that makes the gate necessary.
|
|
1181
|
+
*/
|
|
1182
|
+
describe("getEarliestEventStartDate: the young-monitor false-page regression", () => {
|
|
1183
|
+
const SIXTY_MINUTE_WINDOW: UptimeWindow = {
|
|
1184
|
+
startDate: new Date("2026-07-18T23:00:00.000Z"),
|
|
1185
|
+
endDate: NOW,
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
// first ever row is 5 minutes old and carries 20 seconds of downtime.
|
|
1189
|
+
const youngMonitor: Array<{
|
|
1190
|
+
monitorId: ObjectID;
|
|
1191
|
+
timelines: Array<MonitorStatusTimeline>;
|
|
1192
|
+
}> = [
|
|
1193
|
+
{
|
|
1194
|
+
monitorId: MONITOR_A_ID,
|
|
1195
|
+
timelines: [
|
|
1196
|
+
offline(
|
|
1197
|
+
MONITOR_A_ID,
|
|
1198
|
+
"2026-07-18T23:55:00.000Z",
|
|
1199
|
+
"2026-07-18T23:55:20.000Z",
|
|
1200
|
+
),
|
|
1201
|
+
operational(MONITOR_A_ID, "2026-07-18T23:55:20.000Z"),
|
|
1202
|
+
],
|
|
1203
|
+
},
|
|
1204
|
+
];
|
|
1205
|
+
|
|
1206
|
+
it("reports data as only 5 minutes old, i.e. AFTER the start of a 60-minute window - the exact condition the worker skips on", () => {
|
|
1207
|
+
const earliest: Date | null = SloUtil.getEarliestEventStartDate(
|
|
1208
|
+
youngMonitor,
|
|
1209
|
+
SIXTY_MINUTE_WINDOW,
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
expect(earliest!.toISOString()).toBe("2026-07-18T23:55:00.000Z");
|
|
1213
|
+
|
|
1214
|
+
// the worker's gate, expressed exactly as EvaluateSlos computes it.
|
|
1215
|
+
const longWindowStart: Date = new Date(NOW.getTime() - 60 * 60 * 1000);
|
|
1216
|
+
|
|
1217
|
+
expect(earliest!.getTime()).toBeGreaterThan(longWindowStart.getTime());
|
|
1218
|
+
|
|
1219
|
+
const shouldSkipRule: boolean =
|
|
1220
|
+
!earliest || earliest.getTime() > longWindowStart.getTime();
|
|
1221
|
+
|
|
1222
|
+
expect(shouldSkipRule).toBe(true);
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it("a monitor with a full hour of history does NOT trip the gate", () => {
|
|
1226
|
+
const earliest: Date | null = SloUtil.getEarliestEventStartDate(
|
|
1227
|
+
[
|
|
1228
|
+
{
|
|
1229
|
+
monitorId: MONITOR_A_ID,
|
|
1230
|
+
timelines: [operational(MONITOR_A_ID, "2026-07-18T20:00:00.000Z")],
|
|
1231
|
+
},
|
|
1232
|
+
],
|
|
1233
|
+
SIXTY_MINUTE_WINDOW,
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
const longWindowStart: Date = new Date(NOW.getTime() - 60 * 60 * 1000);
|
|
1237
|
+
|
|
1238
|
+
// clipped to the window start, which is exactly the long window start.
|
|
1239
|
+
expect(earliest!.getTime()).toBe(longWindowStart.getTime());
|
|
1240
|
+
expect(earliest!.getTime()).not.toBeGreaterThan(
|
|
1241
|
+
longWindowStart.getTime(),
|
|
1242
|
+
);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it("documents WHY the gate exists: the 60-minute denominator collapses to 300 seconds of data age, not 3600", () => {
|
|
1246
|
+
const sli: TimeSliResult = SloUtil.computeTimeSli({
|
|
1247
|
+
perMonitorTimelines: youngMonitor,
|
|
1248
|
+
downtimeStatuses,
|
|
1249
|
+
window: SIXTY_MINUTE_WINDOW,
|
|
1250
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// NOT 3600 - the clamp is still there, which is why the gate is needed.
|
|
1254
|
+
expect(sli.totalSeconds).toBe(300);
|
|
1255
|
+
expect(sli.badSeconds).toBe(20);
|
|
1256
|
+
expect(sli.sliPercentage).toBeCloseTo(93.3333333333, 8);
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
it("documents the false page: 20s of downtime reads as 66.7x burn instead of 5.6x, crossing a 14.4x page threshold", () => {
|
|
1260
|
+
const sli: TimeSliResult = SloUtil.computeTimeSli({
|
|
1261
|
+
perMonitorTimelines: youngMonitor,
|
|
1262
|
+
downtimeStatuses,
|
|
1263
|
+
window: SIXTY_MINUTE_WINDOW,
|
|
1264
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
const collapsedBurnRate: number = SloUtil.computeBurnRate({
|
|
1268
|
+
badSeconds: sli.badSeconds,
|
|
1269
|
+
totalSeconds: sli.totalSeconds,
|
|
1270
|
+
targetPercentage: 99.9,
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
const honestBurnRate: number = SloUtil.computeBurnRate({
|
|
1274
|
+
badSeconds: 20,
|
|
1275
|
+
totalSeconds: 60 * 60,
|
|
1276
|
+
targetPercentage: 99.9,
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
expect(collapsedBurnRate).toBeCloseTo(66.6666666667, 6);
|
|
1280
|
+
expect(honestBurnRate).toBeCloseTo(5.5555555556, 6);
|
|
1281
|
+
|
|
1282
|
+
// the canonical fast-burn page threshold sits between the two.
|
|
1283
|
+
const PAGE_THRESHOLD: number = 14.4;
|
|
1284
|
+
|
|
1285
|
+
expect(collapsedBurnRate).toBeGreaterThan(PAGE_THRESHOLD);
|
|
1286
|
+
expect(honestBurnRate).toBeLessThan(PAGE_THRESHOLD);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
it("the same 20 seconds inside a monitor that HAS an hour of history stays below the page threshold", () => {
|
|
1290
|
+
const oldMonitor: Array<{
|
|
1291
|
+
monitorId: ObjectID;
|
|
1292
|
+
timelines: Array<MonitorStatusTimeline>;
|
|
1293
|
+
}> = [
|
|
1294
|
+
{
|
|
1295
|
+
monitorId: MONITOR_A_ID,
|
|
1296
|
+
timelines: [
|
|
1297
|
+
operational(
|
|
1298
|
+
MONITOR_A_ID,
|
|
1299
|
+
"2026-07-18T12:00:00.000Z",
|
|
1300
|
+
"2026-07-18T23:55:00.000Z",
|
|
1301
|
+
),
|
|
1302
|
+
offline(
|
|
1303
|
+
MONITOR_A_ID,
|
|
1304
|
+
"2026-07-18T23:55:00.000Z",
|
|
1305
|
+
"2026-07-18T23:55:20.000Z",
|
|
1306
|
+
),
|
|
1307
|
+
operational(MONITOR_A_ID, "2026-07-18T23:55:20.000Z"),
|
|
1308
|
+
],
|
|
1309
|
+
},
|
|
1310
|
+
];
|
|
1311
|
+
|
|
1312
|
+
const sli: TimeSliResult = SloUtil.computeTimeSli({
|
|
1313
|
+
perMonitorTimelines: oldMonitor,
|
|
1314
|
+
downtimeStatuses,
|
|
1315
|
+
window: SIXTY_MINUTE_WINDOW,
|
|
1316
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
expect(sli.totalSeconds).toBe(3600);
|
|
1320
|
+
expect(sli.badSeconds).toBe(20);
|
|
1321
|
+
expect(
|
|
1322
|
+
SloUtil.computeBurnRate({
|
|
1323
|
+
badSeconds: sli.badSeconds,
|
|
1324
|
+
totalSeconds: sli.totalSeconds,
|
|
1325
|
+
targetPercentage: 99.9,
|
|
1326
|
+
}),
|
|
1327
|
+
).toBeCloseTo(5.5555555556, 6);
|
|
1328
|
+
});
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
describe("computeTimeSli: the zero-data contract the worker guards on", () => {
|
|
1332
|
+
it("returns exactly {0, 0, 100} for no rows at all - which EvaluateSlos must treat as Misconfigured, never as a healthy 100%", () => {
|
|
1333
|
+
/*
|
|
1334
|
+
* The 100 here is NOT a measurement: there is nothing to measure. The
|
|
1335
|
+
* zero-data guard in App/FeatureSet/Workers/Jobs/Slo/EvaluateSlos.ts
|
|
1336
|
+
* (`if (sli.totalSeconds === 0)`) turns this into SloStatus.Misconfigured
|
|
1337
|
+
* and writes no history row - without it, an enabled SLO whose monitor has
|
|
1338
|
+
* never written a MonitorStatusTimeline row would persist as Healthy with
|
|
1339
|
+
* currentSliPercentage 100, a full error budget, and sli.percent = 100
|
|
1340
|
+
* charts: a green SLO that measures nothing.
|
|
1341
|
+
*/
|
|
1342
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
1343
|
+
perMonitorTimelines: [{ monitorId: MONITOR_A_ID, timelines: [] }],
|
|
1344
|
+
downtimeStatuses,
|
|
1345
|
+
window: {
|
|
1346
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
1347
|
+
endDate: new Date("2026-07-11T00:00:00.000Z"),
|
|
1348
|
+
},
|
|
1349
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
expect(result).toEqual({
|
|
1353
|
+
badSeconds: 0,
|
|
1354
|
+
totalSeconds: 0,
|
|
1355
|
+
sliPercentage: 100,
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
// the budget derived from it is empty too - nothing to spend, nothing spent.
|
|
1359
|
+
expect(
|
|
1360
|
+
SloUtil.getErrorBudget({
|
|
1361
|
+
badSeconds: result.badSeconds,
|
|
1362
|
+
totalSeconds: result.totalSeconds,
|
|
1363
|
+
targetPercentage: 99.9,
|
|
1364
|
+
}).budgetTotalSeconds,
|
|
1365
|
+
).toBe(0);
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it("returns totalSeconds 0 for a window whose whole length is in the future", () => {
|
|
1369
|
+
// pinned now is 19 July: a window that starts tomorrow has no elapsed time.
|
|
1370
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
1371
|
+
perMonitorTimelines: [
|
|
1372
|
+
{
|
|
1373
|
+
monitorId: MONITOR_A_ID,
|
|
1374
|
+
timelines: [operational(MONITOR_A_ID, "2026-07-01T00:00:00.000Z")],
|
|
1375
|
+
},
|
|
1376
|
+
],
|
|
1377
|
+
downtimeStatuses,
|
|
1378
|
+
window: {
|
|
1379
|
+
startDate: new Date("2026-07-20T00:00:00.000Z"),
|
|
1380
|
+
endDate: new Date("2026-07-21T00:00:00.000Z"),
|
|
1381
|
+
},
|
|
1382
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
expect(result).toEqual({
|
|
1386
|
+
badSeconds: 0,
|
|
1387
|
+
totalSeconds: 0,
|
|
1388
|
+
sliPercentage: 100,
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
describe("computeTimeSli: AnyDown vs MonitorSecondsAverage denominator anchoring", () => {
|
|
1394
|
+
/*
|
|
1395
|
+
* One old monitor (data since 1 June, always up) and one young monitor
|
|
1396
|
+
* (created 18 July, down ever since). The two modes anchor their
|
|
1397
|
+
* denominators differently and MUST disagree:
|
|
1398
|
+
*
|
|
1399
|
+
* - AnyDown anchors the WHOLE SLO at the earliest event across monitors, so
|
|
1400
|
+
* the old monitor's history extends the window the young monitor is judged
|
|
1401
|
+
* over.
|
|
1402
|
+
* - MonitorSecondsAverage anchors PER MONITOR, so the young monitor only
|
|
1403
|
+
* contributes the seconds it has actually existed for.
|
|
1404
|
+
*/
|
|
1405
|
+
const window: UptimeWindow = {
|
|
1406
|
+
startDate: new Date("2026-07-01T00:00:00.000Z"),
|
|
1407
|
+
endDate: new Date("2026-07-19T00:00:00.000Z"),
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
const perMonitorTimelines: Array<{
|
|
1411
|
+
monitorId: ObjectID;
|
|
1412
|
+
timelines: Array<MonitorStatusTimeline>;
|
|
1413
|
+
}> = [
|
|
1414
|
+
{
|
|
1415
|
+
monitorId: MONITOR_A_ID,
|
|
1416
|
+
timelines: [operational(MONITOR_A_ID, "2026-06-01T00:00:00.000Z")],
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
monitorId: MONITOR_B_ID,
|
|
1420
|
+
timelines: [offline(MONITOR_B_ID, "2026-07-18T00:00:00.000Z")],
|
|
1421
|
+
},
|
|
1422
|
+
];
|
|
1423
|
+
|
|
1424
|
+
it("AnyDown measures the young monitor's outage over the OLD monitor's 18-day window", () => {
|
|
1425
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
1426
|
+
perMonitorTimelines,
|
|
1427
|
+
downtimeStatuses,
|
|
1428
|
+
window,
|
|
1429
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
expect(result.totalSeconds).toBe(18 * SECONDS_IN_DAY);
|
|
1433
|
+
expect(result.badSeconds).toBe(SECONDS_IN_DAY);
|
|
1434
|
+
expect(result.sliPercentage).toBeCloseTo((17 / 18) * 100, 10);
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it("MonitorSecondsAverage sums a per-monitor denominator, so the young monitor only contributes its own day", () => {
|
|
1438
|
+
const result: TimeSliResult = SloUtil.computeTimeSli({
|
|
1439
|
+
perMonitorTimelines,
|
|
1440
|
+
downtimeStatuses,
|
|
1441
|
+
window,
|
|
1442
|
+
mode: SloMultiMonitorMode.MonitorSecondsAverage,
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
// 18 days (A, clipped to the window) + 1 day (B, clamped to its first event).
|
|
1446
|
+
expect(result.totalSeconds).toBe(19 * SECONDS_IN_DAY);
|
|
1447
|
+
expect(result.badSeconds).toBe(SECONDS_IN_DAY);
|
|
1448
|
+
expect(result.sliPercentage).toBeCloseTo((1 - 1 / 19) * 100, 10);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it("the two modes really do disagree on this input (94.44% vs 94.74%)", () => {
|
|
1452
|
+
const anyDown: TimeSliResult = SloUtil.computeTimeSli({
|
|
1453
|
+
perMonitorTimelines,
|
|
1454
|
+
downtimeStatuses,
|
|
1455
|
+
window,
|
|
1456
|
+
mode: SloMultiMonitorMode.AnyDown,
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
const average: TimeSliResult = SloUtil.computeTimeSli({
|
|
1460
|
+
perMonitorTimelines,
|
|
1461
|
+
downtimeStatuses,
|
|
1462
|
+
window,
|
|
1463
|
+
mode: SloMultiMonitorMode.MonitorSecondsAverage,
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
expect(anyDown.sliPercentage).toBeCloseTo(94.4444444444, 8);
|
|
1467
|
+
expect(average.sliPercentage).toBeCloseTo(94.7368421053, 8);
|
|
1468
|
+
expect(average.sliPercentage).toBeGreaterThan(anyDown.sliPercentage);
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
describe("interval union edge cases", () => {
|
|
1473
|
+
const window: UptimeWindow = {
|
|
1474
|
+
startDate: new Date("2026-07-18T09:00:00.000Z"),
|
|
1475
|
+
endDate: new Date("2026-07-18T14:00:00.000Z"),
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
it("merges exactly-touching intervals (end === next start) into ONE interval", () => {
|
|
1479
|
+
const merged: Array<DowntimeInterval> = SloUtil.mergeIntervals([
|
|
1480
|
+
{
|
|
1481
|
+
startDate: new Date("2026-07-18T10:00:00.000Z"),
|
|
1482
|
+
endDate: new Date("2026-07-18T10:30:00.000Z"),
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
startDate: new Date("2026-07-18T10:30:00.000Z"),
|
|
1486
|
+
endDate: new Date("2026-07-18T11:00:00.000Z"),
|
|
1487
|
+
},
|
|
1488
|
+
]);
|
|
1489
|
+
|
|
1490
|
+
expect(merged).toHaveLength(1);
|
|
1491
|
+
expect(merged[0]!.startDate.toISOString()).toBe(
|
|
1492
|
+
"2026-07-18T10:00:00.000Z",
|
|
1493
|
+
);
|
|
1494
|
+
expect(merged[0]!.endDate.toISOString()).toBe("2026-07-18T11:00:00.000Z");
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it("absorbs a fully nested interval instead of extending or splitting the outer one", () => {
|
|
1498
|
+
const merged: Array<DowntimeInterval> = SloUtil.mergeIntervals([
|
|
1499
|
+
{
|
|
1500
|
+
startDate: new Date("2026-07-18T10:00:00.000Z"),
|
|
1501
|
+
endDate: new Date("2026-07-18T12:00:00.000Z"),
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
startDate: new Date("2026-07-18T10:30:00.000Z"),
|
|
1505
|
+
endDate: new Date("2026-07-18T11:00:00.000Z"),
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
startDate: new Date("2026-07-18T11:15:00.000Z"),
|
|
1509
|
+
endDate: new Date("2026-07-18T11:30:00.000Z"),
|
|
1510
|
+
},
|
|
1511
|
+
]);
|
|
1512
|
+
|
|
1513
|
+
expect(merged).toHaveLength(1);
|
|
1514
|
+
expect(merged[0]!.startDate.toISOString()).toBe(
|
|
1515
|
+
"2026-07-18T10:00:00.000Z",
|
|
1516
|
+
);
|
|
1517
|
+
expect(merged[0]!.endDate.toISOString()).toBe("2026-07-18T12:00:00.000Z");
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it("drops an inverted interval whose end precedes its start", () => {
|
|
1521
|
+
expect(
|
|
1522
|
+
SloUtil.mergeIntervals([
|
|
1523
|
+
{
|
|
1524
|
+
startDate: new Date("2026-07-18T12:00:00.000Z"),
|
|
1525
|
+
endDate: new Date("2026-07-18T11:00:00.000Z"),
|
|
1526
|
+
},
|
|
1527
|
+
]),
|
|
1528
|
+
).toHaveLength(0);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it("counts overlapping down rows of ONE monitor once: 10:00-11:00 plus 10:30-11:30 is 5400s, not 7200s", () => {
|
|
1532
|
+
/*
|
|
1533
|
+
* Duplicate/overlapping rows for a single monitor do happen (retried
|
|
1534
|
+
* writes, probe races). The union must not double-count them.
|
|
1535
|
+
*/
|
|
1536
|
+
expect(
|
|
1537
|
+
SloUtil.getUnionDowntimeSeconds(
|
|
1538
|
+
[
|
|
1539
|
+
{
|
|
1540
|
+
monitorId: MONITOR_A_ID,
|
|
1541
|
+
timelines: [
|
|
1542
|
+
offline(
|
|
1543
|
+
MONITOR_A_ID,
|
|
1544
|
+
"2026-07-18T10:00:00.000Z",
|
|
1545
|
+
"2026-07-18T11:00:00.000Z",
|
|
1546
|
+
),
|
|
1547
|
+
offline(
|
|
1548
|
+
MONITOR_A_ID,
|
|
1549
|
+
"2026-07-18T10:30:00.000Z",
|
|
1550
|
+
"2026-07-18T11:30:00.000Z",
|
|
1551
|
+
),
|
|
1552
|
+
],
|
|
1553
|
+
},
|
|
1554
|
+
],
|
|
1555
|
+
downtimeStatuses,
|
|
1556
|
+
window,
|
|
1557
|
+
),
|
|
1558
|
+
).toBe(5400);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it("counts a down row fully nested inside another down row of the same monitor once", () => {
|
|
1562
|
+
expect(
|
|
1563
|
+
SloUtil.getUnionDowntimeSeconds(
|
|
1564
|
+
[
|
|
1565
|
+
{
|
|
1566
|
+
monitorId: MONITOR_A_ID,
|
|
1567
|
+
timelines: [
|
|
1568
|
+
offline(
|
|
1569
|
+
MONITOR_A_ID,
|
|
1570
|
+
"2026-07-18T10:00:00.000Z",
|
|
1571
|
+
"2026-07-18T12:00:00.000Z",
|
|
1572
|
+
),
|
|
1573
|
+
offline(
|
|
1574
|
+
MONITOR_A_ID,
|
|
1575
|
+
"2026-07-18T10:30:00.000Z",
|
|
1576
|
+
"2026-07-18T11:00:00.000Z",
|
|
1577
|
+
),
|
|
1578
|
+
],
|
|
1579
|
+
},
|
|
1580
|
+
],
|
|
1581
|
+
downtimeStatuses,
|
|
1582
|
+
window,
|
|
1583
|
+
),
|
|
1584
|
+
).toBe(2 * SECONDS_IN_HOUR);
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it("chains three monitors whose outages touch end-to-start into one continuous 3600s outage", () => {
|
|
1588
|
+
expect(
|
|
1589
|
+
SloUtil.getUnionDowntimeSeconds(
|
|
1590
|
+
[
|
|
1591
|
+
{
|
|
1592
|
+
monitorId: MONITOR_A_ID,
|
|
1593
|
+
timelines: [
|
|
1594
|
+
offline(
|
|
1595
|
+
MONITOR_A_ID,
|
|
1596
|
+
"2026-07-18T10:00:00.000Z",
|
|
1597
|
+
"2026-07-18T10:20:00.000Z",
|
|
1598
|
+
),
|
|
1599
|
+
],
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
monitorId: MONITOR_B_ID,
|
|
1603
|
+
timelines: [
|
|
1604
|
+
offline(
|
|
1605
|
+
MONITOR_B_ID,
|
|
1606
|
+
"2026-07-18T10:20:00.000Z",
|
|
1607
|
+
"2026-07-18T10:40:00.000Z",
|
|
1608
|
+
),
|
|
1609
|
+
],
|
|
1610
|
+
},
|
|
1611
|
+
{
|
|
1612
|
+
monitorId: MONITOR_C_ID,
|
|
1613
|
+
timelines: [
|
|
1614
|
+
offline(
|
|
1615
|
+
MONITOR_C_ID,
|
|
1616
|
+
"2026-07-18T10:40:00.000Z",
|
|
1617
|
+
"2026-07-18T11:00:00.000Z",
|
|
1618
|
+
),
|
|
1619
|
+
],
|
|
1620
|
+
},
|
|
1621
|
+
],
|
|
1622
|
+
downtimeStatuses,
|
|
1623
|
+
window,
|
|
1624
|
+
),
|
|
1625
|
+
).toBe(SECONDS_IN_HOUR);
|
|
1626
|
+
});
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
describe("getErrorBudget edge cases", () => {
|
|
1630
|
+
it("reports a full untouched budget when totalSeconds is 0 and only an SLI percentage is given", () => {
|
|
1631
|
+
// the no-elapsed-window short circuit runs before either input is used.
|
|
1632
|
+
expect(
|
|
1633
|
+
SloUtil.getErrorBudget({
|
|
1634
|
+
sliPercentage: 42,
|
|
1635
|
+
totalSeconds: 0,
|
|
1636
|
+
targetPercentage: 99.9,
|
|
1637
|
+
}),
|
|
1638
|
+
).toEqual({
|
|
1639
|
+
budgetTotalSeconds: 0,
|
|
1640
|
+
budgetConsumedSeconds: 0,
|
|
1641
|
+
budgetRemainingSeconds: 0,
|
|
1642
|
+
budgetRemainingPercentage: 100,
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
it("prefers badSeconds over sliPercentage when both are supplied", () => {
|
|
1647
|
+
// 99% target over 86400s: 864s of budget; badSeconds says 432s were spent.
|
|
1648
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
1649
|
+
badSeconds: 432,
|
|
1650
|
+
sliPercentage: 50,
|
|
1651
|
+
totalSeconds: SECONDS_IN_DAY,
|
|
1652
|
+
targetPercentage: 99,
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
expect(budget.budgetConsumedSeconds).toBe(432);
|
|
1656
|
+
expect(budget.budgetRemainingSeconds).toBeCloseTo(432, 6);
|
|
1657
|
+
expect(budget.budgetRemainingPercentage).toBeCloseTo(50, 6);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it("reports a signed overage of exactly -1136s / -131.48% when 2000s burn a 864s budget", () => {
|
|
1661
|
+
// 99% target over one day: budget is 864s and 2000s of downtime blew it.
|
|
1662
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
1663
|
+
badSeconds: 2000,
|
|
1664
|
+
totalSeconds: SECONDS_IN_DAY,
|
|
1665
|
+
targetPercentage: 99,
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
expect(budget.budgetTotalSeconds).toBeCloseTo(864, 6);
|
|
1669
|
+
expect(budget.budgetConsumedSeconds).toBe(2000);
|
|
1670
|
+
expect(budget.budgetRemainingSeconds).toBeCloseTo(-1136, 6);
|
|
1671
|
+
expect(budget.budgetRemainingPercentage).toBeCloseTo(-131.4814814815, 8);
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
it("reports exactly 0% remaining when the budget is consumed to the last second", () => {
|
|
1675
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
1676
|
+
badSeconds: 864,
|
|
1677
|
+
totalSeconds: SECONDS_IN_DAY,
|
|
1678
|
+
targetPercentage: 99,
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
expect(budget.budgetRemainingSeconds).toBeCloseTo(0, 6);
|
|
1682
|
+
expect(budget.budgetRemainingPercentage).toBeCloseTo(0, 6);
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
it("keeps a fractional 99.99% target honest: 8.64s of budget in a day", () => {
|
|
1686
|
+
const budget: ErrorBudgetResult = SloUtil.getErrorBudget({
|
|
1687
|
+
badSeconds: 4.32,
|
|
1688
|
+
totalSeconds: SECONDS_IN_DAY,
|
|
1689
|
+
targetPercentage: 99.99,
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
expect(budget.budgetTotalSeconds).toBeCloseTo(8.64, 6);
|
|
1693
|
+
expect(budget.budgetRemainingSeconds).toBeCloseTo(4.32, 6);
|
|
1694
|
+
expect(budget.budgetRemainingPercentage).toBeCloseTo(50, 6);
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
describe("computeBurnRate edge cases", () => {
|
|
1699
|
+
it.each([
|
|
1700
|
+
[99.9, 1000],
|
|
1701
|
+
[99.5, 200],
|
|
1702
|
+
[99, 100],
|
|
1703
|
+
[95, 20],
|
|
1704
|
+
])(
|
|
1705
|
+
"a total outage at a %s%% target burns exactly %sx (1 / allowedBadFraction)",
|
|
1706
|
+
(targetPercentage: number, expectedBurnRate: number) => {
|
|
1707
|
+
expect(
|
|
1708
|
+
SloUtil.computeBurnRate({
|
|
1709
|
+
badSeconds: 3600,
|
|
1710
|
+
totalSeconds: 3600,
|
|
1711
|
+
targetPercentage,
|
|
1712
|
+
}),
|
|
1713
|
+
).toBeCloseTo(expectedBurnRate, 6);
|
|
1714
|
+
},
|
|
1715
|
+
);
|
|
1716
|
+
|
|
1717
|
+
it("burns 2x against a fractional 99.5% target when 1% of the window is bad", () => {
|
|
1718
|
+
expect(
|
|
1719
|
+
SloUtil.computeBurnRate({
|
|
1720
|
+
badSeconds: 36,
|
|
1721
|
+
totalSeconds: 3600,
|
|
1722
|
+
targetPercentage: 99.5,
|
|
1723
|
+
}),
|
|
1724
|
+
).toBeCloseTo(2, 10);
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
it("burns 100x against a fractional 99.99% target when 1% of the window is bad", () => {
|
|
1728
|
+
expect(
|
|
1729
|
+
SloUtil.computeBurnRate({
|
|
1730
|
+
badSeconds: 36,
|
|
1731
|
+
totalSeconds: 3600,
|
|
1732
|
+
targetPercentage: 99.99,
|
|
1733
|
+
}),
|
|
1734
|
+
).toBeCloseTo(100, 8);
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
it("returns 0 for an empty window even when badSeconds is non-zero (no denominator, no evidence)", () => {
|
|
1738
|
+
expect(
|
|
1739
|
+
SloUtil.computeBurnRate({
|
|
1740
|
+
badSeconds: 42,
|
|
1741
|
+
totalSeconds: 0,
|
|
1742
|
+
targetPercentage: 99.9,
|
|
1743
|
+
}),
|
|
1744
|
+
).toBe(0);
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
it("crosses the canonical 14.4x page threshold at exactly 1.44% of a 99.9% window", () => {
|
|
1748
|
+
// 51.84s of a 3600s hour = 1.44%; the 30-day fast-burn threshold is 14.4x.
|
|
1749
|
+
expect(
|
|
1750
|
+
SloUtil.computeBurnRate({
|
|
1751
|
+
badSeconds: 51.84,
|
|
1752
|
+
totalSeconds: 3600,
|
|
1753
|
+
targetPercentage: 99.9,
|
|
1754
|
+
}),
|
|
1755
|
+
).toBeCloseTo(14.4, 6);
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
describe("computeSloStatus: the full hysteresis band walk (threshold 20)", () => {
|
|
1760
|
+
type BandStep = { remaining: number; expected: SloStatus };
|
|
1761
|
+
|
|
1762
|
+
it("walks Healthy -> AtRisk -> (held AtRisk inside the 20-25 band) -> Healthy without flapping", () => {
|
|
1763
|
+
const steps: Array<BandStep> = [
|
|
1764
|
+
{ remaining: 80, expected: SloStatus.Healthy },
|
|
1765
|
+
{ remaining: 25, expected: SloStatus.Healthy },
|
|
1766
|
+
{ remaining: 20.000001, expected: SloStatus.Healthy },
|
|
1767
|
+
// enters at <= threshold.
|
|
1768
|
+
{ remaining: 20, expected: SloStatus.AtRisk },
|
|
1769
|
+
{ remaining: 12, expected: SloStatus.AtRisk },
|
|
1770
|
+
// held through the whole band above the threshold.
|
|
1771
|
+
{ remaining: 20.5, expected: SloStatus.AtRisk },
|
|
1772
|
+
{ remaining: 22, expected: SloStatus.AtRisk },
|
|
1773
|
+
{ remaining: 24.999999, expected: SloStatus.AtRisk },
|
|
1774
|
+
// exits only at threshold + 5.
|
|
1775
|
+
{ remaining: 25, expected: SloStatus.Healthy },
|
|
1776
|
+
{ remaining: 24.999999, expected: SloStatus.Healthy },
|
|
1777
|
+
];
|
|
1778
|
+
|
|
1779
|
+
let status: SloStatus = SloStatus.Healthy;
|
|
1780
|
+
|
|
1781
|
+
for (const step of steps) {
|
|
1782
|
+
status = SloUtil.computeSloStatus({
|
|
1783
|
+
budgetRemainingPercentage: step.remaining,
|
|
1784
|
+
currentStatus: status,
|
|
1785
|
+
atRiskThresholdPercentage: 20,
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
expect(status).toBe(step.expected);
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
it("enters BudgetExhausted at exactly 0 and holds it until the budget reaches 2%", () => {
|
|
1793
|
+
const steps: Array<BandStep> = [
|
|
1794
|
+
{ remaining: 0.1, expected: SloStatus.AtRisk },
|
|
1795
|
+
// enters at <= 0.
|
|
1796
|
+
{ remaining: 0, expected: SloStatus.BudgetExhausted },
|
|
1797
|
+
{ remaining: -55, expected: SloStatus.BudgetExhausted },
|
|
1798
|
+
// held all the way up to (but not including) 2.
|
|
1799
|
+
{ remaining: 0.5, expected: SloStatus.BudgetExhausted },
|
|
1800
|
+
{ remaining: 1.999999, expected: SloStatus.BudgetExhausted },
|
|
1801
|
+
// exits at exactly 2 - into AtRisk, still under the at-risk threshold.
|
|
1802
|
+
{ remaining: 2, expected: SloStatus.AtRisk },
|
|
1803
|
+
];
|
|
1804
|
+
|
|
1805
|
+
let status: SloStatus = SloStatus.Healthy;
|
|
1806
|
+
|
|
1807
|
+
for (const step of steps) {
|
|
1808
|
+
status = SloUtil.computeSloStatus({
|
|
1809
|
+
budgetRemainingPercentage: step.remaining,
|
|
1810
|
+
currentStatus: status,
|
|
1811
|
+
atRiskThresholdPercentage: 20,
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
expect(status).toBe(step.expected);
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
it("does not flap while a rolling window oscillates around 0% remaining", () => {
|
|
1819
|
+
// bad seconds ageing out of a rolling window re-cross 0 repeatedly.
|
|
1820
|
+
let status: SloStatus = SloStatus.BudgetExhausted;
|
|
1821
|
+
|
|
1822
|
+
for (const remaining of [-2, 0, 1, 0.2, 1.99, -0.5, 1.5]) {
|
|
1823
|
+
status = SloUtil.computeSloStatus({
|
|
1824
|
+
budgetRemainingPercentage: remaining,
|
|
1825
|
+
currentStatus: status,
|
|
1826
|
+
atRiskThresholdPercentage: 20,
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
expect(status).toBe(SloStatus.BudgetExhausted);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// only a real recovery past 2% clears it.
|
|
1833
|
+
expect(
|
|
1834
|
+
SloUtil.computeSloStatus({
|
|
1835
|
+
budgetRemainingPercentage: 2.5,
|
|
1836
|
+
currentStatus: status,
|
|
1837
|
+
atRiskThresholdPercentage: 20,
|
|
1838
|
+
}),
|
|
1839
|
+
).toBe(SloStatus.AtRisk);
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
it("a full recovery jumps straight from BudgetExhausted to Healthy in one step", () => {
|
|
1843
|
+
expect(
|
|
1844
|
+
SloUtil.computeSloStatus({
|
|
1845
|
+
budgetRemainingPercentage: 100,
|
|
1846
|
+
currentStatus: SloStatus.BudgetExhausted,
|
|
1847
|
+
atRiskThresholdPercentage: 20,
|
|
1848
|
+
}),
|
|
1849
|
+
).toBe(SloStatus.Healthy);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
it("honours a custom at-risk threshold of 50 for both entry and the 55% exit", () => {
|
|
1853
|
+
expect(
|
|
1854
|
+
SloUtil.computeSloStatus({
|
|
1855
|
+
budgetRemainingPercentage: 50,
|
|
1856
|
+
currentStatus: SloStatus.Healthy,
|
|
1857
|
+
atRiskThresholdPercentage: 50,
|
|
1858
|
+
}),
|
|
1859
|
+
).toBe(SloStatus.AtRisk);
|
|
1860
|
+
|
|
1861
|
+
expect(
|
|
1862
|
+
SloUtil.computeSloStatus({
|
|
1863
|
+
budgetRemainingPercentage: 54.9,
|
|
1864
|
+
currentStatus: SloStatus.AtRisk,
|
|
1865
|
+
atRiskThresholdPercentage: 50,
|
|
1866
|
+
}),
|
|
1867
|
+
).toBe(SloStatus.AtRisk);
|
|
1868
|
+
|
|
1869
|
+
expect(
|
|
1870
|
+
SloUtil.computeSloStatus({
|
|
1871
|
+
budgetRemainingPercentage: 55,
|
|
1872
|
+
currentStatus: SloStatus.AtRisk,
|
|
1873
|
+
atRiskThresholdPercentage: 50,
|
|
1874
|
+
}),
|
|
1875
|
+
).toBe(SloStatus.Healthy);
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
it("a threshold of 0 leaves only the exhausted band, so 0.5% remaining is still Healthy", () => {
|
|
1879
|
+
expect(
|
|
1880
|
+
SloUtil.computeSloStatus({
|
|
1881
|
+
budgetRemainingPercentage: 0.5,
|
|
1882
|
+
currentStatus: SloStatus.Healthy,
|
|
1883
|
+
atRiskThresholdPercentage: 0,
|
|
1884
|
+
}),
|
|
1885
|
+
).toBe(SloStatus.Healthy);
|
|
1886
|
+
|
|
1887
|
+
expect(
|
|
1888
|
+
SloUtil.computeSloStatus({
|
|
1889
|
+
budgetRemainingPercentage: 0,
|
|
1890
|
+
currentStatus: SloStatus.Healthy,
|
|
1891
|
+
atRiskThresholdPercentage: 0,
|
|
1892
|
+
}),
|
|
1893
|
+
).toBe(SloStatus.BudgetExhausted);
|
|
1894
|
+
});
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
describe("getCalendarMonthWindow: full-period denominators and zone edges", () => {
|
|
1898
|
+
it("reports the FULL month, not the elapsed part, ten minutes into the 1st", () => {
|
|
1899
|
+
/*
|
|
1900
|
+
* The budget denominator is fixed at period start. If it were elapsed
|
|
1901
|
+
* time, a one-minute blip at 00:10 on the 1st would read as instant budget
|
|
1902
|
+
* exhaustion (60s against a 600s window).
|
|
1903
|
+
*/
|
|
1904
|
+
const window: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
1905
|
+
at: new Date("2026-07-01T00:10:00.000Z"),
|
|
1906
|
+
timezone: "UTC",
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
expect(window.startDate.toISOString()).toBe("2026-07-01T00:00:00.000Z");
|
|
1910
|
+
expect(window.totalSecondsInFullPeriod).toBe(31 * SECONDS_IN_DAY);
|
|
1911
|
+
expect(window.totalSecondsInFullPeriod).not.toBe(600);
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
it("reports the FULL month on the last second of the month too", () => {
|
|
1915
|
+
const window: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
1916
|
+
at: new Date("2026-07-31T23:59:59.000Z"),
|
|
1917
|
+
timezone: "UTC",
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
expect(window.totalSecondsInFullPeriod).toBe(31 * SECONDS_IN_DAY);
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
it("gets a 28-day February right in a non-UTC zone with a half-hour offset", () => {
|
|
1924
|
+
// Asia/Kolkata is UTC+5:30 year round: the month starts at 18:30 UTC.
|
|
1925
|
+
const window: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
1926
|
+
at: new Date("2027-02-14T00:00:00.000Z"),
|
|
1927
|
+
timezone: "Asia/Kolkata",
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
expect(window.startDate.toISOString()).toBe("2027-01-31T18:30:00.000Z");
|
|
1931
|
+
expect(window.endDate.toISOString()).toBe("2027-02-28T18:30:00.000Z");
|
|
1932
|
+
expect(window.totalSecondsInFullPeriod).toBe(28 * SECONDS_IN_DAY);
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
it("a fall-back DST month in New York is one real hour LONGER than 30 days", () => {
|
|
1936
|
+
// November 2026 falls back on 1 November: 30 days + 3600 real seconds.
|
|
1937
|
+
const window: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
1938
|
+
at: new Date("2026-11-15T12:00:00.000Z"),
|
|
1939
|
+
timezone: "America/New_York",
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
expect(window.startDate.toISOString()).toBe("2026-11-01T04:00:00.000Z");
|
|
1943
|
+
expect(window.endDate.toISOString()).toBe("2026-12-01T05:00:00.000Z");
|
|
1944
|
+
expect(window.totalSecondsInFullPeriod).toBe(
|
|
1945
|
+
30 * SECONDS_IN_DAY + SECONDS_IN_HOUR,
|
|
1946
|
+
);
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
it("a spring-forward month in New York loses an hour of real budget denominator", () => {
|
|
1950
|
+
const marchWindow: CalendarMonthWindow = SloUtil.getCalendarMonthWindow({
|
|
1951
|
+
at: new Date("2026-03-15T12:00:00.000Z"),
|
|
1952
|
+
timezone: "America/New_York",
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
const utcMarchWindow: CalendarMonthWindow =
|
|
1956
|
+
SloUtil.getCalendarMonthWindow({
|
|
1957
|
+
at: new Date("2026-03-15T12:00:00.000Z"),
|
|
1958
|
+
timezone: "UTC",
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
expect(
|
|
1962
|
+
utcMarchWindow.totalSecondsInFullPeriod -
|
|
1963
|
+
marchWindow.totalSecondsInFullPeriod,
|
|
1964
|
+
).toBe(SECONDS_IN_HOUR);
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
it("defaults to UTC when no timezone is supplied", () => {
|
|
1968
|
+
expect(
|
|
1969
|
+
SloUtil.getCalendarMonthWindow({
|
|
1970
|
+
at: new Date("2026-03-15T12:00:00.000Z"),
|
|
1971
|
+
}).startDate.toISOString(),
|
|
1972
|
+
).toBe(
|
|
1973
|
+
SloUtil.getCalendarMonthWindow({
|
|
1974
|
+
at: new Date("2026-03-15T12:00:00.000Z"),
|
|
1975
|
+
timezone: "UTC",
|
|
1976
|
+
}).startDate.toISOString(),
|
|
1977
|
+
);
|
|
1978
|
+
});
|
|
1979
|
+
});
|
|
1980
|
+
});
|