@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
@@ -45,6 +45,10 @@ import StatusPageSubscriberService from "./StatusPageSubscriberService";
45
45
  import StatusPageSubscriber from "../../Models/DatabaseModels/StatusPageSubscriber";
46
46
  import MailService from "./MailService";
47
47
  import EmailTemplateType from "../../Types/Email/EmailTemplateType";
48
+ import StatusPageSubscriberNotificationTemplateService from "./StatusPageSubscriberNotificationTemplateService";
49
+ import StatusPageSubscriberNotificationTemplate from "../../Models/DatabaseModels/StatusPageSubscriberNotificationTemplate";
50
+ import StatusPageSubscriberNotificationEventType from "../../Types/StatusPage/StatusPageSubscriberNotificationEventType";
51
+ import StatusPageSubscriberNotificationMethod from "../../Types/StatusPage/StatusPageSubscriberNotificationMethod";
48
52
  import { StatusPageApiRoute } from "../../ServiceRoute";
49
53
  import ProjectSMTPConfigService from "./ProjectSmtpConfigService";
50
54
  import StatusPageResource from "../../Models/DatabaseModels/StatusPageResource";
@@ -867,6 +871,25 @@ export class Service extends DatabaseService<StatusPage> {
867
871
  historyDays: statuspage.reportDataInDays || 14,
868
872
  });
869
873
 
874
+ /*
875
+ * Look up a custom report email template for this status page (if any).
876
+ * When present (and a custom SMTP is configured, mirroring the gating used
877
+ * for other subscriber notifications), the subscriber receives the custom
878
+ * template instead of the built-in StatusPageSubscriberReport.hbs. The
879
+ * custom body is rendered through Handlebars on the notification side
880
+ * (templateType omitted => MailService compiles the body string with the
881
+ * same `report` vars, helpers and partials), so it supports loops/
882
+ * conditionals such as {{#each report.resources}}.
883
+ */
884
+ const customReportEmailTemplate: StatusPageSubscriberNotificationTemplate | null =
885
+ await StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
886
+ {
887
+ statusPageId: statuspage.id!,
888
+ eventType: StatusPageSubscriberNotificationEventType.SubscriberReport,
889
+ notificationMethod: StatusPageSubscriberNotificationMethod.Email,
890
+ },
891
+ );
892
+
870
893
  type SendEmailFunction = (
871
894
  email: Email,
872
895
  unsubscribeUrl: URL | null,
@@ -878,33 +901,53 @@ export class Service extends DatabaseService<StatusPage> {
878
901
  ): Promise<void> => {
879
902
  // send email here.
880
903
 
904
+ const vars: Dictionary<string | JSONObject> = {
905
+ statusPageName: statusPageName,
906
+ subscriberEmailNotificationFooterText:
907
+ Service.getSubscriberEmailFooterText(statuspage),
908
+ statusPageUrl: statusPageURL,
909
+ detailsUrl: statusPageURL,
910
+ hasResources: report.totalResources > 0 ? "true" : "false",
911
+ report: report as any,
912
+ logoUrl:
913
+ statuspage.logoFileId && statusPageIdString
914
+ ? new URL(httpProtocol, host)
915
+ .addRoute(StatusPageApiRoute)
916
+ .addRoute(`/logo/${statusPageIdString}`)
917
+ .toString()
918
+ : "",
919
+ isPublicStatusPage: statuspage.isPublicStatusPage ? "true" : "false",
920
+
921
+ unsubscribeUrl: unsubscribeUrl?.toString() || "",
922
+ };
923
+
924
+ /*
925
+ * Use the custom template only when a custom SMTP is configured for the
926
+ * status page, matching the gating used by the other subscriber
927
+ * notifications (e.g. Incident). Custom-authored HTML is then sent from
928
+ * the customer's own mail server rather than the shared servers.
929
+ */
930
+ const useCustomTemplate: boolean = Boolean(
931
+ customReportEmailTemplate?.templateBody && statuspage.smtpConfig,
932
+ );
933
+
881
934
  MailService.sendMail(
882
- {
883
- toEmail: email,
884
- templateType: EmailTemplateType.StatusPageSubscriberReport,
885
- vars: {
886
- statusPageName: statusPageName,
887
- subscriberEmailNotificationFooterText:
888
- Service.getSubscriberEmailFooterText(statuspage),
889
- statusPageUrl: statusPageURL,
890
- detailsUrl: statusPageURL,
891
- hasResources: report.totalResources > 0 ? "true" : "false",
892
- report: report as any,
893
- logoUrl:
894
- statuspage.logoFileId && statusPageIdString
895
- ? new URL(httpProtocol, host)
896
- .addRoute(StatusPageApiRoute)
897
- .addRoute(`/logo/${statusPageIdString}`)
898
- .toString()
899
- : "",
900
- isPublicStatusPage: statuspage.isPublicStatusPage
901
- ? "true"
902
- : "false",
903
-
904
- unsubscribeUrl: unsubscribeUrl?.toString() || "",
905
- },
906
- subject: "[Report] " + statusPageName,
907
- },
935
+ useCustomTemplate
936
+ ? {
937
+ toEmail: email,
938
+ // templateType omitted => body is compiled as a Handlebars string.
939
+ body: customReportEmailTemplate!.templateBody!,
940
+ vars: vars,
941
+ subject: customReportEmailTemplate!.emailSubject
942
+ ? customReportEmailTemplate!.emailSubject
943
+ : "[Report] " + statusPageName,
944
+ }
945
+ : {
946
+ toEmail: email,
947
+ templateType: EmailTemplateType.StatusPageSubscriberReport,
948
+ vars: vars,
949
+ subject: "[Report] " + statusPageName,
950
+ },
908
951
  {
909
952
  mailServer: ProjectSMTPConfigService.toEmailServer(
910
953
  statuspage.smtpConfig,
@@ -245,6 +245,42 @@ export class Service extends DatabaseService<Model> {
245
245
  },
246
246
  ];
247
247
 
248
+ case StatusPageSubscriberNotificationEventType.SubscriberReport:
249
+ /*
250
+ * The report template is rendered through the full Handlebars engine
251
+ * with the structured `report` object, so the per-resource fields are
252
+ * accessed inside {{#each report.resources}} rather than as flat
253
+ * scalars. The entries below document the top-level paths.
254
+ */
255
+ return [
256
+ ...commonVariables,
257
+ {
258
+ name: "report.reportDates",
259
+ description: "The reporting period",
260
+ },
261
+ {
262
+ name: "report.averageUptimePercent",
263
+ description: "Average uptime across all resources",
264
+ },
265
+ {
266
+ name: "report.totalDowntimeInHoursAndMinutes",
267
+ description: "Total downtime in the period",
268
+ },
269
+ {
270
+ name: "report.totalIncidents",
271
+ description: "Total number of incidents in the period",
272
+ },
273
+ {
274
+ name: "report.totalResources",
275
+ description: "Number of resources on the status page",
276
+ },
277
+ {
278
+ name: "report.resources",
279
+ description:
280
+ "Array of per-resource rows (resourceName, uptimePercentAsString, downtimeInHoursAndMinutes, totalIncidentCount) to loop over with {{#each}}",
281
+ },
282
+ ];
283
+
248
284
  default:
249
285
  throw new BadDataException(`Unknown event type: ${eventType}`);
250
286
  }
@@ -29,11 +29,13 @@ import PodmanHostService from "./PodmanHostService";
29
29
  import KubernetesClusterService from "./KubernetesClusterService";
30
30
  import ProxmoxClusterService from "./ProxmoxClusterService";
31
31
  import CephClusterService from "./CephClusterService";
32
+ import IoTFleetService from "./IoTFleetService";
32
33
  import Host from "../../Models/DatabaseModels/Host";
33
34
  import DockerHost from "../../Models/DatabaseModels/DockerHost";
34
35
  import PodmanHost from "../../Models/DatabaseModels/PodmanHost";
35
36
  import KubernetesCluster from "../../Models/DatabaseModels/KubernetesCluster";
36
37
  import ProxmoxCluster from "../../Models/DatabaseModels/ProxmoxCluster";
38
+ import IoTFleet from "../../Models/DatabaseModels/IoTFleet";
37
39
  import CephCluster from "../../Models/DatabaseModels/CephCluster";
38
40
  import ServiceType from "../../Types/Telemetry/ServiceType";
39
41
  import {
@@ -478,6 +480,22 @@ export class Service extends DatabaseService<Model> {
478
480
  }
479
481
  }
480
482
 
483
+ const iotFleets: Array<IoTFleet> = await IoTFleetService.findBy({
484
+ query: { projectId: projectId },
485
+ select: { _id: true, retainTelemetryDataForDays: true },
486
+ skip: 0,
487
+ limit: LIMIT_MAX,
488
+ props: { isRoot: true },
489
+ });
490
+ for (const iotFleet of iotFleets) {
491
+ if (iotFleet.id && iotFleet.retainTelemetryDataForDays) {
492
+ retentionByServiceId.set(
493
+ iotFleet.id.toString(),
494
+ iotFleet.retainTelemetryDataForDays,
495
+ );
496
+ }
497
+ }
498
+
481
499
  return retentionByServiceId;
482
500
  }
483
501
 
@@ -568,6 +568,180 @@ export class TraceAggregationService {
568
568
  return { serviceCounts, statusCounts };
569
569
  }
570
570
 
571
+ /*
572
+ * Exact root-span vs non-root-span counts over the full window, backing the
573
+ * "Span Type" sidebar facet. Like getResourceFacetCounts this rides the
574
+ * proj_hist_by_minute aggregate projection (isRootSpan is a projection key),
575
+ * so it reads a few pre-aggregated minute rows in single-digit ms rather than
576
+ * scanning raw spans — same toStartOfMinute()/optimize_use_projections
577
+ * requirements apply.
578
+ *
579
+ * rootOnly is deliberately forced OFF here: a facet must not filter by its
580
+ * own dimension, or the unselected bucket would always read 0 and the user
581
+ * could never see how many root vs non-root spans exist (the exact confusion
582
+ * the facet is meant to resolve). Every OTHER active filter (service /
583
+ * status / ...) still applies, so the counts narrow with the rest of the
584
+ * sidebar.
585
+ */
586
+ @CaptureSpan()
587
+ public static async getRootSpanCounts(request: MultiFacetRequest): Promise<{
588
+ rootCount: number;
589
+ nonRootCount: number;
590
+ }> {
591
+ const statement: Statement = new Statement();
592
+ statement.append(
593
+ `SELECT isRootSpan, count() AS cnt FROM ${TraceAggregationService.TABLE_NAME}`,
594
+ );
595
+ statement.append(
596
+ SQL` WHERE projectId = ${{
597
+ type: TableColumnType.ObjectID,
598
+ value: request.projectId,
599
+ }} AND toStartOfMinute(startTime) >= toStartOfMinute(${{
600
+ type: TableColumnType.Date,
601
+ value: request.startTime,
602
+ }}) AND toStartOfMinute(startTime) <= toStartOfMinute(${{
603
+ type: TableColumnType.Date,
604
+ value: request.endTime,
605
+ }})`,
606
+ );
607
+
608
+ // Force rootOnly off so both buckets survive (see method comment).
609
+ TraceAggregationService.appendCommonFilters(statement, {
610
+ ...request,
611
+ rootOnly: false,
612
+ });
613
+
614
+ statement.append(" GROUP BY isRootSpan");
615
+
616
+ statement.append(
617
+ getQuerySettings({
618
+ maxExecutionTimeInSeconds: 45,
619
+ timeoutOverflowMode: "break",
620
+ additionalSettings: { optimize_use_projections: 1 },
621
+ }),
622
+ );
623
+
624
+ let rootCount: number = 0;
625
+ let nonRootCount: number = 0;
626
+
627
+ const dbResult: Results = await SpanService.executeQuery(statement);
628
+
629
+ let rows: Array<JSONObject> = [];
630
+ try {
631
+ const response: DbJSONResponse = await dbResult.json<{
632
+ data?: Array<JSONObject>;
633
+ }>();
634
+ rows = response.data || [];
635
+ } catch {
636
+ // 'break' mode can truncate JSON on timeout — degrade to zero counts.
637
+ logger.warn(
638
+ "Root span count query returned unparseable response, returning zero counts",
639
+ );
640
+ return { rootCount, nonRootCount };
641
+ }
642
+
643
+ for (const row of rows) {
644
+ const cnt: number = Number(row["cnt"] || 0);
645
+ const raw: unknown = row["isRootSpan"];
646
+ // ClickHouse Bool may serialize as true/false, 1/0, or "1"/"true".
647
+ const isRoot: boolean =
648
+ raw === true || raw === 1 || raw === "1" || raw === "true";
649
+ if (isRoot) {
650
+ rootCount += cnt;
651
+ } else {
652
+ nonRootCount += cnt;
653
+ }
654
+ }
655
+
656
+ return { rootCount, nonRootCount };
657
+ }
658
+
659
+ /*
660
+ * Exact has-exception vs no-exception counts over the window, backing the
661
+ * "Has Exception" sidebar facet. Unlike getResourceFacetCounts /
662
+ * getRootSpanCounts, hasException is not a proj_hist_by_minute key, so this
663
+ * is a base-table GROUP BY (still bounded by max_execution_time / 'break').
664
+ * It is a single low-cardinality column read, so it is cheap for typical
665
+ * windows and degrades gracefully on very wide ones.
666
+ *
667
+ * Exactness matters specifically here: exception spans are usually rare, so
668
+ * the recent-N sample (getFacetValuesFromSample) can miss them entirely and
669
+ * report 0 — actively misleading for the one facet whose purpose is to find
670
+ * exceptions. Every active filter (incl. rootOnly) still applies via
671
+ * appendCommonFilters, so the count narrows with the rest of the sidebar.
672
+ */
673
+ @CaptureSpan()
674
+ public static async getHasExceptionCounts(
675
+ request: MultiFacetRequest,
676
+ ): Promise<{
677
+ withExceptionCount: number;
678
+ withoutExceptionCount: number;
679
+ }> {
680
+ const statement: Statement = new Statement();
681
+ statement.append(
682
+ `SELECT hasException, count() AS cnt FROM ${TraceAggregationService.TABLE_NAME}`,
683
+ );
684
+ statement.append(
685
+ SQL` WHERE projectId = ${{
686
+ type: TableColumnType.ObjectID,
687
+ value: request.projectId,
688
+ }} AND startTime >= ${{
689
+ type: TableColumnType.Date,
690
+ value: request.startTime,
691
+ }} AND startTime <= ${{
692
+ type: TableColumnType.Date,
693
+ value: request.endTime,
694
+ }}`,
695
+ );
696
+
697
+ statement.append(TraceAggregationService.RETENTION_FILTER);
698
+
699
+ TraceAggregationService.appendCommonFilters(statement, request);
700
+
701
+ statement.append(" GROUP BY hasException");
702
+
703
+ statement.append(
704
+ getQuerySettings({
705
+ maxExecutionTimeInSeconds: 45,
706
+ timeoutOverflowMode: "break",
707
+ }),
708
+ );
709
+
710
+ let withExceptionCount: number = 0;
711
+ let withoutExceptionCount: number = 0;
712
+
713
+ const dbResult: Results = await SpanService.executeQuery(statement);
714
+
715
+ let rows: Array<JSONObject> = [];
716
+ try {
717
+ const response: DbJSONResponse = await dbResult.json<{
718
+ data?: Array<JSONObject>;
719
+ }>();
720
+ rows = response.data || [];
721
+ } catch {
722
+ // 'break' mode can truncate JSON on timeout — degrade to zero counts.
723
+ logger.warn(
724
+ "Has-exception count query returned unparseable response, returning zero counts",
725
+ );
726
+ return { withExceptionCount, withoutExceptionCount };
727
+ }
728
+
729
+ for (const row of rows) {
730
+ const cnt: number = Number(row["cnt"] || 0);
731
+ const raw: unknown = row["hasException"];
732
+ // ClickHouse Bool may serialize as true/false, 1/0, or "1"/"true".
733
+ const hasException: boolean =
734
+ raw === true || raw === 1 || raw === "1" || raw === "true";
735
+ if (hasException) {
736
+ withExceptionCount += cnt;
737
+ } else {
738
+ withoutExceptionCount += cnt;
739
+ }
740
+ }
741
+
742
+ return { withExceptionCount, withoutExceptionCount };
743
+ }
744
+
571
745
  private static mapStatusCodeToSeries(code: number): string {
572
746
  if (code === 1) {
573
747
  return "ok";
@@ -192,7 +192,7 @@ export default class ServerMonitorCriteria {
192
192
 
193
193
  const diskMetric: BasicDiskMetrics | undefined = (
194
194
  input.dataToProcess as ServerMonitorResponse
195
- ).basicInfrastructureMetrics?.diskMetrics.find(
195
+ ).basicInfrastructureMetrics?.diskMetrics?.find(
196
196
  (item: BasicDiskMetrics) => {
197
197
  return normalizeDiskPath(item.diskPath) === normalizedDiskPath;
198
198
  },
@@ -8,6 +8,7 @@ import Label from "../../../Models/DatabaseModels/Label";
8
8
  import Monitor from "../../../Models/DatabaseModels/Monitor";
9
9
  import OnCallDutyPolicy from "../../../Models/DatabaseModels/OnCallDutyPolicy";
10
10
  import ProxmoxCluster from "../../../Models/DatabaseModels/ProxmoxCluster";
11
+ import IoTFleet from "../../../Models/DatabaseModels/IoTFleet";
11
12
  import Service from "../../../Models/DatabaseModels/Service";
12
13
  import SortOrder from "../../../Types/BaseDatabase/SortOrder";
13
14
  import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
@@ -217,11 +218,19 @@ export default class MonitorAlert {
217
218
  continue;
218
219
  }
219
220
 
221
+ /*
222
+ * Mirror the create path: `createdCriteriaId` is only set when the
223
+ * criteria has an `id`. Guard the `.toString()` with `?.` (a criteria
224
+ * with a missing id otherwise threw "Cannot read properties of
225
+ * undefined (reading 'toString')" and failed the queue job every
226
+ * cycle) and normalise both sides to `undefined` on missing so dedupe
227
+ * stays correct instead of creating a duplicate alert each cycle.
228
+ */
220
229
  const alreadyOpenAlert: Alert | undefined = openAlerts.find(
221
230
  (alert: Alert) => {
222
231
  return (
223
- alert.createdCriteriaId ===
224
- input.criteriaInstance.data?.id.toString() &&
232
+ (alert.createdCriteriaId || undefined) ===
233
+ (input.criteriaInstance.data?.id?.toString() || undefined) &&
225
234
  (alert.seriesFingerprint || undefined) === seriesFingerprint
226
235
  );
227
236
  },
@@ -318,7 +327,9 @@ export default class MonitorAlert {
318
327
  JSON.stringify(input.dataToProcess, null, 2),
319
328
  );
320
329
 
321
- alert.createdCriteriaId = input.criteriaInstance.data.id.toString();
330
+ if (input.criteriaInstance.data?.id) {
331
+ alert.createdCriteriaId = input.criteriaInstance.data.id.toString();
332
+ }
322
333
 
323
334
  if (seriesFingerprint) {
324
335
  alert.seriesFingerprint = seriesFingerprint;
@@ -430,6 +441,15 @@ export default class MonitorAlert {
430
441
  },
431
442
  );
432
443
  }
444
+ if (clusterContext.iotFleetIds.length > 0) {
445
+ alert.iotFleets = clusterContext.iotFleetIds.map(
446
+ (id: string): IoTFleet => {
447
+ const fleet: IoTFleet = new IoTFleet();
448
+ fleet._id = id;
449
+ return fleet;
450
+ },
451
+ );
452
+ }
433
453
 
434
454
  alert.onCallDutyPolicies =
435
455
  criteriaAlert.onCallPolicyIds?.map((id: ObjectID) => {
@@ -610,7 +630,7 @@ export default class MonitorAlert {
610
630
 
611
631
  if (
612
632
  input.openAlert.createdCriteriaId?.toString() ===
613
- input.criteriaInstance?.data?.id.toString()
633
+ input.criteriaInstance?.data?.id?.toString()
614
634
  ) {
615
635
  // same alert active. So, do not close.
616
636
  return false;
@@ -1,11 +1,13 @@
1
1
  import CephCluster from "../../../Models/DatabaseModels/CephCluster";
2
2
  import DockerSwarmCluster from "../../../Models/DatabaseModels/DockerSwarmCluster";
3
+ import IoTFleet from "../../../Models/DatabaseModels/IoTFleet";
3
4
  import Monitor from "../../../Models/DatabaseModels/Monitor";
4
5
  import ProxmoxCluster from "../../../Models/DatabaseModels/ProxmoxCluster";
5
6
  import MonitorStep from "../../../Types/Monitor/MonitorStep";
6
7
  import MonitorType from "../../../Types/Monitor/MonitorType";
7
8
  import CephClusterService from "../../Services/CephClusterService";
8
9
  import DockerSwarmClusterService from "../../Services/DockerSwarmClusterService";
10
+ import IoTFleetService from "../../Services/IoTFleetService";
9
11
  import ProxmoxClusterService from "../../Services/ProxmoxClusterService";
10
12
  import QueryHelper from "../../Types/Database/QueryHelper";
11
13
  import logger from "../Logger";
@@ -26,6 +28,7 @@ export interface MonitorClusterContext {
26
28
  proxmoxClusterIds: Array<string>;
27
29
  cephClusterIds: Array<string>;
28
30
  dockerSwarmClusterIds: Array<string>;
31
+ iotFleetIds: Array<string>;
29
32
  }
30
33
 
31
34
  export default class MonitorClusterContextUtil {
@@ -46,6 +49,7 @@ export default class MonitorClusterContextUtil {
46
49
  proxmoxClusterIds: [],
47
50
  cephClusterIds: [],
48
51
  dockerSwarmClusterIds: [],
52
+ iotFleetIds: [],
49
53
  };
50
54
 
51
55
  const monitorType: MonitorType | undefined = input.monitor.monitorType;
@@ -53,7 +57,8 @@ export default class MonitorClusterContextUtil {
53
57
  if (
54
58
  monitorType !== MonitorType.Proxmox &&
55
59
  monitorType !== MonitorType.Ceph &&
56
- monitorType !== MonitorType.DockerSwarm
60
+ monitorType !== MonitorType.DockerSwarm &&
61
+ monitorType !== MonitorType.IoTDevice
57
62
  ) {
58
63
  return context;
59
64
  }
@@ -77,6 +82,8 @@ export default class MonitorClusterContextUtil {
77
82
  } else if (monitorType === MonitorType.DockerSwarm) {
78
83
  clusterIdentifier =
79
84
  monitorStep.data?.dockerSwarmMonitor?.clusterIdentifier;
85
+ } else if (monitorType === MonitorType.IoTDevice) {
86
+ clusterIdentifier = monitorStep.data?.iotMonitor?.fleetIdentifier;
80
87
  }
81
88
 
82
89
  if (clusterIdentifier && clusterIdentifier.trim().length > 0) {
@@ -127,7 +134,7 @@ export default class MonitorClusterContextUtil {
127
134
  if (cephCluster?._id) {
128
135
  context.cephClusterIds.push(String(cephCluster._id));
129
136
  }
130
- } else {
137
+ } else if (monitorType === MonitorType.DockerSwarm) {
131
138
  const dockerSwarmCluster: DockerSwarmCluster | null =
132
139
  await DockerSwarmClusterService.findOneBy({
133
140
  query: {
@@ -145,6 +152,23 @@ export default class MonitorClusterContextUtil {
145
152
  if (dockerSwarmCluster?._id) {
146
153
  context.dockerSwarmClusterIds.push(String(dockerSwarmCluster._id));
147
154
  }
155
+ } else {
156
+ const iotFleet: IoTFleet | null = await IoTFleetService.findOneBy({
157
+ query: {
158
+ projectId: input.monitor.projectId,
159
+ name: QueryHelper.findWithSameText(clusterIdentifier),
160
+ },
161
+ select: {
162
+ _id: true,
163
+ },
164
+ props: {
165
+ isRoot: true,
166
+ },
167
+ });
168
+
169
+ if (iotFleet?._id) {
170
+ context.iotFleetIds.push(String(iotFleet._id));
171
+ }
148
172
  }
149
173
  } catch (err) {
150
174
  logger.error(
@@ -246,7 +246,8 @@ ${contextBlock}
246
246
  input.monitor.monitorType !== MonitorType.Podman &&
247
247
  input.monitor.monitorType !== MonitorType.DockerSwarm &&
248
248
  input.monitor.monitorType !== MonitorType.Proxmox &&
249
- input.monitor.monitorType !== MonitorType.Ceph
249
+ input.monitor.monitorType !== MonitorType.Ceph &&
250
+ input.monitor.monitorType !== MonitorType.IoTDevice
250
251
  ) {
251
252
  return [];
252
253
  }
@@ -697,7 +698,8 @@ ${contextBlock}
697
698
  input.monitor.monitorType === MonitorType.Podman ||
698
699
  input.monitor.monitorType === MonitorType.DockerSwarm ||
699
700
  input.monitor.monitorType === MonitorType.Proxmox ||
700
- input.monitor.monitorType === MonitorType.Ceph
701
+ input.monitor.monitorType === MonitorType.Ceph ||
702
+ input.monitor.monitorType === MonitorType.IoTDevice
701
703
  ) {
702
704
  const metricMonitorResult: string | null =
703
705
  await MetricMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
@@ -12,6 +12,7 @@ import Monitor from "../../../Models/DatabaseModels/Monitor";
12
12
  import OnCallDutyPolicy from "../../../Models/DatabaseModels/OnCallDutyPolicy";
13
13
  import PodmanHost from "../../../Models/DatabaseModels/PodmanHost";
14
14
  import ProxmoxCluster from "../../../Models/DatabaseModels/ProxmoxCluster";
15
+ import IoTFleet from "../../../Models/DatabaseModels/IoTFleet";
15
16
  import Service from "../../../Models/DatabaseModels/Service";
16
17
  import Includes from "../../../Types/BaseDatabase/Includes";
17
18
  import SortOrder from "../../../Types/BaseDatabase/SortOrder";
@@ -267,13 +268,25 @@ export default class MonitorIncident {
267
268
  continue;
268
269
  }
269
270
 
271
+ /*
272
+ * Dedupe match must mirror the create path below (which sets
273
+ * `createdCriteriaId` / `createdIncidentTemplateId` only when the
274
+ * corresponding id is present). A criteria incident template can be
275
+ * missing its `id` (legacy/API-authored criteria), so guard the
276
+ * `.toString()` with `?.` — an unguarded call previously threw
277
+ * "Cannot read properties of undefined (reading 'toString')" here and
278
+ * failed the probe/telemetry queue job on every cycle for the affected
279
+ * monitor. Normalise both sides to `undefined` on missing so a created
280
+ * incident (whose template id was left NULL) still matches itself next
281
+ * cycle instead of being recreated as a duplicate.
282
+ */
270
283
  const alreadyOpenIncident: Incident | undefined = openIncidents.find(
271
284
  (incident: Incident) => {
272
285
  return (
273
- incident.createdCriteriaId ===
274
- input.criteriaInstance.data?.id.toString() &&
275
- incident.createdIncidentTemplateId ===
276
- criteriaIncident.id.toString() &&
286
+ (incident.createdCriteriaId || undefined) ===
287
+ (input.criteriaInstance.data?.id?.toString() || undefined) &&
288
+ (incident.createdIncidentTemplateId || undefined) ===
289
+ (criteriaIncident.id?.toString() || undefined) &&
277
290
  (incident.seriesFingerprint || undefined) === seriesFingerprint
278
291
  );
279
292
  },
@@ -808,6 +821,27 @@ export default class MonitorIncident {
808
821
 
809
822
  input.incident.dockerSwarmClusters = merged;
810
823
  }
824
+
825
+ if (input.clusterContext.iotFleetIds.length > 0) {
826
+ const existingIds: Set<string> = new Set<string>(
827
+ (input.incident.iotFleets || []).map((fleet: IoTFleet) => {
828
+ return String(fleet._id);
829
+ }),
830
+ );
831
+
832
+ const merged: Array<IoTFleet> = [...(input.incident.iotFleets || [])];
833
+
834
+ for (const id of input.clusterContext.iotFleetIds) {
835
+ if (existingIds.has(id)) {
836
+ continue;
837
+ }
838
+ const fleet: IoTFleet = new IoTFleet();
839
+ fleet._id = id;
840
+ merged.push(fleet);
841
+ }
842
+
843
+ input.incident.iotFleets = merged;
844
+ }
811
845
  }
812
846
 
813
847
  /*
@@ -1002,7 +1036,7 @@ export default class MonitorIncident {
1002
1036
 
1003
1037
  if (
1004
1038
  input.openIncident.createdCriteriaId?.toString() ===
1005
- input.criteriaInstance?.data?.id.toString()
1039
+ input.criteriaInstance?.data?.id?.toString()
1006
1040
  ) {
1007
1041
  // same incident active. So, do not close.
1008
1042
  return false;