@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,190 @@
1
+ import Models from "../../../Models/DatabaseModels/Index";
2
+ import StatusPageMonitorRule from "../../../Models/DatabaseModels/StatusPageMonitorRule";
3
+ import StatusPageResource from "../../../Models/DatabaseModels/StatusPageResource";
4
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
5
+ import Migrations from "../../../Server/Infrastructure/Postgres/SchemaMigrations/Index";
6
+ import { ColumnAccessControl } from "../../../Types/BaseDatabase/AccessControl";
7
+ import Permission, {
8
+ PermissionGroup,
9
+ PermissionHelper,
10
+ PermissionProps,
11
+ } from "../../../Types/Permission";
12
+ import { describe, expect, it } from "@jest/globals";
13
+
14
+ /*
15
+ * Schema-level guarantees behind status page monitor rules. Each of these is
16
+ * something the engine's own tests cannot see: they pass just as happily
17
+ * against a model that is unreachable over the API or writable by the wrong
18
+ * person.
19
+ */
20
+
21
+ describe("StatusPageMonitorRule model", () => {
22
+ const model: StatusPageMonitorRule = new StatusPageMonitorRule();
23
+
24
+ it("is registered in the model index, so its table is created and its API is mounted", () => {
25
+ const isRegistered: boolean = Models.some(
26
+ (registered: { new (): BaseModel }) => {
27
+ return registered === StatusPageMonitorRule;
28
+ },
29
+ );
30
+
31
+ expect(isRegistered).toBe(true);
32
+ });
33
+
34
+ it("is scoped to a project, so one tenant's rules cannot read another's", () => {
35
+ expect(model.getTenantColumn()).toBe("projectId");
36
+ });
37
+
38
+ it("is served from its own CRUD endpoint", () => {
39
+ expect(model.getCrudApiPath()?.toString()).toBe(
40
+ "/status-page-monitor-rule",
41
+ );
42
+ });
43
+
44
+ it("declares every field the rule form writes", () => {
45
+ const columns: Array<string> = model.getTableColumns().columns;
46
+
47
+ for (const column of [
48
+ "statusPageId",
49
+ "statusPageGroupId",
50
+ "isEnabled",
51
+ "monitorLabels",
52
+ "monitorNamePattern",
53
+ "monitorDescriptionPattern",
54
+ "showCurrentStatus",
55
+ "showUptimePercent",
56
+ "uptimePercentPrecision",
57
+ "showStatusHistoryChart",
58
+ ]) {
59
+ expect(columns).toContain(column);
60
+ }
61
+ });
62
+
63
+ /*
64
+ * The rule decides what shows up on a public page, so it is gated behind
65
+ * the status page permissions rather than the generic project ones.
66
+ */
67
+ it("requires a status page permission to create", () => {
68
+ const create: Array<Permission> = model.getCreatePermissions();
69
+
70
+ expect(create).toContain(Permission.CreateStatusPageMonitorRule);
71
+ expect(create).toContain(Permission.StatusPageAdmin);
72
+ });
73
+
74
+ it("requires a status page permission to delete", () => {
75
+ expect(model.getDeletePermissions()).toContain(
76
+ Permission.DeleteStatusPageMonitorRule,
77
+ );
78
+ });
79
+
80
+ it("does not let a plain viewer edit a rule", () => {
81
+ expect(model.getUpdatePermissions()).not.toContain(Permission.Viewer);
82
+ });
83
+
84
+ /*
85
+ * A saved rule must not be re-pointable at a different status page: the
86
+ * project-scope check runs on create, and letting statusPageId move
87
+ * afterwards would route a rule's monitors onto a page nobody re-validated.
88
+ * Create-only in the column ACL is what enforces it.
89
+ */
90
+ it("does not let a saved rule be re-pointed at another status page", () => {
91
+ const accessControl: Record<string, ColumnAccessControl> =
92
+ model.getColumnAccessControlForAllColumns();
93
+
94
+ expect(accessControl["statusPageId"]?.update).toEqual([]);
95
+ expect(accessControl["statusPageId"]?.create?.length).toBeGreaterThan(0);
96
+ });
97
+
98
+ it("lets the destination group be changed on a saved rule", () => {
99
+ const accessControl: Record<string, ColumnAccessControl> =
100
+ model.getColumnAccessControlForAllColumns();
101
+
102
+ expect(accessControl["statusPageGroupId"]?.update).toContain(
103
+ Permission.EditStatusPageMonitorRule,
104
+ );
105
+ });
106
+
107
+ it("is scoped by the status page it belongs to, like every other status page child", () => {
108
+ expect(model.canAccessIfCanReadOn).toBe("statusPage");
109
+ });
110
+ });
111
+
112
+ describe("Status page monitor rule migration", () => {
113
+ /*
114
+ * A migration that is not in this array never runs, so the table simply does
115
+ * not exist in a fresh database and the CI drift check fails. Cheaper to
116
+ * catch here than in the drift job.
117
+ */
118
+ it("is registered, so the table is actually created", () => {
119
+ // The array holds migration classes; `.name` is the class name.
120
+ const registeredNames: Array<string> = (
121
+ Migrations as unknown as Array<{ name: string }>
122
+ ).map((migration: { name: string }) => {
123
+ return migration.name;
124
+ });
125
+
126
+ expect(registeredNames).toContain("AddStatusPageMonitorRule1786005052769");
127
+ });
128
+ });
129
+
130
+ describe("StatusPageResource.statusPageMonitorRuleId - the ownership marker", () => {
131
+ const accessControl: Record<string, ColumnAccessControl> =
132
+ new StatusPageResource().getColumnAccessControlForAllColumns();
133
+
134
+ it("exists on the resource, so a rule can tell its own work apart from a human's", () => {
135
+ expect(new StatusPageResource().getTableColumns().columns).toContain(
136
+ "statusPageMonitorRuleId",
137
+ );
138
+ });
139
+
140
+ /*
141
+ * This is the load-bearing one. If the column were writable over the API,
142
+ * anyone could stamp a rule onto a hand-made resource and that resource
143
+ * would then be deleted the next time the rule stopped matching - silently
144
+ * losing configuration the rule never created. The engine writes it as
145
+ * root, which bypasses column ACLs, so an empty list costs nothing.
146
+ */
147
+ it("cannot be set through the API on create", () => {
148
+ expect(accessControl["statusPageMonitorRuleId"]?.create).toEqual([]);
149
+ });
150
+
151
+ it("cannot be reassigned through the API on update", () => {
152
+ expect(accessControl["statusPageMonitorRuleId"]?.update).toEqual([]);
153
+ });
154
+
155
+ it("is readable, so the dashboard can tell which resources a rule owns", () => {
156
+ expect(accessControl["statusPageMonitorRuleId"]?.read).toContain(
157
+ Permission.ReadStatusPageResource,
158
+ );
159
+ });
160
+ });
161
+
162
+ describe("Status page monitor rule permissions", () => {
163
+ const allProps: Array<PermissionProps> =
164
+ PermissionHelper.getAllPermissionProps();
165
+
166
+ /*
167
+ * A permission that is only in the enum cannot be granted: the roles UI is
168
+ * built from the catalogue, so an unlisted permission is one nobody can ever
169
+ * hold, and the feature is then unusable by anyone but a project admin.
170
+ */
171
+ it.each([
172
+ Permission.CreateStatusPageMonitorRule,
173
+ Permission.ReadStatusPageMonitorRule,
174
+ Permission.EditStatusPageMonitorRule,
175
+ Permission.DeleteStatusPageMonitorRule,
176
+ ])(
177
+ "describes %s in the catalogue so it can be granted",
178
+ (permission: Permission) => {
179
+ const props: PermissionProps | undefined = allProps.find(
180
+ (candidate: PermissionProps) => {
181
+ return candidate.permission === permission;
182
+ },
183
+ );
184
+
185
+ expect(props).toBeDefined();
186
+ expect(props!.isAssignableToTenant).toBe(true);
187
+ expect(props!.group).toBe(PermissionGroup.StatusPage);
188
+ },
189
+ );
190
+ });
@@ -11,6 +11,7 @@ import IncidentService from "../../../Server/Services/IncidentService";
11
11
  import KubernetesResourceService from "../../../Server/Services/KubernetesResourceService";
12
12
  import LogService from "../../../Server/Services/LogService";
13
13
  import MonitorService from "../../../Server/Services/MonitorService";
14
+ import NetworkSiteService from "../../../Server/Services/NetworkSiteService";
14
15
  import PodmanHostService from "../../../Server/Services/PodmanHostService";
15
16
  import PodmanResourceService from "../../../Server/Services/PodmanResourceService";
16
17
  import ProxmoxResourceService from "../../../Server/Services/ProxmoxResourceService";
@@ -141,6 +142,30 @@ const RESOURCE_CASES: Array<ResourceCase> = [
141
142
  nonSelectColumn: "monitorSteps",
142
143
  selectKeys: ["_id", "name", "monitorType", "currentMonitorStatus"],
143
144
  },
145
+ {
146
+ /*
147
+ * The Network Map draws a FLAT set of pins, so the hierarchy columns
148
+ * (parentSiteId, materializedPath, depth) are deliberately outside both
149
+ * the allowlist and the select — structure an anonymous viewer would be
150
+ * able to walk but the map never shows them.
151
+ */
152
+ resourceType: "network-site",
153
+ componentType: DashboardComponentType.NetworkMap,
154
+ service: NetworkSiteService,
155
+ kind: null,
156
+ allowedQueryKey: "networkSiteTypeId",
157
+ forbiddenQueryKey: "parentSiteId",
158
+ selectColumn: "name",
159
+ nonSelectColumn: "address",
160
+ selectKeys: [
161
+ "_id",
162
+ "name",
163
+ "networkSiteType",
164
+ "latitude",
165
+ "longitude",
166
+ "currentMonitorStatus",
167
+ ],
168
+ },
144
169
  {
145
170
  resourceType: "host",
146
171
  componentType: DashboardComponentType.HostList,
@@ -465,6 +490,7 @@ const ALL_SERVICES: Array<ListService> = [
465
490
  IncidentService,
466
491
  AlertService,
467
492
  MonitorService,
493
+ NetworkSiteService,
468
494
  HostService,
469
495
  KubernetesResourceService,
470
496
  DockerHostService,
@@ -18,6 +18,7 @@ type MockClient = {
18
18
  expire: jest.Mock;
19
19
  get: jest.Mock;
20
20
  del: jest.Mock;
21
+ eval: jest.Mock;
21
22
  };
22
23
 
23
24
  describe("GlobalCache.setString", () => {
@@ -29,6 +30,7 @@ describe("GlobalCache.setString", () => {
29
30
  expire: jest.fn().mockResolvedValue(1),
30
31
  get: jest.fn(),
31
32
  del: jest.fn().mockResolvedValue(1),
33
+ eval: jest.fn().mockResolvedValue(1),
32
34
  };
33
35
  (Redis.getClient as jest.Mock).mockReturnValue(client);
34
36
  (Redis.isConnected as jest.Mock).mockReturnValue(true);
@@ -110,6 +112,7 @@ describe("GlobalCache.deleteKey", () => {
110
112
  expire: jest.fn().mockResolvedValue(1),
111
113
  get: jest.fn(),
112
114
  del: jest.fn().mockResolvedValue(1),
115
+ eval: jest.fn().mockResolvedValue(1),
113
116
  };
114
117
  (Redis.getClient as jest.Mock).mockReturnValue(client);
115
118
  (Redis.isConnected as jest.Mock).mockReturnValue(true);
@@ -141,3 +144,226 @@ describe("GlobalCache.deleteKey", () => {
141
144
  expect(client.del).not.toHaveBeenCalled();
142
145
  });
143
146
  });
147
+
148
+ /*
149
+ * The atomic fence primitives.
150
+ *
151
+ * These exist because `getString()` followed by `setString()` is
152
+ * check-then-act, and the OneUptime ingest path is precisely the workload
153
+ * that turns that race from theoretical into load-bearing: when thousands of
154
+ * concurrent jobs across ~100 worker pods resolve the same row in the same
155
+ * instant, every one of them reads the same miss and every one of them
156
+ * proceeds. A fence written to admit ONE writer per window admitted all of
157
+ * them, and the row-lock convoy that followed took production down (1,017
158
+ * active connections, 892 parked on row locks, the tail waiting 3.7 hours).
159
+ *
160
+ * The assertions below are about atomicity — that exactly one Redis command
161
+ * is issued, and that it carries the NX / compare-and-claim semantics. A
162
+ * refactor that "simplifies" either of these back into a read plus a write
163
+ * would keep every other test in the repo passing and reintroduce the outage.
164
+ */
165
+ describe("GlobalCache.setStringIfNotExists", () => {
166
+ let client: MockClient;
167
+
168
+ beforeEach(() => {
169
+ client = {
170
+ set: jest.fn().mockResolvedValue("OK"),
171
+ expire: jest.fn().mockResolvedValue(1),
172
+ get: jest.fn(),
173
+ del: jest.fn().mockResolvedValue(1),
174
+ eval: jest.fn().mockResolvedValue(1),
175
+ };
176
+ (Redis.getClient as jest.Mock).mockReturnValue(client);
177
+ (Redis.isConnected as jest.Mock).mockReturnValue(true);
178
+ });
179
+
180
+ afterEach(() => {
181
+ jest.clearAllMocks();
182
+ });
183
+
184
+ test("claims the fence with a single atomic SET ... EX ... NX", async () => {
185
+ const acquired: boolean = await GlobalCache.setStringIfNotExists(
186
+ "ns",
187
+ "key",
188
+ "1",
189
+ { expiresInSeconds: 60 },
190
+ );
191
+
192
+ expect(acquired).toBe(true);
193
+ expect(client.set).toHaveBeenCalledTimes(1);
194
+ expect(client.set).toHaveBeenCalledWith("ns-key", "1", "EX", 60, "NX");
195
+ // No read: a GET here would be the check-then-act race coming back.
196
+ expect(client.get).not.toHaveBeenCalled();
197
+ });
198
+
199
+ test("returns false when another caller already holds the fence", async () => {
200
+ client.set.mockResolvedValue(null);
201
+
202
+ await expect(
203
+ GlobalCache.setStringIfNotExists("ns", "key", "1", {
204
+ expiresInSeconds: 60,
205
+ }),
206
+ ).resolves.toBe(false);
207
+ });
208
+
209
+ /*
210
+ * Only a literal "OK" means the key was created. Anything else — a driver
211
+ * change, a proxy rewriting the reply — must read as "did not acquire":
212
+ * losing a fence costs one redundant write, wrongly winning one costs the
213
+ * mutual exclusion the fence exists to provide.
214
+ */
215
+ test("treats any non-OK reply as not acquired", async () => {
216
+ client.set.mockResolvedValue("QUEUED");
217
+
218
+ await expect(
219
+ GlobalCache.setStringIfNotExists("ns", "key", "1"),
220
+ ).resolves.toBe(false);
221
+ });
222
+
223
+ test("throws when the cache is not connected", async () => {
224
+ (Redis.isConnected as jest.Mock).mockReturnValue(false);
225
+
226
+ await expect(
227
+ GlobalCache.setStringIfNotExists("ns", "key", "1"),
228
+ ).rejects.toThrow(DatabaseNotConnectedException);
229
+ expect(client.set).not.toHaveBeenCalled();
230
+ });
231
+
232
+ test("defaults the TTL to 30 days when no option is passed", async () => {
233
+ await GlobalCache.setStringIfNotExists("ns", "key", "1");
234
+
235
+ expect(client.set).toHaveBeenCalledWith(
236
+ "ns-key",
237
+ "1",
238
+ "EX",
239
+ OneUptimeDate.getSecondsInDays(30),
240
+ "NX",
241
+ );
242
+ });
243
+ });
244
+
245
+ describe("GlobalCache.setStringIfChanged", () => {
246
+ let client: MockClient;
247
+
248
+ beforeEach(() => {
249
+ client = {
250
+ set: jest.fn().mockResolvedValue("OK"),
251
+ expire: jest.fn().mockResolvedValue(1),
252
+ get: jest.fn(),
253
+ del: jest.fn().mockResolvedValue(1),
254
+ eval: jest.fn().mockResolvedValue(1),
255
+ };
256
+ (Redis.getClient as jest.Mock).mockReturnValue(client);
257
+ (Redis.isConnected as jest.Mock).mockReturnValue(true);
258
+ });
259
+
260
+ afterEach(() => {
261
+ jest.clearAllMocks();
262
+ });
263
+
264
+ /*
265
+ * Plain NX is the wrong primitive for a fingerprint throttle: it fails
266
+ * whenever the key exists, including when it holds a STALE fingerprint, so
267
+ * a genuinely changed payload (a new service.version after a deploy) would
268
+ * be suppressed for the whole window and never persisted at all.
269
+ */
270
+ test("claims the window in one atomic EVAL, with no separate read", async () => {
271
+ const claimed: boolean = await GlobalCache.setStringIfChanged(
272
+ "ns",
273
+ "key",
274
+ "fingerprint",
275
+ { expiresInSeconds: 300 },
276
+ );
277
+
278
+ expect(claimed).toBe(true);
279
+ expect(client.eval).toHaveBeenCalledTimes(1);
280
+ expect(client.get).not.toHaveBeenCalled();
281
+ expect(client.set).not.toHaveBeenCalled();
282
+ });
283
+
284
+ test("returns false when the stored value already matches", async () => {
285
+ client.eval.mockResolvedValue(0);
286
+
287
+ await expect(
288
+ GlobalCache.setStringIfChanged("ns", "key", "fingerprint"),
289
+ ).resolves.toBe(false);
290
+ });
291
+
292
+ /*
293
+ * The key must travel as KEYS[1], not be interpolated into the script body
294
+ * — Redis Cluster routes a script by its declared keys, and an inlined key
295
+ * makes the script run on an arbitrary node.
296
+ */
297
+ test("declares exactly one key, passed as KEYS[1]", async () => {
298
+ await GlobalCache.setStringIfChanged("ns", "key", "fingerprint", {
299
+ expiresInSeconds: 300,
300
+ });
301
+
302
+ const args: Array<unknown> = client.eval.mock.calls[0] as Array<unknown>;
303
+ const script: string = args[0] as string;
304
+
305
+ expect(args[1]).toBe(1);
306
+ expect(args[2]).toBe("ns-key");
307
+ expect(args[3]).toBe("fingerprint");
308
+ expect(args[4]).toBe("300");
309
+ expect(script).toContain("KEYS[1]");
310
+ expect(script).not.toContain("ns-key");
311
+ });
312
+
313
+ test("throws when the cache is not connected", async () => {
314
+ (Redis.isConnected as jest.Mock).mockReturnValue(false);
315
+
316
+ await expect(
317
+ GlobalCache.setStringIfChanged("ns", "key", "fingerprint"),
318
+ ).rejects.toThrow(DatabaseNotConnectedException);
319
+ expect(client.eval).not.toHaveBeenCalled();
320
+ });
321
+ });
322
+
323
+ /*
324
+ * A fixed TTL lets a fleet-wide event — a deploy, a scale-up, a Redis restart
325
+ * — align every row's throttle window. They then all expire in the same
326
+ * second and the herd re-forms on a fixed period, which is the convoy back
327
+ * again on a timer. Jitter breaks the alignment permanently: windows drift
328
+ * apart after the first expiry and stay apart.
329
+ */
330
+ describe("GlobalCache.withJitter", () => {
331
+ test("never returns less than the requested TTL", () => {
332
+ for (let i: number = 0; i < 500; i++) {
333
+ expect(GlobalCache.withJitter(60)).toBeGreaterThanOrEqual(60);
334
+ }
335
+ });
336
+
337
+ /*
338
+ * The ceiling is load-bearing. Every markDisconnected* sweep runs at 15
339
+ * minutes against a 5-minute fence; +25% takes that to 6.25 minutes, still
340
+ * 2.4x clear. Widening this without re-checking those thresholds would flap
341
+ * healthy resources between connected and disconnected.
342
+ */
343
+ test("never exceeds the requested TTL by more than 25%", () => {
344
+ for (let i: number = 0; i < 500; i++) {
345
+ expect(GlobalCache.withJitter(300)).toBeLessThanOrEqual(375);
346
+ }
347
+ });
348
+
349
+ test("actually spreads values rather than returning a constant", () => {
350
+ const seen: Set<number> = new Set<number>();
351
+
352
+ for (let i: number = 0; i < 200; i++) {
353
+ seen.add(GlobalCache.withJitter(600));
354
+ }
355
+
356
+ expect(seen.size).toBeGreaterThan(1);
357
+ });
358
+
359
+ test("returns integers — Redis EX rejects fractional seconds", () => {
360
+ for (let i: number = 0; i < 100; i++) {
361
+ expect(Number.isInteger(GlobalCache.withJitter(37))).toBe(true);
362
+ }
363
+ });
364
+
365
+ test("passes through non-positive TTLs unchanged", () => {
366
+ expect(GlobalCache.withJitter(0)).toBe(0);
367
+ expect(GlobalCache.withJitter(-1)).toBe(-1);
368
+ });
369
+ });
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+
3
+ /*
4
+ * The Postgres connection deadlines.
5
+ *
6
+ * `lock_timeout` was missing entirely, and its absence is what let the row-lock
7
+ * convoy run for hours. It is a distinct thing from `statement_timeout`: the
8
+ * latter caps how long a statement RUNS, the former caps how long it WAITS for
9
+ * a lock. Without it, contention on a hot row degrades into a strictly-ordered
10
+ * queue in which every waiter pins a backend for the sum of everyone ahead of
11
+ * it — 892 connections parked on locks with a 3.7-hour tail, on a database that
12
+ * was never short of capacity.
13
+ *
14
+ * Two subtleties this suite pins, both of which are easy to get wrong and
15
+ * silent when wrong:
16
+ *
17
+ * 1. The MIGRATION path must be exempt. App/Migrate.ts loads these same
18
+ * options and connects directly to the backend, where startup parameters
19
+ * really do apply. A 3s lock_timeout there would abort ACCESS EXCLUSIVE
20
+ * DDL against any table with live traffic. Only the two migrations that
21
+ * set their own `SET LOCAL lock_timeout` expect to fail that way.
22
+ *
23
+ * 2. The deadlines must be ORDERED so the server wins. The client-side
24
+ * `query_timeout` does not cancel anything — it abandons the query while
25
+ * the backend keeps running and keeps its place in the lock queue. That is
26
+ * how killed and scaled-down workers left orphaned statements holding the
27
+ * queue through pgbouncer. It used to be set equal to statement_timeout,
28
+ * and since the client timer starts before the packet reaches the backend,
29
+ * the client always won by a round trip and the server-side timeout never
30
+ * fired at all.
31
+ *
32
+ * The module reads env at import time, so each case re-imports it in isolation.
33
+ */
34
+
35
+ interface DataSourceExtra {
36
+ lock_timeout?: number;
37
+ statement_timeout?: number;
38
+ query_timeout?: number;
39
+ idle_in_transaction_session_timeout?: number;
40
+ }
41
+
42
+ function loadExtra(env: Record<string, string | undefined>): DataSourceExtra {
43
+ let extra: DataSourceExtra = {};
44
+
45
+ jest.isolateModules(() => {
46
+ const previous: Record<string, string | undefined> = {};
47
+
48
+ for (const [key, value] of Object.entries(env)) {
49
+ previous[key] = process.env[key];
50
+ if (value === undefined) {
51
+ delete process.env[key];
52
+ } else {
53
+ process.env[key] = value;
54
+ }
55
+ }
56
+
57
+ try {
58
+ /* eslint-disable @typescript-eslint/no-var-requires */
59
+ const options: {
60
+ default: { extra: DataSourceExtra };
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ } = require("../../../Server/Infrastructure/Postgres/DataSourceOptions");
63
+ /* eslint-enable @typescript-eslint/no-var-requires */
64
+
65
+ extra = options.default.extra;
66
+ } finally {
67
+ for (const [key, value] of Object.entries(previous)) {
68
+ if (value === undefined) {
69
+ delete process.env[key];
70
+ } else {
71
+ process.env[key] = value;
72
+ }
73
+ }
74
+ }
75
+ });
76
+
77
+ return extra;
78
+ }
79
+
80
+ describe("Postgres connection deadlines", () => {
81
+ describe("lock_timeout", () => {
82
+ test("is applied on runtime pods", () => {
83
+ const extra: DataSourceExtra = loadExtra({
84
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
85
+ DATABASE_LOCK_TIMEOUT_MS: undefined,
86
+ });
87
+
88
+ expect(extra.lock_timeout).toBe(3000);
89
+ });
90
+
91
+ test("is configurable", () => {
92
+ const extra: DataSourceExtra = loadExtra({
93
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
94
+ DATABASE_LOCK_TIMEOUT_MS: "1500",
95
+ });
96
+
97
+ expect(extra.lock_timeout).toBe(1500);
98
+ });
99
+
100
+ /*
101
+ * The exemption is load-bearing, not a nicety. A migration that cannot
102
+ * wait for a lock cannot take one on a busy table at all.
103
+ */
104
+ test("is NOT applied when this process runs migrations", () => {
105
+ const extra: DataSourceExtra = loadExtra({
106
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "true",
107
+ DATABASE_LOCK_TIMEOUT_MS: undefined,
108
+ });
109
+
110
+ expect(extra.lock_timeout).toBeUndefined();
111
+ });
112
+
113
+ test("can be disabled outright with 0", () => {
114
+ const extra: DataSourceExtra = loadExtra({
115
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
116
+ DATABASE_LOCK_TIMEOUT_MS: "0",
117
+ });
118
+
119
+ expect(extra.lock_timeout).toBeUndefined();
120
+ });
121
+ });
122
+
123
+ describe("deadline ordering", () => {
124
+ /*
125
+ * lock_timeout < statement_timeout, so a lock wait surfaces as
126
+ * `lock_not_available` rather than a generic statement timeout. The two
127
+ * want very different handling: one is contention worth retrying, the
128
+ * other is a query that needs fixing.
129
+ */
130
+ test("a lock wait gives up well before the statement does", () => {
131
+ const extra: DataSourceExtra = loadExtra({
132
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
133
+ });
134
+
135
+ expect(extra.lock_timeout!).toBeLessThan(extra.statement_timeout!);
136
+ });
137
+
138
+ /*
139
+ * statement_timeout < query_timeout, so the SERVER cancels first. If the
140
+ * client wins, the statement is only abandoned — the backend keeps
141
+ * running, keeps its lock, and keeps its place in the queue.
142
+ */
143
+ test("the server cancels before the client gives up", () => {
144
+ const extra: DataSourceExtra = loadExtra({
145
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
146
+ });
147
+
148
+ expect(extra.statement_timeout!).toBeLessThan(extra.query_timeout!);
149
+ });
150
+
151
+ test("an explicitly configured query_timeout is still honoured", () => {
152
+ const extra: DataSourceExtra = loadExtra({
153
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
154
+ DATABASE_QUERY_TIMEOUT_MS: "45000",
155
+ });
156
+
157
+ expect(extra.query_timeout).toBe(45000);
158
+ });
159
+
160
+ test("the default query_timeout tracks a raised statement_timeout", () => {
161
+ const extra: DataSourceExtra = loadExtra({
162
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
163
+ DATABASE_STATEMENT_TIMEOUT_MS: "60000",
164
+ DATABASE_QUERY_TIMEOUT_MS: undefined,
165
+ });
166
+
167
+ expect(extra.statement_timeout).toBe(60000);
168
+ expect(extra.query_timeout!).toBeGreaterThan(60000);
169
+ });
170
+ });
171
+
172
+ describe("existing deadlines are unchanged", () => {
173
+ test("idle-in-transaction is still bounded", () => {
174
+ const extra: DataSourceExtra = loadExtra({
175
+ RUN_DATABASE_MIGRATIONS_ON_BOOT: "false",
176
+ });
177
+
178
+ expect(extra.idle_in_transaction_session_timeout).toBe(60000);
179
+ });
180
+ });
181
+ });