@oneuptime/common 11.1.2 → 11.2.0

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 (146) 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/IoTDeviceService.ts +356 -0
  18. package/Server/Services/IoTFleetLabelRuleEngineService.ts +198 -0
  19. package/Server/Services/IoTFleetLabelRuleService.ts +14 -0
  20. package/Server/Services/IoTFleetOwnerRuleEngineService.ts +216 -0
  21. package/Server/Services/IoTFleetOwnerRuleService.ts +14 -0
  22. package/Server/Services/IoTFleetOwnerTeamService.ts +10 -0
  23. package/Server/Services/IoTFleetOwnerUserService.ts +10 -0
  24. package/Server/Services/IoTFleetService.ts +376 -0
  25. package/Server/Services/OpenTelemetryIngestService.ts +16 -0
  26. package/Server/Services/StatusPageService.ts +69 -26
  27. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +36 -0
  28. package/Server/Services/TelemetryUsageBillingService.ts +18 -0
  29. package/Server/Services/TraceAggregationService.ts +174 -0
  30. package/Server/Utils/Monitor/MonitorAlert.ts +24 -4
  31. package/Server/Utils/Monitor/MonitorClusterContext.ts +26 -2
  32. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +4 -2
  33. package/Server/Utils/Monitor/MonitorIncident.ts +39 -5
  34. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +14 -0
  35. package/Server/Utils/Monitor/SeriesResourceLabels.ts +22 -0
  36. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +408 -0
  37. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +36 -0
  38. package/Types/Icon/IconProp.ts +1 -0
  39. package/Types/Monitor/IotAlertTemplates.ts +593 -0
  40. package/Types/Monitor/IotMetricCatalog.ts +146 -0
  41. package/Types/Monitor/MonitorStep.ts +31 -0
  42. package/Types/Monitor/MonitorStepIoTMonitor.ts +65 -0
  43. package/Types/Monitor/MonitorType.ts +12 -1
  44. package/Types/Permission.ts +230 -0
  45. package/Types/StatusPage/StatusPageSubscriberNotificationEventType.ts +3 -0
  46. package/Types/Telemetry/ServiceType.ts +1 -0
  47. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +5 -5
  48. package/UI/Components/Icon/Icon.tsx +18 -2
  49. package/UI/Components/Page/Page.tsx +1 -2
  50. package/build/dist/Models/DatabaseModels/Alert.js +54 -0
  51. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  52. package/build/dist/Models/DatabaseModels/Incident.js +54 -0
  53. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  54. package/build/dist/Models/DatabaseModels/Index.js +12 -0
  55. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  56. package/build/dist/Models/DatabaseModels/IoTDevice.js +722 -0
  57. package/build/dist/Models/DatabaseModels/IoTDevice.js.map +1 -0
  58. package/build/dist/Models/DatabaseModels/IoTFleet.js +882 -0
  59. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -0
  60. package/build/dist/Models/DatabaseModels/IoTFleetLabelRule.js +522 -0
  61. package/build/dist/Models/DatabaseModels/IoTFleetLabelRule.js.map +1 -0
  62. package/build/dist/Models/DatabaseModels/IoTFleetOwnerRule.js +603 -0
  63. package/build/dist/Models/DatabaseModels/IoTFleetOwnerRule.js.map +1 -0
  64. package/build/dist/Models/DatabaseModels/IoTFleetOwnerTeam.js +503 -0
  65. package/build/dist/Models/DatabaseModels/IoTFleetOwnerTeam.js.map +1 -0
  66. package/build/dist/Models/DatabaseModels/IoTFleetOwnerUser.js +502 -0
  67. package/build/dist/Models/DatabaseModels/IoTFleetOwnerUser.js.map +1 -0
  68. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
  69. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  70. package/build/dist/Server/API/IoTDeviceAPI.js +92 -0
  71. package/build/dist/Server/API/IoTDeviceAPI.js.map +1 -0
  72. package/build/dist/Server/API/TelemetryAPI.js +76 -27
  73. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  74. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.js +187 -0
  75. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782900000000-AddIoTFleetAndDeviceTables.js.map +1 -0
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.js +48 -0
  77. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783000000000-AddIoTFleetActivityJoinTables.js.map +1 -0
  78. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.js +340 -0
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783010000000-MigrationName.js.map +1 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  82. package/build/dist/Server/Services/IoTDeviceService.js +249 -0
  83. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -0
  84. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +166 -0
  85. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -0
  86. package/build/dist/Server/Services/IoTFleetLabelRuleService.js +13 -0
  87. package/build/dist/Server/Services/IoTFleetLabelRuleService.js.map +1 -0
  88. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +186 -0
  89. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -0
  90. package/build/dist/Server/Services/IoTFleetOwnerRuleService.js +13 -0
  91. package/build/dist/Server/Services/IoTFleetOwnerRuleService.js.map +1 -0
  92. package/build/dist/Server/Services/IoTFleetOwnerTeamService.js +9 -0
  93. package/build/dist/Server/Services/IoTFleetOwnerTeamService.js.map +1 -0
  94. package/build/dist/Server/Services/IoTFleetOwnerUserService.js +9 -0
  95. package/build/dist/Server/Services/IoTFleetOwnerUserService.js.map +1 -0
  96. package/build/dist/Server/Services/IoTFleetService.js +340 -0
  97. package/build/dist/Server/Services/IoTFleetService.js.map +1 -0
  98. package/build/dist/Server/Services/OpenTelemetryIngestService.js +26 -11
  99. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  100. package/build/dist/Server/Services/StatusPageService.js +57 -23
  101. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  102. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +34 -0
  103. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  104. package/build/dist/Server/Services/TelemetryUsageBillingService.js +13 -0
  105. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  106. package/build/dist/Server/Services/TraceAggregationService.js +136 -0
  107. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  108. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +37 -19
  109. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  110. package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js +27 -4
  111. package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js.map +1 -1
  112. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +4 -2
  113. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  114. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +39 -11
  115. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  116. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +8 -0
  117. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  118. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +17 -0
  119. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  120. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +300 -0
  121. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -0
  122. package/build/dist/Types/Icon/IconProp.js +1 -0
  123. package/build/dist/Types/Icon/IconProp.js.map +1 -1
  124. package/build/dist/Types/Monitor/IotAlertTemplates.js +467 -0
  125. package/build/dist/Types/Monitor/IotAlertTemplates.js.map +1 -0
  126. package/build/dist/Types/Monitor/IotMetricCatalog.js +108 -0
  127. package/build/dist/Types/Monitor/IotMetricCatalog.js.map +1 -0
  128. package/build/dist/Types/Monitor/MonitorStep.js +23 -0
  129. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  130. package/build/dist/Types/Monitor/MonitorStepIoTMonitor.js +32 -0
  131. package/build/dist/Types/Monitor/MonitorStepIoTMonitor.js.map +1 -0
  132. package/build/dist/Types/Monitor/MonitorType.js +11 -1
  133. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  134. package/build/dist/Types/Permission.js +204 -0
  135. package/build/dist/Types/Permission.js.map +1 -1
  136. package/build/dist/Types/StatusPage/StatusPageSubscriberNotificationEventType.js +2 -0
  137. package/build/dist/Types/StatusPage/StatusPageSubscriberNotificationEventType.js.map +1 -1
  138. package/build/dist/Types/Telemetry/ServiceType.js +1 -0
  139. package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
  140. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +3 -3
  141. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  142. package/build/dist/UI/Components/Icon/Icon.js +12 -2
  143. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  144. package/build/dist/UI/Components/Page/Page.js +1 -2
  145. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  146. package/package.json +1 -1
@@ -99,6 +99,23 @@ export const CephClusterNameLabelKeys: ReadonlyArray<string> = [
99
99
  "ceph.cluster.name",
100
100
  ];
101
101
 
102
+ /*
103
+ * IoT fleet identity rides the agent-stamped resource attribute
104
+ * (`iot.fleet.name`) and its ClickHouse `resource.`-prefixed twin.
105
+ * Ingest keys fleet rows by name only — there is no `oneuptime.*.id`
106
+ * stamp for fleets — so only name keys exist. The name maps to the
107
+ * IoTFleet model's `name` column. Like Proxmox/Ceph, the shipped IoT
108
+ * alert templates group by the datapoint label `device.id`, so their
109
+ * series labels do NOT carry these keys; the deterministic fleet link
110
+ * for those monitors comes from the monitor step config instead (see
111
+ * MonitorClusterContext). These keys cover user-built monitors that
112
+ * group by the fleet attribute.
113
+ */
114
+ export const IoTFleetNameLabelKeys: ReadonlyArray<string> = [
115
+ "resource.iot.fleet.name",
116
+ "iot.fleet.name",
117
+ ];
118
+
102
119
  /*
103
120
  * Services come from OTel-ingested telemetry. The ingest pipeline
104
121
  * auto-creates a Service row keyed by `service.name`, so any series
@@ -134,6 +151,7 @@ export interface SeriesResourceRefs {
134
151
  kubernetesClusterNames: Array<string>;
135
152
  proxmoxClusterNames: Array<string>;
136
153
  cephClusterNames: Array<string>;
154
+ iotFleetNames: Array<string>;
137
155
  serviceIds: Array<string>;
138
156
  serviceNames: Array<string>;
139
157
  }
@@ -208,6 +226,10 @@ export default class SeriesResourceLabels {
208
226
  seriesLabels,
209
227
  CephClusterNameLabelKeys,
210
228
  ),
229
+ iotFleetNames: this.collectLabelValues(
230
+ seriesLabels,
231
+ IoTFleetNameLabelKeys,
232
+ ),
211
233
  serviceIds: this.collectLabelValues(seriesLabels, ServiceIdLabelKeys),
212
234
  serviceNames: this.collectLabelValues(seriesLabels, ServiceNameLabelKeys),
213
235
  };
@@ -0,0 +1,408 @@
1
+ import OneUptimeDate from "../../../Types/Date";
2
+ import { JSONArray, JSONObject, JSONValue } from "../../../Types/JSON";
3
+ import logger from "../Logger";
4
+
5
+ /*
6
+ * ------------------------------------------------------------------
7
+ * IoT snapshot scan — pure fold & derive helpers
8
+ * ------------------------------------------------------------------
9
+ *
10
+ * Cloned from ProxmoxCephSnapshotScan.ts (the Proxmox half). The
11
+ * ingest service owns the I/O: it walks the OTLP payload, calls
12
+ * bufferIoTSnapshotMetric per datapoint, and at flush time maps the
13
+ * folded buffers through deriveIoTFleetSnapshotExtras before handing
14
+ * the results to IoTDeviceService / IoTFleetService. Everything in
15
+ * this module is pure (Map/object mutation only — no DB, no network),
16
+ * which is what makes the snapshot-scan semantics unit-testable:
17
+ *
18
+ * - identity labels fold first-non-null-wins; status/metric fields
19
+ * fold newest-observedAt-wins,
20
+ * - count columns are only derived when the batch carried the
21
+ * matching identity series (never zero a count on a partial
22
+ * batch — the COALESCE-per-column contract),
23
+ * - non-allow-listed metric names are skipped via the exported
24
+ * IOT_SNAPSHOT_METRIC_NAMES set.
25
+ */
26
+
27
+ /*
28
+ * IoT snapshot metrics — emitted by IoT devices / gateways pushing
29
+ * OTLP, identity in the `device.id` datapoint label plus the
30
+ * iot.device.kind / iot.device.type / iot.device.firmware attributes
31
+ * the device's SDK stamps. Unlike K8s there is no separate object
32
+ * stream: identity, status AND the latest-metric mirror all arrive on
33
+ * every scrape, so the same scan feeds the IoTDevice inventory upsert
34
+ * and the IoTFleet count snapshot columns (single source — the
35
+ * list-page counts and the sidebar badges can never drift).
36
+ *
37
+ * iot_cpu_usage_ratio is already a true 0..1 ratio — no allocatable-
38
+ * denominator cache is needed, unlike K8s cpuCoresToPercent.
39
+ */
40
+ export const IOT_SNAPSHOT_METRIC_NAMES: ReadonlySet<string> = new Set([
41
+ // Identity / status (fleet counts derive from these)
42
+ "iot_device_up",
43
+ "iot_device_info",
44
+ // Latest-metric mirror (inventory columns)
45
+ "iot_battery_percent",
46
+ "iot_signal_strength_dbm",
47
+ "iot_temperature_celsius",
48
+ "iot_cpu_usage_ratio",
49
+ "iot_memory_usage_bytes",
50
+ "iot_memory_size_bytes",
51
+ "iot_uptime_seconds",
52
+ ]);
53
+
54
+ /*
55
+ * One IoT device folded across a batch. Identity labels are
56
+ * first-non-null-wins (stable for the lifetime of the device);
57
+ * status / metric fields are newest-observedAt-wins.
58
+ */
59
+ export interface IoTDeviceBufferEntry {
60
+ kind: string; // device class — Device | Sensor | Gateway
61
+ externalId: string; // raw `device.id` label
62
+ name: string | null;
63
+ deviceType: string | null;
64
+ firmwareVersion: string | null;
65
+ isUp: boolean | null;
66
+ uptimeSeconds: number | null;
67
+ latestCpuPercent: number | null;
68
+ latestMemoryBytes: number | null;
69
+ maxMemoryBytes: number | null;
70
+ latestBatteryPercent: number | null;
71
+ latestSignalStrengthDbm: number | null;
72
+ latestTemperatureCelsius: number | null;
73
+ observedAt: Date;
74
+ }
75
+
76
+ /*
77
+ * Per-fleet IoT snapshot state. The saw* flags implement the
78
+ * never-zero-a-count-on-a-partial-batch contract: a count column is
79
+ * only written when the batch carried the matching identity series.
80
+ */
81
+ export interface IoTFleetSnapshotBufferEntry {
82
+ sawDeviceIdentity: boolean; // iot_device_info, or iot_device_up
83
+ sawDeviceUp: boolean; // iot_device_up
84
+ agentVersion?: string | undefined;
85
+ }
86
+
87
+ // The IoTFleet snapshot columns derived from one folded batch.
88
+ export interface IoTFleetSnapshotExtras {
89
+ agentVersion?: string | undefined;
90
+ deviceCount?: number | undefined;
91
+ onlineDeviceCount?: number | undefined;
92
+ }
93
+
94
+ /*
95
+ * Same finite-or-null coercion contract as the ingest service's
96
+ * toNumberOrNull (NaN / ±Infinity fold to null so a malformed
97
+ * datapoint is skipped rather than poisoning a snapshot column).
98
+ */
99
+ function toNumberOrNull(value: unknown): number | null {
100
+ if (typeof value === "number") {
101
+ return Number.isFinite(value) ? value : null;
102
+ }
103
+
104
+ if (typeof value === "string") {
105
+ const parsed: number = Number.parseFloat(value);
106
+ return Number.isFinite(parsed) ? parsed : null;
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ /*
113
+ * Same fall-back-to-now parse contract as the ingest service's
114
+ * safeParseUnixNano — only the Date is needed on the snapshot path.
115
+ */
116
+ function parseUnixNanoToDate(
117
+ value: string | number | undefined,
118
+ context: string,
119
+ ): Date {
120
+ let numericValue: number = OneUptimeDate.getCurrentDateAsUnixNano();
121
+
122
+ if (value !== undefined && value !== null) {
123
+ try {
124
+ if (typeof value === "string") {
125
+ const parsed: number = Number.parseFloat(value);
126
+ if (isNaN(parsed)) {
127
+ throw new Error(`Invalid timestamp string: ${value}`);
128
+ }
129
+ numericValue = parsed;
130
+ } else if (typeof value === "number") {
131
+ if (!Number.isFinite(value)) {
132
+ throw new Error(`Invalid timestamp number: ${value}`);
133
+ }
134
+ numericValue = value;
135
+ }
136
+ } catch (error) {
137
+ logger.warn(
138
+ `Error processing ${context}: ${error instanceof Error ? error.message : String(error)}, using current time`,
139
+ );
140
+ numericValue = OneUptimeDate.getCurrentDateAsUnixNano();
141
+ }
142
+ }
143
+
144
+ return OneUptimeDate.fromUnixNano(numericValue);
145
+ }
146
+
147
+ // Same trim-or-null read contract as OtelIngestBaseService.getStringAttribute.
148
+ function getStringAttribute(attributes: JSONArray, key: string): string | null {
149
+ for (const attribute of attributes) {
150
+ if (
151
+ attribute["key"] === key &&
152
+ attribute["value"] &&
153
+ (attribute["value"] as JSONObject)["stringValue"]
154
+ ) {
155
+ const value: JSONValue = (attribute["value"] as JSONObject)[
156
+ "stringValue"
157
+ ];
158
+ if (typeof value === "string" && value.trim()) {
159
+ return value.trim();
160
+ }
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+
166
+ export function getOrCreateIoTFleetSnapshot(
167
+ buffer: Map<string, IoTFleetSnapshotBufferEntry>,
168
+ fleetIdStr: string,
169
+ ): IoTFleetSnapshotBufferEntry {
170
+ let entry: IoTFleetSnapshotBufferEntry | undefined = buffer.get(fleetIdStr);
171
+ if (!entry) {
172
+ entry = {
173
+ sawDeviceIdentity: false,
174
+ sawDeviceUp: false,
175
+ };
176
+ buffer.set(fleetIdStr, entry);
177
+ }
178
+ return entry;
179
+ }
180
+
181
+ /*
182
+ * Fold one iot_* datapoint into the per-fleet buffers. Identity lives
183
+ * in the `device.id` DATAPOINT label, so this reads the raw datapoint
184
+ * attribute array — not the merged resource-prefixed map the K8s scan
185
+ * uses.
186
+ */
187
+ export function bufferIoTSnapshotMetric(data: {
188
+ fleetIdStr: string;
189
+ metricName: string;
190
+ datapoint: JSONObject;
191
+ resourceBuffer: Map<string, Map<string, IoTDeviceBufferEntry>>;
192
+ fleetBuffer: Map<string, IoTFleetSnapshotBufferEntry>;
193
+ }): void {
194
+ const valueFromInt: number | null = toNumberOrNull(data.datapoint["asInt"]);
195
+ const valueFromDouble: number | null = toNumberOrNull(
196
+ data.datapoint["asDouble"],
197
+ );
198
+ const rawValue: number | null = valueFromDouble ?? valueFromInt;
199
+ if (rawValue === null) {
200
+ return;
201
+ }
202
+
203
+ const observedAt: Date = parseUnixNanoToDate(
204
+ data.datapoint["timeUnixNano"] as string | number | undefined,
205
+ "iot snapshot timeUnixNano",
206
+ );
207
+
208
+ const dpAttributes: JSONArray =
209
+ (data.datapoint["attributes"] as JSONArray) || [];
210
+
211
+ const fleet: IoTFleetSnapshotBufferEntry = getOrCreateIoTFleetSnapshot(
212
+ data.fleetBuffer,
213
+ data.fleetIdStr,
214
+ );
215
+
216
+ // Identity lives in the `device.id` datapoint label.
217
+ const externalId: string | null = getStringAttribute(
218
+ dpAttributes,
219
+ "device.id",
220
+ );
221
+ if (!externalId) {
222
+ return;
223
+ }
224
+
225
+ /*
226
+ * Kind/type/firmware: read from the iot.device.* attributes the
227
+ * device SDK stamps; kind falls back to "Device" so inventory still
228
+ * populates without an explicit class label.
229
+ */
230
+ const kind: string =
231
+ getStringAttribute(dpAttributes, "iot.device.kind") || "Device";
232
+
233
+ const patch: IoTDeviceBufferEntry = {
234
+ kind,
235
+ externalId,
236
+ name: null,
237
+ deviceType: null,
238
+ firmwareVersion: null,
239
+ isUp: null,
240
+ uptimeSeconds: null,
241
+ latestCpuPercent: null,
242
+ latestMemoryBytes: null,
243
+ maxMemoryBytes: null,
244
+ latestBatteryPercent: null,
245
+ latestSignalStrengthDbm: null,
246
+ latestTemperatureCelsius: null,
247
+ observedAt,
248
+ };
249
+
250
+ // deviceType / firmware ride every series the device emits.
251
+ patch.deviceType = getStringAttribute(dpAttributes, "iot.device.type");
252
+ patch.firmwareVersion = getStringAttribute(
253
+ dpAttributes,
254
+ "iot.device.firmware",
255
+ );
256
+
257
+ switch (data.metricName) {
258
+ case "iot_device_up": {
259
+ patch.isUp = rawValue >= 1;
260
+ fleet.sawDeviceIdentity = true;
261
+ fleet.sawDeviceUp = true;
262
+ break;
263
+ }
264
+ case "iot_device_info": {
265
+ // Identity-only series — carries the human-readable device name.
266
+ patch.name = getStringAttribute(dpAttributes, "name");
267
+ fleet.sawDeviceIdentity = true;
268
+ break;
269
+ }
270
+ case "iot_battery_percent": {
271
+ patch.latestBatteryPercent = rawValue;
272
+ break;
273
+ }
274
+ case "iot_signal_strength_dbm": {
275
+ patch.latestSignalStrengthDbm = rawValue;
276
+ break;
277
+ }
278
+ case "iot_temperature_celsius": {
279
+ patch.latestTemperatureCelsius = rawValue;
280
+ break;
281
+ }
282
+ case "iot_cpu_usage_ratio": {
283
+ /*
284
+ * Already a true 0..1 ratio — no allocatable-denominator cache
285
+ * needed, unlike K8s cpuCoresToPercent.
286
+ */
287
+ patch.latestCpuPercent = rawValue * 100;
288
+ break;
289
+ }
290
+ case "iot_memory_usage_bytes": {
291
+ patch.latestMemoryBytes = Math.max(0, Math.trunc(rawValue));
292
+ break;
293
+ }
294
+ case "iot_memory_size_bytes": {
295
+ patch.maxMemoryBytes = Math.max(0, Math.trunc(rawValue));
296
+ break;
297
+ }
298
+ case "iot_uptime_seconds": {
299
+ patch.uptimeSeconds = Math.max(0, Math.trunc(rawValue));
300
+ break;
301
+ }
302
+ default: {
303
+ return;
304
+ }
305
+ }
306
+
307
+ foldIoTDeviceSnapshot({
308
+ buffer: data.resourceBuffer,
309
+ fleetIdStr: data.fleetIdStr,
310
+ patch,
311
+ });
312
+ }
313
+
314
+ /*
315
+ * Merge a patch into the per-fleet IoT buffer: identity labels are
316
+ * first-non-null-wins (stable, and a batch missing an info series
317
+ * must not blank them), status/metric fields are
318
+ * newest-observedAt-wins (K8s buffer semantics).
319
+ */
320
+ export function foldIoTDeviceSnapshot(data: {
321
+ buffer: Map<string, Map<string, IoTDeviceBufferEntry>>;
322
+ fleetIdStr: string;
323
+ patch: IoTDeviceBufferEntry;
324
+ }): void {
325
+ let perFleet: Map<string, IoTDeviceBufferEntry> | undefined = data.buffer.get(
326
+ data.fleetIdStr,
327
+ );
328
+ if (!perFleet) {
329
+ perFleet = new Map();
330
+ data.buffer.set(data.fleetIdStr, perFleet);
331
+ }
332
+ const key: string = `${data.patch.kind}|${data.patch.externalId}`;
333
+ const existing: IoTDeviceBufferEntry | undefined = perFleet.get(key);
334
+ if (!existing) {
335
+ perFleet.set(key, data.patch);
336
+ return;
337
+ }
338
+
339
+ const patch: IoTDeviceBufferEntry = data.patch;
340
+ const newer: boolean = patch.observedAt >= existing.observedAt;
341
+
342
+ // Identity: first-non-null wins.
343
+ if (existing.name === null && patch.name !== null) {
344
+ existing.name = patch.name;
345
+ }
346
+ if (existing.deviceType === null && patch.deviceType !== null) {
347
+ existing.deviceType = patch.deviceType;
348
+ }
349
+ if (existing.firmwareVersion === null && patch.firmwareVersion !== null) {
350
+ existing.firmwareVersion = patch.firmwareVersion;
351
+ }
352
+
353
+ // Status / metrics: newest observation wins.
354
+ if (patch.isUp !== null && newer) {
355
+ existing.isUp = patch.isUp;
356
+ }
357
+ if (patch.uptimeSeconds !== null && newer) {
358
+ existing.uptimeSeconds = patch.uptimeSeconds;
359
+ }
360
+ if (patch.latestCpuPercent !== null && newer) {
361
+ existing.latestCpuPercent = patch.latestCpuPercent;
362
+ }
363
+ if (patch.latestMemoryBytes !== null && newer) {
364
+ existing.latestMemoryBytes = patch.latestMemoryBytes;
365
+ }
366
+ if (patch.maxMemoryBytes !== null && newer) {
367
+ existing.maxMemoryBytes = patch.maxMemoryBytes;
368
+ }
369
+ if (patch.latestBatteryPercent !== null && newer) {
370
+ existing.latestBatteryPercent = patch.latestBatteryPercent;
371
+ }
372
+ if (patch.latestSignalStrengthDbm !== null && newer) {
373
+ existing.latestSignalStrengthDbm = patch.latestSignalStrengthDbm;
374
+ }
375
+ if (patch.latestTemperatureCelsius !== null && newer) {
376
+ existing.latestTemperatureCelsius = patch.latestTemperatureCelsius;
377
+ }
378
+ if (patch.observedAt > existing.observedAt) {
379
+ existing.observedAt = patch.observedAt;
380
+ }
381
+ }
382
+
383
+ /*
384
+ * Derive the IoTFleet snapshot columns from one folded batch. Counts
385
+ * are only set when the batch carried the matching identity series —
386
+ * never zero a count on a partial batch. Returns an object whose keys
387
+ * are exactly the columns to write (empty object = nothing to write).
388
+ */
389
+ export function deriveIoTFleetSnapshotExtras(
390
+ entries: Array<IoTDeviceBufferEntry>,
391
+ snap: IoTFleetSnapshotBufferEntry | undefined,
392
+ ): IoTFleetSnapshotExtras {
393
+ const extras: IoTFleetSnapshotExtras = {};
394
+
395
+ if (snap?.sawDeviceIdentity) {
396
+ extras.deviceCount = entries.length;
397
+ }
398
+ if (snap?.sawDeviceUp) {
399
+ extras.onlineDeviceCount = entries.filter((e: IoTDeviceBufferEntry) => {
400
+ return e.isUp === true;
401
+ }).length;
402
+ }
403
+ if (snap?.agentVersion) {
404
+ extras.agentVersion = snap.agentVersion;
405
+ }
406
+
407
+ return extras;
408
+ }
@@ -15,6 +15,7 @@ function emptyMaintained(): MaintainedResourceKeys {
15
15
  kubernetesClusters: { ids: new Set<string>(), names: new Set<string>() },
16
16
  proxmoxClusters: { ids: new Set<string>(), names: new Set<string>() },
17
17
  cephClusters: { ids: new Set<string>(), names: new Set<string>() },
18
+ iotFleets: { ids: new Set<string>(), names: new Set<string>() },
18
19
  services: { ids: new Set<string>(), names: new Set<string>() },
19
20
  };
20
21
  }
@@ -105,6 +106,21 @@ describe("SeriesResourceLabels", () => {
105
106
  expect(refs.proxmoxClusterNames).toEqual(["pve-1"]);
106
107
  expect(refs.cephClusterNames).toEqual(["ceph-1"]);
107
108
  });
109
+
110
+ it("maps iot fleet name keys (prefixed and unprefixed)", () => {
111
+ const refs: SeriesResourceRefs = SeriesResourceLabels.extractResourceRefs(
112
+ {
113
+ "resource.iot.fleet.name": "fleet-1",
114
+ },
115
+ );
116
+ expect(refs.iotFleetNames).toEqual(["fleet-1"]);
117
+
118
+ const refsUnprefixed: SeriesResourceRefs =
119
+ SeriesResourceLabels.extractResourceRefs({
120
+ "iot.fleet.name": "fleet-2",
121
+ });
122
+ expect(refsUnprefixed.iotFleetNames).toEqual(["fleet-2"]);
123
+ });
108
124
  });
109
125
  });
110
126
 
@@ -171,6 +187,26 @@ describe("MonitorMaintenanceSuppression.getSuppressedFingerprintsForMaintainedRe
171
187
  ]);
172
188
  });
173
189
 
190
+ it("suppresses an IoT series whose fleet is under maintenance", () => {
191
+ const maintained: MaintainedResourceKeys = emptyMaintained();
192
+ maintained.iotFleets.names.add("warehouse-sensors");
193
+
194
+ const result: Set<string> =
195
+ MonitorMaintenanceSuppression.getSuppressedFingerprintsForMaintainedResources(
196
+ {
197
+ matchesPerSeries: [
198
+ series("fpFleet", {
199
+ "resource.iot.fleet.name": "warehouse-sensors",
200
+ }),
201
+ series("fpClear", { "resource.iot.fleet.name": "office-sensors" }),
202
+ ],
203
+ maintained,
204
+ },
205
+ );
206
+
207
+ expect(Array.from(result)).toEqual(["fpFleet"]);
208
+ });
209
+
174
210
  it("does not cross-match resource types that happen to share a name", () => {
175
211
  /*
176
212
  * A service named the same string as the breaching host must not
@@ -327,6 +327,7 @@ enum IconProp {
327
327
  DockerSwarm = "DockerSwarm",
328
328
  Podman = "Podman",
329
329
  Proxmox = "Proxmox",
330
+ IoT = "IoT",
330
331
  Ceph = "Ceph",
331
332
  Gauge = "Gauge",
332
333
  }