@oneuptime/common 12.0.2 → 12.0.3

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 (228) hide show
  1. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +14 -0
  2. package/Models/DatabaseModels/Index.ts +2 -0
  3. package/Models/DatabaseModels/LogDropFilter.ts +9 -20
  4. package/Models/DatabaseModels/StatusPageMonitorRule.ts +911 -0
  5. package/Models/DatabaseModels/StatusPageResource.ts +73 -0
  6. package/Models/DatabaseModels/TraceDropFilter.ts +9 -20
  7. package/Server/API/BaseAPI.ts +13 -2
  8. package/Server/API/DashboardAPI.ts +39 -0
  9. package/Server/EnvironmentConfig.ts +31 -1
  10. package/Server/Infrastructure/GlobalCache.ts +115 -0
  11. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +16 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.ts +123 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  14. package/Server/Services/AlertStateTimelineService.ts +20 -0
  15. package/Server/Services/CephClusterService.ts +26 -86
  16. package/Server/Services/CloudResourceService.ts +23 -83
  17. package/Server/Services/DatabaseService.ts +111 -0
  18. package/Server/Services/DockerHostService.ts +19 -79
  19. package/Server/Services/DockerSwarmClusterService.ts +27 -88
  20. package/Server/Services/HostService.ts +46 -99
  21. package/Server/Services/IncidentStateTimelineService.ts +20 -0
  22. package/Server/Services/Index.ts +2 -0
  23. package/Server/Services/IoTFleetService.ts +19 -79
  24. package/Server/Services/KubernetesClusterService.ts +17 -78
  25. package/Server/Services/LabelService.ts +57 -10
  26. package/Server/Services/MetricTypeService.ts +84 -0
  27. package/Server/Services/MonitorService.ts +89 -14
  28. package/Server/Services/MonitorStatusTimelineService.ts +29 -0
  29. package/Server/Services/MonitorTemplateService.ts +65 -0
  30. package/Server/Services/PodmanHostService.ts +19 -79
  31. package/Server/Services/ProxmoxClusterService.ts +23 -83
  32. package/Server/Services/RumApplicationService.ts +19 -80
  33. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +21 -0
  34. package/Server/Services/ServerlessFunctionService.ts +24 -85
  35. package/Server/Services/ServiceService.ts +299 -73
  36. package/Server/Services/StatusPageMonitorRuleEngineService.ts +752 -0
  37. package/Server/Services/StatusPageMonitorRuleService.ts +471 -0
  38. package/Server/Services/TelemetryEntityRelationshipService.ts +1 -0
  39. package/Server/Services/TelemetryEntityService.ts +1 -0
  40. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +170 -39
  41. package/Server/Utils/Monitor/MonitorResource.ts +63 -36
  42. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +36 -0
  43. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +148 -127
  44. package/Server/Utils/Monitor/MonitorStepsReferenceExtractor.ts +434 -0
  45. package/Server/Utils/SingleFlight.ts +83 -0
  46. package/Server/Utils/StatusPage/MonitorRulePatternValidator.ts +46 -0
  47. package/Server/Utils/Telemetry/EntityRegistry.ts +107 -26
  48. package/Server/Utils/Telemetry/ResourceHeartbeat.ts +314 -0
  49. package/Server/Utils/Telemetry/Telemetry.ts +351 -122
  50. package/Tests/App/Dashboard/WidgetCatalog.test.ts +2 -0
  51. package/Tests/Models/DatabaseModels/ColumnTransformerInvariants.test.ts +306 -0
  52. package/Tests/Models/DatabaseModels/NumberColumnDeserialization.test.ts +333 -0
  53. package/Tests/Models/DatabaseModels/StatusPageMonitorRuleModel.test.ts +190 -0
  54. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +26 -0
  55. package/Tests/Server/Infrastructure/GlobalCache.test.ts +226 -0
  56. package/Tests/Server/Infrastructure/PostgresLockTimeoutOptions.test.ts +181 -0
  57. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlocked.test.ts +342 -0
  58. package/Tests/Server/Services/DropFilterSaveValidation.test.ts +245 -0
  59. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +163 -15
  60. package/Tests/Server/Services/MetricTypeAttachServices.test.ts +222 -0
  61. package/Tests/Server/Services/MissingReferenceWriteGuard.test.ts +614 -0
  62. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +23 -0
  63. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +305 -56
  64. package/Tests/Server/Services/ServiceUpdateLastSeenSplitGates.test.ts +705 -0
  65. package/Tests/Server/Services/StatusPageMonitorRuleEngineService.test.ts +1719 -0
  66. package/Tests/Server/Services/StatusPageMonitorRuleHooks.test.ts +1212 -0
  67. package/Tests/Server/Services/StatusPageMonitorRuleMonitorCreateHook.test.ts +228 -0
  68. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +267 -0
  69. package/Tests/Server/Utils/Database/ProjectScopedReferenceValidator.test.ts +260 -13
  70. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +302 -0
  71. package/Tests/Server/Utils/MetricTypeIndexConvergence.test.ts +598 -0
  72. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineUnusableStatus.test.ts +154 -0
  73. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +638 -92
  74. package/Tests/Server/Utils/Monitor/MonitorStepsReferenceExtractor.test.ts +724 -0
  75. package/Tests/Server/Utils/SingleFlight.test.ts +221 -0
  76. package/Tests/Types/Dashboard/NetworkDashboardTemplate.test.ts +793 -0
  77. package/Tests/Types/Database/BigIntColumnTransformer.test.ts +147 -0
  78. package/Tests/Types/Database/DatabasePropertyTransformer.test.ts +140 -0
  79. package/Tests/Types/Database/NumericColumnValue.test.ts +127 -0
  80. package/Tests/Types/Decimal.test.ts +37 -0
  81. package/Tests/Types/Monitor/SnmpVersionParsing.test.ts +152 -0
  82. package/Tests/Types/Port.test.ts +38 -0
  83. package/Tests/UI/Components/LogsHistogram.test.tsx +276 -0
  84. package/Tests/UI/Components/UseLiveLogsRefresh.test.tsx +489 -0
  85. package/Tests/UI/Components/UseLogsHistogram.test.tsx +500 -0
  86. package/Tests/Utils/Dashboard/Components/DashboardComponentsUtil.test.ts +120 -0
  87. package/Tests/Utils/Dashboard/DashboardNetworkMapComponent.test.ts +327 -0
  88. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  89. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  90. package/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.ts +40 -0
  91. package/Types/Dashboard/DashboardTemplates.ts +397 -0
  92. package/Types/Database/BigIntColumnTransformer.ts +74 -0
  93. package/Types/Database/DatabaseProperty.ts +26 -0
  94. package/Types/Database/NumericColumnValue.ts +128 -0
  95. package/Types/Decimal.ts +16 -4
  96. package/Types/Permission.ts +48 -0
  97. package/Types/Port.ts +16 -4
  98. package/UI/Components/LogsViewer/useLiveLogsRefresh.ts +86 -0
  99. package/UI/Components/LogsViewer/useLogsHistogram.ts +113 -0
  100. package/Utils/Dashboard/Components/DashboardNetworkMapComponent.ts +136 -0
  101. package/Utils/Dashboard/Components/Index.ts +7 -0
  102. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +12 -0
  103. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  105. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/LogDropFilter.js +9 -20
  107. package/build/dist/Models/DatabaseModels/LogDropFilter.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js +925 -0
  109. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js.map +1 -0
  110. package/build/dist/Models/DatabaseModels/StatusPageResource.js +72 -0
  111. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/TraceDropFilter.js +9 -20
  113. package/build/dist/Models/DatabaseModels/TraceDropFilter.js.map +1 -1
  114. package/build/dist/Server/API/BaseAPI.js +11 -1
  115. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  116. package/build/dist/Server/API/DashboardAPI.js +39 -0
  117. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  118. package/build/dist/Server/EnvironmentConfig.js +27 -1
  119. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  120. package/build/dist/Server/Infrastructure/GlobalCache.js +91 -0
  121. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +5 -3
  123. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js +48 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  128. package/build/dist/Server/Services/AlertStateTimelineService.js +19 -0
  129. package/build/dist/Server/Services/AlertStateTimelineService.js.map +1 -1
  130. package/build/dist/Server/Services/CephClusterService.js +26 -76
  131. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  132. package/build/dist/Server/Services/CloudResourceService.js +23 -73
  133. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  134. package/build/dist/Server/Services/DatabaseService.js +86 -0
  135. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  136. package/build/dist/Server/Services/DockerHostService.js +19 -69
  137. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  138. package/build/dist/Server/Services/DockerSwarmClusterService.js +27 -78
  139. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  140. package/build/dist/Server/Services/HostService.js +58 -102
  141. package/build/dist/Server/Services/HostService.js.map +1 -1
  142. package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
  143. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  144. package/build/dist/Server/Services/Index.js +2 -0
  145. package/build/dist/Server/Services/Index.js.map +1 -1
  146. package/build/dist/Server/Services/IoTFleetService.js +19 -69
  147. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  148. package/build/dist/Server/Services/KubernetesClusterService.js +17 -68
  149. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  150. package/build/dist/Server/Services/LabelService.js +42 -7
  151. package/build/dist/Server/Services/LabelService.js.map +1 -1
  152. package/build/dist/Server/Services/MetricTypeService.js +74 -0
  153. package/build/dist/Server/Services/MetricTypeService.js.map +1 -1
  154. package/build/dist/Server/Services/MonitorService.js +81 -9
  155. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  156. package/build/dist/Server/Services/MonitorStatusTimelineService.js +28 -0
  157. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  158. package/build/dist/Server/Services/MonitorTemplateService.js +60 -0
  159. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  160. package/build/dist/Server/Services/PodmanHostService.js +19 -69
  161. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  162. package/build/dist/Server/Services/ProxmoxClusterService.js +23 -73
  163. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  164. package/build/dist/Server/Services/RumApplicationService.js +19 -70
  165. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  166. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +20 -0
  167. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  168. package/build/dist/Server/Services/ServerlessFunctionService.js +24 -75
  169. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  170. package/build/dist/Server/Services/ServiceService.js +236 -61
  171. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  172. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +603 -0
  173. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -0
  174. package/build/dist/Server/Services/StatusPageMonitorRuleService.js +401 -0
  175. package/build/dist/Server/Services/StatusPageMonitorRuleService.js.map +1 -0
  176. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +1 -0
  177. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  178. package/build/dist/Server/Services/TelemetryEntityService.js +1 -0
  179. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  180. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +105 -29
  181. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  182. package/build/dist/Server/Utils/Monitor/MonitorResource.js +56 -32
  183. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  184. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +30 -3
  185. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  186. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +110 -98
  187. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -1
  188. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js +312 -0
  189. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js.map +1 -0
  190. package/build/dist/Server/Utils/SingleFlight.js +76 -0
  191. package/build/dist/Server/Utils/SingleFlight.js.map +1 -0
  192. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js +29 -0
  193. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js.map +1 -0
  194. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +78 -19
  195. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  196. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +160 -0
  197. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -0
  198. package/build/dist/Server/Utils/Telemetry/Telemetry.js +279 -104
  199. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  200. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  201. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  202. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  203. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  204. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js +2 -0
  205. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js.map +1 -0
  206. package/build/dist/Types/Dashboard/DashboardTemplates.js +368 -0
  207. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  208. package/build/dist/Types/Database/BigIntColumnTransformer.js +66 -0
  209. package/build/dist/Types/Database/BigIntColumnTransformer.js.map +1 -0
  210. package/build/dist/Types/Database/DatabaseProperty.js +25 -0
  211. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  212. package/build/dist/Types/Database/NumericColumnValue.js +97 -0
  213. package/build/dist/Types/Database/NumericColumnValue.js.map +1 -0
  214. package/build/dist/Types/Decimal.js +14 -3
  215. package/build/dist/Types/Decimal.js.map +1 -1
  216. package/build/dist/Types/Permission.js +42 -0
  217. package/build/dist/Types/Permission.js.map +1 -1
  218. package/build/dist/Types/Port.js +15 -5
  219. package/build/dist/Types/Port.js.map +1 -1
  220. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js +44 -0
  221. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js.map +1 -0
  222. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js +75 -0
  223. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js.map +1 -0
  224. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js +114 -0
  225. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js.map +1 -0
  226. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  227. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  228. package/package.json +1 -1
@@ -0,0 +1,752 @@
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 { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
6
+ import ObjectID from "../../Types/ObjectID";
7
+ import UptimePrecision from "../../Types/StatusPage/UptimePrecision";
8
+ import RulePatternMatchUtil from "../../Utils/Rules/RulePatternMatchUtil";
9
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
10
+ import logger, { LogAttributes } from "../Utils/Logger";
11
+ import MonitorService from "./MonitorService";
12
+ import StatusPageMonitorRuleService from "./StatusPageMonitorRuleService";
13
+ import StatusPageResourceService from "./StatusPageResourceService";
14
+
15
+ /**
16
+ * What a sync did, so callers (and tests) can assert on the outcome without
17
+ * re-reading the status page.
18
+ */
19
+ export interface StatusPageMonitorRuleSyncResult {
20
+ monitorIdsAdded: Array<string>;
21
+ statusPageResourceIdsRemoved: Array<string>;
22
+ statusPageResourceIdsUpdated: Array<string>;
23
+ /*
24
+ * The monitors whose resources this pass removed. Same event as
25
+ * statusPageResourceIdsRemoved, keyed by monitor instead of by resource,
26
+ * because the caller's next question is "does another rule still want this
27
+ * monitor" and that is asked by monitor id.
28
+ */
29
+ monitorIdsReleased: Array<string>;
30
+ }
31
+
32
+ function emptyResult(): StatusPageMonitorRuleSyncResult {
33
+ return {
34
+ monitorIdsAdded: [],
35
+ statusPageResourceIdsRemoved: [],
36
+ statusPageResourceIdsUpdated: [],
37
+ monitorIdsReleased: [],
38
+ };
39
+ }
40
+
41
+ function toIdSet(
42
+ items: Array<{ id?: ObjectID | null }> | undefined,
43
+ ): Set<string> {
44
+ const ids: Set<string> = new Set<string>();
45
+
46
+ for (const item of items || []) {
47
+ const id: string = item?.id?.toString() || "";
48
+
49
+ if (id) {
50
+ ids.add(id);
51
+ }
52
+ }
53
+
54
+ return ids;
55
+ }
56
+
57
+ /**
58
+ * Everything the engine needs to know about one rule to decide membership.
59
+ * `findBy` hands back partial models, so this is the shape actually selected
60
+ * rather than the full entity.
61
+ */
62
+ const RULE_SELECT: {
63
+ [key: string]: boolean | { [key: string]: boolean };
64
+ } = {
65
+ _id: true,
66
+ projectId: true,
67
+ statusPageId: true,
68
+ statusPageGroupId: true,
69
+ isEnabled: true,
70
+ monitorLabels: { _id: true },
71
+ monitorNamePattern: true,
72
+ monitorDescriptionPattern: true,
73
+ showCurrentStatus: true,
74
+ showUptimePercent: true,
75
+ uptimePercentPrecision: true,
76
+ showStatusHistoryChart: true,
77
+ };
78
+
79
+ /**
80
+ * Keeps a status page's resources in step with its monitor rules.
81
+ *
82
+ * A status page can say "show every monitor labelled Production in the Core
83
+ * Platform group" instead of adding monitors one at a time and typing a
84
+ * display name for each. That promise has two halves, and both have to hold:
85
+ *
86
+ * - a monitor that starts matching gets added to the group the rule names,
87
+ * and a monitor that stops matching gets removed again;
88
+ *
89
+ * - a resource a human added by hand is never touched — not adopted into a
90
+ * rule when it happens to match, and never removed when it stops matching
91
+ * or when the rule is deleted.
92
+ *
93
+ * StatusPageResource.statusPageMonitorRuleId is what makes the second half
94
+ * possible: it records which rule owns a resource, and only owned resources
95
+ * are ever removed.
96
+ *
97
+ * Membership can be invalidated from either side, so there are two entry
98
+ * points:
99
+ *
100
+ * - the rule side (the rule was created or edited) -> syncResourcesForRule
101
+ * - the monitor side (its labels or name changed) -> syncRulesForMonitor
102
+ */
103
+ export class StatusPageMonitorRuleEngineServiceClass {
104
+ /**
105
+ * Re-evaluates one rule against every monitor in its project. Use when the
106
+ * rule itself changed — it is the authoritative, self-healing path and
107
+ * repairs any drift it finds.
108
+ */
109
+ @CaptureSpan()
110
+ public async syncResourcesForRule(data: {
111
+ statusPageMonitorRuleId: ObjectID;
112
+ }): Promise<StatusPageMonitorRuleSyncResult> {
113
+ const rule: StatusPageMonitorRule | null =
114
+ await StatusPageMonitorRuleService.findOneById({
115
+ id: data.statusPageMonitorRuleId,
116
+ select: RULE_SELECT as any,
117
+ props: {
118
+ isRoot: true,
119
+ },
120
+ });
121
+
122
+ if (!rule || !rule.id || !rule.projectId || !rule.statusPageId) {
123
+ return emptyResult();
124
+ }
125
+
126
+ /*
127
+ * A disabled rule matches nothing, which detaches everything it had added.
128
+ * Toggling a rule off therefore undoes it rather than leaving its monitors
129
+ * stranded on a page nobody remembers configuring.
130
+ */
131
+ const matchedMonitors: Array<Monitor> = rule.isEnabled
132
+ ? await this.findMatchingMonitors({ rule: rule })
133
+ : [];
134
+
135
+ const result: StatusPageMonitorRuleSyncResult = await this.writeMembership({
136
+ rule: rule,
137
+ matchedMonitors: matchedMonitors,
138
+ /*
139
+ * This entry point runs because the rule itself changed, so it is the
140
+ * one that re-homes and re-styles the resources the rule already owns.
141
+ * The monitor-side path deliberately does not: nothing about the rule
142
+ * moved there, and reconciling on an unrelated monitor edit would
143
+ * silently revert per-resource tweaks at a surprising moment.
144
+ */
145
+ reconcileOwnedResources: true,
146
+ });
147
+
148
+ /*
149
+ * A monitor this rule just released may still be claimed by another rule
150
+ * on the same page — narrowing this rule does not mean the monitor should
151
+ * vanish from the page. Re-running the monitor-side sync for each released
152
+ * monitor lets whichever other rule still wants it pick it up.
153
+ *
154
+ * Bounded: syncRulesForMonitor only calls writeMembership, never back into
155
+ * this method, so there is no cycle.
156
+ */
157
+ for (const monitorId of result.monitorIdsReleased) {
158
+ try {
159
+ await this.syncRulesForMonitor({
160
+ monitorId: new ObjectID(monitorId),
161
+ projectId: rule.projectId,
162
+ });
163
+ } catch (error) {
164
+ logger.error(
165
+ `Error re-homing monitor ${monitorId} after status page monitor rule ${rule.id.toString()} released it: ${error}`,
166
+ { projectId: rule.projectId.toString() } as LogAttributes,
167
+ );
168
+ }
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ /**
175
+ * Re-evaluates every rule in the project against a single monitor. Use when
176
+ * the monitor changed: it costs one query per project regardless of how many
177
+ * monitors the rules match.
178
+ */
179
+ @CaptureSpan()
180
+ public async syncRulesForMonitor(data: {
181
+ monitorId: ObjectID;
182
+ projectId?: ObjectID | undefined;
183
+ }): Promise<Array<StatusPageMonitorRuleSyncResult>> {
184
+ const monitor: Monitor | null = await MonitorService.findOneById({
185
+ id: data.monitorId,
186
+ select: {
187
+ _id: true,
188
+ projectId: true,
189
+ name: true,
190
+ description: true,
191
+ labels: {
192
+ _id: true,
193
+ },
194
+ },
195
+ props: {
196
+ isRoot: true,
197
+ },
198
+ });
199
+
200
+ if (!monitor || !monitor.id) {
201
+ return [];
202
+ }
203
+
204
+ const projectId: ObjectID | undefined =
205
+ monitor.projectId || data.projectId || undefined;
206
+
207
+ if (!projectId) {
208
+ return [];
209
+ }
210
+
211
+ const rules: Array<StatusPageMonitorRule> =
212
+ await StatusPageMonitorRuleService.findBy({
213
+ query: {
214
+ projectId: projectId,
215
+ isEnabled: true,
216
+ },
217
+ select: RULE_SELECT as any,
218
+ limit: LIMIT_PER_PROJECT,
219
+ skip: 0,
220
+ props: {
221
+ isRoot: true,
222
+ },
223
+ });
224
+
225
+ const results: Array<StatusPageMonitorRuleSyncResult> = [];
226
+
227
+ /*
228
+ * Two passes, removals before additions, and the order matters.
229
+ *
230
+ * Two rules on the same page can match the same monitor; only the first
231
+ * one to see it creates the resource, and the page shows it once. If the
232
+ * monitor then stops matching the OWNING rule but still matches the other,
233
+ * a single interleaved pass gives an order-dependent answer: evaluate the
234
+ * still-matching rule first and it skips the add (the monitor is still on
235
+ * the page), then the owning rule deletes — and the monitor silently
236
+ * disappears from a public page even though a rule still claims it.
237
+ *
238
+ * Draining the removals first means the addition pass sees the page as it
239
+ * will actually be, so whichever rule still claims the monitor picks it up.
240
+ */
241
+ for (const skipAdds of [true, false]) {
242
+ for (const rule of rules) {
243
+ if (!rule.id || !rule.statusPageId) {
244
+ continue;
245
+ }
246
+
247
+ const doesMatch: boolean = this.doesMonitorMatchRule({
248
+ monitor: monitor,
249
+ rule: rule,
250
+ });
251
+
252
+ try {
253
+ const result: StatusPageMonitorRuleSyncResult =
254
+ await this.writeMembership({
255
+ rule: rule,
256
+ matchedMonitors: doesMatch ? [monitor] : [],
257
+ /*
258
+ * Only this monitor's membership is in question. Every other
259
+ * resource the rule owns is left exactly where it is.
260
+ */
261
+ restrictToMonitorId: monitor.id,
262
+ skipAdds: skipAdds,
263
+ skipRemoves: !skipAdds,
264
+ });
265
+
266
+ if (
267
+ result.monitorIdsAdded.length > 0 ||
268
+ result.statusPageResourceIdsRemoved.length > 0
269
+ ) {
270
+ results.push(result);
271
+ }
272
+ } catch (error) {
273
+ /*
274
+ * One rule failing must not stop the others: the monitor has already
275
+ * changed, and half a sync is better than none.
276
+ */
277
+ logger.error(
278
+ `Error syncing status page monitor rule ${rule.id.toString()} for monitor ${monitor.id.toString()}: ${error}`,
279
+ {
280
+ projectId: projectId.toString(),
281
+ monitorId: monitor.id.toString(),
282
+ } as LogAttributes,
283
+ );
284
+ }
285
+ }
286
+ }
287
+
288
+ return results;
289
+ }
290
+
291
+ /**
292
+ * Removes every resource a rule added, without touching anything else on the
293
+ * page. Called before the rule row goes away so deleting a rule undoes it.
294
+ */
295
+ @CaptureSpan()
296
+ public async removeResourcesAddedByRule(data: {
297
+ statusPageMonitorRuleId: ObjectID;
298
+ }): Promise<Array<string>> {
299
+ const ownedResources: Array<StatusPageResource> =
300
+ await StatusPageResourceService.findBy({
301
+ query: {
302
+ statusPageMonitorRuleId: data.statusPageMonitorRuleId,
303
+ },
304
+ select: {
305
+ _id: true,
306
+ },
307
+ limit: LIMIT_PER_PROJECT,
308
+ skip: 0,
309
+ props: {
310
+ isRoot: true,
311
+ },
312
+ });
313
+
314
+ const removedIds: Array<string> = [];
315
+
316
+ for (const resource of ownedResources) {
317
+ if (!resource.id) {
318
+ continue;
319
+ }
320
+
321
+ await StatusPageResourceService.deleteOneById({
322
+ id: resource.id,
323
+ props: {
324
+ isRoot: true,
325
+ },
326
+ });
327
+
328
+ removedIds.push(resource.id.toString());
329
+ }
330
+
331
+ return removedIds;
332
+ }
333
+
334
+ /**
335
+ * Does this monitor satisfy every criterion the rule specifies?
336
+ *
337
+ * Criteria are ANDed and empty ones are skipped, which mirrors how every
338
+ * other rule in the product reads. A rule with no criteria at all matches
339
+ * nothing — silently putting the entire project on a public status page is
340
+ * not a reasonable reading of an empty form. Users who genuinely want that
341
+ * write `.*` as the name pattern.
342
+ *
343
+ * Pattern matching goes through RulePatternMatchUtil, the same matcher the
344
+ * network device rules use, so a pattern means the same thing everywhere in
345
+ * the product: a case-insensitive regex, or a `*` wildcard glob. Rolling a
346
+ * private regex test here would have reintroduced #2940 — a user typing
347
+ * `*api*` (a glob, not a regex) gets a rule that silently matches nothing.
348
+ */
349
+ public doesMonitorMatchRule(data: {
350
+ monitor: Monitor;
351
+ rule: StatusPageMonitorRule;
352
+ }): boolean {
353
+ const { monitor, rule } = data;
354
+
355
+ const hasLabelCriteria: boolean = Boolean(
356
+ rule.monitorLabels && rule.monitorLabels.length > 0,
357
+ );
358
+ const hasNameCriteria: boolean = Boolean(rule.monitorNamePattern);
359
+ const hasDescriptionCriteria: boolean = Boolean(
360
+ rule.monitorDescriptionPattern,
361
+ );
362
+
363
+ if (!hasLabelCriteria && !hasNameCriteria && !hasDescriptionCriteria) {
364
+ return false;
365
+ }
366
+
367
+ if (hasLabelCriteria) {
368
+ const ruleLabelIds: Set<string> = toIdSet(rule.monitorLabels);
369
+ const monitorLabelIds: Set<string> = toIdSet(monitor.labels);
370
+
371
+ let hasAnyLabel: boolean = false;
372
+
373
+ for (const labelId of monitorLabelIds) {
374
+ if (ruleLabelIds.has(labelId)) {
375
+ hasAnyLabel = true;
376
+ break;
377
+ }
378
+ }
379
+
380
+ if (!hasAnyLabel) {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ if (hasNameCriteria) {
386
+ if (
387
+ !RulePatternMatchUtil.matches(monitor.name, rule.monitorNamePattern)
388
+ ) {
389
+ return false;
390
+ }
391
+ }
392
+
393
+ if (hasDescriptionCriteria) {
394
+ if (
395
+ !RulePatternMatchUtil.matches(
396
+ monitor.description,
397
+ rule.monitorDescriptionPattern,
398
+ )
399
+ ) {
400
+ return false;
401
+ }
402
+ }
403
+
404
+ return true;
405
+ }
406
+
407
+ /**
408
+ * Every monitor in the rule's project that the rule matches.
409
+ *
410
+ * The label filter is pushed into the query when there is one, because it is
411
+ * the criterion that usually narrows the project the most. The regexes are
412
+ * then applied in memory — Postgres regex semantics and JavaScript's are not
413
+ * the same dialect, and the pattern a user typed has to mean one thing.
414
+ */
415
+ @CaptureSpan()
416
+ private async findMatchingMonitors(data: {
417
+ rule: StatusPageMonitorRule;
418
+ }): Promise<Array<Monitor>> {
419
+ const { rule } = data;
420
+
421
+ if (!rule.projectId) {
422
+ return [];
423
+ }
424
+
425
+ const hasLabelCriteria: boolean = Boolean(
426
+ rule.monitorLabels && rule.monitorLabels.length > 0,
427
+ );
428
+ const hasNameCriteria: boolean = Boolean(rule.monitorNamePattern);
429
+ const hasDescriptionCriteria: boolean = Boolean(
430
+ rule.monitorDescriptionPattern,
431
+ );
432
+
433
+ if (!hasLabelCriteria && !hasNameCriteria && !hasDescriptionCriteria) {
434
+ return [];
435
+ }
436
+
437
+ const query: {
438
+ projectId: ObjectID;
439
+ labels?: Array<ObjectID>;
440
+ } = {
441
+ projectId: rule.projectId,
442
+ };
443
+
444
+ if (hasLabelCriteria) {
445
+ query.labels = (rule.monitorLabels || [])
446
+ .filter((label: Label) => {
447
+ return Boolean(label.id);
448
+ })
449
+ .map((label: Label) => {
450
+ return label.id!;
451
+ });
452
+ }
453
+
454
+ const monitors: Array<Monitor> = await MonitorService.findBy({
455
+ query: query as any,
456
+ select: {
457
+ _id: true,
458
+ name: true,
459
+ description: true,
460
+ labels: {
461
+ _id: true,
462
+ },
463
+ },
464
+ limit: LIMIT_PER_PROJECT,
465
+ skip: 0,
466
+ props: {
467
+ isRoot: true,
468
+ },
469
+ });
470
+
471
+ return monitors.filter((monitor: Monitor) => {
472
+ return this.doesMonitorMatchRule({ monitor: monitor, rule: rule });
473
+ });
474
+ }
475
+
476
+ /**
477
+ * Applies a computed match set to one rule and persists the difference.
478
+ *
479
+ * `matchedMonitors` is what the rule claims. A claimed monitor that has no
480
+ * resource on the page yet gets one; a resource the rule owns whose monitor
481
+ * it no longer claims is deleted. Resources that are not rule-owned are
482
+ * invisible to both halves, which is what keeps manual resources safe — and
483
+ * a monitor a human already added is not added a second time, so the page
484
+ * never shows a duplicate.
485
+ *
486
+ * `restrictToMonitorId` narrows both halves to a single monitor, for the
487
+ * monitor-side entry point where every other monitor's membership is
488
+ * unchanged and re-deriving it would be wasted work.
489
+ *
490
+ * `reconcileOwnedResources` additionally drags resources the rule already
491
+ * owns back into line with the rule — see reconcileResource. Only the
492
+ * rule-side entry point asks for it.
493
+ */
494
+ private async writeMembership(data: {
495
+ rule: StatusPageMonitorRule;
496
+ matchedMonitors: Array<Monitor>;
497
+ restrictToMonitorId?: ObjectID | undefined;
498
+ reconcileOwnedResources?: boolean | undefined;
499
+ skipAdds?: boolean | undefined;
500
+ skipRemoves?: boolean | undefined;
501
+ }): Promise<StatusPageMonitorRuleSyncResult> {
502
+ const { rule, matchedMonitors, restrictToMonitorId } = data;
503
+
504
+ if (!rule.id || !rule.statusPageId || !rule.projectId) {
505
+ return emptyResult();
506
+ }
507
+
508
+ const existingResources: Array<StatusPageResource> =
509
+ await StatusPageResourceService.findBy({
510
+ query: {
511
+ statusPageId: rule.statusPageId,
512
+ },
513
+ select: {
514
+ _id: true,
515
+ monitorId: true,
516
+ statusPageMonitorRuleId: true,
517
+ statusPageGroupId: true,
518
+ showCurrentStatus: true,
519
+ showUptimePercent: true,
520
+ uptimePercentPrecision: true,
521
+ showStatusHistoryChart: true,
522
+ },
523
+ limit: LIMIT_PER_PROJECT,
524
+ skip: 0,
525
+ props: {
526
+ isRoot: true,
527
+ },
528
+ });
529
+
530
+ const monitorIdsOnPage: Set<string> = new Set<string>();
531
+ const ownedResourcesByMonitorId: Map<string, StatusPageResource> = new Map<
532
+ string,
533
+ StatusPageResource
534
+ >();
535
+
536
+ for (const resource of existingResources) {
537
+ const monitorId: string = resource.monitorId?.toString() || "";
538
+
539
+ if (!monitorId) {
540
+ continue;
541
+ }
542
+
543
+ monitorIdsOnPage.add(monitorId);
544
+
545
+ if (resource.statusPageMonitorRuleId?.toString() === rule.id.toString()) {
546
+ ownedResourcesByMonitorId.set(monitorId, resource);
547
+ }
548
+ }
549
+
550
+ const matchedMonitorIds: Set<string> = toIdSet(matchedMonitors);
551
+
552
+ const monitorIdsAdded: Array<string> = [];
553
+ const statusPageResourceIdsRemoved: Array<string> = [];
554
+ const statusPageResourceIdsUpdated: Array<string> = [];
555
+ const monitorIdsReleased: Array<string> = [];
556
+
557
+ // Remove: resources this rule owns whose monitor it no longer claims.
558
+ if (!data.skipRemoves) {
559
+ for (const [monitorId, resource] of ownedResourcesByMonitorId) {
560
+ if (matchedMonitorIds.has(monitorId)) {
561
+ continue;
562
+ }
563
+
564
+ if (
565
+ restrictToMonitorId &&
566
+ restrictToMonitorId.toString() !== monitorId
567
+ ) {
568
+ continue;
569
+ }
570
+
571
+ if (!resource.id) {
572
+ continue;
573
+ }
574
+
575
+ await StatusPageResourceService.deleteOneById({
576
+ id: resource.id,
577
+ props: {
578
+ isRoot: true,
579
+ },
580
+ });
581
+
582
+ statusPageResourceIdsRemoved.push(resource.id.toString());
583
+ monitorIdsReleased.push(monitorId);
584
+ monitorIdsOnPage.delete(monitorId);
585
+ }
586
+ }
587
+
588
+ /*
589
+ * Add: matched monitors that are not on the page at all yet.
590
+ *
591
+ * "Not on the page" and not merely "not owned by this rule" — a monitor a
592
+ * human already added, or that another rule added, must not get a second
593
+ * resource, because the public page would then list it twice.
594
+ */
595
+ if (!data.skipAdds) {
596
+ for (const monitor of matchedMonitors) {
597
+ const monitorId: string = monitor.id?.toString() || "";
598
+
599
+ if (!monitorId || monitorIdsOnPage.has(monitorId)) {
600
+ continue;
601
+ }
602
+
603
+ await this.createResource({ rule: rule, monitor: monitor });
604
+ monitorIdsAdded.push(monitorId);
605
+ }
606
+ }
607
+
608
+ /*
609
+ * Reconcile: resources the rule still owns and still claims are dragged
610
+ * back into line with the rule. Without this, editing "Add Monitors To
611
+ * Group" (or any display option) silently does nothing to the monitors the
612
+ * rule already added - it would only apply to ones it adds in future,
613
+ * which is not what changing a rule looks like it should do.
614
+ */
615
+ if (data.reconcileOwnedResources) {
616
+ for (const [monitorId, resource] of ownedResourcesByMonitorId) {
617
+ if (!matchedMonitorIds.has(monitorId) || !resource.id) {
618
+ continue;
619
+ }
620
+
621
+ if (await this.reconcileResource({ rule: rule, resource: resource })) {
622
+ statusPageResourceIdsUpdated.push(resource.id.toString());
623
+ }
624
+ }
625
+ }
626
+
627
+ if (
628
+ monitorIdsAdded.length > 0 ||
629
+ statusPageResourceIdsRemoved.length > 0 ||
630
+ statusPageResourceIdsUpdated.length > 0
631
+ ) {
632
+ logger.debug(
633
+ `Status page monitor rule ${rule.id.toString()} synced status page ${rule.statusPageId.toString()}: +${monitorIdsAdded.length} / -${statusPageResourceIdsRemoved.length} / ~${statusPageResourceIdsUpdated.length} resources`,
634
+ { projectId: rule.projectId.toString() } as LogAttributes,
635
+ );
636
+ }
637
+
638
+ return {
639
+ monitorIdsAdded: monitorIdsAdded,
640
+ statusPageResourceIdsRemoved: statusPageResourceIdsRemoved,
641
+ statusPageResourceIdsUpdated: statusPageResourceIdsUpdated,
642
+ monitorIdsReleased: monitorIdsReleased,
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Brings one rule-owned resource back into line with its rule, and reports
648
+ * whether anything actually changed.
649
+ *
650
+ * Only the fields the rule owns are considered — the group it places
651
+ * monitors in and the display options it sets. displayName is deliberately
652
+ * not among them: it is seeded from the monitor at creation time and then
653
+ * belongs to the status page, so renaming a monitor does not rewrite a
654
+ * display name someone may have polished for customers.
655
+ *
656
+ * Writes nothing when the resource already agrees, so re-running a rule that
657
+ * did not change costs a read and no writes.
658
+ */
659
+ private async reconcileResource(data: {
660
+ rule: StatusPageMonitorRule;
661
+ resource: StatusPageResource;
662
+ }): Promise<boolean> {
663
+ const { rule, resource } = data;
664
+
665
+ if (!resource.id) {
666
+ return false;
667
+ }
668
+
669
+ const desiredGroupId: string = rule.statusPageGroupId?.toString() || "";
670
+ const currentGroupId: string = resource.statusPageGroupId?.toString() || "";
671
+
672
+ const desired: {
673
+ statusPageGroupId: ObjectID | null;
674
+ showCurrentStatus: boolean;
675
+ showUptimePercent: boolean;
676
+ showStatusHistoryChart: boolean;
677
+ uptimePercentPrecision: UptimePrecision | null;
678
+ } = {
679
+ statusPageGroupId: rule.statusPageGroupId || null,
680
+ showCurrentStatus: rule.showCurrentStatus ?? true,
681
+ showUptimePercent: rule.showUptimePercent ?? true,
682
+ showStatusHistoryChart: rule.showStatusHistoryChart ?? true,
683
+ uptimePercentPrecision: rule.uptimePercentPrecision || null,
684
+ };
685
+
686
+ const isSame: boolean =
687
+ desiredGroupId === currentGroupId &&
688
+ desired.showCurrentStatus === (resource.showCurrentStatus ?? true) &&
689
+ desired.showUptimePercent === (resource.showUptimePercent ?? true) &&
690
+ desired.showStatusHistoryChart ===
691
+ (resource.showStatusHistoryChart ?? true) &&
692
+ desired.uptimePercentPrecision ===
693
+ (resource.uptimePercentPrecision || null);
694
+
695
+ if (isSame) {
696
+ return false;
697
+ }
698
+
699
+ await StatusPageResourceService.updateOneById({
700
+ id: resource.id,
701
+ data: desired as never,
702
+ props: {
703
+ isRoot: true,
704
+ },
705
+ });
706
+
707
+ return true;
708
+ }
709
+
710
+ private async createResource(data: {
711
+ rule: StatusPageMonitorRule;
712
+ monitor: Monitor;
713
+ }): Promise<void> {
714
+ const { rule, monitor } = data;
715
+
716
+ const resource: StatusPageResource = new StatusPageResource();
717
+
718
+ resource.projectId = rule.projectId!;
719
+ resource.statusPageId = rule.statusPageId!;
720
+ resource.monitorId = monitor.id!;
721
+ resource.statusPageMonitorRuleId = rule.id!;
722
+
723
+ if (rule.statusPageGroupId) {
724
+ resource.statusPageGroupId = rule.statusPageGroupId;
725
+ }
726
+
727
+ /*
728
+ * displayName is required on the resource and the whole point of the rule
729
+ * is not having to type one, so it mirrors the monitor. Renaming the
730
+ * monitor later does not rewrite it — the display name belongs to the
731
+ * status page from the moment the resource exists.
732
+ */
733
+ resource.displayName = monitor.name || "";
734
+
735
+ resource.showCurrentStatus = rule.showCurrentStatus ?? true;
736
+ resource.showUptimePercent = rule.showUptimePercent ?? true;
737
+ resource.showStatusHistoryChart = rule.showStatusHistoryChart ?? true;
738
+
739
+ if (rule.uptimePercentPrecision) {
740
+ resource.uptimePercentPrecision = rule.uptimePercentPrecision;
741
+ }
742
+
743
+ await StatusPageResourceService.create({
744
+ data: resource,
745
+ props: {
746
+ isRoot: true,
747
+ },
748
+ });
749
+ }
750
+ }
751
+
752
+ export default new StatusPageMonitorRuleEngineServiceClass();