@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
@@ -0,0 +1,216 @@
1
+ import Label from "../../Models/DatabaseModels/Label";
2
+ import IoTFleet from "../../Models/DatabaseModels/IoTFleet";
3
+ import IoTFleetOwnerRule from "../../Models/DatabaseModels/IoTFleetOwnerRule";
4
+ import IoTFleetOwnerUser from "../../Models/DatabaseModels/IoTFleetOwnerUser";
5
+ import IoTFleetOwnerTeam from "../../Models/DatabaseModels/IoTFleetOwnerTeam";
6
+ import IoTFleetOwnerRuleService from "./IoTFleetOwnerRuleService";
7
+ import IoTFleetOwnerUserService from "./IoTFleetOwnerUserService";
8
+ import IoTFleetOwnerTeamService from "./IoTFleetOwnerTeamService";
9
+ import IoTFleetService from "./IoTFleetService";
10
+ import ObjectID from "../../Types/ObjectID";
11
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
12
+ import logger, { LogAttributes } from "../Utils/Logger";
13
+
14
+ class IoTFleetOwnerRuleEngineServiceClass {
15
+ /**
16
+ * Evaluates IoTFleetOwnerRule rows for the given IoT fleet and adds matched
17
+ * owner users / teams via IoTFleetOwnerUserService / IoTFleetOwnerTeamService. Rules
18
+ * with notifyOwners set notify the added owners; rules with notifyOwners off
19
+ * add silently.
20
+ */
21
+ @CaptureSpan()
22
+ public async applyRulesToIoTFleet(iotFleet: IoTFleet): Promise<void> {
23
+ if (!iotFleet.id || !iotFleet.projectId) {
24
+ return;
25
+ }
26
+
27
+ try {
28
+ const rules: Array<IoTFleetOwnerRule> =
29
+ await IoTFleetOwnerRuleService.findBy({
30
+ query: {
31
+ projectId: iotFleet.projectId,
32
+ isEnabled: true,
33
+ },
34
+ props: { isRoot: true },
35
+ select: {
36
+ _id: true,
37
+ name: true,
38
+ notifyOwners: true,
39
+ iotFleetLabels: { _id: true },
40
+ iotFleetNamePattern: true,
41
+ iotFleetDescriptionPattern: true,
42
+ ownerUsers: { _id: true },
43
+ ownerTeams: { _id: true },
44
+ },
45
+ limit: 100,
46
+ skip: 0,
47
+ });
48
+
49
+ if (rules.length === 0) {
50
+ return;
51
+ }
52
+
53
+ const iotFleetWithDetails: IoTFleet | null =
54
+ await IoTFleetService.findOneById({
55
+ id: iotFleet.id,
56
+ select: {
57
+ name: true,
58
+ description: true,
59
+ labels: { _id: true },
60
+ },
61
+ props: { isRoot: true },
62
+ });
63
+
64
+ if (!iotFleetWithDetails) {
65
+ return;
66
+ }
67
+
68
+ const usersByNotify: Map<boolean, Set<string>> = new Map([
69
+ [true, new Set()],
70
+ [false, new Set()],
71
+ ]);
72
+ const teamsByNotify: Map<boolean, Set<string>> = new Map([
73
+ [true, new Set()],
74
+ [false, new Set()],
75
+ ]);
76
+
77
+ const matchedRules: Array<IoTFleetOwnerRule> = [];
78
+
79
+ for (const rule of rules) {
80
+ const matches: boolean = this.doesIoTFleetMatchRule(
81
+ iotFleetWithDetails,
82
+ rule,
83
+ );
84
+ if (!matches) {
85
+ continue;
86
+ }
87
+ let ruleAddedAny: boolean = false;
88
+ const notify: boolean = rule.notifyOwners !== false;
89
+ for (const user of rule.ownerUsers || []) {
90
+ if (user.id) {
91
+ usersByNotify.get(notify)!.add(user.id.toString());
92
+ ruleAddedAny = true;
93
+ }
94
+ }
95
+ for (const team of rule.ownerTeams || []) {
96
+ if (team.id) {
97
+ teamsByNotify.get(notify)!.add(team.id.toString());
98
+ ruleAddedAny = true;
99
+ }
100
+ }
101
+ if (ruleAddedAny) {
102
+ matchedRules.push(rule);
103
+ }
104
+ }
105
+
106
+ if (matchedRules.length === 0) {
107
+ return;
108
+ }
109
+
110
+ for (const notify of [true, false]) {
111
+ const userIds: Set<string> = usersByNotify.get(notify)!;
112
+ const teamIds: Set<string> = teamsByNotify.get(notify)!;
113
+
114
+ for (const userId of userIds) {
115
+ const owner: IoTFleetOwnerUser = new IoTFleetOwnerUser();
116
+ owner.iotFleetId = iotFleet.id;
117
+ owner.projectId = iotFleet.projectId;
118
+ owner.userId = new ObjectID(userId);
119
+ owner.isOwnerNotified = !notify;
120
+ await IoTFleetOwnerUserService.create({
121
+ data: owner,
122
+ props: { isRoot: true },
123
+ });
124
+ }
125
+
126
+ for (const teamId of teamIds) {
127
+ const owner: IoTFleetOwnerTeam = new IoTFleetOwnerTeam();
128
+ owner.iotFleetId = iotFleet.id;
129
+ owner.projectId = iotFleet.projectId;
130
+ owner.teamId = new ObjectID(teamId);
131
+ owner.isOwnerNotified = !notify;
132
+ await IoTFleetOwnerTeamService.create({
133
+ data: owner,
134
+ props: { isRoot: true },
135
+ });
136
+ }
137
+ }
138
+
139
+ logger.debug(
140
+ `IoTFleetOwnerRuleEngine added owners to IoT fleet ${iotFleet.id}`,
141
+ { projectId: iotFleet.projectId.toString() } as LogAttributes,
142
+ );
143
+ } catch (error) {
144
+ logger.error(`Error applying IoT fleet owner rules: ${error}`, {
145
+ projectId: iotFleet.projectId?.toString(),
146
+ iotFleetId: iotFleet.id?.toString(),
147
+ } as LogAttributes);
148
+ }
149
+ }
150
+
151
+ private doesIoTFleetMatchRule(
152
+ iotFleet: IoTFleet,
153
+ rule: IoTFleetOwnerRule,
154
+ ): boolean {
155
+ if (rule.iotFleetLabels && rule.iotFleetLabels.length > 0) {
156
+ if (!iotFleet.labels || iotFleet.labels.length === 0) {
157
+ return false;
158
+ }
159
+ const ruleLabelIds: Array<string> = rule.iotFleetLabels.map(
160
+ (l: Label) => {
161
+ return l.id?.toString() || "";
162
+ },
163
+ );
164
+ const labelIds: Array<string> = iotFleet.labels.map((l: Label) => {
165
+ return l.id?.toString() || "";
166
+ });
167
+ if (
168
+ !ruleLabelIds.some((id: string) => {
169
+ return labelIds.includes(id);
170
+ })
171
+ ) {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ if (
177
+ rule.iotFleetNamePattern &&
178
+ (!iotFleet.name ||
179
+ !this.testRegex(rule.iotFleetNamePattern, iotFleet.name, rule))
180
+ ) {
181
+ return false;
182
+ }
183
+
184
+ if (
185
+ rule.iotFleetDescriptionPattern &&
186
+ (!iotFleet.description ||
187
+ !this.testRegex(
188
+ rule.iotFleetDescriptionPattern,
189
+ iotFleet.description,
190
+ rule,
191
+ ))
192
+ ) {
193
+ return false;
194
+ }
195
+
196
+ return true;
197
+ }
198
+
199
+ private testRegex(
200
+ pattern: string,
201
+ value: string,
202
+ rule: IoTFleetOwnerRule,
203
+ ): boolean {
204
+ try {
205
+ const regex: RegExp = new RegExp(pattern, "i");
206
+ return regex.test(value);
207
+ } catch {
208
+ logger.warn(
209
+ `Invalid regex in IoT fleet owner rule ${rule.id}: ${pattern}`,
210
+ );
211
+ return false;
212
+ }
213
+ }
214
+ }
215
+
216
+ export default new IoTFleetOwnerRuleEngineServiceClass();
@@ -0,0 +1,14 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/IoTFleetOwnerRule";
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();
@@ -0,0 +1,10 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/IoTFleetOwnerTeam";
3
+
4
+ export class Service extends DatabaseService<Model> {
5
+ public constructor() {
6
+ super(Model);
7
+ }
8
+ }
9
+
10
+ export default new Service();
@@ -0,0 +1,10 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/IoTFleetOwnerUser";
3
+
4
+ export class Service extends DatabaseService<Model> {
5
+ public constructor() {
6
+ super(Model);
7
+ }
8
+ }
9
+
10
+ export default new Service();
@@ -0,0 +1,376 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import IoTFleetLabelRuleEngineService from "./IoTFleetLabelRuleEngineService";
3
+ import IoTFleetOwnerRuleEngineService from "./IoTFleetOwnerRuleEngineService";
4
+ import Model from "../../Models/DatabaseModels/IoTFleet";
5
+ import Label from "../../Models/DatabaseModels/Label";
6
+ import { OnCreate } from "../Types/Database/Hooks";
7
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
8
+ import ObjectID from "../../Types/ObjectID";
9
+ import QueryHelper from "../Types/Database/QueryHelper";
10
+ import OneUptimeDate from "../../Types/Date";
11
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
12
+ import GlobalCache from "../Infrastructure/GlobalCache";
13
+ import logger, { LogAttributes } from "../Utils/Logger";
14
+ import crypto from "crypto";
15
+
16
+ const LAST_SEEN_CACHE_NAMESPACE: string = "iot-fleet-last-seen";
17
+ const LAST_SEEN_THROTTLE_SECONDS: number = 60;
18
+
19
+ const LABELS_APPLIED_CACHE_NAMESPACE: string = "iot-fleet-labels-applied";
20
+ const LABELS_APPLIED_CACHE_TTL_SECONDS: number = 60;
21
+
22
+ export class Service extends DatabaseService<Model> {
23
+ public constructor() {
24
+ super(Model);
25
+ }
26
+
27
+ @CaptureSpan()
28
+ protected override async onCreateSuccess(
29
+ _onCreate: OnCreate<Model>,
30
+ createdItem: Model,
31
+ ): Promise<Model> {
32
+ /*
33
+ * Rules run once, on creation only — exact parity with
34
+ * KubernetesClusterService. Label engine first: it syncs the
35
+ * in-memory labels so the owner engine can match rule-added labels.
36
+ */
37
+ if (createdItem.projectId && createdItem.id) {
38
+ Promise.resolve()
39
+ .then(async () => {
40
+ await IoTFleetLabelRuleEngineService.applyRulesToIoTFleet(
41
+ createdItem,
42
+ );
43
+ })
44
+ .then(async () => {
45
+ await IoTFleetOwnerRuleEngineService.applyRulesToIoTFleet(
46
+ createdItem,
47
+ );
48
+ })
49
+ .catch((error: Error) => {
50
+ logger.error(
51
+ `Error applying iot fleet rules in IoTFleetService.onCreateSuccess: ${error}`,
52
+ {
53
+ projectId: createdItem.projectId?.toString(),
54
+ iotFleetId: createdItem.id?.toString(),
55
+ } as LogAttributes,
56
+ );
57
+ });
58
+ }
59
+ return createdItem;
60
+ }
61
+
62
+ @CaptureSpan()
63
+ public async findOrCreateByName(data: {
64
+ projectId: ObjectID;
65
+ name: string;
66
+ }): Promise<Model> {
67
+ /*
68
+ * An IoT fleet is keyed by the `iot.fleet.name` OTel resource
69
+ * attribute, which the user configures on the agent. Look it up
70
+ * case-insensitively: the unique guard (checkUniqueColumnBy ->
71
+ * findWithSameText) compares case-insensitively, so a case-sensitive
72
+ * lookup would miss an existing row that differs only by case, then
73
+ * fail to create it — wedging ingest for that fleet. Unlike
74
+ * DockerHost.hostIdentifier (host.name casing is unstable on Windows)
75
+ * the configured fleet name's casing is stable, so we preserve the
76
+ * user's casing on create instead of canonicalizing to lowercase.
77
+ */
78
+ const name: string = data.name.trim();
79
+
80
+ const existingFleet: Model | null = await this.findOneBy({
81
+ query: {
82
+ projectId: data.projectId,
83
+ name: QueryHelper.findWithSameText(name),
84
+ },
85
+ select: {
86
+ _id: true,
87
+ projectId: true,
88
+ name: true,
89
+ },
90
+ props: {
91
+ isRoot: true,
92
+ },
93
+ });
94
+
95
+ if (existingFleet) {
96
+ return existingFleet;
97
+ }
98
+
99
+ try {
100
+ // Create new fleet
101
+ const newFleet: Model = new Model();
102
+ newFleet.projectId = data.projectId;
103
+ newFleet.name = name;
104
+ newFleet.otelCollectorStatus = "connected";
105
+ newFleet.lastSeenAt = OneUptimeDate.getCurrentDate();
106
+
107
+ const createdFleet: Model = await this.create({
108
+ data: newFleet,
109
+ props: {
110
+ isRoot: true,
111
+ },
112
+ });
113
+
114
+ return createdFleet;
115
+ } catch {
116
+ /*
117
+ * Either two ingest workers raced to create the same fleet, or a
118
+ * fleet with this name in a different case already existed and the
119
+ * unique guard rejected the insert. Re-resolve case-insensitively so
120
+ * the caller still gets the existing row instead of throwing.
121
+ */
122
+ const reFetchedFleet: Model | null = await this.findOneBy({
123
+ query: {
124
+ projectId: data.projectId,
125
+ name: QueryHelper.findWithSameText(name),
126
+ },
127
+ select: {
128
+ _id: true,
129
+ projectId: true,
130
+ name: true,
131
+ },
132
+ props: {
133
+ isRoot: true,
134
+ },
135
+ });
136
+
137
+ if (reFetchedFleet) {
138
+ return reFetchedFleet;
139
+ }
140
+
141
+ throw new Error("Failed to create or find IoT fleet: " + name);
142
+ }
143
+ }
144
+
145
+ /*
146
+ * Refresh lastSeenAt / connection status and (optionally) the
147
+ * snapshot columns the list page renders. Count columns ride this
148
+ * extras path with COALESCE-per-column semantics: a key that is
149
+ * undefined is simply not written, so a partial batch (one that
150
+ * lacked the matching *_info series) never zeroes a count. The
151
+ * 60-second extras fingerprint cache is the write throttle — the
152
+ * steady state (identical snapshot every scrape) costs one Redis
153
+ * read per batch and at most one Postgres UPDATE per minute.
154
+ *
155
+ * Two callers share this throttle with DISJOINT extras shapes: the
156
+ * metrics snapshot flush (counts, every batch) and the fenced
157
+ * autoDiscoverIoTFleet maintenance path (agentVersion only — and
158
+ * usually an all-null fingerprint, since the shipped agent config
159
+ * does not stamp oneuptime.agent.version). The single fingerprint
160
+ * covers the whole extras object, so each alternation between the
161
+ * two shapes busts the throttle: at most one extra Postgres UPDATE
162
+ * per maintenance-fence window (~5 min), which is accepted. Do NOT
163
+ * key the cache per-caller — that would let two callers each refresh
164
+ * lastSeenAt under their own throttle and is not worth the
165
+ * complexity for one UPDATE per 5 minutes.
166
+ */
167
+ @CaptureSpan()
168
+ public async updateLastSeen(
169
+ fleetId: ObjectID,
170
+ extra?: {
171
+ agentVersion?: string | undefined;
172
+ deviceCount?: number | undefined;
173
+ onlineDeviceCount?: number | undefined;
174
+ },
175
+ ): Promise<void> {
176
+ const cacheKey: string = fleetId.toString();
177
+ const extrasFingerprint: string = crypto
178
+ .createHash("sha1")
179
+ .update(
180
+ JSON.stringify({
181
+ agentVersion: extra?.agentVersion ?? null,
182
+ deviceCount: extra?.deviceCount ?? null,
183
+ onlineDeviceCount: extra?.onlineDeviceCount ?? null,
184
+ }),
185
+ )
186
+ .digest("hex");
187
+
188
+ let cached: string | null = null;
189
+ try {
190
+ cached = await GlobalCache.getString(LAST_SEEN_CACHE_NAMESPACE, cacheKey);
191
+ } catch {
192
+ /*
193
+ * Cache unavailable — fail open and refresh lastSeenAt anyway. A
194
+ * cache error must never skip the DB write below, otherwise the
195
+ * resource is wrongly marked "disconnected" while telemetry is
196
+ * still flowing. Mirrors shouldRunMaintenance's fail-open stance.
197
+ */
198
+ cached = null;
199
+ }
200
+
201
+ if (cached === extrasFingerprint) {
202
+ return; // same data was written recently
203
+ }
204
+
205
+ try {
206
+ await GlobalCache.setString(
207
+ LAST_SEEN_CACHE_NAMESPACE,
208
+ cacheKey,
209
+ extrasFingerprint,
210
+ { expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS },
211
+ );
212
+ } catch {
213
+ // Best-effort throttle write; proceed with the DB update regardless.
214
+ }
215
+
216
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ const data: any = {
218
+ lastSeenAt: OneUptimeDate.getCurrentDate(),
219
+ otelCollectorStatus: "connected",
220
+ };
221
+
222
+ if (extra?.agentVersion) {
223
+ data.agentVersion = extra.agentVersion;
224
+ }
225
+ // Counts: 0 is a legitimate value — gate on undefined, not falsiness.
226
+ if (extra?.deviceCount !== undefined) {
227
+ data.deviceCount = extra.deviceCount;
228
+ }
229
+ if (extra?.onlineDeviceCount !== undefined) {
230
+ data.onlineDeviceCount = extra.onlineDeviceCount;
231
+ }
232
+
233
+ /*
234
+ * Heartbeat write: a single-statement UPDATE with no hooks and no
235
+ * `version` bump, avoiding the hot-row Postgres lock convoy that the
236
+ * full updateOneById pipeline causes. See ServiceService.updateLastSeen.
237
+ */
238
+ await this.updateColumnsByIdWithoutHooks({
239
+ id: fleetId,
240
+ data: data,
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Additively attach labels to an IoT fleet. Existing labels are
246
+ * never removed — manual labels set via the UI survive ingest. The
247
+ * set of labelIds passed in is fingerprinted and cached for 60s so
248
+ * the common case (steady-state collector pushing the same label
249
+ * set every batch) costs one in-memory lookup, not a join-table
250
+ * scan.
251
+ */
252
+ @CaptureSpan()
253
+ public async attachLabels(data: {
254
+ iotFleetId: ObjectID;
255
+ labelIds: Array<ObjectID>;
256
+ }): Promise<void> {
257
+ if (!data.labelIds || data.labelIds.length === 0) {
258
+ return;
259
+ }
260
+
261
+ const cacheKey: string = data.iotFleetId.toString();
262
+ const fingerprint: string = fingerprintLabelIds(data.labelIds);
263
+ const cached: string | null = await GlobalCache.getString(
264
+ LABELS_APPLIED_CACHE_NAMESPACE,
265
+ cacheKey,
266
+ );
267
+ if (cached === fingerprint) {
268
+ return;
269
+ }
270
+
271
+ try {
272
+ const iotFleetIdStr: string = data.iotFleetId.toString();
273
+ const existingLabels: Array<Label> = await this.getRepository()
274
+ .createQueryBuilder()
275
+ .relation(Model, "labels")
276
+ .of(iotFleetIdStr)
277
+ .loadMany();
278
+
279
+ const existingIds: Set<string> = new Set();
280
+ for (const lbl of existingLabels) {
281
+ const idStr: string | undefined = lbl._id?.toString();
282
+ if (idStr) {
283
+ existingIds.add(idStr);
284
+ }
285
+ }
286
+
287
+ const toAddIds: Array<string> = [];
288
+ const seen: Set<string> = new Set();
289
+ for (const id of data.labelIds) {
290
+ const idStr: string = id.toString();
291
+ if (existingIds.has(idStr) || seen.has(idStr)) {
292
+ continue;
293
+ }
294
+ seen.add(idStr);
295
+ toAddIds.push(idStr);
296
+ }
297
+
298
+ if (toAddIds.length > 0) {
299
+ await this.getRepository()
300
+ .createQueryBuilder()
301
+ .relation(Model, "labels")
302
+ .of(iotFleetIdStr)
303
+ .add(toAddIds);
304
+ }
305
+
306
+ await GlobalCache.setString(
307
+ LABELS_APPLIED_CACHE_NAMESPACE,
308
+ cacheKey,
309
+ fingerprint,
310
+ { expiresInSeconds: LABELS_APPLIED_CACHE_TTL_SECONDS },
311
+ );
312
+ } catch (err) {
313
+ logger.warn(
314
+ `IoTFleetService.attachLabels failed for iot fleet ${data.iotFleetId.toString()}: ${
315
+ err instanceof Error ? err.message : String(err)
316
+ }`,
317
+ );
318
+ }
319
+ }
320
+
321
+ @CaptureSpan()
322
+ public async markDisconnectedFleets(): Promise<void> {
323
+ /*
324
+ * Threshold must stay well above the 5-minute OTel ingest
325
+ * maintenance fence (MAINTENANCE_FENCE_TTL_SECONDS in
326
+ * OtelIngestBaseService) — lastSeenAt is legitimately up to
327
+ * ~5 minutes stale during continuous telemetry, so a threshold
328
+ * equal to the fence TTL flaps healthy resources. 15 minutes
329
+ * gives 3x headroom.
330
+ */
331
+ const fifteenMinutesAgo: Date = OneUptimeDate.addRemoveMinutes(
332
+ OneUptimeDate.getCurrentDate(),
333
+ -15,
334
+ );
335
+
336
+ const connectedFleets: Array<Model> = await this.findBy({
337
+ query: {
338
+ otelCollectorStatus: "connected",
339
+ lastSeenAt: QueryHelper.lessThan(fifteenMinutesAgo),
340
+ },
341
+ select: {
342
+ _id: true,
343
+ },
344
+ limit: LIMIT_MAX,
345
+ skip: 0,
346
+ props: {
347
+ isRoot: true,
348
+ },
349
+ });
350
+
351
+ for (const fleet of connectedFleets) {
352
+ if (fleet._id) {
353
+ await this.updateOneById({
354
+ id: new ObjectID(fleet._id.toString()),
355
+ data: {
356
+ otelCollectorStatus: "disconnected",
357
+ },
358
+ props: {
359
+ isRoot: true,
360
+ },
361
+ });
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ function fingerprintLabelIds(labelIds: Array<ObjectID>): string {
368
+ const sorted: Array<string> = labelIds
369
+ .map((id: ObjectID) => {
370
+ return id.toString();
371
+ })
372
+ .sort();
373
+ return crypto.createHash("sha1").update(sorted.join(",")).digest("hex");
374
+ }
375
+
376
+ export default new Service();
@@ -28,6 +28,7 @@ import DockerSwarmCluster from "../../Models/DatabaseModels/DockerSwarmCluster";
28
28
  import ServerlessFunction from "../../Models/DatabaseModels/ServerlessFunction";
29
29
  import CloudResource from "../../Models/DatabaseModels/CloudResource";
30
30
  import RumApplication from "../../Models/DatabaseModels/RumApplication";
31
+ import IoTFleet from "../../Models/DatabaseModels/IoTFleet";
31
32
  import HostService from "./HostService";
32
33
  import DockerHostService from "./DockerHostService";
33
34
  import PodmanHostService from "./PodmanHostService";
@@ -38,6 +39,7 @@ import DockerSwarmClusterService from "./DockerSwarmClusterService";
38
39
  import ServerlessFunctionService from "./ServerlessFunctionService";
39
40
  import CloudResourceService from "./CloudResourceService";
40
41
  import RumApplicationService from "./RumApplicationService";
42
+ import IoTFleetService from "./IoTFleetService";
41
43
  import GlobalCache from "../Infrastructure/GlobalCache";
42
44
  import ColumnLength from "../../Types/Database/ColumnLength";
43
45
  import EntityType from "../../Types/Telemetry/EntityType";
@@ -728,6 +730,20 @@ export default class OTelIngestService {
728
730
  telemetryRetentionConfig: cluster?.telemetryRetentionConfig ?? null,
729
731
  };
730
732
  }
733
+ if (primaryEntityType === ServiceType.IoTDevice) {
734
+ const fleet: IoTFleet | null = await IoTFleetService.findOneById({
735
+ id: resourceId,
736
+ select: {
737
+ retainTelemetryDataForDays: true,
738
+ telemetryRetentionConfig: true,
739
+ },
740
+ props: { isRoot: true },
741
+ });
742
+ return {
743
+ retainTelemetryDataForDays: fleet?.retainTelemetryDataForDays ?? null,
744
+ telemetryRetentionConfig: fleet?.telemetryRetentionConfig ?? null,
745
+ };
746
+ }
731
747
  if (primaryEntityType === ServiceType.CephCluster) {
732
748
  const cluster: CephCluster | null =
733
749
  await CephClusterService.findOneById({