@oneuptime/common 12.0.1 → 12.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  5. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  6. package/Models/DatabaseModels/StatusPage.ts +95 -0
  7. package/Server/API/TeamMemberAPI.ts +85 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  13. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  14. package/Server/Services/BillingService.ts +600 -51
  15. package/Server/Services/CephClusterService.ts +47 -5
  16. package/Server/Services/CephResourceService.ts +43 -4
  17. package/Server/Services/CloudResourceService.ts +47 -5
  18. package/Server/Services/DatabaseService.ts +87 -6
  19. package/Server/Services/DockerHostService.ts +47 -5
  20. package/Server/Services/DockerResourceService.ts +62 -8
  21. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  22. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  23. package/Server/Services/HostService.ts +48 -5
  24. package/Server/Services/IoTDeviceService.ts +1 -17
  25. package/Server/Services/IoTFleetService.ts +47 -5
  26. package/Server/Services/KubernetesClusterService.ts +48 -5
  27. package/Server/Services/KubernetesContainerService.ts +50 -9
  28. package/Server/Services/KubernetesResourceService.ts +53 -7
  29. package/Server/Services/LabelService.ts +106 -1
  30. package/Server/Services/MonitorService.ts +57 -0
  31. package/Server/Services/PodmanHostService.ts +47 -5
  32. package/Server/Services/PodmanResourceService.ts +62 -8
  33. package/Server/Services/ProjectService.ts +165 -35
  34. package/Server/Services/ProxmoxClusterService.ts +47 -5
  35. package/Server/Services/ProxmoxResourceService.ts +43 -4
  36. package/Server/Services/RumApplicationService.ts +48 -5
  37. package/Server/Services/ServerlessFunctionService.ts +48 -5
  38. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  39. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  40. package/Server/Services/StatusPageService.ts +95 -36
  41. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  42. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  43. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  44. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  45. package/Server/Types/Database/QueryHelper.ts +17 -32
  46. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  47. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  48. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  49. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  50. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  51. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  52. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  53. package/Tests/Server/Services/BillingService.test.ts +78 -3
  54. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  55. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  56. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  57. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  58. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  59. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  60. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  61. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  62. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  63. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  64. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  65. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  66. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  67. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  68. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  69. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  70. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  71. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  72. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  73. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  74. package/Tests/Types/Events/Recurring.test.ts +157 -0
  75. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  76. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  77. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  78. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  79. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  80. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  81. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  82. package/Types/Date.ts +112 -0
  83. package/Types/Events/Recurring.ts +99 -1
  84. package/Types/StatusPage/StatusPageReport.ts +11 -0
  85. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  86. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  87. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  88. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  89. package/UI/Utils/TeamMembersByUser.ts +302 -0
  90. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  91. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  92. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  93. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  95. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  97. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  99. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  101. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  103. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  104. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  105. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  113. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  114. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  116. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  117. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/BillingService.js +466 -36
  119. package/build/dist/Server/Services/BillingService.js.map +1 -1
  120. package/build/dist/Server/Services/CephClusterService.js +42 -5
  121. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  122. package/build/dist/Server/Services/CephResourceService.js +28 -3
  123. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  124. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  125. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  126. package/build/dist/Server/Services/DatabaseService.js +69 -2
  127. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  128. package/build/dist/Server/Services/DockerHostService.js +42 -5
  129. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  130. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  131. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  132. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  133. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  134. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  135. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  136. package/build/dist/Server/Services/HostService.js +43 -5
  137. package/build/dist/Server/Services/HostService.js.map +1 -1
  138. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  139. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  140. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  141. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  142. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  143. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  144. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  145. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  146. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  147. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  148. package/build/dist/Server/Services/LabelService.js +92 -0
  149. package/build/dist/Server/Services/LabelService.js.map +1 -1
  150. package/build/dist/Server/Services/MonitorService.js +47 -0
  151. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  152. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  153. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  154. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  155. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  156. package/build/dist/Server/Services/ProjectService.js +135 -21
  157. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  158. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  159. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  160. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  161. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  162. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  163. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  164. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  165. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  166. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  167. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  168. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  169. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  170. package/build/dist/Server/Services/StatusPageService.js +69 -46
  171. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  172. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  173. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  174. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  175. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  176. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  177. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  178. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  179. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  180. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  181. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  182. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  183. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  184. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  185. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  186. package/build/dist/Types/Date.js +68 -0
  187. package/build/dist/Types/Date.js.map +1 -1
  188. package/build/dist/Types/Events/Recurring.js +63 -0
  189. package/build/dist/Types/Events/Recurring.js.map +1 -1
  190. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  191. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  192. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  193. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  194. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  195. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  196. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  197. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  198. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  199. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  200. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  201. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  202. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  203. package/jest.config.json +2 -0
  204. package/package.json +1 -1
  205. package/tsconfig.json +12 -1
@@ -221,11 +221,14 @@ export class Service extends DatabaseService<Model> {
221
221
  }
222
222
 
223
223
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
- const data: any = {
224
+ const liveness: any = {
225
225
  lastSeenAt: OneUptimeDate.getCurrentDate(),
226
226
  otelCollectorStatus: "connected",
227
227
  };
228
228
 
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ const data: any = { ...liveness };
231
+
229
232
  if (extra?.osType) {
230
233
  data.osType = extra.osType;
231
234
  }
@@ -240,11 +243,50 @@ export class Service extends DatabaseService<Model> {
240
243
  * Heartbeat write: a single-statement UPDATE with no hooks and no
241
244
  * `version` bump, avoiding the hot-row Postgres lock convoy that the
242
245
  * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
246
+ *
247
+ * Collector-supplied strings are clamped to their declared column
248
+ * widths inside updateColumnsByIdWithoutHooks — see
249
+ * DatabaseService.truncateStringColumnsToMaxLength.
243
250
  */
244
- await this.updateColumnsByIdWithoutHooks({
245
- id: hostId,
246
- data: data,
247
- });
251
+ try {
252
+ await this.updateColumnsByIdWithoutHooks({
253
+ id: hostId,
254
+ data: data,
255
+ });
256
+ } catch (err) {
257
+ /*
258
+ * Liveness must survive bad metadata. The enriched write carries
259
+ * `lastSeenAt` and `otelCollectorStatus` alongside optional,
260
+ * collector-supplied columns; if any one of them makes Postgres
261
+ * reject the statement, lastSeenAt silently stops advancing and
262
+ * markDisconnectedHosts flips a perfectly healthy Podman host to
263
+ * "disconnected" 15 minutes later — with telemetry still arriving the
264
+ * whole time. That was issue #3006 on the Host path (a host.ip list
265
+ * longer than the then-varchar(500) column). Retry with liveness only
266
+ * so the enrichment is what gets dropped, never the heartbeat.
267
+ */
268
+ logger.warn(
269
+ `PodmanHostService.updateLastSeen: metadata write failed for Podman host ${hostId.toString()}, falling back to a liveness-only update: ${
270
+ err instanceof Error ? err.message : String(err)
271
+ }`,
272
+ );
273
+
274
+ /*
275
+ * The throttle fingerprint was already stored, so without this the
276
+ * next batch would short-circuit on a cache hit and skip the retry
277
+ * for the rest of the window.
278
+ */
279
+ try {
280
+ await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
281
+ } catch {
282
+ // Best-effort; the fallback write below is what actually matters.
283
+ }
284
+
285
+ await this.updateColumnsByIdWithoutHooks({
286
+ id: hostId,
287
+ data: liveness,
288
+ });
289
+ }
248
290
  }
249
291
 
250
292
  /**
@@ -1,9 +1,14 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/PodmanResource";
3
3
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ColumnLength from "../../Types/Database/ColumnLength";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import OneUptimeDate from "../../Types/Date";
6
7
  import { JSONObject } from "../../Types/JSON";
8
+ import {
9
+ truncateLongText,
10
+ truncateShortText,
11
+ } from "../Utils/Database/TruncateColumnValue";
7
12
  import logger from "../Utils/Logger";
8
13
 
9
14
  /*
@@ -56,6 +61,35 @@ export interface PodmanHostInventoryCounts {
56
61
  const UPSERT_BATCH_SIZE: number = 500;
57
62
  const STALE_DELETE_WARN_THRESHOLD: number = 100;
58
63
 
64
+ /*
65
+ * PodmanResource's text columns are bounded varchars — name / imageName
66
+ * at 500 chars, kind / containerId / state at 100. These bulk paths go
67
+ * through manager.query(), which skips the length validation
68
+ * DatabaseService applies to ordinary writes, so a single oversized
69
+ * value aborts the whole 500-row INSERT chunk it rides in. Clamp per
70
+ * value instead, so one pathological container can never drop the rest.
71
+ */
72
+ function sanitizeContainer(c: ParsedPodmanContainer): ParsedPodmanContainer {
73
+ return {
74
+ ...c,
75
+ containerName: truncateLongText(c.containerName),
76
+ containerId: truncateShortText(c.containerId),
77
+ imageName: truncateLongText(c.imageName),
78
+ state: truncateShortText(c.state),
79
+ };
80
+ }
81
+
82
+ function sanitizeResource(r: ParsedPodmanResource): ParsedPodmanResource {
83
+ return {
84
+ ...r,
85
+ kind: truncateShortText(r.kind),
86
+ name: truncateLongText(r.name),
87
+ containerId: truncateShortText(r.containerId),
88
+ imageName: truncateLongText(r.imageName),
89
+ state: truncateShortText(r.state),
90
+ };
91
+ }
92
+
59
93
  export class Service extends DatabaseService<Model> {
60
94
  public constructor() {
61
95
  super(Model);
@@ -83,12 +117,20 @@ export class Service extends DatabaseService<Model> {
83
117
  return;
84
118
  }
85
119
 
86
- for (
87
- let i: number = 0;
88
- i < data.containers.length;
89
- i += UPSERT_BATCH_SIZE
90
- ) {
91
- const chunk: Array<ParsedPodmanContainer> = data.containers.slice(
120
+ const containers: Array<ParsedPodmanContainer> = data.containers.map(
121
+ (c: ParsedPodmanContainer) => {
122
+ const sanitized: ParsedPodmanContainer = sanitizeContainer(c);
123
+ if (sanitized.containerName !== c.containerName) {
124
+ logger.warn(
125
+ `PodmanResource container name exceeds ${ColumnLength.LongText} chars; truncated to "${sanitized.containerName}" (host ${data.podmanHostId.toString()}).`,
126
+ );
127
+ }
128
+ return sanitized;
129
+ },
130
+ );
131
+
132
+ for (let i: number = 0; i < containers.length; i += UPSERT_BATCH_SIZE) {
133
+ const chunk: Array<ParsedPodmanContainer> = containers.slice(
92
134
  i,
93
135
  i + UPSERT_BATCH_SIZE,
94
136
  );
@@ -164,8 +206,20 @@ export class Service extends DatabaseService<Model> {
164
206
  return;
165
207
  }
166
208
 
167
- for (let i: number = 0; i < data.resources.length; i += UPSERT_BATCH_SIZE) {
168
- const chunk: Array<ParsedPodmanResource> = data.resources.slice(
209
+ const resources: Array<ParsedPodmanResource> = data.resources.map(
210
+ (r: ParsedPodmanResource) => {
211
+ const sanitized: ParsedPodmanResource = sanitizeResource(r);
212
+ if (sanitized.name !== r.name) {
213
+ logger.warn(
214
+ `PodmanResource name exceeds ${ColumnLength.LongText} chars; truncated to "${sanitized.name}" (host ${data.podmanHostId.toString()}).`,
215
+ );
216
+ }
217
+ return sanitized;
218
+ },
219
+ );
220
+
221
+ for (let i: number = 0; i < resources.length; i += UPSERT_BATCH_SIZE) {
222
+ const chunk: Array<ParsedPodmanResource> = resources.slice(
169
223
  i,
170
224
  i + UPSERT_BATCH_SIZE,
171
225
  );
@@ -606,6 +606,7 @@ export class ProjectService extends DatabaseService<Model> {
606
606
  subscriptionId: string;
607
607
  meteredSubscriptionId: string;
608
608
  trialEndsAt?: Date | undefined;
609
+ subscriptionIdsPendingCancellation: Array<string>;
609
610
  } = await BillingService.changePlan({
610
611
  projectId: project.id!,
611
612
  subscriptionId: project.paymentProviderSubscriptionId,
@@ -617,36 +618,51 @@ export class ProjectService extends DatabaseService<Model> {
617
618
  endTrialAt: endTrialAt,
618
619
  });
619
620
 
620
- const subscriptionState: SubscriptionStatus =
621
- await BillingService.getSubscriptionStatus(subscription.subscriptionId);
621
+ const subscriptionState: SubscriptionStatus | null =
622
+ await this.findSubscriptionStatus(subscription.subscriptionId);
622
623
 
623
- const meteredSubscriptionState: SubscriptionStatus =
624
- await BillingService.getSubscriptionStatus(
625
- subscription.meteredSubscriptionId,
626
- );
624
+ const meteredSubscriptionState: SubscriptionStatus | null =
625
+ await this.findSubscriptionStatus(subscription.meteredSubscriptionId);
626
+
627
+ const updateData: QueryDeepPartialEntity<Model> = {
628
+ paymentProviderPlanId: params.paymentProviderPlanId,
629
+ paymentProviderSubscriptionId: subscription.subscriptionId,
630
+ paymentProviderMeteredSubscriptionId: subscription.meteredSubscriptionId,
631
+ paymentProviderSubscriptionSeats: seats,
632
+ trialEndsAt: subscription.trialEndsAt || endTrialAt || new Date(),
633
+ planName: SubscriptionPlan.getPlanType(
634
+ params.paymentProviderPlanId,
635
+ getAllEnvVars(),
636
+ ),
637
+ };
638
+
639
+ if (meteredSubscriptionState) {
640
+ updateData.paymentProviderMeteredSubscriptionStatus =
641
+ meteredSubscriptionState;
642
+ }
643
+
644
+ if (subscriptionState) {
645
+ updateData.paymentProviderSubscriptionStatus = subscriptionState;
646
+ }
627
647
 
628
648
  await this.updateOneById({
629
649
  id: project.id!,
630
- data: {
631
- paymentProviderPlanId: params.paymentProviderPlanId,
632
- paymentProviderSubscriptionId: subscription.subscriptionId,
633
- paymentProviderMeteredSubscriptionId:
634
- subscription.meteredSubscriptionId,
635
- paymentProviderSubscriptionSeats: seats,
636
- trialEndsAt: subscription.trialEndsAt || endTrialAt || new Date(),
637
- planName: SubscriptionPlan.getPlanType(
638
- params.paymentProviderPlanId,
639
- getAllEnvVars(),
640
- ),
641
- paymentProviderMeteredSubscriptionStatus: meteredSubscriptionState,
642
- paymentProviderSubscriptionStatus: subscriptionState,
643
- },
650
+ data: updateData,
644
651
  props: {
645
652
  isRoot: true,
646
653
  ignoreHooks: true,
647
654
  },
648
655
  });
649
656
 
657
+ /*
658
+ * Raised after the row is written, because the row is what makes these ids
659
+ * unreachable: it now carries the replacements.
660
+ */
661
+ this.alertOnSubscriptionsPendingCancellation({
662
+ projectId: project.id!,
663
+ subscriptionIds: subscription.subscriptionIdsPendingCancellation,
664
+ });
665
+
650
666
  this.capturePlanChangeAnalytics({
651
667
  project: project,
652
668
  newPlan: plan,
@@ -657,6 +673,103 @@ export class ProjectService extends DatabaseService<Model> {
657
673
  await this.sendSubscriptionChangeWebhookSlackNotification(project.id!);
658
674
  }
659
675
 
676
+ /*
677
+ * Reads a subscription's status back, and gives up rather than throwing.
678
+ *
679
+ * This sits between a plan change returning and the project row being
680
+ * written, and that write is what records the subscription ids the plan
681
+ * change produced. A replacement already exists at the payment provider and
682
+ * is already billing by this point, and the row is the only thing in
683
+ * OneUptime that will ever point at it - so a rate limit or a dropped
684
+ * connection reading a status back must not be what stops it being written
685
+ * down. Worse than the missing status: reactivation is retried from
686
+ * BillingInvoiceService whenever the customer's invoices look settled, so a
687
+ * row left pointing at the old id mints another live subscription every
688
+ * time it runs.
689
+ *
690
+ * The status columns are refreshed daily by
691
+ * PaymentProvider:CheckSubscriptionStatus, so leaving one behind costs a
692
+ * day of staleness. Losing the id costs a subscription nobody can find.
693
+ */
694
+ private async findSubscriptionStatus(
695
+ subscriptionId: string,
696
+ ): Promise<SubscriptionStatus | null> {
697
+ try {
698
+ return await BillingService.getSubscriptionStatus(subscriptionId);
699
+ } catch (err) {
700
+ logger.error(err, { subscriptionId } as LogAttributes);
701
+ return null;
702
+ }
703
+ }
704
+
705
+ /*
706
+ * Raises the subscriptions a plan change replaced but could not cancel.
707
+ *
708
+ * The project row now carries the replacement ids, so these ones exist
709
+ * nowhere in OneUptime any more. They were not billing when the plan change
710
+ * read them and the cancel has already been retried, so this is a loose end
711
+ * rather than a double charge - but it is one nobody will find on their own,
712
+ * which is why the ids travel with the alert instead of only reaching a log
713
+ * line. The replacements also carry them in their payment-provider metadata,
714
+ * as replacedSubscriptionId.
715
+ *
716
+ * Called once the plan change has already been applied and written down, so
717
+ * nothing in here is allowed to throw, and nothing in here is allowed to
718
+ * hold the response open either: failing - or being slow - to raise a loose
719
+ * end must not turn a plan change that worked into one the caller is told
720
+ * failed, or one that never comes back.
721
+ */
722
+ private alertOnSubscriptionsPendingCancellation(data: {
723
+ projectId: ObjectID;
724
+ subscriptionIds: Array<string>;
725
+ }): void {
726
+ if (!data.subscriptionIds || data.subscriptionIds.length === 0) {
727
+ return;
728
+ }
729
+
730
+ logger.error(
731
+ `Could not cancel subscriptions replaced by a plan change for project ${data.projectId.toString()}. They are still open at the payment provider and have to be cancelled by hand: ${data.subscriptionIds.join(
732
+ ", ",
733
+ )}`,
734
+ {
735
+ projectId: data.projectId.toString(),
736
+ } as LogAttributes,
737
+ );
738
+
739
+ if (!NotificationSlackWebhookOnSubscriptionUpdate) {
740
+ return;
741
+ }
742
+
743
+ const slackMessage: string = `*Subscriptions could not be cancelled after a plan change:*
744
+ *Project ID:* ${data.projectId.toString()}
745
+ *Subscriptions still open at the payment provider:* ${data.subscriptionIds.join(", ")}
746
+ These are no longer recorded against the project and have to be cancelled by hand.
747
+ `;
748
+
749
+ /*
750
+ * Delivery is deliberately not awaited, and neither is reading the
751
+ * configured webhook. SlackUtil retries three times with exponential
752
+ * backoff and sets no HTTP timeout, so awaiting it would let a webhook
753
+ * that hangs hold the plan-change response open with it - for a message
754
+ * whose contents are already on the error log above. Same shape as
755
+ * sendSubscriptionChangeWebhookSlackNotification below.
756
+ */
757
+ try {
758
+ SlackUtil.sendMessageToChannelViaIncomingWebhook({
759
+ url: URL.fromString(NotificationSlackWebhookOnSubscriptionUpdate),
760
+ text: slackMessage,
761
+ }).catch((error: Error) => {
762
+ logger.error("Error sending slack message: " + error, {
763
+ projectId: data.projectId.toString(),
764
+ } as LogAttributes);
765
+ });
766
+ } catch (error) {
767
+ logger.error("Error sending slack message: " + error, {
768
+ projectId: data.projectId.toString(),
769
+ } as LogAttributes);
770
+ }
771
+ }
772
+
660
773
  /*
661
774
  * Pushes a project's trial end date out to a new date, in the payment
662
775
  * provider first and then on the project row.
@@ -2224,11 +2337,13 @@ export class ProjectService extends DatabaseService<Model> {
2224
2337
  }
2225
2338
 
2226
2339
  /*
2227
- * Reactivating goes through changePlan, which cancels both subscriptions
2228
- * and creates new ones - so a trial the project still has to run has to be
2229
- * carried onto the replacements explicitly. Otherwise a customer whose
2230
- * trial a master admin extended as a goodwill gesture loses the extension
2231
- * the moment their subscription is reactivated, and is billed right away.
2340
+ * Reactivating goes through changePlan, and it is the one caller that
2341
+ * genuinely lands on its recreate path: the subscriptions being reactivated
2342
+ * are not live, so there is nothing to update and new ones are created. A
2343
+ * trial the project still has to run therefore has to be carried onto the
2344
+ * replacements explicitly. Otherwise a customer whose trial a master admin
2345
+ * extended as a goodwill gesture loses the extension the moment their
2346
+ * subscription is reactivated, and is billed right away.
2232
2347
  *
2233
2348
  * A trial that has already lapsed is not revived: reactivation is not the
2234
2349
  * place to hand out free service.
@@ -2242,6 +2357,7 @@ export class ProjectService extends DatabaseService<Model> {
2242
2357
  subscriptionId: string;
2243
2358
  meteredSubscriptionId: string;
2244
2359
  trialEndsAt?: Date | undefined;
2360
+ subscriptionIdsPendingCancellation: Array<string>;
2245
2361
  } = await BillingService.changePlan({
2246
2362
  projectId: project.id as ObjectID,
2247
2363
  subscriptionId: project.paymentProviderSubscriptionId,
@@ -2254,25 +2370,34 @@ export class ProjectService extends DatabaseService<Model> {
2254
2370
  });
2255
2371
 
2256
2372
  // refresh subscription status.
2257
- const subscriptionState: SubscriptionStatus =
2258
- await BillingService.getSubscriptionStatus(
2259
- result.subscriptionId as string,
2260
- );
2373
+ const subscriptionState: SubscriptionStatus | null =
2374
+ await this.findSubscriptionStatus(result.subscriptionId as string);
2261
2375
 
2262
- const meteredSubscriptionState: SubscriptionStatus =
2263
- await BillingService.getSubscriptionStatus(
2264
- project.paymentProviderMeteredSubscriptionId as string,
2265
- );
2376
+ /*
2377
+ * The metered subscription changePlan returns, not the one the project row
2378
+ * still points at. When changePlan replaced the subscriptions the old id
2379
+ * names a cancelled subscription, and its status would be persisted against
2380
+ * the new one.
2381
+ */
2382
+ const meteredSubscriptionState: SubscriptionStatus | null =
2383
+ await this.findSubscriptionStatus(result.meteredSubscriptionId as string);
2266
2384
 
2267
2385
  // now update project with new subscription id.
2268
2386
 
2269
2387
  const updateData: QueryDeepPartialEntity<Model> = {
2270
2388
  paymentProviderSubscriptionId: result.subscriptionId,
2271
2389
  paymentProviderMeteredSubscriptionId: result.meteredSubscriptionId,
2272
- paymentProviderSubscriptionStatus: subscriptionState,
2273
- paymentProviderMeteredSubscriptionStatus: meteredSubscriptionState,
2274
2390
  };
2275
2391
 
2392
+ if (subscriptionState) {
2393
+ updateData.paymentProviderSubscriptionStatus = subscriptionState;
2394
+ }
2395
+
2396
+ if (meteredSubscriptionState) {
2397
+ updateData.paymentProviderMeteredSubscriptionStatus =
2398
+ meteredSubscriptionState;
2399
+ }
2400
+
2276
2401
  /*
2277
2402
  * The payment provider is the source of truth for the trial the new
2278
2403
  * subscriptions were created with, so its date wins over the one sent.
@@ -2293,6 +2418,11 @@ export class ProjectService extends DatabaseService<Model> {
2293
2418
  },
2294
2419
  });
2295
2420
 
2421
+ this.alertOnSubscriptionsPendingCancellation({
2422
+ projectId: project.id!,
2423
+ subscriptionIds: result.subscriptionIdsPendingCancellation,
2424
+ });
2425
+
2296
2426
  // send slack message on plan change.
2297
2427
  await this.sendSubscriptionChangeWebhookSlackNotification(projectId);
2298
2428
  }
@@ -222,11 +222,14 @@ export class Service extends DatabaseService<Model> {
222
222
  }
223
223
 
224
224
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
- const data: any = {
225
+ const liveness: any = {
226
226
  lastSeenAt: OneUptimeDate.getCurrentDate(),
227
227
  otelCollectorStatus: "connected",
228
228
  };
229
229
 
230
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
231
+ const data: any = { ...liveness };
232
+
230
233
  if (extra?.pveVersion) {
231
234
  data.pveVersion = extra.pveVersion;
232
235
  }
@@ -254,11 +257,50 @@ export class Service extends DatabaseService<Model> {
254
257
  * Heartbeat write: a single-statement UPDATE with no hooks and no
255
258
  * `version` bump, avoiding the hot-row Postgres lock convoy that the
256
259
  * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
260
+ *
261
+ * Collector-supplied strings are clamped to their declared column
262
+ * widths inside updateColumnsByIdWithoutHooks — see
263
+ * DatabaseService.truncateStringColumnsToMaxLength.
257
264
  */
258
- await this.updateColumnsByIdWithoutHooks({
259
- id: clusterId,
260
- data: data,
261
- });
265
+ try {
266
+ await this.updateColumnsByIdWithoutHooks({
267
+ id: clusterId,
268
+ data: data,
269
+ });
270
+ } catch (err) {
271
+ /*
272
+ * Liveness must survive bad metadata. The enriched write carries
273
+ * `lastSeenAt` and `otelCollectorStatus` alongside optional,
274
+ * collector-supplied columns; if any one of them makes Postgres
275
+ * reject the statement, lastSeenAt silently stops advancing and
276
+ * markDisconnectedClusters flips a perfectly healthy Proxmox cluster
277
+ * to "disconnected" 15 minutes later — with telemetry still arriving
278
+ * the whole time. That was issue #3006 on the Host path (a host.ip
279
+ * list longer than the then-varchar(500) column). Retry with liveness
280
+ * only so the enrichment is what gets dropped, never the heartbeat.
281
+ */
282
+ logger.warn(
283
+ `ProxmoxClusterService.updateLastSeen: metadata write failed for Proxmox cluster ${clusterId.toString()}, falling back to a liveness-only update: ${
284
+ err instanceof Error ? err.message : String(err)
285
+ }`,
286
+ );
287
+
288
+ /*
289
+ * The throttle fingerprint was already stored, so without this the
290
+ * next batch would short-circuit on a cache hit and skip the retry
291
+ * for the rest of the window.
292
+ */
293
+ try {
294
+ await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
295
+ } catch {
296
+ // Best-effort; the fallback write below is what actually matters.
297
+ }
298
+
299
+ await this.updateColumnsByIdWithoutHooks({
300
+ id: clusterId,
301
+ data: liveness,
302
+ });
303
+ }
262
304
  }
263
305
 
264
306
  /**
@@ -1,9 +1,11 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/ProxmoxResource";
3
3
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ColumnLength from "../../Types/Database/ColumnLength";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import OneUptimeDate from "../../Types/Date";
6
7
  import QueryHelper from "../Types/Database/QueryHelper";
8
+ import { truncateShortText } from "../Utils/Database/TruncateColumnValue";
7
9
  import logger from "../Utils/Logger";
8
10
 
9
11
  /*
@@ -89,6 +91,26 @@ const UPSERT_COLUMNS: Array<string> = [
89
91
  "version",
90
92
  ];
91
93
 
94
+ /*
95
+ * ProxmoxResource's text columns are all ShortText (100 chars). The
96
+ * bulk paths below go through manager.query(), which skips the length
97
+ * validation DatabaseService applies to ordinary writes, so a single
98
+ * oversized value aborts the whole 500-row INSERT chunk it rides in.
99
+ * Clamp per value instead, so one pathological guest can never drop the
100
+ * rest of the scrape.
101
+ */
102
+ function sanitizeResource(r: ParsedProxmoxResource): ParsedProxmoxResource {
103
+ return {
104
+ ...r,
105
+ kind: truncateShortText(r.kind),
106
+ externalId: truncateShortText(r.externalId),
107
+ name: truncateShortText(r.name),
108
+ guestType: truncateShortText(r.guestType),
109
+ parentNodeName: truncateShortText(r.parentNodeName),
110
+ haState: truncateShortText(r.haState),
111
+ };
112
+ }
113
+
92
114
  export class Service extends DatabaseService<Model> {
93
115
  public constructor() {
94
116
  super(Model);
@@ -116,9 +138,21 @@ export class Service extends DatabaseService<Model> {
116
138
  return;
117
139
  }
118
140
 
141
+ const resources: Array<ParsedProxmoxResource> = data.resources.map(
142
+ (r: ParsedProxmoxResource) => {
143
+ const sanitized: ParsedProxmoxResource = sanitizeResource(r);
144
+ if (sanitized.externalId !== r.externalId) {
145
+ logger.warn(
146
+ `ProxmoxResource externalId exceeds ${ColumnLength.ShortText} chars; truncated to "${sanitized.externalId}" (cluster ${data.proxmoxClusterId.toString()}).`,
147
+ );
148
+ }
149
+ return sanitized;
150
+ },
151
+ );
152
+
119
153
  // Chunk to keep individual statement parameter counts reasonable.
120
- for (let i: number = 0; i < data.resources.length; i += UPSERT_BATCH_SIZE) {
121
- const chunk: Array<ParsedProxmoxResource> = data.resources.slice(
154
+ for (let i: number = 0; i < resources.length; i += UPSERT_BATCH_SIZE) {
155
+ const chunk: Array<ParsedProxmoxResource> = resources.slice(
122
156
  i,
123
157
  i + UPSERT_BATCH_SIZE,
124
158
  );
@@ -219,9 +253,14 @@ export class Service extends DatabaseService<Model> {
219
253
  valueFragments.push(
220
254
  `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}::numeric, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::numeric, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::timestamptz)`,
221
255
  );
256
+ /*
257
+ * The identity clamps MUST match bulkUpsert's, or this mirror
258
+ * UPDATE would silently miss the row the upsert just wrote
259
+ * under the truncated externalId.
260
+ */
222
261
  params.push(
223
- m.kind,
224
- m.externalId,
262
+ truncateShortText(m.kind),
263
+ truncateShortText(m.externalId),
225
264
  m.cpuPercent !== null && m.cpuPercent !== undefined
226
265
  ? m.cpuPercent
227
266
  : null,
@@ -317,11 +317,14 @@ export class Service extends DatabaseService<Model> {
317
317
  }
318
318
 
319
319
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
320
- const data: any = {
320
+ const liveness: any = {
321
321
  lastSeenAt: OneUptimeDate.getCurrentDate(),
322
322
  otelCollectorStatus: "connected",
323
323
  };
324
324
 
325
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
326
+ const data: any = { ...liveness };
327
+
325
328
  if (extra?.agentVersion) {
326
329
  data.agentVersion = extra.agentVersion;
327
330
  }
@@ -336,11 +339,51 @@ export class Service extends DatabaseService<Model> {
336
339
  * Heartbeat write: a single-statement UPDATE with no hooks and no
337
340
  * `version` bump, avoiding the hot-row Postgres lock convoy that the
338
341
  * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
342
+ *
343
+ * Collector-supplied strings are clamped to their declared column
344
+ * widths inside updateColumnsByIdWithoutHooks — see
345
+ * DatabaseService.truncateStringColumnsToMaxLength.
339
346
  */
340
- await this.updateColumnsByIdWithoutHooks({
341
- id: rumApplicationId,
342
- data: data,
343
- });
347
+ try {
348
+ await this.updateColumnsByIdWithoutHooks({
349
+ id: rumApplicationId,
350
+ data: data,
351
+ });
352
+ } catch (err) {
353
+ /*
354
+ * Liveness must survive bad metadata. The enriched write carries
355
+ * `lastSeenAt` and `otelCollectorStatus` alongside optional,
356
+ * collector-supplied columns; if any one of them makes Postgres
357
+ * reject the statement, lastSeenAt silently stops advancing and
358
+ * markDisconnectedApplications flips a perfectly healthy RUM
359
+ * application to "disconnected" 15 minutes later — with telemetry
360
+ * still arriving the whole time. That was issue #3006 on the Host
361
+ * path (a host.ip list longer than the then-varchar(500) column).
362
+ * Retry with liveness only so the enrichment is what gets dropped,
363
+ * never the heartbeat.
364
+ */
365
+ logger.warn(
366
+ `RumApplicationService.updateLastSeen: metadata write failed for RUM application ${rumApplicationId.toString()}, falling back to a liveness-only update: ${
367
+ err instanceof Error ? err.message : String(err)
368
+ }`,
369
+ );
370
+
371
+ /*
372
+ * The throttle fingerprint was already stored, so without this the
373
+ * next batch would short-circuit on a cache hit and skip the retry
374
+ * for the rest of the window.
375
+ */
376
+ try {
377
+ await GlobalCache.deleteKey(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
378
+ } catch {
379
+ // Best-effort; the fallback write below is what actually matters.
380
+ }
381
+
382
+ await this.updateColumnsByIdWithoutHooks({
383
+ id: rumApplicationId,
384
+ data: liveness,
385
+ });
386
+ }
344
387
  }
345
388
 
346
389
  /*