@oneuptime/common 11.1.2 → 11.2.1

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 (155) hide show
  1. package/Models/DatabaseModels/Alert.ts +55 -0
  2. package/Models/DatabaseModels/Incident.ts +55 -0
  3. package/Models/DatabaseModels/Index.ts +12 -0
  4. package/Models/DatabaseModels/IoTDevice.ts +690 -0
  5. package/Models/DatabaseModels/IoTFleet.ts +862 -0
  6. package/Models/DatabaseModels/IoTFleetLabelRule.ts +514 -0
  7. package/Models/DatabaseModels/IoTFleetOwnerRule.ts +596 -0
  8. package/Models/DatabaseModels/IoTFleetOwnerTeam.ts +485 -0
  9. package/Models/DatabaseModels/IoTFleetOwnerUser.ts +484 -0
  10. package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
  11. package/Server/API/IoTDeviceAPI.ts +128 -0
  12. package/Server/API/TelemetryAPI.ts +104 -35
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.ts +399 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.ts +97 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.ts +979 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  17. package/Server/Services/AlertService.ts +24 -3
  18. package/Server/Services/IncidentService.ts +24 -3
  19. package/Server/Services/IoTDeviceService.ts +356 -0
  20. package/Server/Services/IoTFleetLabelRuleEngineService.ts +198 -0
  21. package/Server/Services/IoTFleetLabelRuleService.ts +14 -0
  22. package/Server/Services/IoTFleetOwnerRuleEngineService.ts +216 -0
  23. package/Server/Services/IoTFleetOwnerRuleService.ts +14 -0
  24. package/Server/Services/IoTFleetOwnerTeamService.ts +10 -0
  25. package/Server/Services/IoTFleetOwnerUserService.ts +10 -0
  26. package/Server/Services/IoTFleetService.ts +376 -0
  27. package/Server/Services/OpenTelemetryIngestService.ts +16 -0
  28. package/Server/Services/StatusPageService.ts +69 -26
  29. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +36 -0
  30. package/Server/Services/TelemetryUsageBillingService.ts +18 -0
  31. package/Server/Services/TraceAggregationService.ts +174 -0
  32. package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +1 -1
  33. package/Server/Utils/Monitor/MonitorAlert.ts +24 -4
  34. package/Server/Utils/Monitor/MonitorClusterContext.ts +26 -2
  35. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +4 -2
  36. package/Server/Utils/Monitor/MonitorIncident.ts +39 -5
  37. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +14 -0
  38. package/Server/Utils/Monitor/SeriesResourceLabels.ts +22 -0
  39. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +408 -0
  40. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +36 -0
  41. package/Types/Icon/IconProp.ts +1 -0
  42. package/Types/Monitor/IotAlertTemplates.ts +593 -0
  43. package/Types/Monitor/IotMetricCatalog.ts +146 -0
  44. package/Types/Monitor/MonitorStep.ts +31 -0
  45. package/Types/Monitor/MonitorStepIoTMonitor.ts +65 -0
  46. package/Types/Monitor/MonitorType.ts +12 -1
  47. package/Types/Permission.ts +230 -0
  48. package/Types/StatusPage/StatusPageSubscriberNotificationEventType.ts +3 -0
  49. package/Types/Telemetry/ServiceType.ts +1 -0
  50. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +5 -5
  51. package/UI/Components/Icon/Icon.tsx +18 -2
  52. package/UI/Components/Page/Page.tsx +1 -2
  53. package/build/dist/Models/DatabaseModels/Alert.js +54 -0
  54. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  55. package/build/dist/Models/DatabaseModels/Incident.js +54 -0
  56. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  57. package/build/dist/Models/DatabaseModels/Index.js +12 -0
  58. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  59. package/build/dist/Models/DatabaseModels/IoTDevice.js +722 -0
  60. package/build/dist/Models/DatabaseModels/IoTDevice.js.map +1 -0
  61. package/build/dist/Models/DatabaseModels/IoTFleet.js +882 -0
  62. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -0
  63. package/build/dist/Models/DatabaseModels/IoTFleetLabelRule.js +522 -0
  64. package/build/dist/Models/DatabaseModels/IoTFleetLabelRule.js.map +1 -0
  65. package/build/dist/Models/DatabaseModels/IoTFleetOwnerRule.js +603 -0
  66. package/build/dist/Models/DatabaseModels/IoTFleetOwnerRule.js.map +1 -0
  67. package/build/dist/Models/DatabaseModels/IoTFleetOwnerTeam.js +503 -0
  68. package/build/dist/Models/DatabaseModels/IoTFleetOwnerTeam.js.map +1 -0
  69. package/build/dist/Models/DatabaseModels/IoTFleetOwnerUser.js +502 -0
  70. package/build/dist/Models/DatabaseModels/IoTFleetOwnerUser.js.map +1 -0
  71. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
  72. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  73. package/build/dist/Server/API/IoTDeviceAPI.js +92 -0
  74. package/build/dist/Server/API/IoTDeviceAPI.js.map +1 -0
  75. package/build/dist/Server/API/TelemetryAPI.js +76 -27
  76. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  77. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.js +187 -0
  78. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.js.map +1 -0
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.js +48 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.js +340 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.js.map +1 -0
  83. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  84. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  85. package/build/dist/Server/Services/AlertService.js +22 -3
  86. package/build/dist/Server/Services/AlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +22 -3
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/IoTDeviceService.js +249 -0
  90. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -0
  91. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +166 -0
  92. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -0
  93. package/build/dist/Server/Services/IoTFleetLabelRuleService.js +13 -0
  94. package/build/dist/Server/Services/IoTFleetLabelRuleService.js.map +1 -0
  95. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +186 -0
  96. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -0
  97. package/build/dist/Server/Services/IoTFleetOwnerRuleService.js +13 -0
  98. package/build/dist/Server/Services/IoTFleetOwnerRuleService.js.map +1 -0
  99. package/build/dist/Server/Services/IoTFleetOwnerTeamService.js +9 -0
  100. package/build/dist/Server/Services/IoTFleetOwnerTeamService.js.map +1 -0
  101. package/build/dist/Server/Services/IoTFleetOwnerUserService.js +9 -0
  102. package/build/dist/Server/Services/IoTFleetOwnerUserService.js.map +1 -0
  103. package/build/dist/Server/Services/IoTFleetService.js +340 -0
  104. package/build/dist/Server/Services/IoTFleetService.js.map +1 -0
  105. package/build/dist/Server/Services/OpenTelemetryIngestService.js +26 -11
  106. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  107. package/build/dist/Server/Services/StatusPageService.js +57 -23
  108. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  109. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +34 -0
  110. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  111. package/build/dist/Server/Services/TelemetryUsageBillingService.js +13 -0
  112. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  113. package/build/dist/Server/Services/TraceAggregationService.js +136 -0
  114. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  115. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +15 -15
  116. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
  117. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +37 -19
  118. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  119. package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js +27 -4
  120. package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js.map +1 -1
  121. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +4 -2
  122. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  123. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +39 -11
  124. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  125. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +8 -0
  126. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  127. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +17 -0
  128. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  129. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +300 -0
  130. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -0
  131. package/build/dist/Types/Icon/IconProp.js +1 -0
  132. package/build/dist/Types/Icon/IconProp.js.map +1 -1
  133. package/build/dist/Types/Monitor/IotAlertTemplates.js +467 -0
  134. package/build/dist/Types/Monitor/IotAlertTemplates.js.map +1 -0
  135. package/build/dist/Types/Monitor/IotMetricCatalog.js +108 -0
  136. package/build/dist/Types/Monitor/IotMetricCatalog.js.map +1 -0
  137. package/build/dist/Types/Monitor/MonitorStep.js +23 -0
  138. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  139. package/build/dist/Types/Monitor/MonitorStepIoTMonitor.js +32 -0
  140. package/build/dist/Types/Monitor/MonitorStepIoTMonitor.js.map +1 -0
  141. package/build/dist/Types/Monitor/MonitorType.js +11 -1
  142. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  143. package/build/dist/Types/Permission.js +204 -0
  144. package/build/dist/Types/Permission.js.map +1 -1
  145. package/build/dist/Types/StatusPage/StatusPageSubscriberNotificationEventType.js +2 -0
  146. package/build/dist/Types/StatusPage/StatusPageSubscriberNotificationEventType.js.map +1 -1
  147. package/build/dist/Types/Telemetry/ServiceType.js +1 -0
  148. package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
  149. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +3 -3
  150. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  151. package/build/dist/UI/Components/Icon/Icon.js +12 -2
  152. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  153. package/build/dist/UI/Components/Page/Page.js +1 -2
  154. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  155. package/package.json +1 -1
@@ -400,6 +400,9 @@ import { OptimizeTelemetryExceptionWritePath1782500000000 } from "./178250000000
400
400
  import { AddArchiveToResources1782600000000 } from "./1782600000000-AddArchiveToResources";
401
401
  import { RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000 } from "./1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage";
402
402
  import { WidenDockerResourceImageColumns1782800000000 } from "./1782800000000-WidenDockerResourceImageColumns";
403
+ import { AddIoTFleetAndDeviceTables1782900000000 } from "./1782900000000-AddIoTFleetAndDeviceTables";
404
+ import { AddIoTFleetActivityJoinTables1783000000000 } from "./1783000000000-AddIoTFleetActivityJoinTables";
405
+ import { MigrationName1783010000000 } from "./1783010000000-MigrationName";
403
406
 
404
407
  export default [
405
408
  InitialMigration,
@@ -804,4 +807,7 @@ export default [
804
807
  AddArchiveToResources1782600000000,
805
808
  RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000,
806
809
  WidenDockerResourceImageColumns1782800000000,
810
+ AddIoTFleetAndDeviceTables1782900000000,
811
+ AddIoTFleetActivityJoinTables1783000000000,
812
+ MigrationName1783010000000,
807
813
  ];
@@ -772,11 +772,32 @@ ${alert.remediationNotes || "No remediation notes provided."}
772
772
  },
773
773
  });
774
774
 
775
- if (!timeline || !timeline.startsAt) {
776
- throw new BadDataException("Alert identified date not found.");
775
+ if (timeline && timeline.startsAt) {
776
+ return timeline.startsAt;
777
777
  }
778
778
 
779
- return timeline.startsAt;
779
+ /*
780
+ * The identified-state timeline is created asynchronously after the alert
781
+ * is committed (see onCreateSuccess), so it may not exist yet, or may be
782
+ * missing entirely if that step failed. Fall back to the alert's creation
783
+ * date instead of throwing, otherwise the owner-notification cron fails
784
+ * permanently for this alert and retries every minute forever.
785
+ */
786
+ const alert: Model | null = await this.findOneById({
787
+ id: alertId,
788
+ select: {
789
+ createdAt: true,
790
+ },
791
+ props: {
792
+ isRoot: true,
793
+ },
794
+ });
795
+
796
+ if (alert && alert.createdAt) {
797
+ return alert.createdAt;
798
+ }
799
+
800
+ throw new BadDataException("Alert identified date not found.");
780
801
  }
781
802
 
782
803
  @CaptureSpan()
@@ -1474,11 +1474,32 @@ ${incident.remediationNotes || "No remediation notes provided."}
1474
1474
  },
1475
1475
  });
1476
1476
 
1477
- if (!timeline || !timeline.startsAt) {
1478
- throw new BadDataException("Incident identified date not found.");
1477
+ if (timeline && timeline.startsAt) {
1478
+ return timeline.startsAt;
1479
1479
  }
1480
1480
 
1481
- return timeline.startsAt;
1481
+ /*
1482
+ * The identified-state timeline is created asynchronously after the
1483
+ * incident is committed (see onCreateSuccess), so it may not exist yet, or
1484
+ * may be missing entirely if that step failed. Fall back to the incident's
1485
+ * creation date instead of throwing, otherwise the owner-notification cron
1486
+ * fails permanently for this incident and retries every minute forever.
1487
+ */
1488
+ const incident: Model | null = await this.findOneById({
1489
+ id: incidentId,
1490
+ select: {
1491
+ createdAt: true,
1492
+ },
1493
+ props: {
1494
+ isRoot: true,
1495
+ },
1496
+ });
1497
+
1498
+ if (incident && incident.createdAt) {
1499
+ return incident.createdAt;
1500
+ }
1501
+
1502
+ throw new BadDataException("Incident identified date not found.");
1482
1503
  }
1483
1504
 
1484
1505
  @CaptureSpan()
@@ -0,0 +1,356 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/IoTDevice";
3
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ObjectID from "../../Types/ObjectID";
5
+ import OneUptimeDate from "../../Types/Date";
6
+ import logger from "../Utils/Logger";
7
+
8
+ /*
9
+ * ------------------------------------------------------------------
10
+ * IoTDeviceService
11
+ *
12
+ * Writes and reads the IoT device inventory table populated by the
13
+ * telemetry ingest path. Callers are either:
14
+ * - OtelMetricsIngestService (bulkUpsert + bulkUpdateLatestMetrics,
15
+ * from the iot_* snapshot scan in processMetricsAsync)
16
+ * - CleanupStaleResources worker (deleteStaleForFleet)
17
+ * - IoTDeviceAPI / the dashboard pages (reads via the inherited
18
+ * DatabaseService CRUD)
19
+ *
20
+ * Identity + status and the latest-metric mirror both arrive on the
21
+ * same metric scrape (unlike K8s, which needs a separate k8sobjects
22
+ * log stream for identity), so both writes happen in the same flush.
23
+ *
24
+ * ------------------------------------------------------------------
25
+ */
26
+
27
+ export interface ParsedIoTDevice {
28
+ kind: string; // Device | Sensor | Gateway
29
+ externalId: string; // raw iot `device.id` label
30
+ name: string | null;
31
+ deviceType: string | null;
32
+ firmwareVersion: string | null;
33
+ isUp: boolean | null;
34
+ uptimeSeconds: number | null;
35
+ lastSeenAt: Date;
36
+ }
37
+
38
+ export interface IoTDeviceLatestMetric {
39
+ kind: string;
40
+ externalId: string;
41
+ cpuPercent: number | null;
42
+ memoryBytes: number | null;
43
+ maxMemoryBytes: number | null;
44
+ memoryPercent: number | null;
45
+ batteryPercent: number | null;
46
+ signalStrengthDbm: number | null;
47
+ temperatureCelsius: number | null;
48
+ observedAt: Date;
49
+ }
50
+
51
+ export interface IoTInventorySummary {
52
+ deviceCount: number;
53
+ onlineDeviceCount: number;
54
+ countsByKind: Record<string, number>;
55
+ }
56
+
57
+ const UPSERT_BATCH_SIZE: number = 500;
58
+ const STALE_DELETE_WARN_THRESHOLD: number = 100;
59
+
60
+ /*
61
+ * Column order used by bulkUpsert() and its generated parameter tuples.
62
+ * Keep this and the INSERT column list in perfect sync.
63
+ */
64
+ const UPSERT_COLUMNS: Array<string> = [
65
+ "projectId",
66
+ "iotFleetId",
67
+ "kind",
68
+ "externalId",
69
+ "name",
70
+ "deviceType",
71
+ "firmwareVersion",
72
+ "isUp",
73
+ "uptimeSeconds",
74
+ "lastSeenAt",
75
+ "version",
76
+ ];
77
+
78
+ export class Service extends DatabaseService<Model> {
79
+ public constructor() {
80
+ super(Model);
81
+ }
82
+
83
+ /**
84
+ * Upsert a batch of parsed devices for a single (project, fleet)
85
+ * pair. Uses ON CONFLICT on the UNIQUE (projectId, iotFleetId,
86
+ * kind, externalId) index with a dominance guard on lastSeenAt so
87
+ * out-of-order ingest never regresses a newer snapshot.
88
+ *
89
+ * Identity/status columns COALESCE against the existing row (unlike
90
+ * the K8s upsert, which overwrites): an iot-exporter batch is usually
91
+ * a complete scrape, but a batch that happens to lack an info series
92
+ * (e.g. only iot_device_up made it through a pipeline filter) must
93
+ * not blank name/deviceType/firmwareVersion that an earlier batch
94
+ * already filled.
95
+ */
96
+ @CaptureSpan()
97
+ public async bulkUpsert(data: {
98
+ projectId: ObjectID;
99
+ iotFleetId: ObjectID;
100
+ devices: Array<ParsedIoTDevice>;
101
+ }): Promise<void> {
102
+ if (data.devices.length === 0) {
103
+ return;
104
+ }
105
+
106
+ // Chunk to keep individual statement parameter counts reasonable.
107
+ for (let i: number = 0; i < data.devices.length; i += UPSERT_BATCH_SIZE) {
108
+ const chunk: Array<ParsedIoTDevice> = data.devices.slice(
109
+ i,
110
+ i + UPSERT_BATCH_SIZE,
111
+ );
112
+
113
+ const valueFragments: Array<string> = [];
114
+ const params: Array<unknown> = [];
115
+ let paramIndex: number = 1;
116
+
117
+ for (const r of chunk) {
118
+ const placeholders: Array<string> = [];
119
+ for (let c: number = 0; c < UPSERT_COLUMNS.length; c++) {
120
+ placeholders.push(`$${paramIndex++}`);
121
+ }
122
+ valueFragments.push(`(${placeholders.join(", ")})`);
123
+
124
+ params.push(
125
+ data.projectId.toString(),
126
+ data.iotFleetId.toString(),
127
+ r.kind,
128
+ r.externalId,
129
+ r.name,
130
+ r.deviceType,
131
+ r.firmwareVersion,
132
+ r.isUp,
133
+ r.uptimeSeconds !== null ? Math.trunc(r.uptimeSeconds) : null,
134
+ r.lastSeenAt,
135
+ 0, // version (BaseModel @VersionColumn)
136
+ );
137
+ }
138
+
139
+ const sql: string = `
140
+ INSERT INTO "IoTDevice" (
141
+ "projectId", "iotFleetId", "kind", "externalId",
142
+ "name", "deviceType", "firmwareVersion",
143
+ "isUp", "uptimeSeconds",
144
+ "lastSeenAt", "version"
145
+ )
146
+ VALUES ${valueFragments.join(", ")}
147
+ ON CONFLICT ("projectId", "iotFleetId", "kind", "externalId")
148
+ DO UPDATE SET
149
+ "name" = COALESCE(EXCLUDED."name", "IoTDevice"."name"),
150
+ "deviceType" = COALESCE(EXCLUDED."deviceType", "IoTDevice"."deviceType"),
151
+ "firmwareVersion" = COALESCE(EXCLUDED."firmwareVersion", "IoTDevice"."firmwareVersion"),
152
+ "isUp" = COALESCE(EXCLUDED."isUp", "IoTDevice"."isUp"),
153
+ "uptimeSeconds" = COALESCE(EXCLUDED."uptimeSeconds", "IoTDevice"."uptimeSeconds"),
154
+ "lastSeenAt" = EXCLUDED."lastSeenAt",
155
+ "updatedAt" = now()
156
+ WHERE EXCLUDED."lastSeenAt" >= "IoTDevice"."lastSeenAt"
157
+ `;
158
+
159
+ await this.getRepository().manager.query(sql, params);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Update the latest-metric mirror columns for a batch of devices.
165
+ * Plain UPDATE: in practice the row always exists because bulkUpsert
166
+ * runs in the same flush; if it somehow doesn't, the write is
167
+ * silently skipped and the next flush catches up.
168
+ *
169
+ * Guarded by metricsUpdatedAt so out-of-order points don't regress a
170
+ * newer observation. COALESCE keeps the existing value when a batch
171
+ * lacks a series — notably a battery/signal/temperature value stays
172
+ * at its last-known reading (never 0) when a device omits that series.
173
+ */
174
+ @CaptureSpan()
175
+ public async bulkUpdateLatestMetrics(data: {
176
+ projectId: ObjectID;
177
+ iotFleetId: ObjectID;
178
+ metrics: Array<IoTDeviceLatestMetric>;
179
+ }): Promise<void> {
180
+ if (data.metrics.length === 0) {
181
+ return;
182
+ }
183
+
184
+ for (let i: number = 0; i < data.metrics.length; i += UPSERT_BATCH_SIZE) {
185
+ const chunk: Array<IoTDeviceLatestMetric> = data.metrics.slice(
186
+ i,
187
+ i + UPSERT_BATCH_SIZE,
188
+ );
189
+
190
+ const valueFragments: Array<string> = [];
191
+ const params: Array<unknown> = [
192
+ data.projectId.toString(),
193
+ data.iotFleetId.toString(),
194
+ ];
195
+ let paramIndex: number = 3;
196
+
197
+ for (const m of chunk) {
198
+ valueFragments.push(
199
+ `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}::numeric, $${paramIndex++}::bigint, $${paramIndex++}::bigint, $${paramIndex++}::numeric, $${paramIndex++}::numeric, $${paramIndex++}::numeric, $${paramIndex++}::timestamptz)`,
200
+ );
201
+ params.push(
202
+ m.kind,
203
+ m.externalId,
204
+ m.cpuPercent !== null && m.cpuPercent !== undefined
205
+ ? m.cpuPercent
206
+ : null,
207
+ m.memoryBytes !== null && m.memoryBytes !== undefined
208
+ ? Math.trunc(m.memoryBytes).toString()
209
+ : null,
210
+ m.maxMemoryBytes !== null && m.maxMemoryBytes !== undefined
211
+ ? Math.trunc(m.maxMemoryBytes).toString()
212
+ : null,
213
+ m.memoryPercent !== null && m.memoryPercent !== undefined
214
+ ? m.memoryPercent
215
+ : null,
216
+ m.batteryPercent !== null && m.batteryPercent !== undefined
217
+ ? m.batteryPercent
218
+ : null,
219
+ m.signalStrengthDbm !== null && m.signalStrengthDbm !== undefined
220
+ ? m.signalStrengthDbm
221
+ : null,
222
+ m.temperatureCelsius !== null && m.temperatureCelsius !== undefined
223
+ ? m.temperatureCelsius
224
+ : null,
225
+ m.observedAt,
226
+ );
227
+ }
228
+
229
+ const sql: string = `
230
+ UPDATE "IoTDevice" AS p
231
+ SET
232
+ "latestCpuPercent" = COALESCE(v."cpu", p."latestCpuPercent"),
233
+ "latestMemoryBytes" = COALESCE(v."mem", p."latestMemoryBytes"),
234
+ "maxMemoryBytes" = COALESCE(v."maxMem", p."maxMemoryBytes"),
235
+ "latestMemoryPercent" = COALESCE(v."memPct", p."latestMemoryPercent"),
236
+ "latestBatteryPercent" = COALESCE(v."battery", p."latestBatteryPercent"),
237
+ "latestSignalStrengthDbm" = COALESCE(v."signal", p."latestSignalStrengthDbm"),
238
+ "latestTemperatureCelsius" = COALESCE(v."temp", p."latestTemperatureCelsius"),
239
+ "metricsUpdatedAt" = v."observedAt",
240
+ "updatedAt" = now()
241
+ FROM (VALUES ${valueFragments.join(", ")})
242
+ AS v("kind", "externalId", "cpu", "mem", "maxMem", "memPct", "battery", "signal", "temp", "observedAt")
243
+ WHERE
244
+ p."projectId" = $1
245
+ AND p."iotFleetId" = $2
246
+ AND p."kind" = v."kind"
247
+ AND p."externalId" = v."externalId"
248
+ AND (p."metricsUpdatedAt" IS NULL OR v."observedAt" >= p."metricsUpdatedAt")
249
+ `;
250
+
251
+ await this.getRepository().manager.query(sql, params);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Hard-delete all devices in a fleet whose last scrape is older
257
+ * than olderThan. Returns the number of deleted rows. Only called by
258
+ * the cleanup worker for fleets that are still connected — a
259
+ * disconnected fleet keeps its last-known inventory.
260
+ */
261
+ @CaptureSpan()
262
+ public async deleteStaleForFleet(data: {
263
+ iotFleetId: ObjectID;
264
+ olderThan: Date;
265
+ }): Promise<number> {
266
+ const result: Array<{ affected?: number }> | { affected?: number } =
267
+ await this.getRepository().manager.query(
268
+ `DELETE FROM "IoTDevice" WHERE "iotFleetId" = $1 AND "lastSeenAt" < $2`,
269
+ [data.iotFleetId.toString(), data.olderThan],
270
+ );
271
+
272
+ // Postgres driver returns [rows, affected] for DELETE — normalize.
273
+ let affected: number = 0;
274
+ if (Array.isArray(result) && result.length >= 2) {
275
+ const second: unknown = (result as Array<unknown>)[1];
276
+ if (typeof second === "number") {
277
+ affected = second;
278
+ }
279
+ }
280
+
281
+ if (affected > STALE_DELETE_WARN_THRESHOLD) {
282
+ logger.warn(
283
+ `IoTDevice cleanup deleted ${affected} stale rows for fleet ${data.iotFleetId.toString()} — larger than expected; investigate agent health.`,
284
+ );
285
+ }
286
+
287
+ return affected;
288
+ }
289
+
290
+ /**
291
+ * Compute the sidebar/overview summary in Postgres: counts per kind
292
+ * plus the total and online device breakdowns, in a single round-trip.
293
+ */
294
+ @CaptureSpan()
295
+ public async getInventorySummary(data: {
296
+ projectId: ObjectID;
297
+ iotFleetId: ObjectID;
298
+ }): Promise<IoTInventorySummary> {
299
+ const rows: Array<{
300
+ kind: string;
301
+ count: string;
302
+ upCount: string;
303
+ }> = await this.getRepository().manager.query(
304
+ `SELECT "kind",
305
+ COUNT(*)::text AS count,
306
+ COUNT(*) FILTER (WHERE "isUp" IS TRUE)::text AS "upCount"
307
+ FROM "IoTDevice"
308
+ WHERE "projectId" = $1 AND "iotFleetId" = $2 AND "deletedAt" IS NULL
309
+ GROUP BY "kind"`,
310
+ [data.projectId.toString(), data.iotFleetId.toString()],
311
+ );
312
+
313
+ const countsByKind: Record<string, number> = {};
314
+ let deviceCount: number = 0;
315
+ let onlineDeviceCount: number = 0;
316
+ for (const row of rows) {
317
+ const count: number = parseInt(row.count, 10) || 0;
318
+ const upCount: number = parseInt(row.upCount, 10) || 0;
319
+ countsByKind[row.kind] = count;
320
+ deviceCount += count;
321
+ onlineDeviceCount += upCount;
322
+ }
323
+
324
+ return {
325
+ deviceCount,
326
+ onlineDeviceCount,
327
+ countsByKind,
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Helper for the cleanup worker: snapshot-interval aware cutoff.
333
+ * 3× the 5-minute scrape interval by default. Tune via
334
+ * IOT_INVENTORY_STALE_MINUTES (min 5).
335
+ */
336
+ public getStaleThresholdDate(nowOverride?: Date): Date {
337
+ const minutes: number = this.getStaleThresholdMinutes();
338
+ return OneUptimeDate.addRemoveMinutes(
339
+ nowOverride || OneUptimeDate.getCurrentDate(),
340
+ -minutes,
341
+ );
342
+ }
343
+
344
+ public getStaleThresholdMinutes(): number {
345
+ const raw: string | undefined = process.env["IOT_INVENTORY_STALE_MINUTES"];
346
+ if (raw) {
347
+ const parsed: number = parseInt(raw, 10);
348
+ if (!isNaN(parsed) && parsed >= 5) {
349
+ return parsed;
350
+ }
351
+ }
352
+ return 15;
353
+ }
354
+ }
355
+
356
+ export default new Service();
@@ -0,0 +1,198 @@
1
+ import Label from "../../Models/DatabaseModels/Label";
2
+ import IoTFleet from "../../Models/DatabaseModels/IoTFleet";
3
+ import IoTFleetLabelRule from "../../Models/DatabaseModels/IoTFleetLabelRule";
4
+ import IoTFleetLabelRuleService from "./IoTFleetLabelRuleService";
5
+ import IoTFleetService from "./IoTFleetService";
6
+ import ObjectID from "../../Types/ObjectID";
7
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
8
+ import logger, { LogAttributes } from "../Utils/Logger";
9
+
10
+ class IoTFleetLabelRuleEngineServiceClass {
11
+ /**
12
+ * Evaluates IoTFleetLabelRule rows for the given IoT fleet and attaches matched
13
+ * labels to it. The union is deduped against labels already on the IoT fleet
14
+ * before insert to avoid PK conflicts on the join table.
15
+ */
16
+ @CaptureSpan()
17
+ public async applyRulesToIoTFleet(iotFleet: IoTFleet): Promise<void> {
18
+ if (!iotFleet.id || !iotFleet.projectId) {
19
+ return;
20
+ }
21
+
22
+ try {
23
+ const rules: Array<IoTFleetLabelRule> =
24
+ await IoTFleetLabelRuleService.findBy({
25
+ query: {
26
+ projectId: iotFleet.projectId,
27
+ isEnabled: true,
28
+ },
29
+ props: { isRoot: true },
30
+ select: {
31
+ _id: true,
32
+ name: true,
33
+ iotFleetLabels: { _id: true },
34
+ iotFleetNamePattern: true,
35
+ iotFleetDescriptionPattern: true,
36
+ labelsToAdd: { _id: true },
37
+ },
38
+ limit: 100,
39
+ skip: 0,
40
+ });
41
+
42
+ if (rules.length === 0) {
43
+ return;
44
+ }
45
+
46
+ const iotFleetWithDetails: IoTFleet | null =
47
+ await IoTFleetService.findOneById({
48
+ id: iotFleet.id,
49
+ select: {
50
+ name: true,
51
+ description: true,
52
+ labels: { _id: true },
53
+ },
54
+ props: { isRoot: true },
55
+ });
56
+
57
+ if (!iotFleetWithDetails) {
58
+ return;
59
+ }
60
+
61
+ const labelIdsToAdd: Set<string> = new Set();
62
+
63
+ for (const rule of rules) {
64
+ const matches: boolean = this.doesIoTFleetMatchRule(
65
+ iotFleetWithDetails,
66
+ rule,
67
+ );
68
+ if (!matches) {
69
+ continue;
70
+ }
71
+ for (const label of rule.labelsToAdd || []) {
72
+ if (label.id) {
73
+ labelIdsToAdd.add(label.id.toString());
74
+ }
75
+ }
76
+ }
77
+
78
+ if (labelIdsToAdd.size === 0) {
79
+ return;
80
+ }
81
+
82
+ const existingLabelIds: Set<string> = new Set(
83
+ (iotFleetWithDetails.labels || [])
84
+ .map((l: Label) => {
85
+ return l.id?.toString() || "";
86
+ })
87
+ .filter((id: string) => {
88
+ return id !== "";
89
+ }),
90
+ );
91
+
92
+ const newLabelIds: Array<string> = Array.from(labelIdsToAdd).filter(
93
+ (id: string) => {
94
+ return !existingLabelIds.has(id);
95
+ },
96
+ );
97
+ if (newLabelIds.length === 0) {
98
+ return;
99
+ }
100
+
101
+ await IoTFleetService.getRepository()
102
+ .createQueryBuilder()
103
+ .relation(IoTFleet, "labels")
104
+ .of(iotFleet.id.toString())
105
+ .add(newLabelIds);
106
+
107
+ /*
108
+ * Sync in-memory iotFleet.labels so a downstream owner-rule engine in
109
+ * the same onCreateSuccess chain can match on rule-added labels.
110
+ */
111
+ const mergedLabelIds: Set<string> = new Set([
112
+ ...existingLabelIds,
113
+ ...newLabelIds,
114
+ ]);
115
+ iotFleet.labels = Array.from(mergedLabelIds).map((id: string) => {
116
+ const label: Label = new Label();
117
+ label.id = new ObjectID(id);
118
+ return label;
119
+ });
120
+
121
+ logger.debug(
122
+ `IoTFleetLabelRuleEngine attached ${newLabelIds.length} labels to IoT fleet ${iotFleet.id}`,
123
+ { projectId: iotFleet.projectId.toString() } as LogAttributes,
124
+ );
125
+ } catch (error) {
126
+ logger.error(`Error applying IoT fleet label rules: ${error}`, {
127
+ projectId: iotFleet.projectId?.toString(),
128
+ iotFleetId: iotFleet.id?.toString(),
129
+ } as LogAttributes);
130
+ }
131
+ }
132
+
133
+ private doesIoTFleetMatchRule(
134
+ iotFleet: IoTFleet,
135
+ rule: IoTFleetLabelRule,
136
+ ): boolean {
137
+ if (rule.iotFleetLabels && rule.iotFleetLabels.length > 0) {
138
+ if (!iotFleet.labels || iotFleet.labels.length === 0) {
139
+ return false;
140
+ }
141
+ const ruleLabelIds: Array<string> = rule.iotFleetLabels.map(
142
+ (l: Label) => {
143
+ return l.id?.toString() || "";
144
+ },
145
+ );
146
+ const labelIds: Array<string> = iotFleet.labels.map((l: Label) => {
147
+ return l.id?.toString() || "";
148
+ });
149
+ if (
150
+ !ruleLabelIds.some((id: string) => {
151
+ return labelIds.includes(id);
152
+ })
153
+ ) {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ if (
159
+ rule.iotFleetNamePattern &&
160
+ (!iotFleet.name ||
161
+ !this.testRegex(rule.iotFleetNamePattern, iotFleet.name, rule))
162
+ ) {
163
+ return false;
164
+ }
165
+
166
+ if (
167
+ rule.iotFleetDescriptionPattern &&
168
+ (!iotFleet.description ||
169
+ !this.testRegex(
170
+ rule.iotFleetDescriptionPattern,
171
+ iotFleet.description,
172
+ rule,
173
+ ))
174
+ ) {
175
+ return false;
176
+ }
177
+
178
+ return true;
179
+ }
180
+
181
+ private testRegex(
182
+ pattern: string,
183
+ value: string,
184
+ rule: IoTFleetLabelRule,
185
+ ): boolean {
186
+ try {
187
+ const regex: RegExp = new RegExp(pattern, "i");
188
+ return regex.test(value);
189
+ } catch {
190
+ logger.warn(
191
+ `Invalid regex in IoT fleet label rule ${rule.id}: ${pattern}`,
192
+ );
193
+ return false;
194
+ }
195
+ }
196
+ }
197
+
198
+ export default new IoTFleetLabelRuleEngineServiceClass();
@@ -0,0 +1,14 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/IoTFleetLabelRule";
3
+ import { IsBillingEnabled } from "../EnvironmentConfig";
4
+
5
+ export class Service extends DatabaseService<Model> {
6
+ public constructor() {
7
+ super(Model);
8
+ if (IsBillingEnabled) {
9
+ this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365);
10
+ }
11
+ }
12
+ }
13
+
14
+ export default new Service();