@oneuptime/common 12.0.2 → 12.0.4

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 (384) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +14 -0
  4. package/Models/DatabaseModels/DockerHost.ts +14 -0
  5. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  6. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  7. package/Models/DatabaseModels/Index.ts +2 -0
  8. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  9. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  10. package/Models/DatabaseModels/LogDropFilter.ts +9 -20
  11. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  12. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  13. package/Models/DatabaseModels/Project.ts +16 -2
  14. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  15. package/Models/DatabaseModels/Service.ts +30 -0
  16. package/Models/DatabaseModels/StatusPageMonitorRule.ts +911 -0
  17. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  18. package/Models/DatabaseModels/StatusPageResource.ts +73 -0
  19. package/Models/DatabaseModels/TraceDropFilter.ts +9 -20
  20. package/Models/DatabaseModels/User.ts +30 -0
  21. package/Server/API/AIAgentDataAPI.ts +31 -0
  22. package/Server/API/BaseAPI.ts +13 -2
  23. package/Server/API/DashboardAPI.ts +458 -12
  24. package/Server/API/GitHubAPI.ts +119 -283
  25. package/Server/API/UserAPI.ts +263 -1
  26. package/Server/EnvironmentConfig.ts +38 -4
  27. package/Server/Infrastructure/GlobalCache.ts +152 -0
  28. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +16 -0
  29. package/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.ts +123 -0
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  35. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +12 -0
  36. package/Server/Services/AlertStateTimelineService.ts +20 -0
  37. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  38. package/Server/Services/CephClusterService.ts +26 -86
  39. package/Server/Services/CloudResourceService.ts +23 -83
  40. package/Server/Services/CodeRepositoryService.ts +105 -2
  41. package/Server/Services/DatabaseService.ts +296 -2
  42. package/Server/Services/DockerHostService.ts +19 -79
  43. package/Server/Services/DockerSwarmClusterService.ts +27 -88
  44. package/Server/Services/HostService.ts +46 -99
  45. package/Server/Services/IncidentStateTimelineService.ts +20 -0
  46. package/Server/Services/Index.ts +2 -0
  47. package/Server/Services/IoTFleetService.ts +19 -79
  48. package/Server/Services/KubernetesClusterService.ts +17 -78
  49. package/Server/Services/LabelService.ts +57 -10
  50. package/Server/Services/MetricTypeService.ts +84 -0
  51. package/Server/Services/MonitorService.ts +89 -14
  52. package/Server/Services/MonitorStatusTimelineService.ts +29 -0
  53. package/Server/Services/MonitorTemplateService.ts +65 -0
  54. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  55. package/Server/Services/PodmanHostService.ts +19 -79
  56. package/Server/Services/ProxmoxClusterService.ts +23 -83
  57. package/Server/Services/RumApplicationService.ts +19 -80
  58. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +21 -0
  59. package/Server/Services/ServerlessFunctionService.ts +24 -85
  60. package/Server/Services/ServiceService.ts +299 -73
  61. package/Server/Services/StatusPageMonitorRuleEngineService.ts +752 -0
  62. package/Server/Services/StatusPageMonitorRuleService.ts +471 -0
  63. package/Server/Services/TelemetryEntityRelationshipService.ts +1 -0
  64. package/Server/Services/TelemetryEntityService.ts +1 -0
  65. package/Server/Services/UserService.ts +184 -0
  66. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  67. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  68. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  69. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  70. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +170 -39
  71. package/Server/Utils/Monitor/MonitorResource.ts +63 -36
  72. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +36 -0
  73. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +148 -127
  74. package/Server/Utils/Monitor/MonitorStepsReferenceExtractor.ts +434 -0
  75. package/Server/Utils/PasswordHash.ts +306 -0
  76. package/Server/Utils/SingleFlight.ts +83 -0
  77. package/Server/Utils/StatusPage/MonitorRulePatternValidator.ts +46 -0
  78. package/Server/Utils/Telemetry/EntityRegistry.ts +107 -26
  79. package/Server/Utils/Telemetry/ResourceHeartbeat.ts +314 -0
  80. package/Server/Utils/Telemetry/Telemetry.ts +351 -122
  81. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  82. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  83. package/Tests/App/Dashboard/WidgetCatalog.test.ts +2 -0
  84. package/Tests/Models/DatabaseModels/ColumnTransformerInvariants.test.ts +306 -0
  85. package/Tests/Models/DatabaseModels/NumberColumnDeserialization.test.ts +333 -0
  86. package/Tests/Models/DatabaseModels/StatusPageMonitorRuleModel.test.ts +190 -0
  87. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  88. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  89. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  90. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +26 -0
  91. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  92. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  93. package/Tests/Server/API/Helpers.ts +6 -1
  94. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  95. package/Tests/Server/Infrastructure/GlobalCache.test.ts +354 -0
  96. package/Tests/Server/Infrastructure/PostgresLockTimeoutOptions.test.ts +181 -0
  97. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  98. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  99. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  100. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  101. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  102. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  103. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  104. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlocked.test.ts +342 -0
  105. package/Tests/Server/Services/DropFilterSaveValidation.test.ts +245 -0
  106. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +163 -15
  107. package/Tests/Server/Services/MetricTypeAttachServices.test.ts +222 -0
  108. package/Tests/Server/Services/MissingReferenceWriteGuard.test.ts +614 -0
  109. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +23 -0
  110. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  111. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +305 -56
  112. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  113. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  114. package/Tests/Server/Services/ServiceUpdateLastSeenSplitGates.test.ts +705 -0
  115. package/Tests/Server/Services/StatusPageMonitorRuleEngineService.test.ts +1719 -0
  116. package/Tests/Server/Services/StatusPageMonitorRuleHooks.test.ts +1212 -0
  117. package/Tests/Server/Services/StatusPageMonitorRuleMonitorCreateHook.test.ts +228 -0
  118. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  119. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  120. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  121. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  122. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +267 -0
  123. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  124. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  125. package/Tests/Server/Utils/Database/ProjectScopedReferenceValidator.test.ts +260 -13
  126. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +302 -0
  127. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  128. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  129. package/Tests/Server/Utils/MetricTypeIndexConvergence.test.ts +598 -0
  130. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineUnusableStatus.test.ts +154 -0
  131. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +638 -92
  132. package/Tests/Server/Utils/Monitor/MonitorStepsReferenceExtractor.test.ts +724 -0
  133. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  134. package/Tests/Server/Utils/SingleFlight.test.ts +221 -0
  135. package/Tests/Types/Dashboard/NetworkDashboardTemplate.test.ts +793 -0
  136. package/Tests/Types/Database/BigIntColumnTransformer.test.ts +147 -0
  137. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  138. package/Tests/Types/Database/DatabasePropertyTransformer.test.ts +140 -0
  139. package/Tests/Types/Database/NumericColumnValue.test.ts +127 -0
  140. package/Tests/Types/Decimal.test.ts +37 -0
  141. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  142. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  143. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  144. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  145. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  146. package/Tests/Types/Monitor/SnmpVersionParsing.test.ts +152 -0
  147. package/Tests/Types/Port.test.ts +38 -0
  148. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  149. package/Tests/UI/Components/LogsHistogram.test.tsx +276 -0
  150. package/Tests/UI/Components/UseLiveLogsRefresh.test.tsx +489 -0
  151. package/Tests/UI/Components/UseLogsHistogram.test.tsx +500 -0
  152. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  153. package/Tests/Utils/Dashboard/Components/DashboardComponentsUtil.test.ts +120 -0
  154. package/Tests/Utils/Dashboard/DashboardNetworkMapComponent.test.ts +327 -0
  155. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  156. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  157. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  158. package/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.ts +40 -0
  159. package/Types/Dashboard/DashboardTemplates.ts +397 -0
  160. package/Types/Database/BigIntColumnTransformer.ts +74 -0
  161. package/Types/Database/ColumnLength.ts +9 -1
  162. package/Types/Database/DatabaseProperty.ts +26 -0
  163. package/Types/Database/NumericColumnValue.ts +128 -0
  164. package/Types/Database/TableColumn.ts +11 -0
  165. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  166. package/Types/Decimal.ts +16 -4
  167. package/Types/HashedString.ts +139 -4
  168. package/Types/Permission.ts +48 -0
  169. package/Types/Port.ts +16 -4
  170. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  171. package/UI/Components/LogsViewer/useLiveLogsRefresh.ts +86 -0
  172. package/UI/Components/LogsViewer/useLogsHistogram.ts +113 -0
  173. package/UI/Components/Navbar/NavBar.tsx +12 -2
  174. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  175. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  176. package/Utils/Dashboard/Components/DashboardNetworkMapComponent.ts +136 -0
  177. package/Utils/Dashboard/Components/Index.ts +7 -0
  178. package/Utils/TeamMembersByProject.ts +237 -0
  179. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  180. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  181. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  182. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  183. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +12 -0
  184. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  185. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  186. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  187. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  188. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  189. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  190. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  191. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  192. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  193. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  194. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  195. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  196. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  197. package/build/dist/Models/DatabaseModels/LogDropFilter.js +9 -20
  198. package/build/dist/Models/DatabaseModels/LogDropFilter.js.map +1 -1
  199. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  200. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  202. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  204. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  205. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  206. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  207. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  208. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  209. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js +925 -0
  210. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js.map +1 -0
  211. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  212. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  213. package/build/dist/Models/DatabaseModels/StatusPageResource.js +72 -0
  214. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  215. package/build/dist/Models/DatabaseModels/TraceDropFilter.js +9 -20
  216. package/build/dist/Models/DatabaseModels/TraceDropFilter.js.map +1 -1
  217. package/build/dist/Models/DatabaseModels/User.js +32 -0
  218. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  219. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  220. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  221. package/build/dist/Server/API/BaseAPI.js +11 -1
  222. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  223. package/build/dist/Server/API/DashboardAPI.js +343 -10
  224. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  225. package/build/dist/Server/API/GitHubAPI.js +94 -167
  226. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  227. package/build/dist/Server/API/UserAPI.js +187 -2
  228. package/build/dist/Server/API/UserAPI.js.map +1 -1
  229. package/build/dist/Server/EnvironmentConfig.js +34 -4
  230. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  231. package/build/dist/Server/Infrastructure/GlobalCache.js +120 -0
  232. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  233. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +5 -3
  234. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  235. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js +48 -0
  236. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js.map +1 -0
  237. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  238. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  239. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  240. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  241. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  242. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  243. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  244. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  245. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  246. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  247. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +12 -0
  248. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  249. package/build/dist/Server/Services/AlertStateTimelineService.js +19 -0
  250. package/build/dist/Server/Services/AlertStateTimelineService.js.map +1 -1
  251. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  252. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  253. package/build/dist/Server/Services/CephClusterService.js +26 -76
  254. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  255. package/build/dist/Server/Services/CloudResourceService.js +23 -73
  256. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  257. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  258. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  259. package/build/dist/Server/Services/DatabaseService.js +246 -2
  260. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  261. package/build/dist/Server/Services/DockerHostService.js +19 -69
  262. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  263. package/build/dist/Server/Services/DockerSwarmClusterService.js +27 -78
  264. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  265. package/build/dist/Server/Services/HostService.js +58 -102
  266. package/build/dist/Server/Services/HostService.js.map +1 -1
  267. package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
  268. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  269. package/build/dist/Server/Services/Index.js +2 -0
  270. package/build/dist/Server/Services/Index.js.map +1 -1
  271. package/build/dist/Server/Services/IoTFleetService.js +19 -69
  272. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  273. package/build/dist/Server/Services/KubernetesClusterService.js +17 -68
  274. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  275. package/build/dist/Server/Services/LabelService.js +42 -7
  276. package/build/dist/Server/Services/LabelService.js.map +1 -1
  277. package/build/dist/Server/Services/MetricTypeService.js +74 -0
  278. package/build/dist/Server/Services/MetricTypeService.js.map +1 -1
  279. package/build/dist/Server/Services/MonitorService.js +81 -9
  280. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  281. package/build/dist/Server/Services/MonitorStatusTimelineService.js +28 -0
  282. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  283. package/build/dist/Server/Services/MonitorTemplateService.js +60 -0
  284. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  285. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  286. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  287. package/build/dist/Server/Services/PodmanHostService.js +19 -69
  288. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  289. package/build/dist/Server/Services/ProxmoxClusterService.js +23 -73
  290. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  291. package/build/dist/Server/Services/RumApplicationService.js +19 -70
  292. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  293. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +20 -0
  294. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  295. package/build/dist/Server/Services/ServerlessFunctionService.js +24 -75
  296. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  297. package/build/dist/Server/Services/ServiceService.js +236 -61
  298. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  299. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +603 -0
  300. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -0
  301. package/build/dist/Server/Services/StatusPageMonitorRuleService.js +401 -0
  302. package/build/dist/Server/Services/StatusPageMonitorRuleService.js.map +1 -0
  303. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +1 -0
  304. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  305. package/build/dist/Server/Services/TelemetryEntityService.js +1 -0
  306. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  307. package/build/dist/Server/Services/UserService.js +165 -0
  308. package/build/dist/Server/Services/UserService.js.map +1 -1
  309. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  310. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  311. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  312. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  313. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  314. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  315. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  316. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  317. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +105 -29
  318. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  319. package/build/dist/Server/Utils/Monitor/MonitorResource.js +56 -32
  320. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  321. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +30 -3
  322. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  323. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +110 -98
  324. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -1
  325. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js +312 -0
  326. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js.map +1 -0
  327. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  328. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  329. package/build/dist/Server/Utils/SingleFlight.js +76 -0
  330. package/build/dist/Server/Utils/SingleFlight.js.map +1 -0
  331. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js +29 -0
  332. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js.map +1 -0
  333. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +78 -19
  334. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  335. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +160 -0
  336. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -0
  337. package/build/dist/Server/Utils/Telemetry/Telemetry.js +279 -104
  338. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  339. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  340. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  341. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  342. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  343. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js +2 -0
  344. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js.map +1 -0
  345. package/build/dist/Types/Dashboard/DashboardTemplates.js +368 -0
  346. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  347. package/build/dist/Types/Database/BigIntColumnTransformer.js +66 -0
  348. package/build/dist/Types/Database/BigIntColumnTransformer.js.map +1 -0
  349. package/build/dist/Types/Database/ColumnLength.js +9 -1
  350. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  351. package/build/dist/Types/Database/DatabaseProperty.js +25 -0
  352. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  353. package/build/dist/Types/Database/NumericColumnValue.js +97 -0
  354. package/build/dist/Types/Database/NumericColumnValue.js.map +1 -0
  355. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  356. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  357. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  358. package/build/dist/Types/Decimal.js +14 -3
  359. package/build/dist/Types/Decimal.js.map +1 -1
  360. package/build/dist/Types/HashedString.js +95 -5
  361. package/build/dist/Types/HashedString.js.map +1 -1
  362. package/build/dist/Types/Permission.js +42 -0
  363. package/build/dist/Types/Permission.js.map +1 -1
  364. package/build/dist/Types/Port.js +15 -5
  365. package/build/dist/Types/Port.js.map +1 -1
  366. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  367. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  368. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js +44 -0
  369. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js.map +1 -0
  370. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js +75 -0
  371. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js.map +1 -0
  372. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  373. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  374. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  375. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  376. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  377. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  378. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js +114 -0
  379. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js.map +1 -0
  380. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  381. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  382. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  383. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  384. package/package.json +1 -1
@@ -0,0 +1,1719 @@
1
+ import Label from "../../../Models/DatabaseModels/Label";
2
+ import Monitor from "../../../Models/DatabaseModels/Monitor";
3
+ import StatusPageMonitorRule from "../../../Models/DatabaseModels/StatusPageMonitorRule";
4
+ import StatusPageResource from "../../../Models/DatabaseModels/StatusPageResource";
5
+ import MonitorService from "../../../Server/Services/MonitorService";
6
+ import StatusPageMonitorRuleEngineService, {
7
+ StatusPageMonitorRuleSyncResult,
8
+ } from "../../../Server/Services/StatusPageMonitorRuleEngineService";
9
+ import StatusPageMonitorRuleService from "../../../Server/Services/StatusPageMonitorRuleService";
10
+ import StatusPageResourceService from "../../../Server/Services/StatusPageResourceService";
11
+ import ObjectID from "../../../Types/ObjectID";
12
+ import UptimePrecision from "../../../Types/StatusPage/UptimePrecision";
13
+ import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
14
+
15
+ /*
16
+ * Contract under test - status page monitor rules.
17
+ *
18
+ * A status page can say "show every monitor labelled Production in the Core
19
+ * Platform group" instead of picking monitors one at a time and typing a
20
+ * display name for each. That promise has two halves, and both have to hold
21
+ * or the page quietly shows the wrong thing:
22
+ *
23
+ * - a monitor that starts matching is added to the group the rule names,
24
+ * and a monitor that stops matching is removed again (whether the rule
25
+ * moved or the monitor did);
26
+ *
27
+ * - a resource a human added by hand is never touched. It is not adopted
28
+ * into the rule when it happens to match, and - the case that would
29
+ * actually lose someone's configuration - it is never removed when it
30
+ * stops matching, or when the rule is deleted outright.
31
+ *
32
+ * StatusPageResource.statusPageMonitorRuleId is what the second half rests
33
+ * on, so most of what follows is about that boundary. The other thing worth
34
+ * pinning down is that a monitor already on the page is never added twice,
35
+ * because that is the failure a public page shows to customers.
36
+ */
37
+
38
+ const PROJECT_ID: ObjectID = new ObjectID(
39
+ "22222222-2222-4222-8222-222222222222",
40
+ );
41
+ const STATUS_PAGE_ID: ObjectID = new ObjectID(
42
+ "33333333-3333-4333-8333-333333333333",
43
+ );
44
+ const GROUP_ID: ObjectID = new ObjectID("44444444-4444-4444-8444-444444444444");
45
+ const OTHER_GROUP_ID: ObjectID = new ObjectID(
46
+ "45454545-4545-4545-8545-454545454545",
47
+ );
48
+ const RULE_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
49
+ const OTHER_RULE_ID: ObjectID = new ObjectID(
50
+ "1a1a1a1a-1a1a-4a1a-8a1a-1a1a1a1a1a1a",
51
+ );
52
+ const MONITOR_A_ID: ObjectID = new ObjectID(
53
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
54
+ );
55
+ const MONITOR_B_ID: ObjectID = new ObjectID(
56
+ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
57
+ );
58
+ const MONITOR_C_ID: ObjectID = new ObjectID(
59
+ "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
60
+ );
61
+ const RESOURCE_A_ID: ObjectID = new ObjectID(
62
+ "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1",
63
+ );
64
+ const RESOURCE_B_ID: ObjectID = new ObjectID(
65
+ "b1b1b1b1-b1b1-4b1b-8b1b-b1b1b1b1b1b1",
66
+ );
67
+ const LABEL_PRODUCTION_ID: ObjectID = new ObjectID(
68
+ "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
69
+ );
70
+ const LABEL_TIER1_ID: ObjectID = new ObjectID(
71
+ "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
72
+ );
73
+ const LABEL_STAGING_ID: ObjectID = new ObjectID(
74
+ "ffffffff-ffff-4fff-8fff-ffffffffffff",
75
+ );
76
+
77
+ function fakeLabel(id: ObjectID): Label {
78
+ return { id: id, _id: id.toString() } as unknown as Label;
79
+ }
80
+
81
+ function fakeMonitor(
82
+ id: ObjectID,
83
+ fields?: {
84
+ name?: string | undefined;
85
+ description?: string | undefined;
86
+ labels?: Array<ObjectID> | undefined;
87
+ projectId?: ObjectID | null | undefined;
88
+ },
89
+ ): Monitor {
90
+ return {
91
+ id: id,
92
+ _id: id.toString(),
93
+ projectId: fields?.projectId === undefined ? PROJECT_ID : fields.projectId,
94
+ name: fields?.name === undefined ? "API Monitor" : fields.name,
95
+ description: fields?.description,
96
+ labels: (fields?.labels || []).map(fakeLabel),
97
+ } as unknown as Monitor;
98
+ }
99
+
100
+ function fakeRule(fields?: {
101
+ id?: ObjectID | undefined;
102
+ projectId?: ObjectID | null | undefined;
103
+ statusPageId?: ObjectID | null | undefined;
104
+ statusPageGroupId?: ObjectID | undefined;
105
+ isEnabled?: boolean | undefined;
106
+ monitorLabels?: Array<ObjectID> | undefined;
107
+ monitorNamePattern?: string | undefined;
108
+ monitorDescriptionPattern?: string | undefined;
109
+ showCurrentStatus?: boolean | undefined;
110
+ showUptimePercent?: boolean | undefined;
111
+ showStatusHistoryChart?: boolean | undefined;
112
+ uptimePercentPrecision?: UptimePrecision | undefined;
113
+ }): StatusPageMonitorRule {
114
+ const id: ObjectID = fields?.id || RULE_ID;
115
+
116
+ return {
117
+ id: id,
118
+ _id: id.toString(),
119
+ projectId: fields?.projectId === undefined ? PROJECT_ID : fields.projectId,
120
+ statusPageId:
121
+ fields?.statusPageId === undefined ? STATUS_PAGE_ID : fields.statusPageId,
122
+ statusPageGroupId: fields?.statusPageGroupId,
123
+ isEnabled: fields?.isEnabled === undefined ? true : fields.isEnabled,
124
+ monitorLabels: (fields?.monitorLabels || []).map(fakeLabel),
125
+ monitorNamePattern: fields?.monitorNamePattern,
126
+ monitorDescriptionPattern: fields?.monitorDescriptionPattern,
127
+ showCurrentStatus: fields?.showCurrentStatus,
128
+ showUptimePercent: fields?.showUptimePercent,
129
+ showStatusHistoryChart: fields?.showStatusHistoryChart,
130
+ uptimePercentPrecision: fields?.uptimePercentPrecision,
131
+ } as unknown as StatusPageMonitorRule;
132
+ }
133
+
134
+ function fakeResource(fields: {
135
+ id: ObjectID;
136
+ monitorId: ObjectID;
137
+ statusPageMonitorRuleId?: ObjectID | undefined;
138
+ statusPageGroupId?: ObjectID | undefined;
139
+ showCurrentStatus?: boolean | undefined;
140
+ showUptimePercent?: boolean | undefined;
141
+ showStatusHistoryChart?: boolean | undefined;
142
+ uptimePercentPrecision?: UptimePrecision | undefined;
143
+ }): StatusPageResource {
144
+ return {
145
+ id: fields.id,
146
+ _id: fields.id.toString(),
147
+ monitorId: fields.monitorId,
148
+ statusPageMonitorRuleId: fields.statusPageMonitorRuleId,
149
+ statusPageGroupId: fields.statusPageGroupId,
150
+ showCurrentStatus: fields.showCurrentStatus,
151
+ showUptimePercent: fields.showUptimePercent,
152
+ showStatusHistoryChart: fields.showStatusHistoryChart,
153
+ uptimePercentPrecision: fields.uptimePercentPrecision,
154
+ } as unknown as StatusPageResource;
155
+ }
156
+
157
+ /** The resource the engine handed to StatusPageResourceService.create. */
158
+ function createdResource(
159
+ createSpy: jest.SpyInstance,
160
+ callIndex: number = 0,
161
+ ): StatusPageResource {
162
+ const call: { data: StatusPageResource } = createSpy.mock.calls[
163
+ callIndex
164
+ ]![0] as { data: StatusPageResource };
165
+
166
+ return call.data;
167
+ }
168
+
169
+ describe("StatusPageMonitorRuleEngineService.doesMonitorMatchRule", () => {
170
+ it("matches a monitor carrying any one of the rule's labels", () => {
171
+ expect(
172
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
173
+ monitor: fakeMonitor(MONITOR_A_ID, {
174
+ labels: [LABEL_STAGING_ID, LABEL_TIER1_ID],
175
+ }),
176
+ rule: fakeRule({
177
+ monitorLabels: [LABEL_PRODUCTION_ID, LABEL_TIER1_ID],
178
+ }),
179
+ }),
180
+ ).toBe(true);
181
+ });
182
+
183
+ it("rejects a monitor carrying none of them", () => {
184
+ expect(
185
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
186
+ monitor: fakeMonitor(MONITOR_A_ID, { labels: [LABEL_STAGING_ID] }),
187
+ rule: fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
188
+ }),
189
+ ).toBe(false);
190
+ });
191
+
192
+ it("rejects an unlabelled monitor against a label rule", () => {
193
+ expect(
194
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
195
+ monitor: fakeMonitor(MONITOR_A_ID, { labels: [] }),
196
+ rule: fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
197
+ }),
198
+ ).toBe(false);
199
+ });
200
+
201
+ it("matches the name pattern case-insensitively", () => {
202
+ expect(
203
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
204
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "API Gateway" }),
205
+ rule: fakeRule({ monitorNamePattern: "^api" }),
206
+ }),
207
+ ).toBe(true);
208
+ });
209
+
210
+ it("treats the name pattern as a regex, not a substring", () => {
211
+ expect(
212
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
213
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "internal-api" }),
214
+ rule: fakeRule({ monitorNamePattern: "^api" }),
215
+ }),
216
+ ).toBe(false);
217
+ });
218
+
219
+ it("matches the description pattern", () => {
220
+ expect(
221
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
222
+ monitor: fakeMonitor(MONITOR_A_ID, {
223
+ description: "Customer facing checkout flow",
224
+ }),
225
+ rule: fakeRule({ monitorDescriptionPattern: "customer facing" }),
226
+ }),
227
+ ).toBe(true);
228
+ });
229
+
230
+ it("rejects a monitor with no description against a description rule", () => {
231
+ expect(
232
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
233
+ monitor: fakeMonitor(MONITOR_A_ID, { description: undefined }),
234
+ rule: fakeRule({ monitorDescriptionPattern: "tier-1" }),
235
+ }),
236
+ ).toBe(false);
237
+ });
238
+
239
+ it("ANDs the criteria - passing the labels is not enough if the name fails", () => {
240
+ expect(
241
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
242
+ monitor: fakeMonitor(MONITOR_A_ID, {
243
+ name: "worker-queue",
244
+ labels: [LABEL_PRODUCTION_ID],
245
+ }),
246
+ rule: fakeRule({
247
+ monitorLabels: [LABEL_PRODUCTION_ID],
248
+ monitorNamePattern: "^api",
249
+ }),
250
+ }),
251
+ ).toBe(false);
252
+ });
253
+
254
+ it("matches when every specified criterion passes", () => {
255
+ expect(
256
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
257
+ monitor: fakeMonitor(MONITOR_A_ID, {
258
+ name: "api-checkout",
259
+ description: "tier-1 service",
260
+ labels: [LABEL_PRODUCTION_ID],
261
+ }),
262
+ rule: fakeRule({
263
+ monitorLabels: [LABEL_PRODUCTION_ID],
264
+ monitorNamePattern: "^api",
265
+ monitorDescriptionPattern: "tier-1",
266
+ }),
267
+ }),
268
+ ).toBe(true);
269
+ });
270
+
271
+ it("matches everything when the name pattern is .* - the documented escape hatch", () => {
272
+ expect(
273
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
274
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "anything at all" }),
275
+ rule: fakeRule({ monitorNamePattern: ".*" }),
276
+ }),
277
+ ).toBe(true);
278
+ });
279
+
280
+ /*
281
+ * The safety property: an empty form must not put every monitor in the
282
+ * project on a public status page.
283
+ */
284
+ it("matches nothing when the rule has no criteria at all", () => {
285
+ expect(
286
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
287
+ monitor: fakeMonitor(MONITOR_A_ID, {
288
+ name: "api",
289
+ labels: [LABEL_PRODUCTION_ID],
290
+ }),
291
+ rule: fakeRule({}),
292
+ }),
293
+ ).toBe(false);
294
+ });
295
+
296
+ it("matches nothing rather than throwing when the pattern is not a valid regex", () => {
297
+ expect(
298
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
299
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "api" }),
300
+ rule: fakeRule({ monitorNamePattern: "([unclosed" }),
301
+ }),
302
+ ).toBe(false);
303
+ });
304
+
305
+ /*
306
+ * #2940. `*api*` throws "Nothing to repeat" as a regex, so a private regex
307
+ * test here would give the user a rule that silently matches nothing. The
308
+ * shared RulePatternMatchUtil falls back to a wildcard glob, which is what
309
+ * the sibling network device rules do and what people actually type.
310
+ */
311
+ it("matches a `*` wildcard glob, not just a regex", () => {
312
+ expect(
313
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
314
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "prod-checkout-api" }),
315
+ rule: fakeRule({ monitorNamePattern: "*checkout*" }),
316
+ }),
317
+ ).toBe(true);
318
+ });
319
+
320
+ it("does not match a glob that the name fails", () => {
321
+ expect(
322
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
323
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "prod-worker" }),
324
+ rule: fakeRule({ monitorNamePattern: "*checkout*" }),
325
+ }),
326
+ ).toBe(false);
327
+ });
328
+
329
+ it("matches a glob case-insensitively", () => {
330
+ expect(
331
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
332
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "Prod-Checkout-API" }),
333
+ rule: fakeRule({ monitorNamePattern: "*checkout*" }),
334
+ }),
335
+ ).toBe(true);
336
+ });
337
+
338
+ /*
339
+ * The glob is anchored to the whole name, unlike the (unanchored) regex
340
+ * attempt - `*gateway` means "ends with gateway", not "contains it".
341
+ */
342
+ it("anchors a glob to the whole name", () => {
343
+ expect(
344
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
345
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "prod-api-gateway" }),
346
+ rule: fakeRule({ monitorNamePattern: "*gateway" }),
347
+ }),
348
+ ).toBe(true);
349
+
350
+ expect(
351
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
352
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "gateway-prod" }),
353
+ rule: fakeRule({ monitorNamePattern: "*gateway" }),
354
+ }),
355
+ ).toBe(false);
356
+ });
357
+
358
+ it("applies the glob fallback to the description pattern too", () => {
359
+ expect(
360
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
361
+ monitor: fakeMonitor(MONITOR_A_ID, {
362
+ description: "customer facing tier-1 service",
363
+ }),
364
+ rule: fakeRule({ monitorDescriptionPattern: "*tier-1*" }),
365
+ }),
366
+ ).toBe(true);
367
+ });
368
+
369
+ /*
370
+ * A pattern is a superset: anything that matched as a regex before still
371
+ * matches, and the glob only ever adds. `.*` must not start behaving like a
372
+ * literal glob.
373
+ */
374
+ it("still prefers regex semantics for a pattern that is valid as both", () => {
375
+ expect(
376
+ StatusPageMonitorRuleEngineService.doesMonitorMatchRule({
377
+ monitor: fakeMonitor(MONITOR_A_ID, { name: "anything" }),
378
+ rule: fakeRule({ monitorNamePattern: ".*" }),
379
+ }),
380
+ ).toBe(true);
381
+ });
382
+ });
383
+
384
+ describe("StatusPageMonitorRuleEngineService.syncResourcesForRule", () => {
385
+ let ruleFindOneByIdSpy: jest.SpyInstance;
386
+ let monitorFindBySpy: jest.SpyInstance;
387
+ let resourceFindBySpy: jest.SpyInstance;
388
+ let resourceCreateSpy: jest.SpyInstance;
389
+ let resourceDeleteSpy: jest.SpyInstance;
390
+ let resourceUpdateSpy: jest.SpyInstance;
391
+
392
+ beforeEach(() => {
393
+ ruleFindOneByIdSpy = jest.spyOn(
394
+ StatusPageMonitorRuleService,
395
+ "findOneById",
396
+ );
397
+ monitorFindBySpy = jest.spyOn(MonitorService, "findBy");
398
+ resourceFindBySpy = jest
399
+ .spyOn(StatusPageResourceService, "findBy")
400
+ .mockResolvedValue([]);
401
+ resourceCreateSpy = jest
402
+ .spyOn(StatusPageResourceService, "create")
403
+ .mockResolvedValue(new StatusPageResource());
404
+ resourceDeleteSpy = jest
405
+ .spyOn(StatusPageResourceService, "deleteOneById")
406
+ .mockResolvedValue(1);
407
+ resourceUpdateSpy = jest
408
+ .spyOn(StatusPageResourceService, "updateOneById")
409
+ .mockResolvedValue(1);
410
+ });
411
+
412
+ afterEach(() => {
413
+ jest.restoreAllMocks();
414
+ });
415
+
416
+ it("adds every monitor in the project that the rule matches", async () => {
417
+ ruleFindOneByIdSpy.mockResolvedValue(
418
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
419
+ );
420
+ monitorFindBySpy.mockResolvedValue([
421
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
422
+ fakeMonitor(MONITOR_B_ID, { labels: [LABEL_PRODUCTION_ID] }),
423
+ ]);
424
+
425
+ const result: StatusPageMonitorRuleSyncResult =
426
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
427
+ statusPageMonitorRuleId: RULE_ID,
428
+ });
429
+
430
+ expect(resourceCreateSpy).toHaveBeenCalledTimes(2);
431
+ expect(result.monitorIdsAdded.sort()).toEqual(
432
+ [MONITOR_A_ID.toString(), MONITOR_B_ID.toString()].sort(),
433
+ );
434
+ expect(result.statusPageResourceIdsRemoved).toEqual([]);
435
+ });
436
+
437
+ it("puts them in the group the rule names - the whole point of asking", async () => {
438
+ ruleFindOneByIdSpy.mockResolvedValue(
439
+ fakeRule({
440
+ monitorLabels: [LABEL_PRODUCTION_ID],
441
+ statusPageGroupId: GROUP_ID,
442
+ }),
443
+ );
444
+ monitorFindBySpy.mockResolvedValue([
445
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
446
+ ]);
447
+
448
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
449
+ statusPageMonitorRuleId: RULE_ID,
450
+ });
451
+
452
+ expect(createdResource(resourceCreateSpy).statusPageGroupId).toEqual(
453
+ GROUP_ID,
454
+ );
455
+ });
456
+
457
+ it("leaves the group empty when the rule names none, so the resource lands ungrouped", async () => {
458
+ ruleFindOneByIdSpy.mockResolvedValue(
459
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
460
+ );
461
+ monitorFindBySpy.mockResolvedValue([
462
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
463
+ ]);
464
+
465
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
466
+ statusPageMonitorRuleId: RULE_ID,
467
+ });
468
+
469
+ expect(
470
+ createdResource(resourceCreateSpy).statusPageGroupId,
471
+ ).toBeUndefined();
472
+ });
473
+
474
+ it("stamps the rule on what it creates so it can undo it later", async () => {
475
+ ruleFindOneByIdSpy.mockResolvedValue(
476
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
477
+ );
478
+ monitorFindBySpy.mockResolvedValue([
479
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
480
+ ]);
481
+
482
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
483
+ statusPageMonitorRuleId: RULE_ID,
484
+ });
485
+
486
+ expect(createdResource(resourceCreateSpy).statusPageMonitorRuleId).toEqual(
487
+ RULE_ID,
488
+ );
489
+ });
490
+
491
+ it("names the resource after the monitor, so nobody has to type one", async () => {
492
+ ruleFindOneByIdSpy.mockResolvedValue(
493
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
494
+ );
495
+ monitorFindBySpy.mockResolvedValue([
496
+ fakeMonitor(MONITOR_A_ID, {
497
+ name: "Checkout API",
498
+ labels: [LABEL_PRODUCTION_ID],
499
+ }),
500
+ ]);
501
+
502
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
503
+ statusPageMonitorRuleId: RULE_ID,
504
+ });
505
+
506
+ expect(createdResource(resourceCreateSpy).displayName).toBe("Checkout API");
507
+ });
508
+
509
+ it("copies the rule's display options onto every resource it creates", async () => {
510
+ ruleFindOneByIdSpy.mockResolvedValue(
511
+ fakeRule({
512
+ monitorLabels: [LABEL_PRODUCTION_ID],
513
+ showCurrentStatus: false,
514
+ showUptimePercent: true,
515
+ showStatusHistoryChart: false,
516
+ uptimePercentPrecision: UptimePrecision.THREE_DECIMAL,
517
+ }),
518
+ );
519
+ monitorFindBySpy.mockResolvedValue([
520
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
521
+ ]);
522
+
523
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
524
+ statusPageMonitorRuleId: RULE_ID,
525
+ });
526
+
527
+ const resource: StatusPageResource = createdResource(resourceCreateSpy);
528
+
529
+ expect(resource.showCurrentStatus).toBe(false);
530
+ expect(resource.showUptimePercent).toBe(true);
531
+ expect(resource.showStatusHistoryChart).toBe(false);
532
+ expect(resource.uptimePercentPrecision).toBe(UptimePrecision.THREE_DECIMAL);
533
+ });
534
+
535
+ it("writes as root - the rule is server-side and must not need a caller's permissions", async () => {
536
+ ruleFindOneByIdSpy.mockResolvedValue(
537
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
538
+ );
539
+ monitorFindBySpy.mockResolvedValue([
540
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
541
+ ]);
542
+
543
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
544
+ statusPageMonitorRuleId: RULE_ID,
545
+ });
546
+
547
+ const call: { props: { isRoot: boolean } } = resourceCreateSpy.mock
548
+ .calls[0]![0] as { props: { isRoot: boolean } };
549
+
550
+ expect(call.props.isRoot).toBe(true);
551
+ });
552
+
553
+ it("applies the regex in memory, so a Postgres-only pattern cannot leak a monitor onto the page", async () => {
554
+ ruleFindOneByIdSpy.mockResolvedValue(
555
+ fakeRule({
556
+ monitorLabels: [LABEL_PRODUCTION_ID],
557
+ monitorNamePattern: "^api",
558
+ }),
559
+ );
560
+ // The label query is what the database narrows; the name still has to hold.
561
+ monitorFindBySpy.mockResolvedValue([
562
+ fakeMonitor(MONITOR_A_ID, {
563
+ name: "api-checkout",
564
+ labels: [LABEL_PRODUCTION_ID],
565
+ }),
566
+ fakeMonitor(MONITOR_B_ID, {
567
+ name: "worker-queue",
568
+ labels: [LABEL_PRODUCTION_ID],
569
+ }),
570
+ ]);
571
+
572
+ const result: StatusPageMonitorRuleSyncResult =
573
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
574
+ statusPageMonitorRuleId: RULE_ID,
575
+ });
576
+
577
+ expect(result.monitorIdsAdded).toEqual([MONITOR_A_ID.toString()]);
578
+ });
579
+
580
+ it("does not add a monitor that is already on the page - no duplicates for customers to see", async () => {
581
+ ruleFindOneByIdSpy.mockResolvedValue(
582
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
583
+ );
584
+ monitorFindBySpy.mockResolvedValue([
585
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
586
+ ]);
587
+ resourceFindBySpy.mockResolvedValue([
588
+ fakeResource({ id: RESOURCE_A_ID, monitorId: MONITOR_A_ID }),
589
+ ]);
590
+
591
+ const result: StatusPageMonitorRuleSyncResult =
592
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
593
+ statusPageMonitorRuleId: RULE_ID,
594
+ });
595
+
596
+ expect(resourceCreateSpy).not.toHaveBeenCalled();
597
+ expect(result.monitorIdsAdded).toEqual([]);
598
+ });
599
+
600
+ it("removes a resource it owns once the monitor stops matching", async () => {
601
+ ruleFindOneByIdSpy.mockResolvedValue(
602
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
603
+ );
604
+ monitorFindBySpy.mockResolvedValue([]);
605
+ resourceFindBySpy.mockResolvedValue([
606
+ fakeResource({
607
+ id: RESOURCE_A_ID,
608
+ monitorId: MONITOR_A_ID,
609
+ statusPageMonitorRuleId: RULE_ID,
610
+ }),
611
+ ]);
612
+
613
+ const result: StatusPageMonitorRuleSyncResult =
614
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
615
+ statusPageMonitorRuleId: RULE_ID,
616
+ });
617
+
618
+ expect(resourceDeleteSpy).toHaveBeenCalledTimes(1);
619
+ expect(result.statusPageResourceIdsRemoved).toEqual([
620
+ RESOURCE_A_ID.toString(),
621
+ ]);
622
+ });
623
+
624
+ /*
625
+ * The configuration-losing case. A resource with no rule stamped on it was
626
+ * added by a person, and no rule evaluation may ever remove it.
627
+ */
628
+ it("never removes a resource a human added, even when it no longer matches", async () => {
629
+ ruleFindOneByIdSpy.mockResolvedValue(
630
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
631
+ );
632
+ monitorFindBySpy.mockResolvedValue([]);
633
+ resourceFindBySpy.mockResolvedValue([
634
+ fakeResource({ id: RESOURCE_A_ID, monitorId: MONITOR_A_ID }),
635
+ ]);
636
+
637
+ const result: StatusPageMonitorRuleSyncResult =
638
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
639
+ statusPageMonitorRuleId: RULE_ID,
640
+ });
641
+
642
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
643
+ expect(result.statusPageResourceIdsRemoved).toEqual([]);
644
+ });
645
+
646
+ it("never removes a resource another rule owns", async () => {
647
+ ruleFindOneByIdSpy.mockResolvedValue(
648
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
649
+ );
650
+ monitorFindBySpy.mockResolvedValue([]);
651
+ resourceFindBySpy.mockResolvedValue([
652
+ fakeResource({
653
+ id: RESOURCE_B_ID,
654
+ monitorId: MONITOR_B_ID,
655
+ statusPageMonitorRuleId: OTHER_RULE_ID,
656
+ }),
657
+ ]);
658
+
659
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
660
+ statusPageMonitorRuleId: RULE_ID,
661
+ });
662
+
663
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
664
+ });
665
+
666
+ it("does not adopt a manual resource it happens to match, so a later label change leaves it alone", async () => {
667
+ ruleFindOneByIdSpy.mockResolvedValue(
668
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
669
+ );
670
+ monitorFindBySpy.mockResolvedValue([
671
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
672
+ ]);
673
+ resourceFindBySpy.mockResolvedValue([
674
+ fakeResource({ id: RESOURCE_A_ID, monitorId: MONITOR_A_ID }),
675
+ ]);
676
+
677
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
678
+ statusPageMonitorRuleId: RULE_ID,
679
+ });
680
+
681
+ // Nothing created and nothing stamped: the resource is still manual.
682
+ expect(resourceCreateSpy).not.toHaveBeenCalled();
683
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
684
+ });
685
+
686
+ /*
687
+ * Editing "Add Monitors To Group" has to move the monitors the rule already
688
+ * added. A rule that only applied its new group to monitors it adds in
689
+ * FUTURE would make the edit look like it silently did nothing — the user
690
+ * changed the one field the whole feature is about and the page did not
691
+ * move.
692
+ */
693
+ it("re-homes the resources it owns when the rule's group changes", async () => {
694
+ ruleFindOneByIdSpy.mockResolvedValue(
695
+ fakeRule({
696
+ monitorLabels: [LABEL_PRODUCTION_ID],
697
+ statusPageGroupId: OTHER_GROUP_ID,
698
+ }),
699
+ );
700
+ monitorFindBySpy.mockResolvedValue([
701
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
702
+ ]);
703
+ resourceFindBySpy.mockResolvedValue([
704
+ fakeResource({
705
+ id: RESOURCE_A_ID,
706
+ monitorId: MONITOR_A_ID,
707
+ statusPageMonitorRuleId: RULE_ID,
708
+ statusPageGroupId: GROUP_ID,
709
+ }),
710
+ ]);
711
+
712
+ const result: StatusPageMonitorRuleSyncResult =
713
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
714
+ statusPageMonitorRuleId: RULE_ID,
715
+ });
716
+
717
+ expect(resourceUpdateSpy).toHaveBeenCalledTimes(1);
718
+ const call: { id: ObjectID; data: { statusPageGroupId: ObjectID } } =
719
+ resourceUpdateSpy.mock.calls[0]![0] as {
720
+ id: ObjectID;
721
+ data: { statusPageGroupId: ObjectID };
722
+ };
723
+ expect(call.id).toEqual(RESOURCE_A_ID);
724
+ expect(call.data.statusPageGroupId).toEqual(OTHER_GROUP_ID);
725
+ expect(result.statusPageResourceIdsUpdated).toEqual([
726
+ RESOURCE_A_ID.toString(),
727
+ ]);
728
+ });
729
+
730
+ it("moves them back to ungrouped when the rule's group is cleared", async () => {
731
+ ruleFindOneByIdSpy.mockResolvedValue(
732
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
733
+ );
734
+ monitorFindBySpy.mockResolvedValue([
735
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
736
+ ]);
737
+ resourceFindBySpy.mockResolvedValue([
738
+ fakeResource({
739
+ id: RESOURCE_A_ID,
740
+ monitorId: MONITOR_A_ID,
741
+ statusPageMonitorRuleId: RULE_ID,
742
+ statusPageGroupId: GROUP_ID,
743
+ }),
744
+ ]);
745
+
746
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
747
+ statusPageMonitorRuleId: RULE_ID,
748
+ });
749
+
750
+ const call: { data: { statusPageGroupId: ObjectID | null } } =
751
+ resourceUpdateSpy.mock.calls[0]![0] as {
752
+ data: { statusPageGroupId: ObjectID | null };
753
+ };
754
+ expect(call.data.statusPageGroupId).toBeNull();
755
+ });
756
+
757
+ it("re-applies the rule's display options to the resources it owns", async () => {
758
+ ruleFindOneByIdSpy.mockResolvedValue(
759
+ fakeRule({
760
+ monitorLabels: [LABEL_PRODUCTION_ID],
761
+ showUptimePercent: false,
762
+ uptimePercentPrecision: UptimePrecision.TWO_DECIMAL,
763
+ }),
764
+ );
765
+ monitorFindBySpy.mockResolvedValue([
766
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
767
+ ]);
768
+ resourceFindBySpy.mockResolvedValue([
769
+ fakeResource({
770
+ id: RESOURCE_A_ID,
771
+ monitorId: MONITOR_A_ID,
772
+ statusPageMonitorRuleId: RULE_ID,
773
+ showUptimePercent: true,
774
+ uptimePercentPrecision: UptimePrecision.ONE_DECIMAL,
775
+ }),
776
+ ]);
777
+
778
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
779
+ statusPageMonitorRuleId: RULE_ID,
780
+ });
781
+
782
+ const call: {
783
+ data: {
784
+ showUptimePercent: boolean;
785
+ uptimePercentPrecision: UptimePrecision;
786
+ };
787
+ } = resourceUpdateSpy.mock.calls[0]![0] as {
788
+ data: {
789
+ showUptimePercent: boolean;
790
+ uptimePercentPrecision: UptimePrecision;
791
+ };
792
+ };
793
+ expect(call.data.showUptimePercent).toBe(false);
794
+ expect(call.data.uptimePercentPrecision).toBe(UptimePrecision.TWO_DECIMAL);
795
+ });
796
+
797
+ /*
798
+ * Re-running an unchanged rule is the common case (any monitor edit can
799
+ * trigger it), so it must not write.
800
+ */
801
+ it("writes nothing when the resources it owns already agree with the rule", async () => {
802
+ ruleFindOneByIdSpy.mockResolvedValue(
803
+ fakeRule({
804
+ monitorLabels: [LABEL_PRODUCTION_ID],
805
+ statusPageGroupId: GROUP_ID,
806
+ }),
807
+ );
808
+ monitorFindBySpy.mockResolvedValue([
809
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
810
+ ]);
811
+ resourceFindBySpy.mockResolvedValue([
812
+ fakeResource({
813
+ id: RESOURCE_A_ID,
814
+ monitorId: MONITOR_A_ID,
815
+ statusPageMonitorRuleId: RULE_ID,
816
+ statusPageGroupId: GROUP_ID,
817
+ showCurrentStatus: true,
818
+ showUptimePercent: true,
819
+ showStatusHistoryChart: true,
820
+ }),
821
+ ]);
822
+
823
+ const result: StatusPageMonitorRuleSyncResult =
824
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
825
+ statusPageMonitorRuleId: RULE_ID,
826
+ });
827
+
828
+ expect(resourceUpdateSpy).not.toHaveBeenCalled();
829
+ expect(result.statusPageResourceIdsUpdated).toEqual([]);
830
+ });
831
+
832
+ it("never re-homes a resource a human added, however far it has drifted", async () => {
833
+ ruleFindOneByIdSpy.mockResolvedValue(
834
+ fakeRule({
835
+ monitorLabels: [LABEL_PRODUCTION_ID],
836
+ statusPageGroupId: OTHER_GROUP_ID,
837
+ }),
838
+ );
839
+ monitorFindBySpy.mockResolvedValue([
840
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
841
+ ]);
842
+ resourceFindBySpy.mockResolvedValue([
843
+ fakeResource({
844
+ id: RESOURCE_A_ID,
845
+ monitorId: MONITOR_A_ID,
846
+ statusPageGroupId: GROUP_ID,
847
+ }),
848
+ ]);
849
+
850
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
851
+ statusPageMonitorRuleId: RULE_ID,
852
+ });
853
+
854
+ expect(resourceUpdateSpy).not.toHaveBeenCalled();
855
+ });
856
+
857
+ it("never re-homes a resource another rule owns", async () => {
858
+ ruleFindOneByIdSpy.mockResolvedValue(
859
+ fakeRule({
860
+ monitorLabels: [LABEL_PRODUCTION_ID],
861
+ statusPageGroupId: OTHER_GROUP_ID,
862
+ }),
863
+ );
864
+ monitorFindBySpy.mockResolvedValue([
865
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
866
+ ]);
867
+ resourceFindBySpy.mockResolvedValue([
868
+ fakeResource({
869
+ id: RESOURCE_A_ID,
870
+ monitorId: MONITOR_A_ID,
871
+ statusPageMonitorRuleId: OTHER_RULE_ID,
872
+ statusPageGroupId: GROUP_ID,
873
+ }),
874
+ ]);
875
+
876
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
877
+ statusPageMonitorRuleId: RULE_ID,
878
+ });
879
+
880
+ expect(resourceUpdateSpy).not.toHaveBeenCalled();
881
+ });
882
+
883
+ it("does not reconcile a resource whose monitor the rule has stopped claiming - it deletes it", async () => {
884
+ ruleFindOneByIdSpy.mockResolvedValue(
885
+ fakeRule({
886
+ monitorLabels: [LABEL_PRODUCTION_ID],
887
+ statusPageGroupId: OTHER_GROUP_ID,
888
+ }),
889
+ );
890
+ monitorFindBySpy.mockResolvedValue([]);
891
+ resourceFindBySpy.mockResolvedValue([
892
+ fakeResource({
893
+ id: RESOURCE_A_ID,
894
+ monitorId: MONITOR_A_ID,
895
+ statusPageMonitorRuleId: RULE_ID,
896
+ statusPageGroupId: GROUP_ID,
897
+ }),
898
+ ]);
899
+
900
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
901
+ statusPageMonitorRuleId: RULE_ID,
902
+ });
903
+
904
+ expect(resourceUpdateSpy).not.toHaveBeenCalled();
905
+ expect(resourceDeleteSpy).toHaveBeenCalledTimes(1);
906
+ });
907
+
908
+ /*
909
+ * Narrowing one rule must not take a monitor off the page that a different
910
+ * rule on the same page still claims. The rule-side pass only knows its own
911
+ * rule, so it hands each released monitor to the monitor-side pass, which
912
+ * consults every rule.
913
+ */
914
+ it("hands a released monitor to another rule that still claims it", async () => {
915
+ ruleFindOneByIdSpy.mockResolvedValue(
916
+ fakeRule({ monitorLabels: [LABEL_TIER1_ID] }),
917
+ );
918
+ monitorFindBySpy.mockResolvedValue([]);
919
+ resourceFindBySpy.mockResolvedValue([
920
+ fakeResource({
921
+ id: RESOURCE_A_ID,
922
+ monitorId: MONITOR_A_ID,
923
+ statusPageMonitorRuleId: RULE_ID,
924
+ }),
925
+ ]);
926
+
927
+ const syncRulesForMonitorSpy: jest.SpyInstance = jest
928
+ .spyOn(StatusPageMonitorRuleEngineService, "syncRulesForMonitor")
929
+ .mockResolvedValue([]);
930
+
931
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
932
+ statusPageMonitorRuleId: RULE_ID,
933
+ });
934
+
935
+ expect(syncRulesForMonitorSpy).toHaveBeenCalledTimes(1);
936
+ const call: { monitorId: ObjectID } = syncRulesForMonitorSpy.mock
937
+ .calls[0]![0] as { monitorId: ObjectID };
938
+ expect(call.monitorId.toString()).toBe(MONITOR_A_ID.toString());
939
+ });
940
+
941
+ it("does not re-home monitors it did not release", async () => {
942
+ ruleFindOneByIdSpy.mockResolvedValue(
943
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
944
+ );
945
+ monitorFindBySpy.mockResolvedValue([
946
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
947
+ ]);
948
+
949
+ const syncRulesForMonitorSpy: jest.SpyInstance = jest
950
+ .spyOn(StatusPageMonitorRuleEngineService, "syncRulesForMonitor")
951
+ .mockResolvedValue([]);
952
+
953
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
954
+ statusPageMonitorRuleId: RULE_ID,
955
+ });
956
+
957
+ expect(syncRulesForMonitorSpy).not.toHaveBeenCalled();
958
+ });
959
+
960
+ it("still reports its own removal when the hand-off fails", async () => {
961
+ ruleFindOneByIdSpy.mockResolvedValue(
962
+ fakeRule({ monitorLabels: [LABEL_TIER1_ID] }),
963
+ );
964
+ monitorFindBySpy.mockResolvedValue([]);
965
+ resourceFindBySpy.mockResolvedValue([
966
+ fakeResource({
967
+ id: RESOURCE_A_ID,
968
+ monitorId: MONITOR_A_ID,
969
+ statusPageMonitorRuleId: RULE_ID,
970
+ }),
971
+ ]);
972
+ jest
973
+ .spyOn(StatusPageMonitorRuleEngineService, "syncRulesForMonitor")
974
+ .mockRejectedValue(new Error("db down"));
975
+
976
+ const result: StatusPageMonitorRuleSyncResult =
977
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
978
+ statusPageMonitorRuleId: RULE_ID,
979
+ });
980
+
981
+ expect(result.statusPageResourceIdsRemoved).toEqual([
982
+ RESOURCE_A_ID.toString(),
983
+ ]);
984
+ });
985
+
986
+ it("adds and removes in the same pass, so a re-pointed rule fully re-derives the page", async () => {
987
+ ruleFindOneByIdSpy.mockResolvedValue(
988
+ fakeRule({ monitorLabels: [LABEL_TIER1_ID] }),
989
+ );
990
+ monitorFindBySpy.mockResolvedValue([
991
+ fakeMonitor(MONITOR_B_ID, { labels: [LABEL_TIER1_ID] }),
992
+ ]);
993
+ resourceFindBySpy.mockResolvedValue([
994
+ fakeResource({
995
+ id: RESOURCE_A_ID,
996
+ monitorId: MONITOR_A_ID,
997
+ statusPageMonitorRuleId: RULE_ID,
998
+ }),
999
+ ]);
1000
+
1001
+ const result: StatusPageMonitorRuleSyncResult =
1002
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1003
+ statusPageMonitorRuleId: RULE_ID,
1004
+ });
1005
+
1006
+ expect(result.monitorIdsAdded).toEqual([MONITOR_B_ID.toString()]);
1007
+ expect(result.statusPageResourceIdsRemoved).toEqual([
1008
+ RESOURCE_A_ID.toString(),
1009
+ ]);
1010
+ });
1011
+
1012
+ /*
1013
+ * Disabling is the reversible half of deleting. If it left the resources
1014
+ * behind, the toggle would not mean anything.
1015
+ */
1016
+ it("removes everything it added when the rule is disabled", async () => {
1017
+ ruleFindOneByIdSpy.mockResolvedValue(
1018
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID], isEnabled: false }),
1019
+ );
1020
+ resourceFindBySpy.mockResolvedValue([
1021
+ fakeResource({
1022
+ id: RESOURCE_A_ID,
1023
+ monitorId: MONITOR_A_ID,
1024
+ statusPageMonitorRuleId: RULE_ID,
1025
+ }),
1026
+ ]);
1027
+
1028
+ const result: StatusPageMonitorRuleSyncResult =
1029
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1030
+ statusPageMonitorRuleId: RULE_ID,
1031
+ });
1032
+
1033
+ expect(monitorFindBySpy).not.toHaveBeenCalled();
1034
+ expect(result.statusPageResourceIdsRemoved).toEqual([
1035
+ RESOURCE_A_ID.toString(),
1036
+ ]);
1037
+ });
1038
+
1039
+ it("still leaves manual resources alone when the rule is disabled", async () => {
1040
+ ruleFindOneByIdSpy.mockResolvedValue(
1041
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID], isEnabled: false }),
1042
+ );
1043
+ resourceFindBySpy.mockResolvedValue([
1044
+ fakeResource({ id: RESOURCE_A_ID, monitorId: MONITOR_A_ID }),
1045
+ ]);
1046
+
1047
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1048
+ statusPageMonitorRuleId: RULE_ID,
1049
+ });
1050
+
1051
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
1052
+ });
1053
+
1054
+ it("scopes the monitor search to the rule's own project", async () => {
1055
+ ruleFindOneByIdSpy.mockResolvedValue(
1056
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1057
+ );
1058
+ monitorFindBySpy.mockResolvedValue([]);
1059
+
1060
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1061
+ statusPageMonitorRuleId: RULE_ID,
1062
+ });
1063
+
1064
+ const call: { query: { projectId: ObjectID } } = monitorFindBySpy.mock
1065
+ .calls[0]![0] as { query: { projectId: ObjectID } };
1066
+
1067
+ expect(call.query.projectId).toEqual(PROJECT_ID);
1068
+ });
1069
+
1070
+ it("reads only this status page's resources, not the whole project's", async () => {
1071
+ ruleFindOneByIdSpy.mockResolvedValue(
1072
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1073
+ );
1074
+ monitorFindBySpy.mockResolvedValue([]);
1075
+
1076
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1077
+ statusPageMonitorRuleId: RULE_ID,
1078
+ });
1079
+
1080
+ const call: { query: { statusPageId: ObjectID } } = resourceFindBySpy.mock
1081
+ .calls[0]![0] as { query: { statusPageId: ObjectID } };
1082
+
1083
+ expect(call.query.statusPageId).toEqual(STATUS_PAGE_ID);
1084
+ });
1085
+
1086
+ it("does nothing for a rule that no longer exists", async () => {
1087
+ ruleFindOneByIdSpy.mockResolvedValue(null);
1088
+
1089
+ const result: StatusPageMonitorRuleSyncResult =
1090
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1091
+ statusPageMonitorRuleId: RULE_ID,
1092
+ });
1093
+
1094
+ expect(result).toEqual({
1095
+ monitorIdsAdded: [],
1096
+ statusPageResourceIdsRemoved: [],
1097
+ statusPageResourceIdsUpdated: [],
1098
+ monitorIdsReleased: [],
1099
+ });
1100
+ expect(resourceCreateSpy).not.toHaveBeenCalled();
1101
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
1102
+ });
1103
+
1104
+ it("does nothing for a rule with no criteria, rather than adding the whole project", async () => {
1105
+ ruleFindOneByIdSpy.mockResolvedValue(fakeRule({}));
1106
+
1107
+ const result: StatusPageMonitorRuleSyncResult =
1108
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1109
+ statusPageMonitorRuleId: RULE_ID,
1110
+ });
1111
+
1112
+ expect(monitorFindBySpy).not.toHaveBeenCalled();
1113
+ expect(result.monitorIdsAdded).toEqual([]);
1114
+ });
1115
+
1116
+ /*
1117
+ * A rule can lose its last criterion without anyone editing it: its only
1118
+ * criterion was a label, and deleting that label cascaded the join row away.
1119
+ * The rule then claims nothing, and what it had added has to go — otherwise
1120
+ * the page keeps monitors that no surviving configuration explains.
1121
+ */
1122
+ it("removes what it owns once it has been left with no criteria at all", async () => {
1123
+ ruleFindOneByIdSpy.mockResolvedValue(fakeRule({}));
1124
+ resourceFindBySpy.mockResolvedValue([
1125
+ fakeResource({
1126
+ id: RESOURCE_A_ID,
1127
+ monitorId: MONITOR_A_ID,
1128
+ statusPageMonitorRuleId: RULE_ID,
1129
+ }),
1130
+ ]);
1131
+
1132
+ const result: StatusPageMonitorRuleSyncResult =
1133
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1134
+ statusPageMonitorRuleId: RULE_ID,
1135
+ });
1136
+
1137
+ expect(result.statusPageResourceIdsRemoved).toEqual([
1138
+ RESOURCE_A_ID.toString(),
1139
+ ]);
1140
+ });
1141
+
1142
+ it("still spares manual resources when it has been left with no criteria", async () => {
1143
+ ruleFindOneByIdSpy.mockResolvedValue(fakeRule({}));
1144
+ resourceFindBySpy.mockResolvedValue([
1145
+ fakeResource({ id: RESOURCE_A_ID, monitorId: MONITOR_A_ID }),
1146
+ ]);
1147
+
1148
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1149
+ statusPageMonitorRuleId: RULE_ID,
1150
+ });
1151
+
1152
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
1153
+ });
1154
+
1155
+ it("stamps the project and the status page on what it creates", async () => {
1156
+ ruleFindOneByIdSpy.mockResolvedValue(
1157
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1158
+ );
1159
+ monitorFindBySpy.mockResolvedValue([
1160
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
1161
+ ]);
1162
+
1163
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1164
+ statusPageMonitorRuleId: RULE_ID,
1165
+ });
1166
+
1167
+ const resource: StatusPageResource = createdResource(resourceCreateSpy);
1168
+
1169
+ expect(resource.projectId).toEqual(PROJECT_ID);
1170
+ expect(resource.statusPageId).toEqual(STATUS_PAGE_ID);
1171
+ expect(resource.monitorId).toEqual(MONITOR_A_ID);
1172
+ });
1173
+
1174
+ it("defaults the display options to on when the rule sets none", async () => {
1175
+ ruleFindOneByIdSpy.mockResolvedValue(
1176
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1177
+ );
1178
+ monitorFindBySpy.mockResolvedValue([
1179
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
1180
+ ]);
1181
+
1182
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1183
+ statusPageMonitorRuleId: RULE_ID,
1184
+ });
1185
+
1186
+ const resource: StatusPageResource = createdResource(resourceCreateSpy);
1187
+
1188
+ expect(resource.showCurrentStatus).toBe(true);
1189
+ expect(resource.showUptimePercent).toBe(true);
1190
+ expect(resource.showStatusHistoryChart).toBe(true);
1191
+ // Left unset rather than written as null, so the column default applies.
1192
+ expect(resource.uptimePercentPrecision).toBeUndefined();
1193
+ });
1194
+
1195
+ /*
1196
+ * The label filter is the one criterion the database can evaluate, so it is
1197
+ * pushed into the query. A pattern-only rule must not send an empty `labels`
1198
+ * key, which would ask for monitors carrying no labels at all.
1199
+ */
1200
+ it("pushes the label filter into the monitor query when the rule names labels", async () => {
1201
+ ruleFindOneByIdSpy.mockResolvedValue(
1202
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID, LABEL_TIER1_ID] }),
1203
+ );
1204
+ monitorFindBySpy.mockResolvedValue([]);
1205
+
1206
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1207
+ statusPageMonitorRuleId: RULE_ID,
1208
+ });
1209
+
1210
+ const call: { query: { labels?: Array<ObjectID> } } = monitorFindBySpy.mock
1211
+ .calls[0]![0] as { query: { labels?: Array<ObjectID> } };
1212
+
1213
+ expect(
1214
+ (call.query.labels || []).map((id: ObjectID) => {
1215
+ return id.toString();
1216
+ }),
1217
+ ).toEqual([LABEL_PRODUCTION_ID.toString(), LABEL_TIER1_ID.toString()]);
1218
+ });
1219
+
1220
+ it("sends no labels key at all for a pattern-only rule", async () => {
1221
+ ruleFindOneByIdSpy.mockResolvedValue(
1222
+ fakeRule({ monitorNamePattern: "^api" }),
1223
+ );
1224
+ monitorFindBySpy.mockResolvedValue([]);
1225
+
1226
+ await StatusPageMonitorRuleEngineService.syncResourcesForRule({
1227
+ statusPageMonitorRuleId: RULE_ID,
1228
+ });
1229
+
1230
+ const call: { query: Record<string, unknown> } = monitorFindBySpy.mock
1231
+ .calls[0]![0] as { query: Record<string, unknown> };
1232
+
1233
+ expect("labels" in call.query).toBe(false);
1234
+ });
1235
+ });
1236
+
1237
+ describe("StatusPageMonitorRuleEngineService.syncRulesForMonitor", () => {
1238
+ let monitorFindOneByIdSpy: jest.SpyInstance;
1239
+ let ruleFindBySpy: jest.SpyInstance;
1240
+ let resourceFindBySpy: jest.SpyInstance;
1241
+ let resourceCreateSpy: jest.SpyInstance;
1242
+ let resourceDeleteSpy: jest.SpyInstance;
1243
+ let resourceUpdateSpy: jest.SpyInstance;
1244
+
1245
+ beforeEach(() => {
1246
+ monitorFindOneByIdSpy = jest.spyOn(MonitorService, "findOneById");
1247
+ ruleFindBySpy = jest.spyOn(StatusPageMonitorRuleService, "findBy");
1248
+ resourceFindBySpy = jest
1249
+ .spyOn(StatusPageResourceService, "findBy")
1250
+ .mockResolvedValue([]);
1251
+ resourceCreateSpy = jest
1252
+ .spyOn(StatusPageResourceService, "create")
1253
+ .mockResolvedValue(new StatusPageResource());
1254
+ resourceDeleteSpy = jest
1255
+ .spyOn(StatusPageResourceService, "deleteOneById")
1256
+ .mockResolvedValue(1);
1257
+ resourceUpdateSpy = jest
1258
+ .spyOn(StatusPageResourceService, "updateOneById")
1259
+ .mockResolvedValue(1);
1260
+ });
1261
+
1262
+ afterEach(() => {
1263
+ jest.restoreAllMocks();
1264
+ });
1265
+
1266
+ /*
1267
+ * Reconciling belongs to the rule-side path only. Nothing about the rule
1268
+ * moved here, so quietly reverting a per-resource tweak because somebody
1269
+ * relabelled an unrelated monitor would be a surprise with no cause the
1270
+ * user could see.
1271
+ */
1272
+ it("does not re-home drifted resources - only a rule edit does that", async () => {
1273
+ monitorFindOneByIdSpy.mockResolvedValue(
1274
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
1275
+ );
1276
+ ruleFindBySpy.mockResolvedValue([
1277
+ fakeRule({
1278
+ monitorLabels: [LABEL_PRODUCTION_ID],
1279
+ statusPageGroupId: OTHER_GROUP_ID,
1280
+ }),
1281
+ ]);
1282
+ resourceFindBySpy.mockResolvedValue([
1283
+ fakeResource({
1284
+ id: RESOURCE_A_ID,
1285
+ monitorId: MONITOR_A_ID,
1286
+ statusPageMonitorRuleId: RULE_ID,
1287
+ statusPageGroupId: GROUP_ID,
1288
+ }),
1289
+ ]);
1290
+
1291
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1292
+ monitorId: MONITOR_A_ID,
1293
+ });
1294
+
1295
+ expect(resourceUpdateSpy).not.toHaveBeenCalled();
1296
+ });
1297
+
1298
+ /*
1299
+ * Two rules on one page can match the same monitor; only the first creates
1300
+ * the resource, so the page shows it once. When the monitor stops matching
1301
+ * the OWNING rule but still matches the other, the monitor must stay on the
1302
+ * page — and must do so whichever order the rules come back in, because
1303
+ * findBy gives no ordering guarantee.
1304
+ *
1305
+ * Interleaving adds and removes made this order-dependent: evaluate the
1306
+ * still-matching rule first and it skips the add (the monitor is still on
1307
+ * the page), then the owning rule deletes and the monitor silently
1308
+ * disappears from a public status page.
1309
+ */
1310
+ it("keeps a monitor on the page when one rule releases it and another still claims it", async () => {
1311
+ monitorFindOneByIdSpy.mockResolvedValue(
1312
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_TIER1_ID] }),
1313
+ );
1314
+
1315
+ // RULE_ID owns the resource but no longer matches. OTHER_RULE_ID matches.
1316
+ ruleFindBySpy.mockResolvedValue([
1317
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1318
+ fakeRule({ id: OTHER_RULE_ID, monitorLabels: [LABEL_TIER1_ID] }),
1319
+ ]);
1320
+
1321
+ /*
1322
+ * The page as each pass reads it: the resource is there until the removal
1323
+ * pass deletes it, and gone afterwards.
1324
+ */
1325
+ let isOnPage: boolean = true;
1326
+ resourceFindBySpy.mockImplementation(() => {
1327
+ return Promise.resolve(
1328
+ isOnPage
1329
+ ? [
1330
+ fakeResource({
1331
+ id: RESOURCE_A_ID,
1332
+ monitorId: MONITOR_A_ID,
1333
+ statusPageMonitorRuleId: RULE_ID,
1334
+ }),
1335
+ ]
1336
+ : [],
1337
+ );
1338
+ });
1339
+ resourceDeleteSpy.mockImplementation(() => {
1340
+ isOnPage = false;
1341
+ return Promise.resolve(1);
1342
+ });
1343
+
1344
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1345
+ monitorId: MONITOR_A_ID,
1346
+ });
1347
+
1348
+ expect(resourceDeleteSpy).toHaveBeenCalledTimes(1);
1349
+ expect(resourceCreateSpy).toHaveBeenCalledTimes(1);
1350
+ expect(createdResource(resourceCreateSpy).statusPageMonitorRuleId).toEqual(
1351
+ OTHER_RULE_ID,
1352
+ );
1353
+ });
1354
+
1355
+ it("holds that guarantee whichever order the rules come back in", async () => {
1356
+ monitorFindOneByIdSpy.mockResolvedValue(
1357
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_TIER1_ID] }),
1358
+ );
1359
+
1360
+ // Same as above, with the still-matching rule evaluated FIRST.
1361
+ ruleFindBySpy.mockResolvedValue([
1362
+ fakeRule({ id: OTHER_RULE_ID, monitorLabels: [LABEL_TIER1_ID] }),
1363
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1364
+ ]);
1365
+
1366
+ let isOnPage: boolean = true;
1367
+ resourceFindBySpy.mockImplementation(() => {
1368
+ return Promise.resolve(
1369
+ isOnPage
1370
+ ? [
1371
+ fakeResource({
1372
+ id: RESOURCE_A_ID,
1373
+ monitorId: MONITOR_A_ID,
1374
+ statusPageMonitorRuleId: RULE_ID,
1375
+ }),
1376
+ ]
1377
+ : [],
1378
+ );
1379
+ });
1380
+ resourceDeleteSpy.mockImplementation(() => {
1381
+ isOnPage = false;
1382
+ return Promise.resolve(1);
1383
+ });
1384
+
1385
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1386
+ monitorId: MONITOR_A_ID,
1387
+ });
1388
+
1389
+ expect(resourceCreateSpy).toHaveBeenCalledTimes(1);
1390
+ expect(createdResource(resourceCreateSpy).statusPageMonitorRuleId).toEqual(
1391
+ OTHER_RULE_ID,
1392
+ );
1393
+ });
1394
+
1395
+ it("adds the monitor to every status page whose rule it now matches", async () => {
1396
+ monitorFindOneByIdSpy.mockResolvedValue(
1397
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
1398
+ );
1399
+ ruleFindBySpy.mockResolvedValue([
1400
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1401
+ fakeRule({
1402
+ id: OTHER_RULE_ID,
1403
+ monitorLabels: [LABEL_PRODUCTION_ID],
1404
+ statusPageGroupId: GROUP_ID,
1405
+ }),
1406
+ ]);
1407
+
1408
+ const results: Array<StatusPageMonitorRuleSyncResult> =
1409
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1410
+ monitorId: MONITOR_A_ID,
1411
+ });
1412
+
1413
+ expect(resourceCreateSpy).toHaveBeenCalledTimes(2);
1414
+ expect(results).toHaveLength(2);
1415
+ });
1416
+
1417
+ it("only considers enabled rules", async () => {
1418
+ monitorFindOneByIdSpy.mockResolvedValue(
1419
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
1420
+ );
1421
+ ruleFindBySpy.mockResolvedValue([]);
1422
+
1423
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1424
+ monitorId: MONITOR_A_ID,
1425
+ });
1426
+
1427
+ const call: { query: { isEnabled: boolean } } = ruleFindBySpy.mock
1428
+ .calls[0]![0] as { query: { isEnabled: boolean } };
1429
+
1430
+ expect(call.query.isEnabled).toBe(true);
1431
+ });
1432
+
1433
+ it("removes the monitor from a page once it stops matching that page's rule", async () => {
1434
+ monitorFindOneByIdSpy.mockResolvedValue(
1435
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_STAGING_ID] }),
1436
+ );
1437
+ ruleFindBySpy.mockResolvedValue([
1438
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1439
+ ]);
1440
+ resourceFindBySpy.mockResolvedValue([
1441
+ fakeResource({
1442
+ id: RESOURCE_A_ID,
1443
+ monitorId: MONITOR_A_ID,
1444
+ statusPageMonitorRuleId: RULE_ID,
1445
+ }),
1446
+ ]);
1447
+
1448
+ const results: Array<StatusPageMonitorRuleSyncResult> =
1449
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1450
+ monitorId: MONITOR_A_ID,
1451
+ });
1452
+
1453
+ expect(results[0]!.statusPageResourceIdsRemoved).toEqual([
1454
+ RESOURCE_A_ID.toString(),
1455
+ ]);
1456
+ });
1457
+
1458
+ /*
1459
+ * The monitor-side path knows about one monitor. Every other monitor the
1460
+ * rule owns is somebody else's business and must survive untouched.
1461
+ */
1462
+ it("leaves the rule's other monitors alone", async () => {
1463
+ monitorFindOneByIdSpy.mockResolvedValue(
1464
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_STAGING_ID] }),
1465
+ );
1466
+ ruleFindBySpy.mockResolvedValue([
1467
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1468
+ ]);
1469
+ resourceFindBySpy.mockResolvedValue([
1470
+ fakeResource({
1471
+ id: RESOURCE_A_ID,
1472
+ monitorId: MONITOR_A_ID,
1473
+ statusPageMonitorRuleId: RULE_ID,
1474
+ }),
1475
+ fakeResource({
1476
+ id: RESOURCE_B_ID,
1477
+ monitorId: MONITOR_B_ID,
1478
+ statusPageMonitorRuleId: RULE_ID,
1479
+ }),
1480
+ ]);
1481
+
1482
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1483
+ monitorId: MONITOR_A_ID,
1484
+ });
1485
+
1486
+ expect(resourceDeleteSpy).toHaveBeenCalledTimes(1);
1487
+ const call: { id: ObjectID } = resourceDeleteSpy.mock.calls[0]![0] as {
1488
+ id: ObjectID;
1489
+ };
1490
+ expect(call.id).toEqual(RESOURCE_A_ID);
1491
+ });
1492
+
1493
+ it("still refuses to remove the monitor's manual resource", async () => {
1494
+ monitorFindOneByIdSpy.mockResolvedValue(
1495
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_STAGING_ID] }),
1496
+ );
1497
+ ruleFindBySpy.mockResolvedValue([
1498
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1499
+ ]);
1500
+ resourceFindBySpy.mockResolvedValue([
1501
+ fakeResource({ id: RESOURCE_A_ID, monitorId: MONITOR_A_ID }),
1502
+ ]);
1503
+
1504
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1505
+ monitorId: MONITOR_A_ID,
1506
+ });
1507
+
1508
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
1509
+ });
1510
+
1511
+ it("reports nothing for rules that were already in the right state", async () => {
1512
+ monitorFindOneByIdSpy.mockResolvedValue(
1513
+ fakeMonitor(MONITOR_C_ID, { labels: [LABEL_STAGING_ID] }),
1514
+ );
1515
+ ruleFindBySpy.mockResolvedValue([
1516
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1517
+ ]);
1518
+
1519
+ const results: Array<StatusPageMonitorRuleSyncResult> =
1520
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1521
+ monitorId: MONITOR_C_ID,
1522
+ });
1523
+
1524
+ expect(results).toEqual([]);
1525
+ });
1526
+
1527
+ it("keeps going after one rule fails - the monitor has already changed", async () => {
1528
+ monitorFindOneByIdSpy.mockResolvedValue(
1529
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
1530
+ );
1531
+ ruleFindBySpy.mockResolvedValue([
1532
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1533
+ fakeRule({ id: OTHER_RULE_ID, monitorLabels: [LABEL_PRODUCTION_ID] }),
1534
+ ]);
1535
+ resourceCreateSpy
1536
+ .mockRejectedValueOnce(new Error("db down"))
1537
+ .mockResolvedValueOnce(new StatusPageResource());
1538
+
1539
+ const results: Array<StatusPageMonitorRuleSyncResult> =
1540
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1541
+ monitorId: MONITOR_A_ID,
1542
+ });
1543
+
1544
+ expect(results).toHaveLength(1);
1545
+ expect(results[0]!.monitorIdsAdded).toEqual([MONITOR_A_ID.toString()]);
1546
+ });
1547
+
1548
+ it("does nothing for a monitor that no longer exists", async () => {
1549
+ monitorFindOneByIdSpy.mockResolvedValue(null);
1550
+
1551
+ const results: Array<StatusPageMonitorRuleSyncResult> =
1552
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1553
+ monitorId: MONITOR_A_ID,
1554
+ });
1555
+
1556
+ expect(results).toEqual([]);
1557
+ expect(ruleFindBySpy).not.toHaveBeenCalled();
1558
+ });
1559
+
1560
+ it("falls back to the caller's project id when the monitor row does not carry one", async () => {
1561
+ monitorFindOneByIdSpy.mockResolvedValue(
1562
+ fakeMonitor(MONITOR_A_ID, {
1563
+ projectId: null,
1564
+ labels: [LABEL_PRODUCTION_ID],
1565
+ }),
1566
+ );
1567
+ ruleFindBySpy.mockResolvedValue([]);
1568
+
1569
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1570
+ monitorId: MONITOR_A_ID,
1571
+ projectId: PROJECT_ID,
1572
+ });
1573
+
1574
+ const call: { query: { projectId: ObjectID } } = ruleFindBySpy.mock
1575
+ .calls[0]![0] as { query: { projectId: ObjectID } };
1576
+
1577
+ expect(call.query.projectId).toEqual(PROJECT_ID);
1578
+ });
1579
+
1580
+ /*
1581
+ * Two rules on one page both wanting the same monitor is ordinary
1582
+ * configuration (a broad rule and a narrow one). The page must list it once.
1583
+ */
1584
+ it("gives a monitor exactly one resource when two rules on the same page match it", async () => {
1585
+ monitorFindOneByIdSpy.mockResolvedValue(
1586
+ fakeMonitor(MONITOR_A_ID, {
1587
+ labels: [LABEL_PRODUCTION_ID, LABEL_TIER1_ID],
1588
+ }),
1589
+ );
1590
+ ruleFindBySpy.mockResolvedValue([
1591
+ fakeRule({ monitorLabels: [LABEL_PRODUCTION_ID] }),
1592
+ fakeRule({ id: OTHER_RULE_ID, monitorLabels: [LABEL_TIER1_ID] }),
1593
+ ]);
1594
+
1595
+ // Once the first rule creates the resource, the page has it.
1596
+ let created: Array<StatusPageResource> = [];
1597
+ resourceFindBySpy.mockImplementation(() => {
1598
+ return Promise.resolve(created);
1599
+ });
1600
+ resourceCreateSpy.mockImplementation(() => {
1601
+ created = [
1602
+ fakeResource({
1603
+ id: RESOURCE_A_ID,
1604
+ monitorId: MONITOR_A_ID,
1605
+ statusPageMonitorRuleId: RULE_ID,
1606
+ }),
1607
+ ];
1608
+ return Promise.resolve(new StatusPageResource());
1609
+ });
1610
+
1611
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1612
+ monitorId: MONITOR_A_ID,
1613
+ });
1614
+
1615
+ expect(resourceCreateSpy).toHaveBeenCalledTimes(1);
1616
+ });
1617
+
1618
+ it("skips a rule row that came back without a status page", async () => {
1619
+ monitorFindOneByIdSpy.mockResolvedValue(
1620
+ fakeMonitor(MONITOR_A_ID, { labels: [LABEL_PRODUCTION_ID] }),
1621
+ );
1622
+ ruleFindBySpy.mockResolvedValue([
1623
+ fakeRule({
1624
+ statusPageId: null,
1625
+ monitorLabels: [LABEL_PRODUCTION_ID],
1626
+ }),
1627
+ ]);
1628
+
1629
+ const results: Array<StatusPageMonitorRuleSyncResult> =
1630
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1631
+ monitorId: MONITOR_A_ID,
1632
+ });
1633
+
1634
+ expect(results).toEqual([]);
1635
+ expect(resourceCreateSpy).not.toHaveBeenCalled();
1636
+ });
1637
+
1638
+ it("does nothing when no project id can be resolved at all", async () => {
1639
+ monitorFindOneByIdSpy.mockResolvedValue(
1640
+ fakeMonitor(MONITOR_A_ID, { projectId: null }),
1641
+ );
1642
+
1643
+ const results: Array<StatusPageMonitorRuleSyncResult> =
1644
+ await StatusPageMonitorRuleEngineService.syncRulesForMonitor({
1645
+ monitorId: MONITOR_A_ID,
1646
+ });
1647
+
1648
+ expect(results).toEqual([]);
1649
+ expect(ruleFindBySpy).not.toHaveBeenCalled();
1650
+ });
1651
+ });
1652
+
1653
+ describe("StatusPageMonitorRuleEngineService.removeResourcesAddedByRule", () => {
1654
+ let resourceFindBySpy: jest.SpyInstance;
1655
+ let resourceDeleteSpy: jest.SpyInstance;
1656
+
1657
+ beforeEach(() => {
1658
+ resourceFindBySpy = jest.spyOn(StatusPageResourceService, "findBy");
1659
+ resourceDeleteSpy = jest
1660
+ .spyOn(StatusPageResourceService, "deleteOneById")
1661
+ .mockResolvedValue(1);
1662
+ });
1663
+
1664
+ afterEach(() => {
1665
+ jest.restoreAllMocks();
1666
+ });
1667
+
1668
+ it("deletes every resource the rule added", async () => {
1669
+ resourceFindBySpy.mockResolvedValue([
1670
+ fakeResource({
1671
+ id: RESOURCE_A_ID,
1672
+ monitorId: MONITOR_A_ID,
1673
+ statusPageMonitorRuleId: RULE_ID,
1674
+ }),
1675
+ fakeResource({
1676
+ id: RESOURCE_B_ID,
1677
+ monitorId: MONITOR_B_ID,
1678
+ statusPageMonitorRuleId: RULE_ID,
1679
+ }),
1680
+ ]);
1681
+
1682
+ const removed: Array<string> =
1683
+ await StatusPageMonitorRuleEngineService.removeResourcesAddedByRule({
1684
+ statusPageMonitorRuleId: RULE_ID,
1685
+ });
1686
+
1687
+ expect(resourceDeleteSpy).toHaveBeenCalledTimes(2);
1688
+ expect(removed.sort()).toEqual(
1689
+ [RESOURCE_A_ID.toString(), RESOURCE_B_ID.toString()].sort(),
1690
+ );
1691
+ });
1692
+
1693
+ it("asks only for resources stamped with this rule, so manual ones are never in range", async () => {
1694
+ resourceFindBySpy.mockResolvedValue([]);
1695
+
1696
+ await StatusPageMonitorRuleEngineService.removeResourcesAddedByRule({
1697
+ statusPageMonitorRuleId: RULE_ID,
1698
+ });
1699
+
1700
+ const call: { query: { statusPageMonitorRuleId: ObjectID } } =
1701
+ resourceFindBySpy.mock.calls[0]![0] as {
1702
+ query: { statusPageMonitorRuleId: ObjectID };
1703
+ };
1704
+
1705
+ expect(call.query.statusPageMonitorRuleId).toEqual(RULE_ID);
1706
+ });
1707
+
1708
+ it("returns an empty list when the rule added nothing", async () => {
1709
+ resourceFindBySpy.mockResolvedValue([]);
1710
+
1711
+ const removed: Array<string> =
1712
+ await StatusPageMonitorRuleEngineService.removeResourcesAddedByRule({
1713
+ statusPageMonitorRuleId: RULE_ID,
1714
+ });
1715
+
1716
+ expect(removed).toEqual([]);
1717
+ expect(resourceDeleteSpy).not.toHaveBeenCalled();
1718
+ });
1719
+ });