@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.
Files changed (397) hide show
  1. package/Models/AnalyticsModels/Index.ts +4 -0
  2. package/Models/AnalyticsModels/KubernetesCostAllocation.ts +842 -0
  3. package/Models/AnalyticsModels/NetworkFlow.ts +54 -0
  4. package/Models/AnalyticsModels/SloHistory.ts +240 -0
  5. package/Models/DatabaseModels/Index.ts +20 -0
  6. package/Models/DatabaseModels/NetworkDevice.ts +453 -6
  7. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +7 -6
  8. package/Models/DatabaseModels/NetworkEndpoint.ts +753 -0
  9. package/Models/DatabaseModels/NetworkSite.ts +1155 -0
  10. package/Models/DatabaseModels/NetworkSiteAssignmentRule.ts +517 -0
  11. package/Models/DatabaseModels/NetworkSiteLink.ts +611 -0
  12. package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +520 -0
  13. package/Models/DatabaseModels/NetworkSiteType.ts +499 -0
  14. package/Models/DatabaseModels/ServiceLevelObjective.ts +1106 -0
  15. package/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.ts +762 -0
  16. package/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.ts +444 -0
  17. package/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.ts +443 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.ts +359 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.ts +48 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.ts +65 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.ts +88 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.ts +391 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  24. package/Server/Services/AnalyticsDatabaseService.ts +25 -9
  25. package/Server/Services/Index.ts +24 -0
  26. package/Server/Services/KubernetesCostAllocationService.ts +14 -0
  27. package/Server/Services/MetricService.ts +21 -2
  28. package/Server/Services/MonitorService.ts +22 -0
  29. package/Server/Services/NetworkDeviceService.ts +438 -2
  30. package/Server/Services/NetworkEndpointService.ts +414 -0
  31. package/Server/Services/NetworkSiteAssignmentRuleService.ts +98 -0
  32. package/Server/Services/NetworkSiteLinkService.ts +10 -0
  33. package/Server/Services/NetworkSiteService.ts +1254 -0
  34. package/Server/Services/NetworkSiteStatusTimelineService.ts +10 -0
  35. package/Server/Services/NetworkSiteTypeService.ts +10 -0
  36. package/Server/Services/ProjectService.ts +122 -2
  37. package/Server/Services/ServiceLevelObjectiveBurnRateRuleService.ts +457 -0
  38. package/Server/Services/ServiceLevelObjectiveOwnerTeamService.ts +10 -0
  39. package/Server/Services/ServiceLevelObjectiveOwnerUserService.ts +10 -0
  40. package/Server/Services/ServiceLevelObjectiveService.ts +677 -0
  41. package/Server/Services/SloHistoryService.ts +101 -0
  42. package/Server/Services/UserNotificationSettingService.ts +37 -0
  43. package/Server/Types/AnalyticsDatabase/AggregateBy.ts +9 -0
  44. package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +15 -0
  45. package/Server/Utils/AnalyticsDatabase/Statement.ts +39 -3
  46. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +87 -48
  47. package/Server/Utils/Memory.ts +9 -1
  48. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +190 -12
  49. package/Server/Utils/Monitor/Data/OuiVendors.json +6920 -0
  50. package/Server/Utils/Monitor/DnsResolutionCache.ts +129 -0
  51. package/Server/Utils/Monitor/MonitorResource.ts +4 -30
  52. package/Server/Utils/Monitor/NetworkDeviceHydrationUtil.ts +214 -39
  53. package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +343 -0
  54. package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +251 -0
  55. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +176 -142
  56. package/Server/Utils/Monitor/OuiLookupUtil.ts +49 -0
  57. package/Server/Utils/Monitor/SnmpInterfaceRateUtil.ts +17 -13
  58. package/Server/Utils/WhatsAppTemplateUtil.ts +7 -3
  59. package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +135 -0
  60. package/Tests/Models/NetworkSiteHierarchy.test.ts +134 -0
  61. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +239 -0
  62. package/Tests/Server/Services/KubernetesCostAllocationAggregate.test.ts +338 -0
  63. package/Tests/Server/Services/MetricServiceAggregate.test.ts +142 -0
  64. package/Tests/Server/Services/NetworkDeviceSiteAssignment.test.ts +484 -0
  65. package/Tests/Server/Services/NetworkEndpointServiceUpsert.test.ts +686 -0
  66. package/Tests/Server/Services/NetworkSiteAssignmentRuleService.test.ts +185 -0
  67. package/Tests/Server/Services/NetworkSiteService.test.ts +1354 -0
  68. package/Tests/Server/Services/ServiceLevelObjectiveBurnRateRuleService.test.ts +1313 -0
  69. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +1988 -0
  70. package/Tests/Server/Utils/AnalyticsDatabase/Statement.test.ts +22 -0
  71. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +404 -0
  72. package/Tests/Server/Utils/Encryption.test.ts +70 -0
  73. package/Tests/Server/Utils/FileAttachmentMarkdownUtil.test.ts +225 -0
  74. package/Tests/Server/Utils/JsonWebToken.test.ts +175 -0
  75. package/Tests/Server/Utils/LocalFile.test.ts +258 -0
  76. package/Tests/Server/Utils/Memory.test.ts +213 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +30 -0
  78. package/Tests/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.test.ts +385 -0
  79. package/Tests/Server/Utils/Monitor/DnsResolutionCache.test.ts +194 -0
  80. package/Tests/Server/Utils/Monitor/NetworkDeviceHydrationUtil.test.ts +155 -0
  81. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +600 -0
  82. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +151 -124
  83. package/Tests/Server/Utils/Monitor/SnmpInterfaceRateUtil.test.ts +496 -0
  84. package/Tests/Server/Utils/MonitorCriteriaDataExtractor.test.ts +175 -0
  85. package/Tests/Server/Utils/MonitorCriteriaMessageFormatter.test.ts +282 -0
  86. package/Tests/Server/Utils/OuiLookupUtil.test.ts +59 -0
  87. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +117 -0
  88. package/Tests/Server/Utils/StackTraceParser.test.ts +370 -0
  89. package/Tests/Server/Utils/Stream.test.ts +87 -0
  90. package/Tests/Server/Utils/ValidateGlobalProviderProjectTeams.test.ts +221 -0
  91. package/Tests/Server/Utils/WhatsAppTemplateUtil.test.ts +199 -0
  92. package/Tests/Types/Database/CompareOperatorWireSerialization.test.ts +33 -0
  93. package/Tests/Types/Email.test.ts +198 -0
  94. package/Tests/Types/Kubernetes/KubernetesObjectParser.test.ts +177 -0
  95. package/Tests/Types/Log/LogQueryToFilter.test.ts +111 -0
  96. package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +141 -1
  97. package/Tests/Types/Monitor/MonitorType.test.ts +59 -1
  98. package/Tests/Types/Monitor/NetworkDeviceAlertPack.test.ts +97 -0
  99. package/Tests/Types/Monitor/SnmpEndpointDiscovery.test.ts +151 -0
  100. package/Tests/Types/Monitor/SnmpVendorTemplate.test.ts +110 -6
  101. package/Tests/Types/NetworkSite/DefaultNetworkSiteType.test.ts +129 -0
  102. package/Tests/Types/SerializableObjectDictionary.test.ts +166 -0
  103. package/Tests/Types/Time/RangeStartAndEndDateTime.test.ts +98 -0
  104. package/Tests/Types/WebsiteRequest.test.ts +230 -0
  105. package/Tests/UI/Components/EntityFilterRoundTrip.test.tsx +103 -0
  106. package/Tests/UI/Components/ModelTable/BaseModelTableUrlState.test.tsx +612 -0
  107. package/Tests/UI/Components/ModelTable/FilterDataToQuery.test.ts +275 -0
  108. package/Tests/UI/Utils/LogExport.test.ts +159 -0
  109. package/Tests/UI/Utils/Navigation.test.ts +183 -0
  110. package/Tests/UI/Utils/TableFilterUrlState.test.ts +539 -0
  111. package/Tests/UI/Utils/TableViewUrlState.test.ts +272 -0
  112. package/Tests/Utils/Base64.test.ts +51 -0
  113. package/Tests/Utils/Boolean.test.ts +76 -0
  114. package/Tests/Utils/Crypto.test.ts +48 -0
  115. package/Tests/Utils/Dashboard/DashboardSloComponent.test.ts +259 -0
  116. package/Tests/Utils/Dashboard/ModelQueryVariableInterpolation.test.ts +162 -0
  117. package/Tests/Utils/Enum.test.ts +54 -0
  118. package/Tests/Utils/IpCanonicalUtil.test.ts +85 -0
  119. package/Tests/Utils/Metrics/MetricSeriesFingerprint.test.ts +160 -0
  120. package/Tests/Utils/Monitor/EndpointAttachmentUtil.test.ts +836 -0
  121. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +218 -0
  122. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +447 -0
  123. package/Tests/Utils/NetworkSite/CidrMatchUtil.test.ts +440 -0
  124. package/Tests/Utils/NetworkSite/MaterializedPathUtil.test.ts +205 -0
  125. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +221 -0
  126. package/Tests/Utils/NetworkSite/SiteUptimeUtil.test.ts +283 -0
  127. package/Tests/Utils/ObjectUtil.test.ts +30 -0
  128. package/Tests/Utils/Slo/SloStatusColor.test.ts +89 -0
  129. package/Tests/Utils/Slo/SloUtil.test.ts +1980 -0
  130. package/Tests/Utils/Slo/SloWidgetFormat.test.ts +605 -0
  131. package/Tests/Utils/TechStack.test.ts +50 -0
  132. package/Tests/Utils/Traces/CriticalPath.test.ts +178 -0
  133. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +10 -0
  134. package/Types/BaseDatabase/EqualTo.ts +9 -1
  135. package/Types/BaseDatabase/EqualToOrNull.ts +9 -1
  136. package/Types/BaseDatabase/NotEqual.ts +9 -1
  137. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  138. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  139. package/Types/Dashboard/DashboardComponents/DashboardSloComponent.ts +42 -0
  140. package/Types/Dashboard/DashboardTemplates.ts +214 -0
  141. package/Types/Email/EmailTemplateType.ts +2 -0
  142. package/Types/Kubernetes/KubernetesCostIngest.ts +79 -0
  143. package/Types/Monitor/CriteriaFilter.ts +9 -0
  144. package/Types/Monitor/MonitorStep.ts +5 -8
  145. package/Types/Monitor/MonitorStepNetworkDeviceMonitor.ts +27 -5
  146. package/Types/Monitor/MonitorType.ts +15 -5
  147. package/Types/Monitor/SnmpMonitor/ArpEntry.ts +14 -0
  148. package/Types/Monitor/SnmpMonitor/FdbEntry.ts +18 -0
  149. package/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.ts +10 -3
  150. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -5
  151. package/Types/Monitor/SnmpMonitor/SnmpMonitorResponse.ts +12 -0
  152. package/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.ts +231 -3
  153. package/Types/NetworkSite/DefaultNetworkSiteType.ts +20 -0
  154. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  155. package/Types/Permission.ts +449 -0
  156. package/Types/ServiceLevelObjective/SliType.ts +6 -0
  157. package/Types/ServiceLevelObjective/SloMultiMonitorMode.ts +6 -0
  158. package/Types/ServiceLevelObjective/SloStatus.ts +9 -0
  159. package/Types/ServiceLevelObjective/SloWindowType.ts +6 -0
  160. package/Types/Telemetry/ServiceType.ts +1 -0
  161. package/UI/Components/Filters/EntityFilter.tsx +16 -0
  162. package/UI/Components/Filters/FilterViewer.tsx +22 -5
  163. package/UI/Components/List/List.tsx +8 -0
  164. package/UI/Components/ModelTable/BaseModelTable.tsx +309 -106
  165. package/UI/Components/ModelTable/FilterDataToQuery.ts +172 -0
  166. package/UI/Components/Navbar/NavBar.tsx +16 -2
  167. package/UI/Components/Navbar/NavBarMenuModal.tsx +3 -3
  168. package/UI/Utils/Navigation.ts +10 -0
  169. package/UI/Utils/TableFilterUrlState.ts +204 -17
  170. package/UI/Utils/TableViewUrlState.ts +165 -0
  171. package/Utils/Dashboard/Components/DashboardSloComponent.ts +114 -0
  172. package/Utils/Dashboard/Components/Index.ts +7 -0
  173. package/Utils/IpCanonicalUtil.ts +134 -0
  174. package/Utils/Monitor/EndpointAttachmentUtil.ts +508 -0
  175. package/Utils/Monitor/NetworkTopologyUtil.ts +157 -5
  176. package/Utils/NetworkSite/CidrMatchUtil.ts +296 -0
  177. package/Utils/NetworkSite/MaterializedPathUtil.ts +115 -0
  178. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +97 -0
  179. package/Utils/NetworkSite/SiteUptimeUtil.ts +111 -0
  180. package/Utils/Slo/SloStatusColor.ts +54 -0
  181. package/Utils/Slo/SloUtil.ts +637 -0
  182. package/Utils/Slo/SloWidgetFormat.ts +383 -0
  183. package/build/dist/Models/AnalyticsModels/Index.js +4 -0
  184. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  185. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +713 -0
  186. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -0
  187. package/build/dist/Models/AnalyticsModels/NetworkFlow.js +44 -0
  188. package/build/dist/Models/AnalyticsModels/NetworkFlow.js.map +1 -1
  189. package/build/dist/Models/AnalyticsModels/SloHistory.js +215 -0
  190. package/build/dist/Models/AnalyticsModels/SloHistory.js.map +1 -0
  191. package/build/dist/Models/DatabaseModels/Index.js +20 -0
  192. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  193. package/build/dist/Models/DatabaseModels/NetworkDevice.js +458 -6
  194. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  195. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +0 -6
  196. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  197. package/build/dist/Models/DatabaseModels/NetworkEndpoint.js +779 -0
  198. package/build/dist/Models/DatabaseModels/NetworkEndpoint.js.map +1 -0
  199. package/build/dist/Models/DatabaseModels/NetworkSite.js +1180 -0
  200. package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -0
  201. package/build/dist/Models/DatabaseModels/NetworkSiteAssignmentRule.js +534 -0
  202. package/build/dist/Models/DatabaseModels/NetworkSiteAssignmentRule.js.map +1 -0
  203. package/build/dist/Models/DatabaseModels/NetworkSiteLink.js +629 -0
  204. package/build/dist/Models/DatabaseModels/NetworkSiteLink.js.map +1 -0
  205. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +538 -0
  206. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -0
  207. package/build/dist/Models/DatabaseModels/NetworkSiteType.js +522 -0
  208. package/build/dist/Models/DatabaseModels/NetworkSiteType.js.map +1 -0
  209. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +1124 -0
  210. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -0
  211. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.js +769 -0
  212. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule.js.map +1 -0
  213. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.js +460 -0
  214. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerTeam.js.map +1 -0
  215. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.js +459 -0
  216. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerUser.js.map +1 -0
  217. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.js +130 -0
  218. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784757142154-AddNetworkSiteHierarchyAndEndpoints.js.map +1 -0
  219. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.js +31 -0
  220. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784897317860-AddNetworkSiteAlerting.js.map +1 -0
  221. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.js +36 -0
  222. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784970388777-AddNetworkDevicePollingColumns.js.map +1 -0
  223. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.js +49 -0
  224. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784986826214-AddNetworkSiteTypeTable.js.map +1 -0
  225. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.js +140 -0
  226. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784987015619-AddServiceLevelObjective.js.map +1 -0
  227. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  228. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  229. package/build/dist/Server/Services/AnalyticsDatabaseService.js +23 -6
  230. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  231. package/build/dist/Server/Services/Index.js +24 -0
  232. package/build/dist/Server/Services/Index.js.map +1 -1
  233. package/build/dist/Server/Services/KubernetesCostAllocationService.js +12 -0
  234. package/build/dist/Server/Services/KubernetesCostAllocationService.js.map +1 -0
  235. package/build/dist/Server/Services/MetricService.js +21 -3
  236. package/build/dist/Server/Services/MetricService.js.map +1 -1
  237. package/build/dist/Server/Services/MonitorService.js +19 -0
  238. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  239. package/build/dist/Server/Services/NetworkDeviceService.js +351 -1
  240. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  241. package/build/dist/Server/Services/NetworkEndpointService.js +319 -0
  242. package/build/dist/Server/Services/NetworkEndpointService.js.map +1 -0
  243. package/build/dist/Server/Services/NetworkSiteAssignmentRuleService.js +90 -0
  244. package/build/dist/Server/Services/NetworkSiteAssignmentRuleService.js.map +1 -0
  245. package/build/dist/Server/Services/NetworkSiteLinkService.js +9 -0
  246. package/build/dist/Server/Services/NetworkSiteLinkService.js.map +1 -0
  247. package/build/dist/Server/Services/NetworkSiteService.js +1038 -0
  248. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -0
  249. package/build/dist/Server/Services/NetworkSiteStatusTimelineService.js +9 -0
  250. package/build/dist/Server/Services/NetworkSiteStatusTimelineService.js.map +1 -0
  251. package/build/dist/Server/Services/NetworkSiteTypeService.js +9 -0
  252. package/build/dist/Server/Services/NetworkSiteTypeService.js.map +1 -0
  253. package/build/dist/Server/Services/ProjectService.js +98 -2
  254. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  255. package/build/dist/Server/Services/ServiceLevelObjectiveBurnRateRuleService.js +358 -0
  256. package/build/dist/Server/Services/ServiceLevelObjectiveBurnRateRuleService.js.map +1 -0
  257. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerTeamService.js +9 -0
  258. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerTeamService.js.map +1 -0
  259. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerUserService.js +9 -0
  260. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerUserService.js.map +1 -0
  261. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +582 -0
  262. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -0
  263. package/build/dist/Server/Services/SloHistoryService.js +77 -0
  264. package/build/dist/Server/Services/SloHistoryService.js.map +1 -0
  265. package/build/dist/Server/Services/UserNotificationSettingService.js +20 -0
  266. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  267. package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js +9 -0
  268. package/build/dist/Server/Types/AnalyticsDatabase/AggregateBy.js.map +1 -1
  269. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +15 -0
  270. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
  271. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js +31 -0
  272. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js.map +1 -1
  273. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +76 -48
  274. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  275. package/build/dist/Server/Utils/Memory.js +9 -1
  276. package/build/dist/Server/Utils/Memory.js.map +1 -1
  277. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +132 -10
  278. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  279. package/build/dist/Server/Utils/Monitor/Data/OuiVendors.json +6920 -0
  280. package/build/dist/Server/Utils/Monitor/DnsResolutionCache.js +77 -0
  281. package/build/dist/Server/Utils/Monitor/DnsResolutionCache.js.map +1 -0
  282. package/build/dist/Server/Utils/Monitor/MonitorResource.js +21 -41
  283. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  284. package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js +152 -40
  285. package/build/dist/Server/Utils/Monitor/NetworkDeviceHydrationUtil.js.map +1 -1
  286. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +262 -0
  287. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -0
  288. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +201 -0
  289. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -0
  290. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +152 -120
  291. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  292. package/build/dist/Server/Utils/Monitor/OuiLookupUtil.js +44 -0
  293. package/build/dist/Server/Utils/Monitor/OuiLookupUtil.js.map +1 -0
  294. package/build/dist/Server/Utils/Monitor/SnmpInterfaceRateUtil.js +10 -10
  295. package/build/dist/Server/Utils/Monitor/SnmpInterfaceRateUtil.js.map +1 -1
  296. package/build/dist/Server/Utils/WhatsAppTemplateUtil.js +5 -0
  297. package/build/dist/Server/Utils/WhatsAppTemplateUtil.js.map +1 -1
  298. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +10 -0
  299. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  300. package/build/dist/Types/BaseDatabase/EqualTo.js +9 -1
  301. package/build/dist/Types/BaseDatabase/EqualTo.js.map +1 -1
  302. package/build/dist/Types/BaseDatabase/EqualToOrNull.js +9 -1
  303. package/build/dist/Types/BaseDatabase/EqualToOrNull.js.map +1 -1
  304. package/build/dist/Types/BaseDatabase/NotEqual.js +9 -1
  305. package/build/dist/Types/BaseDatabase/NotEqual.js.map +1 -1
  306. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  307. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  308. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  309. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  310. package/build/dist/Types/Dashboard/DashboardComponents/DashboardSloComponent.js +23 -0
  311. package/build/dist/Types/Dashboard/DashboardComponents/DashboardSloComponent.js.map +1 -0
  312. package/build/dist/Types/Dashboard/DashboardTemplates.js +205 -0
  313. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  314. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  315. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  316. package/build/dist/Types/Kubernetes/KubernetesCostIngest.js +21 -0
  317. package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -0
  318. package/build/dist/Types/Monitor/CriteriaFilter.js +1 -0
  319. package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
  320. package/build/dist/Types/Monitor/MonitorStep.js +5 -5
  321. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  322. package/build/dist/Types/Monitor/MonitorStepNetworkDeviceMonitor.js +8 -0
  323. package/build/dist/Types/Monitor/MonitorStepNetworkDeviceMonitor.js.map +1 -1
  324. package/build/dist/Types/Monitor/MonitorType.js +15 -5
  325. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  326. package/build/dist/Types/Monitor/SnmpMonitor/ArpEntry.js +2 -0
  327. package/build/dist/Types/Monitor/SnmpMonitor/ArpEntry.js.map +1 -0
  328. package/build/dist/Types/Monitor/SnmpMonitor/FdbEntry.js +2 -0
  329. package/build/dist/Types/Monitor/SnmpMonitor/FdbEntry.js.map +1 -0
  330. package/build/dist/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.js +9 -3
  331. package/build/dist/Types/Monitor/SnmpMonitor/NetworkDeviceAlertPack.js.map +1 -1
  332. package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js +217 -3
  333. package/build/dist/Types/Monitor/SnmpMonitor/SnmpVendorTemplate.js.map +1 -1
  334. package/build/dist/Types/NetworkSite/DefaultNetworkSiteType.js +21 -0
  335. package/build/dist/Types/NetworkSite/DefaultNetworkSiteType.js.map +1 -0
  336. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  337. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  338. package/build/dist/Types/Permission.js +409 -0
  339. package/build/dist/Types/Permission.js.map +1 -1
  340. package/build/dist/Types/ServiceLevelObjective/SliType.js +7 -0
  341. package/build/dist/Types/ServiceLevelObjective/SliType.js.map +1 -0
  342. package/build/dist/Types/ServiceLevelObjective/SloMultiMonitorMode.js +7 -0
  343. package/build/dist/Types/ServiceLevelObjective/SloMultiMonitorMode.js.map +1 -0
  344. package/build/dist/Types/ServiceLevelObjective/SloStatus.js +10 -0
  345. package/build/dist/Types/ServiceLevelObjective/SloStatus.js.map +1 -0
  346. package/build/dist/Types/ServiceLevelObjective/SloWindowType.js +7 -0
  347. package/build/dist/Types/ServiceLevelObjective/SloWindowType.js.map +1 -0
  348. package/build/dist/Types/Telemetry/ServiceType.js +1 -0
  349. package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
  350. package/build/dist/UI/Components/Filters/EntityFilter.js +15 -0
  351. package/build/dist/UI/Components/Filters/EntityFilter.js.map +1 -1
  352. package/build/dist/UI/Components/Filters/FilterViewer.js +19 -4
  353. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  354. package/build/dist/UI/Components/List/List.js +1 -1
  355. package/build/dist/UI/Components/List/List.js.map +1 -1
  356. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +235 -89
  357. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  358. package/build/dist/UI/Components/ModelTable/FilterDataToQuery.js +96 -0
  359. package/build/dist/UI/Components/ModelTable/FilterDataToQuery.js.map +1 -0
  360. package/build/dist/UI/Components/Navbar/NavBar.js +14 -2
  361. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  362. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +3 -2
  363. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  364. package/build/dist/UI/Utils/Navigation.js +9 -0
  365. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  366. package/build/dist/UI/Utils/TableFilterUrlState.js +159 -15
  367. package/build/dist/UI/Utils/TableFilterUrlState.js.map +1 -1
  368. package/build/dist/UI/Utils/TableViewUrlState.js +103 -0
  369. package/build/dist/UI/Utils/TableViewUrlState.js.map +1 -0
  370. package/build/dist/Utils/Dashboard/Components/DashboardSloComponent.js +93 -0
  371. package/build/dist/Utils/Dashboard/Components/DashboardSloComponent.js.map +1 -0
  372. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  373. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  374. package/build/dist/Utils/IpCanonicalUtil.js +110 -0
  375. package/build/dist/Utils/IpCanonicalUtil.js.map +1 -0
  376. package/build/dist/Utils/Monitor/EndpointAttachmentUtil.js +311 -0
  377. package/build/dist/Utils/Monitor/EndpointAttachmentUtil.js.map +1 -0
  378. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +98 -5
  379. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  380. package/build/dist/Utils/NetworkSite/CidrMatchUtil.js +222 -0
  381. package/build/dist/Utils/NetworkSite/CidrMatchUtil.js.map +1 -0
  382. package/build/dist/Utils/NetworkSite/MaterializedPathUtil.js +89 -0
  383. package/build/dist/Utils/NetworkSite/MaterializedPathUtil.js.map +1 -0
  384. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +60 -0
  385. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -0
  386. package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js +76 -0
  387. package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js.map +1 -0
  388. package/build/dist/Utils/Slo/SloStatusColor.js +18 -0
  389. package/build/dist/Utils/Slo/SloStatusColor.js.map +1 -0
  390. package/build/dist/Utils/Slo/SloUtil.js +393 -0
  391. package/build/dist/Utils/Slo/SloUtil.js.map +1 -0
  392. package/build/dist/Utils/Slo/SloWidgetFormat.js +174 -0
  393. package/build/dist/Utils/Slo/SloWidgetFormat.js.map +1 -0
  394. package/package.json +2 -2
  395. package/Types/Dashboard/DashboardComponents/Index.ts +0 -7
  396. package/build/dist/Types/Dashboard/DashboardComponents/Index.js +0 -8
  397. package/build/dist/Types/Dashboard/DashboardComponents/Index.js.map +0 -1
@@ -0,0 +1,1313 @@
1
+ import Alert from "../../../Models/DatabaseModels/Alert";
2
+ import AlertStateTimeline from "../../../Models/DatabaseModels/AlertStateTimeline";
3
+ import ServiceLevelObjectiveBurnRateRule from "../../../Models/DatabaseModels/ServiceLevelObjectiveBurnRateRule";
4
+ import AlertService from "../../../Server/Services/AlertService";
5
+ import AlertStateTimelineService from "../../../Server/Services/AlertStateTimelineService";
6
+ import ServiceLevelObjectiveBurnRateRuleService from "../../../Server/Services/ServiceLevelObjectiveBurnRateRuleService";
7
+ import CreateBy from "../../../Server/Types/Database/CreateBy";
8
+ import DeleteBy from "../../../Server/Types/Database/DeleteBy";
9
+ import UpdateBy from "../../../Server/Types/Database/UpdateBy";
10
+ import logger from "../../../Server/Utils/Logger";
11
+ import BadDataException from "../../../Types/Exception/BadDataException";
12
+ import ObjectID from "../../../Types/ObjectID";
13
+ import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
14
+
15
+ /*
16
+ * Contract under test for ServiceLevelObjectiveBurnRateRuleService:
17
+ *
18
+ * - a multi-window burn rate rule is only meaningful when the long window
19
+ * is strictly longer than the short one and the threshold is positive;
20
+ * the guard has to hold on create AND on partial updates that touch only
21
+ * one of the two windows (validated against the persisted sibling),
22
+ * - the alert fingerprint is the only link between a rule and the alerts it
23
+ * opened, so it must be exactly `slo:<sloId>:burn-rule:<ruleId>` and
24
+ * never drift,
25
+ * - deleting a rule must resolve the alerts it opened, tolerating the
26
+ * benign same-state race with the evaluation worker and isolating a
27
+ * failure on one alert from the rest.
28
+ */
29
+
30
+ const PROJECT_ID: ObjectID = new ObjectID(
31
+ "22222222-2222-4222-8222-222222222222",
32
+ );
33
+ const SLO_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
34
+ const RULE_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
35
+ const OTHER_RULE_ID: ObjectID = new ObjectID(
36
+ "44444444-4444-4444-8444-444444444444",
37
+ );
38
+ const RESOLVED_STATE_ID: ObjectID = new ObjectID(
39
+ "66666666-6666-4666-8666-666666666666",
40
+ );
41
+
42
+ const THRESHOLD_ERROR_MESSAGE: string =
43
+ "Burn rate threshold must be greater than 0.";
44
+ const WINDOWS_REQUIRED_ERROR_MESSAGE: string =
45
+ "Long window and short window are required for a burn rate rule.";
46
+ const SHORT_WINDOW_ERROR_MESSAGE: string =
47
+ "Short window must be greater than 0 minutes.";
48
+ const LONG_WINDOW_ERROR_MESSAGE: string =
49
+ "Long window must be greater than the short window.";
50
+ const WINDOWS_NOT_NUMERIC_ERROR_MESSAGE: string =
51
+ "Long window and short window must be a number of minutes.";
52
+ const SAME_STATE_RACE_MESSAGE: string =
53
+ "Alert state cannot be same as previous state.";
54
+ const DEFAULT_ROOT_CAUSE: string =
55
+ "Alert auto-resolved because the SLO burn rate rule that created it is no longer active.";
56
+ const DELETE_ROOT_CAUSE: string =
57
+ "Alert auto-resolved because the SLO burn rate rule that created it was deleted.";
58
+
59
+ type RuleFields = Record<string, unknown>;
60
+
61
+ function makeRule(fields: RuleFields): ServiceLevelObjectiveBurnRateRule {
62
+ const rule: ServiceLevelObjectiveBurnRateRule =
63
+ new ServiceLevelObjectiveBurnRateRule();
64
+ const writable: Record<string, unknown> = rule as unknown as Record<
65
+ string,
66
+ unknown
67
+ >;
68
+
69
+ for (const key of Object.keys(fields)) {
70
+ writable[key] = fields[key];
71
+ }
72
+
73
+ return rule;
74
+ }
75
+
76
+ function makeCreateBy(
77
+ fields: RuleFields,
78
+ ): CreateBy<ServiceLevelObjectiveBurnRateRule> {
79
+ return {
80
+ data: makeRule({
81
+ projectId: PROJECT_ID,
82
+ serviceLevelObjectiveId: SLO_ID,
83
+ name: "Fast burn",
84
+ ...fields,
85
+ }),
86
+ props: { isRoot: true },
87
+ };
88
+ }
89
+
90
+ function makeUpdateBy(
91
+ data: RuleFields,
92
+ ): UpdateBy<ServiceLevelObjectiveBurnRateRule> {
93
+ return {
94
+ query: { _id: RULE_ID.toString() },
95
+ data: data,
96
+ props: { isRoot: true },
97
+ limit: 1,
98
+ skip: 0,
99
+ } as unknown as UpdateBy<ServiceLevelObjectiveBurnRateRule>;
100
+ }
101
+
102
+ function makeDeleteBy(): DeleteBy<ServiceLevelObjectiveBurnRateRule> {
103
+ return {
104
+ query: { serviceLevelObjectiveId: SLO_ID },
105
+ props: { isRoot: true },
106
+ limit: 10,
107
+ skip: 0,
108
+ } as unknown as DeleteBy<ServiceLevelObjectiveBurnRateRule>;
109
+ }
110
+
111
+ function makeAlert(id: ObjectID): Alert {
112
+ const alert: Alert = new Alert();
113
+ alert._id = id.toString();
114
+ alert.id = id;
115
+ alert.projectId = PROJECT_ID;
116
+ return alert;
117
+ }
118
+
119
+ // Calls the protected hook without widening the service's public surface.
120
+ function callHook(name: string, ...args: Array<unknown>): Promise<unknown> {
121
+ const hooks: Record<
122
+ string,
123
+ (...hookArgs: Array<unknown>) => Promise<unknown>
124
+ > = ServiceLevelObjectiveBurnRateRuleService as unknown as Record<
125
+ string,
126
+ (...hookArgs: Array<unknown>) => Promise<unknown>
127
+ >;
128
+
129
+ return hooks[name]!.apply(ServiceLevelObjectiveBurnRateRuleService, args);
130
+ }
131
+
132
+ async function expectBadData(
133
+ promise: Promise<unknown>,
134
+ message: string,
135
+ ): Promise<void> {
136
+ await expect(promise).rejects.toThrow(BadDataException);
137
+ await expect(promise).rejects.toThrow(message);
138
+ }
139
+
140
+ describe("ServiceLevelObjectiveBurnRateRuleService.onBeforeCreate", () => {
141
+ afterEach(() => {
142
+ jest.restoreAllMocks();
143
+ });
144
+
145
+ const rejectedThresholds: Array<{ label: string; value: unknown }> = [
146
+ { label: "0", value: 0 },
147
+ { label: "a negative threshold", value: -1 },
148
+ { label: "null", value: null },
149
+ ];
150
+
151
+ for (const rejectedThreshold of rejectedThresholds) {
152
+ it(`rejects a burn rate threshold of ${rejectedThreshold.label}`, async () => {
153
+ await expectBadData(
154
+ callHook(
155
+ "onBeforeCreate",
156
+ makeCreateBy({
157
+ burnRateThreshold: rejectedThreshold.value,
158
+ longWindowInMinutes: 60,
159
+ shortWindowInMinutes: 5,
160
+ }),
161
+ ),
162
+ THRESHOLD_ERROR_MESSAGE,
163
+ );
164
+ });
165
+ }
166
+
167
+ it("rejects a rule created with no burn rate threshold at all", async () => {
168
+ await expectBadData(
169
+ callHook(
170
+ "onBeforeCreate",
171
+ makeCreateBy({
172
+ longWindowInMinutes: 60,
173
+ shortWindowInMinutes: 5,
174
+ }),
175
+ ),
176
+ THRESHOLD_ERROR_MESSAGE,
177
+ );
178
+ });
179
+
180
+ it("rejects a rule with no long window", async () => {
181
+ await expectBadData(
182
+ callHook(
183
+ "onBeforeCreate",
184
+ makeCreateBy({
185
+ burnRateThreshold: 14.4,
186
+ shortWindowInMinutes: 5,
187
+ }),
188
+ ),
189
+ WINDOWS_REQUIRED_ERROR_MESSAGE,
190
+ );
191
+ });
192
+
193
+ it("rejects a rule with no short window", async () => {
194
+ await expectBadData(
195
+ callHook(
196
+ "onBeforeCreate",
197
+ makeCreateBy({
198
+ burnRateThreshold: 14.4,
199
+ longWindowInMinutes: 60,
200
+ }),
201
+ ),
202
+ WINDOWS_REQUIRED_ERROR_MESSAGE,
203
+ );
204
+ });
205
+
206
+ it("rejects a short window of 0 minutes", async () => {
207
+ await expectBadData(
208
+ callHook(
209
+ "onBeforeCreate",
210
+ makeCreateBy({
211
+ burnRateThreshold: 14.4,
212
+ longWindowInMinutes: 60,
213
+ shortWindowInMinutes: 0,
214
+ }),
215
+ ),
216
+ SHORT_WINDOW_ERROR_MESSAGE,
217
+ );
218
+ });
219
+
220
+ it("rejects a negative short window", async () => {
221
+ await expectBadData(
222
+ callHook(
223
+ "onBeforeCreate",
224
+ makeCreateBy({
225
+ burnRateThreshold: 14.4,
226
+ longWindowInMinutes: 60,
227
+ shortWindowInMinutes: -5,
228
+ }),
229
+ ),
230
+ SHORT_WINDOW_ERROR_MESSAGE,
231
+ );
232
+ });
233
+
234
+ it("rejects a long window shorter than the short window", async () => {
235
+ await expectBadData(
236
+ callHook(
237
+ "onBeforeCreate",
238
+ makeCreateBy({
239
+ burnRateThreshold: 14.4,
240
+ longWindowInMinutes: 5,
241
+ shortWindowInMinutes: 60,
242
+ }),
243
+ ),
244
+ LONG_WINDOW_ERROR_MESSAGE,
245
+ );
246
+ });
247
+
248
+ it("rejects a long window equal to the short window", async () => {
249
+ await expectBadData(
250
+ callHook(
251
+ "onBeforeCreate",
252
+ makeCreateBy({
253
+ burnRateThreshold: 14.4,
254
+ longWindowInMinutes: 60,
255
+ shortWindowInMinutes: 60,
256
+ }),
257
+ ),
258
+ LONG_WINDOW_ERROR_MESSAGE,
259
+ );
260
+ });
261
+
262
+ const acceptedRules: Array<{
263
+ label: string;
264
+ burnRateThreshold: number;
265
+ longWindowInMinutes: number;
266
+ shortWindowInMinutes: number;
267
+ }> = [
268
+ {
269
+ label: "the canonical fast burn rule",
270
+ burnRateThreshold: 14.4,
271
+ longWindowInMinutes: 60,
272
+ shortWindowInMinutes: 5,
273
+ },
274
+ {
275
+ label: "the canonical slow burn rule",
276
+ burnRateThreshold: 6,
277
+ longWindowInMinutes: 360,
278
+ shortWindowInMinutes: 30,
279
+ },
280
+ {
281
+ label: "a sub-1x threshold from a short compliance window",
282
+ burnRateThreshold: 0.01,
283
+ longWindowInMinutes: 2,
284
+ shortWindowInMinutes: 1,
285
+ },
286
+ ];
287
+
288
+ for (const acceptedRule of acceptedRules) {
289
+ it(`accepts ${acceptedRule.label}`, async () => {
290
+ await expect(
291
+ callHook(
292
+ "onBeforeCreate",
293
+ makeCreateBy({
294
+ burnRateThreshold: acceptedRule.burnRateThreshold,
295
+ longWindowInMinutes: acceptedRule.longWindowInMinutes,
296
+ shortWindowInMinutes: acceptedRule.shortWindowInMinutes,
297
+ }),
298
+ ),
299
+ ).resolves.toBeDefined();
300
+ });
301
+ }
302
+ });
303
+
304
+ describe("ServiceLevelObjectiveBurnRateRuleService.onBeforeUpdate", () => {
305
+ let findBySpy: jest.SpyInstance;
306
+
307
+ beforeEach(() => {
308
+ findBySpy = jest.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy");
309
+ });
310
+
311
+ afterEach(() => {
312
+ jest.restoreAllMocks();
313
+ });
314
+
315
+ it("rejects an update that drops the threshold to 0", async () => {
316
+ await expectBadData(
317
+ callHook("onBeforeUpdate", makeUpdateBy({ burnRateThreshold: 0 })),
318
+ THRESHOLD_ERROR_MESSAGE,
319
+ );
320
+ });
321
+
322
+ it("rejects an update to a negative threshold", async () => {
323
+ await expectBadData(
324
+ callHook("onBeforeUpdate", makeUpdateBy({ burnRateThreshold: -2 })),
325
+ THRESHOLD_ERROR_MESSAGE,
326
+ );
327
+ });
328
+
329
+ it("accepts an update to a positive threshold without reading the row", async () => {
330
+ await expect(
331
+ callHook("onBeforeUpdate", makeUpdateBy({ burnRateThreshold: 2.5 })),
332
+ ).resolves.toBeDefined();
333
+
334
+ expect(findBySpy).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it("skips validation entirely when neither the threshold nor a window is touched", async () => {
338
+ await expect(
339
+ callHook("onBeforeUpdate", makeUpdateBy({ name: "Renamed rule" })),
340
+ ).resolves.toBeDefined();
341
+
342
+ expect(findBySpy).not.toHaveBeenCalled();
343
+ });
344
+
345
+ it("validates both windows in one payload without reading the row", async () => {
346
+ await expect(
347
+ callHook(
348
+ "onBeforeUpdate",
349
+ makeUpdateBy({ longWindowInMinutes: 120, shortWindowInMinutes: 10 }),
350
+ ),
351
+ ).resolves.toBeDefined();
352
+
353
+ expect(findBySpy).not.toHaveBeenCalled();
354
+ });
355
+
356
+ it("rejects a payload where both windows are inverted", async () => {
357
+ await expectBadData(
358
+ callHook(
359
+ "onBeforeUpdate",
360
+ makeUpdateBy({ longWindowInMinutes: 10, shortWindowInMinutes: 120 }),
361
+ ),
362
+ LONG_WINDOW_ERROR_MESSAGE,
363
+ );
364
+
365
+ expect(findBySpy).not.toHaveBeenCalled();
366
+ });
367
+
368
+ it("rejects a payload that zeroes the short window while widening the long one", async () => {
369
+ await expectBadData(
370
+ callHook(
371
+ "onBeforeUpdate",
372
+ makeUpdateBy({ longWindowInMinutes: 120, shortWindowInMinutes: 0 }),
373
+ ),
374
+ SHORT_WINDOW_ERROR_MESSAGE,
375
+ );
376
+ });
377
+
378
+ it("validates a long-window-only update against the persisted short window and passes", async () => {
379
+ findBySpy.mockResolvedValue([
380
+ makeRule({
381
+ _id: RULE_ID.toString(),
382
+ id: RULE_ID,
383
+ longWindowInMinutes: 60,
384
+ shortWindowInMinutes: 30,
385
+ }),
386
+ ]);
387
+
388
+ await expect(
389
+ callHook("onBeforeUpdate", makeUpdateBy({ longWindowInMinutes: 120 })),
390
+ ).resolves.toBeDefined();
391
+
392
+ expect(findBySpy).toHaveBeenCalledTimes(1);
393
+
394
+ const findByArg: {
395
+ query: Record<string, unknown>;
396
+ select: Record<string, unknown>;
397
+ props: Record<string, unknown>;
398
+ } = findBySpy.mock.calls[0]![0] as {
399
+ query: Record<string, unknown>;
400
+ select: Record<string, unknown>;
401
+ props: Record<string, unknown>;
402
+ };
403
+
404
+ // It must read the row through the caller's own query, as root.
405
+ expect(findByArg.query).toEqual({ _id: RULE_ID.toString() });
406
+ expect(findByArg.select).toEqual({
407
+ _id: true,
408
+ longWindowInMinutes: true,
409
+ shortWindowInMinutes: true,
410
+ });
411
+ expect(findByArg.props).toEqual({ isRoot: true });
412
+ });
413
+
414
+ it("rejects a long-window-only update that would sink below the persisted short window", async () => {
415
+ findBySpy.mockResolvedValue([
416
+ makeRule({
417
+ _id: RULE_ID.toString(),
418
+ id: RULE_ID,
419
+ longWindowInMinutes: 360,
420
+ shortWindowInMinutes: 30,
421
+ }),
422
+ ]);
423
+
424
+ await expectBadData(
425
+ callHook("onBeforeUpdate", makeUpdateBy({ longWindowInMinutes: 20 })),
426
+ LONG_WINDOW_ERROR_MESSAGE,
427
+ );
428
+ });
429
+
430
+ it("validates a short-window-only update against the persisted long window and passes", async () => {
431
+ findBySpy.mockResolvedValue([
432
+ makeRule({
433
+ _id: RULE_ID.toString(),
434
+ id: RULE_ID,
435
+ longWindowInMinutes: 360,
436
+ shortWindowInMinutes: 30,
437
+ }),
438
+ ]);
439
+
440
+ await expect(
441
+ callHook("onBeforeUpdate", makeUpdateBy({ shortWindowInMinutes: 60 })),
442
+ ).resolves.toBeDefined();
443
+ });
444
+
445
+ it("rejects a short-window-only update that would overtake the persisted long window", async () => {
446
+ findBySpy.mockResolvedValue([
447
+ makeRule({
448
+ _id: RULE_ID.toString(),
449
+ id: RULE_ID,
450
+ longWindowInMinutes: 60,
451
+ shortWindowInMinutes: 5,
452
+ }),
453
+ ]);
454
+
455
+ await expectBadData(
456
+ callHook("onBeforeUpdate", makeUpdateBy({ shortWindowInMinutes: 90 })),
457
+ LONG_WINDOW_ERROR_MESSAGE,
458
+ );
459
+ });
460
+
461
+ it("rejects a short-window-only update to 0 regardless of the persisted long window", async () => {
462
+ findBySpy.mockResolvedValue([
463
+ makeRule({
464
+ _id: RULE_ID.toString(),
465
+ id: RULE_ID,
466
+ longWindowInMinutes: 360,
467
+ shortWindowInMinutes: 30,
468
+ }),
469
+ ]);
470
+
471
+ await expectBadData(
472
+ callHook("onBeforeUpdate", makeUpdateBy({ shortWindowInMinutes: 0 })),
473
+ SHORT_WINDOW_ERROR_MESSAGE,
474
+ );
475
+ });
476
+
477
+ it("rejects a bulk window update as soon as any matched row would become invalid", async () => {
478
+ findBySpy.mockResolvedValue([
479
+ makeRule({
480
+ _id: RULE_ID.toString(),
481
+ id: RULE_ID,
482
+ longWindowInMinutes: 360,
483
+ shortWindowInMinutes: 30,
484
+ }),
485
+ makeRule({
486
+ _id: OTHER_RULE_ID.toString(),
487
+ id: OTHER_RULE_ID,
488
+ longWindowInMinutes: 60,
489
+ shortWindowInMinutes: 5,
490
+ }),
491
+ ]);
492
+
493
+ /*
494
+ * 120 still fits under the first row's 360 minute long window, but it
495
+ * overtakes the second row's 60 minute one - the whole update must fail.
496
+ */
497
+ await expectBadData(
498
+ callHook("onBeforeUpdate", makeUpdateBy({ shortWindowInMinutes: 120 })),
499
+ LONG_WINDOW_ERROR_MESSAGE,
500
+ );
501
+ });
502
+
503
+ it("accepts a bulk window update that stays valid for every matched row", async () => {
504
+ findBySpy.mockResolvedValue([
505
+ makeRule({
506
+ _id: RULE_ID.toString(),
507
+ id: RULE_ID,
508
+ longWindowInMinutes: 360,
509
+ shortWindowInMinutes: 30,
510
+ }),
511
+ makeRule({
512
+ _id: OTHER_RULE_ID.toString(),
513
+ id: OTHER_RULE_ID,
514
+ longWindowInMinutes: 60,
515
+ shortWindowInMinutes: 5,
516
+ }),
517
+ ]);
518
+
519
+ await expect(
520
+ callHook("onBeforeUpdate", makeUpdateBy({ shortWindowInMinutes: 10 })),
521
+ ).resolves.toBeDefined();
522
+ });
523
+
524
+ it("treats an explicit null window as 'not being updated' and skips the row read", async () => {
525
+ await expect(
526
+ callHook("onBeforeUpdate", makeUpdateBy({ longWindowInMinutes: null })),
527
+ ).resolves.toBeDefined();
528
+
529
+ expect(findBySpy).not.toHaveBeenCalled();
530
+ });
531
+ });
532
+
533
+ /*
534
+ * Regression: the window columns arrive as STRINGS from the dashboard (number
535
+ * form fields hand Formik `e.target.value`, and neither ModelForm nor
536
+ * BaseModel.fromJSON coerces Number/Decimal columns), so
537
+ * `long <= short` used to be a LEXICOGRAPHIC comparison:
538
+ *
539
+ * long "1440", short "60" -> "1440" <= "60" is TRUE -> wrongly REJECTED
540
+ * (a 24h/60m rule could not be created at all)
541
+ * long "120", short "30" -> "120" <= "30" is TRUE -> wrongly REJECTED
542
+ * long "60", short "360" -> "60" <= "360" is FALSE -> wrongly ACCEPTED
543
+ * (a short window LONGER than the long one,
544
+ * which breaks the whole multi-window design)
545
+ *
546
+ * Every numeric column is now coerced before validation and the coerced number
547
+ * is written back onto the payload.
548
+ */
549
+ describe("ServiceLevelObjectiveBurnRateRuleService - numeric columns supplied as strings", () => {
550
+ let findBySpy: jest.SpyInstance;
551
+
552
+ beforeEach(() => {
553
+ findBySpy = jest.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy");
554
+ });
555
+
556
+ afterEach(() => {
557
+ jest.restoreAllMocks();
558
+ });
559
+
560
+ const lexicographicTraps: Array<{
561
+ label: string;
562
+ longWindowInMinutes: string;
563
+ shortWindowInMinutes: string;
564
+ }> = [
565
+ {
566
+ label: "a 24 hour long window over a 60 minute short window",
567
+ longWindowInMinutes: "1440",
568
+ shortWindowInMinutes: "60",
569
+ },
570
+ {
571
+ label: "a 2 hour long window over a 30 minute short window",
572
+ longWindowInMinutes: "120",
573
+ shortWindowInMinutes: "30",
574
+ },
575
+ {
576
+ label: "a 6 hour long window over a 5 minute short window",
577
+ longWindowInMinutes: "360",
578
+ shortWindowInMinutes: "5",
579
+ },
580
+ ];
581
+
582
+ for (const trap of lexicographicTraps) {
583
+ it(`accepts ${trap.label} sent as strings, and persists both as numbers`, async () => {
584
+ const createBy: CreateBy<ServiceLevelObjectiveBurnRateRule> =
585
+ makeCreateBy({
586
+ burnRateThreshold: "14.4",
587
+ longWindowInMinutes: trap.longWindowInMinutes,
588
+ shortWindowInMinutes: trap.shortWindowInMinutes,
589
+ });
590
+
591
+ await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
592
+
593
+ expect(createBy.data.longWindowInMinutes).toBe(
594
+ Number(trap.longWindowInMinutes),
595
+ );
596
+ expect(createBy.data.shortWindowInMinutes).toBe(
597
+ Number(trap.shortWindowInMinutes),
598
+ );
599
+ expect(createBy.data.burnRateThreshold).toBe(14.4);
600
+
601
+ expect(typeof createBy.data.longWindowInMinutes).toBe("number");
602
+ expect(typeof createBy.data.shortWindowInMinutes).toBe("number");
603
+ expect(typeof createBy.data.burnRateThreshold).toBe("number");
604
+ });
605
+ }
606
+
607
+ it('rejects a short window LONGER than the long window when both are strings ("60" / "360")', async () => {
608
+ await expectBadData(
609
+ callHook(
610
+ "onBeforeCreate",
611
+ makeCreateBy({
612
+ burnRateThreshold: 14.4,
613
+ longWindowInMinutes: "60",
614
+ shortWindowInMinutes: "360",
615
+ }),
616
+ ),
617
+ LONG_WINDOW_ERROR_MESSAGE,
618
+ );
619
+ });
620
+
621
+ it("rejects string windows that are equal", async () => {
622
+ await expectBadData(
623
+ callHook(
624
+ "onBeforeCreate",
625
+ makeCreateBy({
626
+ burnRateThreshold: 14.4,
627
+ longWindowInMinutes: "60",
628
+ shortWindowInMinutes: "60",
629
+ }),
630
+ ),
631
+ LONG_WINDOW_ERROR_MESSAGE,
632
+ );
633
+ });
634
+
635
+ it('rejects a short window of "0" as a string', async () => {
636
+ await expectBadData(
637
+ callHook(
638
+ "onBeforeCreate",
639
+ makeCreateBy({
640
+ burnRateThreshold: 14.4,
641
+ longWindowInMinutes: "60",
642
+ shortWindowInMinutes: "0",
643
+ }),
644
+ ),
645
+ SHORT_WINDOW_ERROR_MESSAGE,
646
+ );
647
+ });
648
+
649
+ const nonNumericWindows: Array<{ label: string; value: unknown }> = [
650
+ { label: "an empty string", value: "" },
651
+ { label: "a whitespace-only string", value: " " },
652
+ { label: "a non-numeric string", value: "abc" },
653
+ { label: "NaN", value: Number.NaN },
654
+ { label: "a boolean", value: true },
655
+ ];
656
+
657
+ for (const nonNumericWindow of nonNumericWindows) {
658
+ it(`rejects a long window of ${nonNumericWindow.label} on create`, async () => {
659
+ await expectBadData(
660
+ callHook(
661
+ "onBeforeCreate",
662
+ makeCreateBy({
663
+ burnRateThreshold: 14.4,
664
+ longWindowInMinutes: nonNumericWindow.value,
665
+ shortWindowInMinutes: 5,
666
+ }),
667
+ ),
668
+ WINDOWS_NOT_NUMERIC_ERROR_MESSAGE,
669
+ );
670
+ });
671
+
672
+ it(`rejects a short window of ${nonNumericWindow.label} on create`, async () => {
673
+ await expectBadData(
674
+ callHook(
675
+ "onBeforeCreate",
676
+ makeCreateBy({
677
+ burnRateThreshold: 14.4,
678
+ longWindowInMinutes: 60,
679
+ shortWindowInMinutes: nonNumericWindow.value,
680
+ }),
681
+ ),
682
+ WINDOWS_NOT_NUMERIC_ERROR_MESSAGE,
683
+ );
684
+ });
685
+ }
686
+
687
+ const rejectedThresholdStrings: Array<{ label: string; value: unknown }> = [
688
+ { label: "an empty string", value: "" },
689
+ { label: "a non-numeric string", value: "abc" },
690
+ { label: 'the string "0"', value: "0" },
691
+ { label: 'the string "-1"', value: "-1" },
692
+ ];
693
+
694
+ for (const rejectedThresholdString of rejectedThresholdStrings) {
695
+ it(`rejects a threshold of ${rejectedThresholdString.label} on create`, async () => {
696
+ await expectBadData(
697
+ callHook(
698
+ "onBeforeCreate",
699
+ makeCreateBy({
700
+ burnRateThreshold: rejectedThresholdString.value,
701
+ longWindowInMinutes: 60,
702
+ shortWindowInMinutes: 5,
703
+ }),
704
+ ),
705
+ THRESHOLD_ERROR_MESSAGE,
706
+ );
707
+ });
708
+
709
+ it(`rejects a threshold of ${rejectedThresholdString.label} on update`, async () => {
710
+ await expectBadData(
711
+ callHook(
712
+ "onBeforeUpdate",
713
+ makeUpdateBy({ burnRateThreshold: rejectedThresholdString.value }),
714
+ ),
715
+ THRESHOLD_ERROR_MESSAGE,
716
+ );
717
+ });
718
+ }
719
+
720
+ it("coerces the optional numeric columns too", async () => {
721
+ const createBy: CreateBy<ServiceLevelObjectiveBurnRateRule> = makeCreateBy({
722
+ burnRateThreshold: "6",
723
+ longWindowInMinutes: "360",
724
+ shortWindowInMinutes: "30",
725
+ minimumSampleCount: "100",
726
+ refireSuppressionMinutes: "360",
727
+ });
728
+
729
+ await expect(callHook("onBeforeCreate", createBy)).resolves.toBeDefined();
730
+
731
+ expect(createBy.data.minimumSampleCount).toBe(100);
732
+ expect(createBy.data.refireSuppressionMinutes).toBe(360);
733
+ expect(typeof createBy.data.minimumSampleCount).toBe("number");
734
+ expect(typeof createBy.data.refireSuppressionMinutes).toBe("number");
735
+ });
736
+
737
+ it("rejects a non-numeric minimum sample count", async () => {
738
+ await expectBadData(
739
+ callHook(
740
+ "onBeforeCreate",
741
+ makeCreateBy({
742
+ burnRateThreshold: 6,
743
+ longWindowInMinutes: 360,
744
+ shortWindowInMinutes: 30,
745
+ minimumSampleCount: "abc",
746
+ }),
747
+ ),
748
+ "Minimum sample count must be a number greater than or equal to 0.",
749
+ );
750
+ });
751
+
752
+ it("rejects a negative re-fire suppression window", async () => {
753
+ await expectBadData(
754
+ callHook(
755
+ "onBeforeUpdate",
756
+ makeUpdateBy({ refireSuppressionMinutes: "-5" }),
757
+ ),
758
+ "Re-fire suppression must be a number of minutes greater than or equal to 0.",
759
+ );
760
+ });
761
+
762
+ it("accepts both windows as strings in one update payload and writes numbers back", async () => {
763
+ const updateBy: UpdateBy<ServiceLevelObjectiveBurnRateRule> = makeUpdateBy({
764
+ longWindowInMinutes: "1440",
765
+ shortWindowInMinutes: "60",
766
+ });
767
+
768
+ await expect(callHook("onBeforeUpdate", updateBy)).resolves.toBeDefined();
769
+
770
+ expect(updateBy.data.longWindowInMinutes).toBe(1440);
771
+ expect(updateBy.data.shortWindowInMinutes).toBe(60);
772
+ expect(typeof updateBy.data.longWindowInMinutes).toBe("number");
773
+ expect(typeof updateBy.data.shortWindowInMinutes).toBe("number");
774
+ expect(findBySpy).not.toHaveBeenCalled();
775
+ });
776
+
777
+ it("rejects an inverted string window pair in one update payload", async () => {
778
+ await expectBadData(
779
+ callHook(
780
+ "onBeforeUpdate",
781
+ makeUpdateBy({
782
+ longWindowInMinutes: "60",
783
+ shortWindowInMinutes: "360",
784
+ }),
785
+ ),
786
+ LONG_WINDOW_ERROR_MESSAGE,
787
+ );
788
+ });
789
+
790
+ it("compares a string long-window update numerically against the persisted short window", async () => {
791
+ findBySpy.mockResolvedValue([
792
+ makeRule({
793
+ _id: RULE_ID.toString(),
794
+ id: RULE_ID,
795
+ longWindowInMinutes: 60,
796
+ shortWindowInMinutes: 30,
797
+ }),
798
+ ]);
799
+
800
+ const updateBy: UpdateBy<ServiceLevelObjectiveBurnRateRule> = makeUpdateBy({
801
+ longWindowInMinutes: "1440",
802
+ });
803
+
804
+ await expect(callHook("onBeforeUpdate", updateBy)).resolves.toBeDefined();
805
+
806
+ expect(updateBy.data.longWindowInMinutes).toBe(1440);
807
+ expect(typeof updateBy.data.longWindowInMinutes).toBe("number");
808
+ });
809
+
810
+ /*
811
+ * Rows written before this fix can still hold a string in a Number/Decimal
812
+ * column, so the PERSISTED sibling has to be coerced as well - otherwise the
813
+ * comparison against it is lexicographic all over again.
814
+ */
815
+ it("coerces the persisted sibling window, so a legacy string row still compares numerically", async () => {
816
+ findBySpy.mockResolvedValue([
817
+ makeRule({
818
+ _id: RULE_ID.toString(),
819
+ id: RULE_ID,
820
+ // A row that predates coercion: the short window is stored as text.
821
+ shortWindowInMinutes: "60",
822
+ }),
823
+ ]);
824
+
825
+ await expect(
826
+ callHook("onBeforeUpdate", makeUpdateBy({ longWindowInMinutes: 1440 })),
827
+ ).resolves.toBeDefined();
828
+ });
829
+
830
+ it("rejects a short-window update that overtakes a persisted long window stored as a string", async () => {
831
+ findBySpy.mockResolvedValue([
832
+ makeRule({
833
+ _id: RULE_ID.toString(),
834
+ id: RULE_ID,
835
+ // "60" lexicographically precedes "360", which used to let this pass.
836
+ longWindowInMinutes: "60",
837
+ }),
838
+ ]);
839
+
840
+ await expectBadData(
841
+ callHook("onBeforeUpdate", makeUpdateBy({ shortWindowInMinutes: "360" })),
842
+ LONG_WINDOW_ERROR_MESSAGE,
843
+ );
844
+ });
845
+
846
+ it("rejects a non-numeric single-window update before it even reads the rows", async () => {
847
+ await expectBadData(
848
+ callHook("onBeforeUpdate", makeUpdateBy({ longWindowInMinutes: "abc" })),
849
+ WINDOWS_NOT_NUMERIC_ERROR_MESSAGE,
850
+ );
851
+
852
+ expect(findBySpy).not.toHaveBeenCalled();
853
+ });
854
+
855
+ it("rejects a persisted sibling that is not numeric at all, rather than accepting a NaN comparison", async () => {
856
+ findBySpy.mockResolvedValue([
857
+ makeRule({
858
+ _id: RULE_ID.toString(),
859
+ id: RULE_ID,
860
+ shortWindowInMinutes: undefined,
861
+ }),
862
+ ]);
863
+
864
+ await expectBadData(
865
+ callHook("onBeforeUpdate", makeUpdateBy({ longWindowInMinutes: 1440 })),
866
+ WINDOWS_NOT_NUMERIC_ERROR_MESSAGE,
867
+ );
868
+ });
869
+
870
+ it("coerces a string single-window update even when the query matches no rows", async () => {
871
+ findBySpy.mockResolvedValue([]);
872
+
873
+ const updateBy: UpdateBy<ServiceLevelObjectiveBurnRateRule> = makeUpdateBy({
874
+ shortWindowInMinutes: "30",
875
+ });
876
+
877
+ await expect(callHook("onBeforeUpdate", updateBy)).resolves.toBeDefined();
878
+
879
+ expect(updateBy.data.shortWindowInMinutes).toBe(30);
880
+ expect(typeof updateBy.data.shortWindowInMinutes).toBe("number");
881
+ });
882
+
883
+ it("writes a coerced threshold back onto the update payload", async () => {
884
+ const updateBy: UpdateBy<ServiceLevelObjectiveBurnRateRule> = makeUpdateBy({
885
+ burnRateThreshold: "2.5",
886
+ });
887
+
888
+ await expect(callHook("onBeforeUpdate", updateBy)).resolves.toBeDefined();
889
+
890
+ expect(updateBy.data.burnRateThreshold).toBe(2.5);
891
+ expect(typeof updateBy.data.burnRateThreshold).toBe("number");
892
+ });
893
+ });
894
+
895
+ describe("ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint", () => {
896
+ it("is exactly slo:<sloId>:burn-rule:<ruleId>", () => {
897
+ expect(
898
+ ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint({
899
+ serviceLevelObjectiveId: SLO_ID,
900
+ burnRateRuleId: RULE_ID,
901
+ }),
902
+ ).toBe(`slo:${SLO_ID.toString()}:burn-rule:${RULE_ID.toString()}`);
903
+ });
904
+
905
+ it("is deterministic across calls", () => {
906
+ const first: string =
907
+ ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint({
908
+ serviceLevelObjectiveId: SLO_ID,
909
+ burnRateRuleId: RULE_ID,
910
+ });
911
+ const second: string =
912
+ ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint({
913
+ serviceLevelObjectiveId: SLO_ID,
914
+ burnRateRuleId: RULE_ID,
915
+ });
916
+
917
+ expect(second).toBe(first);
918
+ });
919
+
920
+ it("distinguishes two rules of the same SLO", () => {
921
+ const first: string =
922
+ ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint({
923
+ serviceLevelObjectiveId: SLO_ID,
924
+ burnRateRuleId: RULE_ID,
925
+ });
926
+ const second: string =
927
+ ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint({
928
+ serviceLevelObjectiveId: SLO_ID,
929
+ burnRateRuleId: OTHER_RULE_ID,
930
+ });
931
+
932
+ expect(second).not.toBe(first);
933
+ });
934
+
935
+ it("distinguishes the same rule id under two different SLOs", () => {
936
+ const first: string =
937
+ ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint({
938
+ serviceLevelObjectiveId: SLO_ID,
939
+ burnRateRuleId: RULE_ID,
940
+ });
941
+ const second: string =
942
+ ServiceLevelObjectiveBurnRateRuleService.getBurnRateAlertFingerprint({
943
+ serviceLevelObjectiveId: OTHER_RULE_ID,
944
+ burnRateRuleId: RULE_ID,
945
+ });
946
+
947
+ expect(second).not.toBe(first);
948
+ });
949
+ });
950
+
951
+ describe("ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule", () => {
952
+ let alertFindBySpy: jest.SpyInstance;
953
+ let resolvedStateSpy: jest.SpyInstance;
954
+ let timelineCreateSpy: jest.SpyInstance;
955
+
956
+ beforeEach(() => {
957
+ alertFindBySpy = jest.spyOn(AlertService, "findBy").mockResolvedValue([]);
958
+
959
+ resolvedStateSpy = jest
960
+ .spyOn(AlertStateTimelineService, "getResolvedStateIdForProject")
961
+ .mockResolvedValue(RESOLVED_STATE_ID);
962
+
963
+ timelineCreateSpy = jest
964
+ .spyOn(AlertStateTimelineService, "create")
965
+ .mockResolvedValue(new AlertStateTimeline());
966
+ });
967
+
968
+ afterEach(() => {
969
+ jest.restoreAllMocks();
970
+ });
971
+
972
+ function timelineRows(): Array<AlertStateTimeline> {
973
+ return timelineCreateSpy.mock.calls.map((call: Array<unknown>) => {
974
+ return (call[0] as CreateBy<AlertStateTimeline>).data;
975
+ });
976
+ }
977
+
978
+ it("looks up only the unresolved alerts carrying this rule's fingerprint", async () => {
979
+ await ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
980
+ serviceLevelObjectiveId: SLO_ID,
981
+ burnRateRuleId: RULE_ID,
982
+ projectId: PROJECT_ID,
983
+ });
984
+
985
+ const findByArg: {
986
+ query: Record<string, unknown>;
987
+ select: Record<string, unknown>;
988
+ props: Record<string, unknown>;
989
+ } = alertFindBySpy.mock.calls[0]![0] as {
990
+ query: Record<string, unknown>;
991
+ select: Record<string, unknown>;
992
+ props: Record<string, unknown>;
993
+ };
994
+
995
+ expect(findByArg.query).toEqual({
996
+ projectId: PROJECT_ID,
997
+ seriesFingerprint: `slo:${SLO_ID.toString()}:burn-rule:${RULE_ID.toString()}`,
998
+ currentAlertState: { isResolvedState: false },
999
+ });
1000
+ // projectId is needed on each row to stamp the timeline entry.
1001
+ expect(findByArg.select).toEqual({ _id: true, projectId: true });
1002
+ expect(findByArg.props).toEqual({ isRoot: true });
1003
+ });
1004
+
1005
+ it("is a no-op when the rule has no open alerts", async () => {
1006
+ await ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1007
+ serviceLevelObjectiveId: SLO_ID,
1008
+ burnRateRuleId: RULE_ID,
1009
+ projectId: PROJECT_ID,
1010
+ });
1011
+
1012
+ // It must not even resolve the project's resolved-state id.
1013
+ expect(resolvedStateSpy).not.toHaveBeenCalled();
1014
+ expect(timelineCreateSpy).not.toHaveBeenCalled();
1015
+ });
1016
+
1017
+ it("appends one resolved state timeline row per open alert", async () => {
1018
+ const firstAlertId: ObjectID = ObjectID.generate();
1019
+ const secondAlertId: ObjectID = ObjectID.generate();
1020
+
1021
+ alertFindBySpy.mockResolvedValue([
1022
+ makeAlert(firstAlertId),
1023
+ makeAlert(secondAlertId),
1024
+ ]);
1025
+
1026
+ await ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1027
+ serviceLevelObjectiveId: SLO_ID,
1028
+ burnRateRuleId: RULE_ID,
1029
+ projectId: PROJECT_ID,
1030
+ });
1031
+
1032
+ // The resolved state is looked up once for the whole batch.
1033
+ expect(resolvedStateSpy).toHaveBeenCalledTimes(1);
1034
+ expect(resolvedStateSpy).toHaveBeenCalledWith(PROJECT_ID);
1035
+
1036
+ const rows: Array<AlertStateTimeline> = timelineRows();
1037
+ expect(rows).toHaveLength(2);
1038
+ expect(rows[0]!.alertId).toEqual(firstAlertId);
1039
+ expect(rows[1]!.alertId).toEqual(secondAlertId);
1040
+
1041
+ for (const row of rows) {
1042
+ expect(row.alertStateId).toEqual(RESOLVED_STATE_ID);
1043
+ expect(row.projectId).toEqual(PROJECT_ID);
1044
+ }
1045
+
1046
+ for (const call of timelineCreateSpy.mock.calls) {
1047
+ const createBy: CreateBy<AlertStateTimeline> = (
1048
+ call as Array<unknown>
1049
+ )[0] as CreateBy<AlertStateTimeline>;
1050
+ expect(createBy.props).toEqual({ isRoot: true });
1051
+ }
1052
+ });
1053
+
1054
+ it("falls back to the generic root cause when the caller supplies none", async () => {
1055
+ alertFindBySpy.mockResolvedValue([makeAlert(ObjectID.generate())]);
1056
+
1057
+ await ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1058
+ serviceLevelObjectiveId: SLO_ID,
1059
+ burnRateRuleId: RULE_ID,
1060
+ projectId: PROJECT_ID,
1061
+ });
1062
+
1063
+ expect(timelineRows()[0]!.rootCause).toBe(DEFAULT_ROOT_CAUSE);
1064
+ });
1065
+
1066
+ it("threads the caller's root cause onto every resolved alert", async () => {
1067
+ alertFindBySpy.mockResolvedValue([
1068
+ makeAlert(ObjectID.generate()),
1069
+ makeAlert(ObjectID.generate()),
1070
+ ]);
1071
+
1072
+ await ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1073
+ serviceLevelObjectiveId: SLO_ID,
1074
+ burnRateRuleId: RULE_ID,
1075
+ projectId: PROJECT_ID,
1076
+ rootCause: "Alert auto-resolved because the SLO was disabled.",
1077
+ });
1078
+
1079
+ for (const row of timelineRows()) {
1080
+ expect(row.rootCause).toBe(
1081
+ "Alert auto-resolved because the SLO was disabled.",
1082
+ );
1083
+ }
1084
+ });
1085
+
1086
+ it("ignores an explicitly undefined root cause and uses the fallback", async () => {
1087
+ alertFindBySpy.mockResolvedValue([makeAlert(ObjectID.generate())]);
1088
+
1089
+ await ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1090
+ serviceLevelObjectiveId: SLO_ID,
1091
+ burnRateRuleId: RULE_ID,
1092
+ projectId: PROJECT_ID,
1093
+ rootCause: undefined,
1094
+ });
1095
+
1096
+ expect(timelineRows()[0]!.rootCause).toBe(DEFAULT_ROOT_CAUSE);
1097
+ });
1098
+
1099
+ it("treats the same-state concurrency race as a benign no-op, not an error", async () => {
1100
+ alertFindBySpy.mockResolvedValue([makeAlert(ObjectID.generate())]);
1101
+ timelineCreateSpy.mockRejectedValue(
1102
+ new BadDataException(SAME_STATE_RACE_MESSAGE),
1103
+ );
1104
+
1105
+ const loggerErrorSpy: jest.SpyInstance = jest
1106
+ .spyOn(logger, "error")
1107
+ .mockImplementation(() => {
1108
+ return undefined;
1109
+ });
1110
+
1111
+ await expect(
1112
+ ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1113
+ serviceLevelObjectiveId: SLO_ID,
1114
+ burnRateRuleId: RULE_ID,
1115
+ projectId: PROJECT_ID,
1116
+ }),
1117
+ ).resolves.toBeUndefined();
1118
+
1119
+ /*
1120
+ * The inner catch must swallow this one specific message - if it were
1121
+ * rethrown, the outer catch would log it as an error.
1122
+ */
1123
+ expect(loggerErrorSpy).not.toHaveBeenCalled();
1124
+ });
1125
+
1126
+ it("keeps resolving the remaining alerts after one alert fails", async () => {
1127
+ const firstAlertId: ObjectID = ObjectID.generate();
1128
+ const secondAlertId: ObjectID = ObjectID.generate();
1129
+
1130
+ alertFindBySpy.mockResolvedValue([
1131
+ makeAlert(firstAlertId),
1132
+ makeAlert(secondAlertId),
1133
+ ]);
1134
+
1135
+ timelineCreateSpy
1136
+ .mockRejectedValueOnce(new Error("write conflict"))
1137
+ .mockResolvedValueOnce(new AlertStateTimeline());
1138
+
1139
+ await expect(
1140
+ ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1141
+ serviceLevelObjectiveId: SLO_ID,
1142
+ burnRateRuleId: RULE_ID,
1143
+ projectId: PROJECT_ID,
1144
+ }),
1145
+ ).resolves.toBeUndefined();
1146
+
1147
+ expect(timelineCreateSpy).toHaveBeenCalledTimes(2);
1148
+ expect(timelineRows()[1]!.alertId).toEqual(secondAlertId);
1149
+ });
1150
+
1151
+ it("does not swallow a non-benign BadDataException silently - it is logged", async () => {
1152
+ alertFindBySpy.mockResolvedValue([makeAlert(ObjectID.generate())]);
1153
+ timelineCreateSpy.mockRejectedValue(
1154
+ new BadDataException("Some other validation problem."),
1155
+ );
1156
+
1157
+ const loggerErrorSpy: jest.SpyInstance = jest
1158
+ .spyOn(logger, "error")
1159
+ .mockImplementation(() => {
1160
+ return undefined;
1161
+ });
1162
+
1163
+ await expect(
1164
+ ServiceLevelObjectiveBurnRateRuleService.resolveOpenAlertsForRule({
1165
+ serviceLevelObjectiveId: SLO_ID,
1166
+ burnRateRuleId: RULE_ID,
1167
+ projectId: PROJECT_ID,
1168
+ }),
1169
+ ).resolves.toBeUndefined();
1170
+
1171
+ expect(loggerErrorSpy).toHaveBeenCalledTimes(1);
1172
+ });
1173
+ });
1174
+
1175
+ describe("ServiceLevelObjectiveBurnRateRuleService.onBeforeDelete", () => {
1176
+ let findBySpy: jest.SpyInstance;
1177
+ let resolveOpenAlertsSpy: jest.SpyInstance;
1178
+
1179
+ beforeEach(() => {
1180
+ findBySpy = jest.spyOn(ServiceLevelObjectiveBurnRateRuleService, "findBy");
1181
+ resolveOpenAlertsSpy = jest
1182
+ .spyOn(
1183
+ ServiceLevelObjectiveBurnRateRuleService,
1184
+ "resolveOpenAlertsForRule",
1185
+ )
1186
+ .mockResolvedValue(undefined);
1187
+ });
1188
+
1189
+ afterEach(() => {
1190
+ jest.restoreAllMocks();
1191
+ });
1192
+
1193
+ it("resolves the open alerts of every rule about to be deleted", async () => {
1194
+ const first: ServiceLevelObjectiveBurnRateRule = makeRule({
1195
+ _id: RULE_ID.toString(),
1196
+ id: RULE_ID,
1197
+ projectId: PROJECT_ID,
1198
+ serviceLevelObjectiveId: SLO_ID,
1199
+ });
1200
+ const second: ServiceLevelObjectiveBurnRateRule = makeRule({
1201
+ _id: OTHER_RULE_ID.toString(),
1202
+ id: OTHER_RULE_ID,
1203
+ projectId: PROJECT_ID,
1204
+ serviceLevelObjectiveId: SLO_ID,
1205
+ });
1206
+
1207
+ findBySpy.mockResolvedValue([first, second]);
1208
+
1209
+ const result: unknown = await callHook("onBeforeDelete", makeDeleteBy());
1210
+
1211
+ expect(resolveOpenAlertsSpy).toHaveBeenCalledTimes(2);
1212
+ expect(resolveOpenAlertsSpy).toHaveBeenNthCalledWith(1, {
1213
+ serviceLevelObjectiveId: SLO_ID,
1214
+ burnRateRuleId: RULE_ID,
1215
+ projectId: PROJECT_ID,
1216
+ rootCause: DELETE_ROOT_CAUSE,
1217
+ });
1218
+ expect(resolveOpenAlertsSpy).toHaveBeenNthCalledWith(2, {
1219
+ serviceLevelObjectiveId: SLO_ID,
1220
+ burnRateRuleId: OTHER_RULE_ID,
1221
+ projectId: PROJECT_ID,
1222
+ rootCause: DELETE_ROOT_CAUSE,
1223
+ });
1224
+
1225
+ expect(
1226
+ (result as { carryForward: { itemsToDelete: Array<unknown> } })
1227
+ .carryForward.itemsToDelete,
1228
+ ).toEqual([first, second]);
1229
+ });
1230
+
1231
+ it("reads the doomed rows as root, honouring the caller's query and paging", async () => {
1232
+ findBySpy.mockResolvedValue([]);
1233
+
1234
+ await callHook("onBeforeDelete", makeDeleteBy());
1235
+
1236
+ const findByArg: {
1237
+ query: Record<string, unknown>;
1238
+ select: Record<string, unknown>;
1239
+ limit: number;
1240
+ skip: number;
1241
+ props: Record<string, unknown>;
1242
+ } = findBySpy.mock.calls[0]![0] as {
1243
+ query: Record<string, unknown>;
1244
+ select: Record<string, unknown>;
1245
+ limit: number;
1246
+ skip: number;
1247
+ props: Record<string, unknown>;
1248
+ };
1249
+
1250
+ expect(findByArg.query).toEqual({ serviceLevelObjectiveId: SLO_ID });
1251
+ expect(findByArg.select).toEqual({
1252
+ _id: true,
1253
+ projectId: true,
1254
+ serviceLevelObjectiveId: true,
1255
+ });
1256
+ expect(findByArg.limit).toBe(10);
1257
+ expect(findByArg.skip).toBe(0);
1258
+ expect(findByArg.props).toEqual({ isRoot: true });
1259
+
1260
+ expect(resolveOpenAlertsSpy).not.toHaveBeenCalled();
1261
+ });
1262
+
1263
+ it("skips rows missing the identity it needs to build a fingerprint", async () => {
1264
+ findBySpy.mockResolvedValue([
1265
+ // no id
1266
+ makeRule({ projectId: PROJECT_ID, serviceLevelObjectiveId: SLO_ID }),
1267
+ // no projectId
1268
+ makeRule({
1269
+ _id: RULE_ID.toString(),
1270
+ id: RULE_ID,
1271
+ serviceLevelObjectiveId: SLO_ID,
1272
+ }),
1273
+ // no serviceLevelObjectiveId
1274
+ makeRule({
1275
+ _id: OTHER_RULE_ID.toString(),
1276
+ id: OTHER_RULE_ID,
1277
+ projectId: PROJECT_ID,
1278
+ }),
1279
+ ]);
1280
+
1281
+ await callHook("onBeforeDelete", makeDeleteBy());
1282
+
1283
+ expect(resolveOpenAlertsSpy).not.toHaveBeenCalled();
1284
+ });
1285
+
1286
+ it("does not block the delete when resolving one rule's alerts throws", async () => {
1287
+ findBySpy.mockResolvedValue([
1288
+ makeRule({
1289
+ _id: RULE_ID.toString(),
1290
+ id: RULE_ID,
1291
+ projectId: PROJECT_ID,
1292
+ serviceLevelObjectiveId: SLO_ID,
1293
+ }),
1294
+ makeRule({
1295
+ _id: OTHER_RULE_ID.toString(),
1296
+ id: OTHER_RULE_ID,
1297
+ projectId: PROJECT_ID,
1298
+ serviceLevelObjectiveId: SLO_ID,
1299
+ }),
1300
+ ]);
1301
+
1302
+ resolveOpenAlertsSpy
1303
+ .mockRejectedValueOnce(new Error("alert service down"))
1304
+ .mockResolvedValueOnce(undefined);
1305
+
1306
+ await expect(
1307
+ callHook("onBeforeDelete", makeDeleteBy()),
1308
+ ).resolves.toBeDefined();
1309
+
1310
+ // The failure on the first rule must not abort the second.
1311
+ expect(resolveOpenAlertsSpy).toHaveBeenCalledTimes(2);
1312
+ });
1313
+ });