@oneuptime/common 11.3.20 → 11.3.22

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 (212) hide show
  1. package/Models/AnalyticsModels/AnalyticsBaseModel/CommonModel.ts +2 -0
  2. package/Models/AnalyticsModels/ExceptionInstance.ts +1 -0
  3. package/Models/AnalyticsModels/Log.ts +1 -0
  4. package/Models/AnalyticsModels/Metric.ts +1 -0
  5. package/Models/AnalyticsModels/MonitorLog.ts +1 -0
  6. package/Models/AnalyticsModels/Span.ts +1 -0
  7. package/Models/DatabaseModels/Alert.ts +100 -0
  8. package/Models/DatabaseModels/AlertInternalNote.ts +2 -0
  9. package/Models/DatabaseModels/AlertReminderRule.ts +597 -0
  10. package/Models/DatabaseModels/AlertSeverity.ts +2 -0
  11. package/Models/DatabaseModels/AlertState.ts +2 -0
  12. package/Models/DatabaseModels/AlertStateTimeline.ts +2 -0
  13. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +173 -0
  14. package/Models/DatabaseModels/GlobalConfig.ts +19 -0
  15. package/Models/DatabaseModels/Incident.ts +100 -0
  16. package/Models/DatabaseModels/IncidentInternalNote.ts +2 -0
  17. package/Models/DatabaseModels/IncidentPublicNote.ts +2 -0
  18. package/Models/DatabaseModels/IncidentReminderRule.ts +597 -0
  19. package/Models/DatabaseModels/IncidentSeverity.ts +2 -0
  20. package/Models/DatabaseModels/IncidentStateTimeline.ts +2 -0
  21. package/Models/DatabaseModels/Index.ts +8 -0
  22. package/Models/DatabaseModels/Label.ts +2 -0
  23. package/Models/DatabaseModels/MonitorStatusTimeline.ts +2 -0
  24. package/Models/DatabaseModels/ScheduledMaintenance.ts +100 -0
  25. package/Models/DatabaseModels/ScheduledMaintenanceReminderRule.ts +585 -0
  26. package/Models/DatabaseModels/ScheduledMaintenanceState.ts +2 -0
  27. package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +2 -0
  28. package/Models/DatabaseModels/StatusPageAnnouncement.ts +2 -0
  29. package/Server/API/DashboardAPI.ts +221 -0
  30. package/Server/API/EnterpriseLicenseAPI.ts +279 -12
  31. package/Server/API/GlobalConfigAPI.ts +81 -16
  32. package/Server/API/StatusPageAPI.ts +894 -791
  33. package/Server/EnvironmentConfig.ts +9 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.ts +47 -0
  35. package/Server/Infrastructure/Postgres/SchemaMigrations/1783234451106-AddIncidentAlertReminderRules.ts +211 -0
  36. package/Server/Infrastructure/Postgres/SchemaMigrations/1783320000000-AddScheduledMaintenanceReminderRules.ts +113 -0
  37. package/Server/Infrastructure/Postgres/SchemaMigrations/1783330000000-AddRemindWhileScheduledToScheduledMaintenanceReminderRule.ts +20 -0
  38. package/Server/Infrastructure/Postgres/SchemaMigrations/1783340000000-AddLabelsToIncidentAndAlertReminderRules.ts +69 -0
  39. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  40. package/Server/Services/AlertReminderRuleService.ts +253 -0
  41. package/Server/Services/AlertService.ts +106 -0
  42. package/Server/Services/EnterpriseLicenseInstanceService.ts +10 -0
  43. package/Server/Services/IncidentReminderRuleService.ts +256 -0
  44. package/Server/Services/IncidentService.ts +143 -0
  45. package/Server/Services/Index.ts +8 -0
  46. package/Server/Services/IoTDeviceService.ts +99 -4
  47. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +236 -0
  48. package/Server/Services/ScheduledMaintenanceService.ts +127 -0
  49. package/Server/Services/StatusPageService.ts +104 -45
  50. package/Server/Services/TelemetryExceptionService.ts +123 -0
  51. package/Server/Services/UserNotificationSettingService.ts +24 -0
  52. package/Server/Utils/AnalyticsDatabase/Statement.ts +7 -3
  53. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +35 -0
  54. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +6 -4
  55. package/Server/Utils/Monitor/MonitorCriteriaDataExtractor.ts +7 -6
  56. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +6 -4
  57. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +45 -7
  58. package/Server/Utils/WhatsAppTemplateUtil.ts +10 -0
  59. package/ServiceRoute.ts +4 -0
  60. package/Tests/Server/Services/IoTDeviceService.test.ts +280 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +49 -0
  62. package/Tests/Server/Utils/Monitor/Criteria/TelemetryStepMetricAlias.test.ts +390 -0
  63. package/Tests/Server/Utils/Telemetry/IoTSnapshotScan.test.ts +202 -0
  64. package/Tests/Utils/EnterpriseLicenseUsage.test.ts +165 -0
  65. package/Types/Email/EmailTemplateType.ts +3 -0
  66. package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +18 -0
  67. package/Types/Monitor/MonitorStep.ts +32 -0
  68. package/Types/NotificationSetting/NotificationSettingEventType.ts +3 -0
  69. package/Types/Permission.ts +144 -0
  70. package/Types/Reminder/ReminderStopState.ts +6 -0
  71. package/Types/Reminder/ScheduledMaintenanceReminderStopState.ts +6 -0
  72. package/Types/WhatsApp/WhatsAppTemplates.ts +14 -0
  73. package/UI/Components/EditionLabel/EditionLabel.tsx +172 -0
  74. package/Utils/EnterpriseLicense/EnterpriseLicenseUsage.ts +125 -0
  75. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/CommonModel.js.map +1 -1
  76. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +1 -0
  77. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  78. package/build/dist/Models/AnalyticsModels/Log.js +1 -0
  79. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  80. package/build/dist/Models/AnalyticsModels/Metric.js +1 -0
  81. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  82. package/build/dist/Models/AnalyticsModels/MonitorLog.js +1 -0
  83. package/build/dist/Models/AnalyticsModels/MonitorLog.js.map +1 -1
  84. package/build/dist/Models/AnalyticsModels/Span.js +1 -0
  85. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/Alert.js +103 -0
  87. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/AlertInternalNote.js +2 -0
  89. package/build/dist/Models/DatabaseModels/AlertInternalNote.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/AlertReminderRule.js +605 -0
  91. package/build/dist/Models/DatabaseModels/AlertReminderRule.js.map +1 -0
  92. package/build/dist/Models/DatabaseModels/AlertSeverity.js +2 -0
  93. package/build/dist/Models/DatabaseModels/AlertSeverity.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/AlertState.js +2 -0
  95. package/build/dist/Models/DatabaseModels/AlertState.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/AlertStateTimeline.js +2 -0
  97. package/build/dist/Models/DatabaseModels/AlertStateTimeline.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +197 -0
  99. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -0
  100. package/build/dist/Models/DatabaseModels/GlobalConfig.js +18 -0
  101. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Incident.js +103 -0
  103. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/IncidentInternalNote.js +2 -0
  105. package/build/dist/Models/DatabaseModels/IncidentInternalNote.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +2 -0
  107. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/IncidentReminderRule.js +605 -0
  109. package/build/dist/Models/DatabaseModels/IncidentReminderRule.js.map +1 -0
  110. package/build/dist/Models/DatabaseModels/IncidentSeverity.js +2 -0
  111. package/build/dist/Models/DatabaseModels/IncidentSeverity.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +2 -0
  113. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
  114. package/build/dist/Models/DatabaseModels/Index.js +8 -0
  115. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  116. package/build/dist/Models/DatabaseModels/Label.js +2 -0
  117. package/build/dist/Models/DatabaseModels/Label.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +2 -0
  119. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  120. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +103 -0
  121. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceReminderRule.js +596 -0
  123. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceReminderRule.js.map +1 -0
  124. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceState.js +2 -0
  125. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceState.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +2 -0
  127. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
  128. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +2 -0
  129. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  130. package/build/dist/Server/API/DashboardAPI.js +166 -16
  131. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  132. package/build/dist/Server/API/EnterpriseLicenseAPI.js +203 -13
  133. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  134. package/build/dist/Server/API/GlobalConfigAPI.js +62 -15
  135. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  136. package/build/dist/Server/API/StatusPageAPI.js +74 -31
  137. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  138. package/build/dist/Server/EnvironmentConfig.js +6 -0
  139. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  140. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.js +22 -0
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.js.map +1 -0
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783234451106-AddIncidentAlertReminderRules.js +78 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783234451106-AddIncidentAlertReminderRules.js.map +1 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783320000000-AddScheduledMaintenanceReminderRules.js +44 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783320000000-AddScheduledMaintenanceReminderRules.js.map +1 -0
  146. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783330000000-AddRemindWhileScheduledToScheduledMaintenanceReminderRule.js +12 -0
  147. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783330000000-AddRemindWhileScheduledToScheduledMaintenanceReminderRule.js.map +1 -0
  148. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783340000000-AddLabelsToIncidentAndAlertReminderRules.js +30 -0
  149. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783340000000-AddLabelsToIncidentAndAlertReminderRules.js.map +1 -0
  150. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  151. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  152. package/build/dist/Server/Services/AlertReminderRuleService.js +228 -0
  153. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -0
  154. package/build/dist/Server/Services/AlertService.js +87 -0
  155. package/build/dist/Server/Services/AlertService.js.map +1 -1
  156. package/build/dist/Server/Services/EnterpriseLicenseInstanceService.js +9 -0
  157. package/build/dist/Server/Services/EnterpriseLicenseInstanceService.js.map +1 -0
  158. package/build/dist/Server/Services/IncidentReminderRuleService.js +231 -0
  159. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -0
  160. package/build/dist/Server/Services/IncidentService.js +116 -0
  161. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  162. package/build/dist/Server/Services/Index.js +8 -0
  163. package/build/dist/Server/Services/Index.js.map +1 -1
  164. package/build/dist/Server/Services/IoTDeviceService.js +66 -4
  165. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  166. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +214 -0
  167. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -0
  168. package/build/dist/Server/Services/ScheduledMaintenanceService.js +102 -0
  169. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  170. package/build/dist/Server/Services/StatusPageService.js +91 -43
  171. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  172. package/build/dist/Server/Services/TelemetryExceptionService.js +101 -0
  173. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  174. package/build/dist/Server/Services/UserNotificationSettingService.js +6 -0
  175. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  176. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js +4 -2
  177. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js.map +1 -1
  178. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +29 -0
  179. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  180. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +4 -5
  181. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  182. package/build/dist/Server/Utils/Monitor/MonitorCriteriaDataExtractor.js +6 -5
  183. package/build/dist/Server/Utils/Monitor/MonitorCriteriaDataExtractor.js.map +1 -1
  184. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +9 -9
  185. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  186. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +33 -2
  187. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
  188. package/build/dist/Server/Utils/WhatsAppTemplateUtil.js +6 -0
  189. package/build/dist/Server/Utils/WhatsAppTemplateUtil.js.map +1 -1
  190. package/build/dist/ServiceRoute.js +1 -0
  191. package/build/dist/ServiceRoute.js.map +1 -1
  192. package/build/dist/Types/Email/EmailTemplateType.js +3 -0
  193. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  194. package/build/dist/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.js +2 -0
  195. package/build/dist/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.js.map +1 -0
  196. package/build/dist/Types/Monitor/MonitorStep.js +25 -0
  197. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  198. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  199. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  200. package/build/dist/Types/Permission.js +126 -0
  201. package/build/dist/Types/Permission.js.map +1 -1
  202. package/build/dist/Types/Reminder/ReminderStopState.js +7 -0
  203. package/build/dist/Types/Reminder/ReminderStopState.js.map +1 -0
  204. package/build/dist/Types/Reminder/ScheduledMaintenanceReminderStopState.js +7 -0
  205. package/build/dist/Types/Reminder/ScheduledMaintenanceReminderStopState.js.map +1 -0
  206. package/build/dist/Types/WhatsApp/WhatsAppTemplates.js +9 -0
  207. package/build/dist/Types/WhatsApp/WhatsAppTemplates.js.map +1 -1
  208. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +92 -0
  209. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  210. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js +83 -0
  211. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js.map +1 -0
  212. package/package.json +1 -1
@@ -29,6 +29,7 @@ import MonitorCriteriaExpectationBuilder from "./MonitorCriteriaExpectationBuild
29
29
  import MetricMonitorResponse from "../../../Types/Monitor/MetricMonitor/MetricMonitorResponse";
30
30
  import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
31
31
  import MetricFormulaConfigData from "../../../Types/Metrics/MetricFormulaConfigData";
32
+ import MetricsViewConfig from "../../../Types/Metrics/MetricsViewConfig";
32
33
  import MetricUnitUtil from "../../../Utils/MetricUnitUtil";
33
34
 
34
35
  export default class MonitorCriteriaObservationBuilder {
@@ -1208,12 +1209,13 @@ export default class MonitorCriteriaObservationBuilder {
1208
1209
  return input.values;
1209
1210
  }
1210
1211
 
1212
+ const metricViewConfig: MetricsViewConfig | undefined =
1213
+ MonitorStep.getMetricsViewConfig(input.monitorStep);
1214
+
1211
1215
  const queryConfigs: Array<MetricQueryConfigData> =
1212
- input.monitorStep.data?.metricMonitor?.metricViewConfig?.queryConfigs ||
1213
- [];
1216
+ metricViewConfig?.queryConfigs || [];
1214
1217
  const formulaConfigs: Array<MetricFormulaConfigData> =
1215
- input.monitorStep.data?.metricMonitor?.metricViewConfig?.formulaConfigs ||
1216
- [];
1218
+ metricViewConfig?.formulaConfigs || [];
1217
1219
 
1218
1220
  const alias: string | null = input.alias;
1219
1221
  const matchedQuery: MetricQueryConfigData | undefined = alias
@@ -1,3 +1,4 @@
1
+ import ColumnLength from "../../../Types/Database/ColumnLength";
1
2
  import OneUptimeDate from "../../../Types/Date";
2
3
  import { JSONArray, JSONObject, JSONValue } from "../../../Types/JSON";
3
4
  import logger from "../Logger";
@@ -109,6 +110,29 @@ function toNumberOrNull(value: unknown): number | null {
109
110
  return null;
110
111
  }
111
112
 
113
+ /*
114
+ * A device with a future-skewed clock must not be allowed to stamp a
115
+ * future observedAt: it becomes lastSeenAt / metricsUpdatedAt, and the
116
+ * `>=` dominance guards in IoTDeviceService would then reject every
117
+ * legitimate (present-time) update until the wall clock catches up to
118
+ * the skew. Anything more than this far ahead of the ingest clock is
119
+ * folded back to "now" at ingest time.
120
+ */
121
+ export const IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES: number = 5;
122
+
123
+ export function clampIoTTimestamp(date: Date, nowOverride?: Date): Date {
124
+ const now: Date = nowOverride || OneUptimeDate.getCurrentDate();
125
+ const maxAllowedMs: number =
126
+ now.getTime() + IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES * 60 * 1000;
127
+ if (date.getTime() > maxAllowedMs) {
128
+ logger.debug(
129
+ `IoT timestamp ${date.toISOString()} is beyond the ${IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES}m future-skew tolerance; clamping to ingest time.`,
130
+ );
131
+ return now;
132
+ }
133
+ return date;
134
+ }
135
+
112
136
  /*
113
137
  * Same fall-back-to-now parse contract as the ingest service's
114
138
  * safeParseUnixNano — only the Date is needed on the snapshot path.
@@ -200,9 +224,11 @@ export function bufferIoTSnapshotMetric(data: {
200
224
  return;
201
225
  }
202
226
 
203
- const observedAt: Date = parseUnixNanoToDate(
204
- data.datapoint["timeUnixNano"] as string | number | undefined,
205
- "iot snapshot timeUnixNano",
227
+ const observedAt: Date = clampIoTTimestamp(
228
+ parseUnixNanoToDate(
229
+ data.datapoint["timeUnixNano"] as string | number | undefined,
230
+ "iot snapshot timeUnixNano",
231
+ ),
206
232
  );
207
233
 
208
234
  const dpAttributes: JSONArray =
@@ -214,14 +240,26 @@ export function bufferIoTSnapshotMetric(data: {
214
240
  );
215
241
 
216
242
  // Identity lives in the `device.id` datapoint label.
217
- const externalId: string | null = getStringAttribute(
218
- dpAttributes,
219
- "device.id",
220
- );
243
+ let externalId: string | null = getStringAttribute(dpAttributes, "device.id");
221
244
  if (!externalId) {
222
245
  return;
223
246
  }
224
247
 
248
+ /*
249
+ * The inventory identity columns are ShortText (100 chars). Truncate
250
+ * over-long ids at ingest so the fold key, the inventory upsert and
251
+ * the latest-metric mirror all agree on the same (truncated)
252
+ * identity — IoTDeviceService also sanitizes before building its
253
+ * INSERT chunks, so one malformed device can never fail the chunk
254
+ * and drop the other rows with it.
255
+ */
256
+ if (externalId.length > ColumnLength.ShortText) {
257
+ logger.warn(
258
+ `IoT device.id exceeds ${ColumnLength.ShortText} chars; truncating to "${externalId.substring(0, ColumnLength.ShortText)}".`,
259
+ );
260
+ externalId = externalId.substring(0, ColumnLength.ShortText);
261
+ }
262
+
225
263
  /*
226
264
  * Kind/type/firmware: read from the iot.device.* attributes the
227
265
  * device SDK stamps; kind falls back to "Device" so inventory still
@@ -35,6 +35,8 @@ const templateDashboardLinkVariableMap: Partial<
35
35
  [WhatsAppTemplateIds.IncidentStateChangedOwnerNotification]: "incident_link",
36
36
  [WhatsAppTemplateIds.IncidentOwnerAddedNotification]: "incident_link",
37
37
  [WhatsAppTemplateIds.IncidentMemberAddedNotification]: "incident_link",
38
+ [WhatsAppTemplateIds.IncidentReminderOwnerNotification]: "incident_link",
39
+ [WhatsAppTemplateIds.AlertReminderOwnerNotification]: "alert_link",
38
40
  [WhatsAppTemplateIds.MonitorOwnerAddedNotification]: "monitor_link",
39
41
  [WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: "monitor_link",
40
42
  [WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: "monitor_link",
@@ -48,6 +50,8 @@ const templateDashboardLinkVariableMap: Partial<
48
50
  "maintenance_link",
49
51
  [WhatsAppTemplateIds.ScheduledMaintenanceStateChangedOwnerNotification]:
50
52
  "maintenance_link",
53
+ [WhatsAppTemplateIds.ScheduledMaintenanceReminderOwnerNotification]:
54
+ "event_link",
51
55
  [WhatsAppTemplateIds.StatusPageAnnouncementCreatedOwnerNotification]:
52
56
  "status_page_link",
53
57
  [WhatsAppTemplateIds.StatusPageCreatedOwnerNotification]: "status_page_link",
@@ -78,6 +82,10 @@ const templateIdByEventType: Record<
78
82
  WhatsAppTemplateIds.IncidentOwnerAddedNotification,
79
83
  [NotificationSettingEventType.SEND_INCIDENT_MEMBER_ADDED_NOTIFICATION]:
80
84
  WhatsAppTemplateIds.IncidentMemberAddedNotification,
85
+ [NotificationSettingEventType.SEND_INCIDENT_REMINDER_OWNER_NOTIFICATION]:
86
+ WhatsAppTemplateIds.IncidentReminderOwnerNotification,
87
+ [NotificationSettingEventType.SEND_ALERT_REMINDER_OWNER_NOTIFICATION]:
88
+ WhatsAppTemplateIds.AlertReminderOwnerNotification,
81
89
  [NotificationSettingEventType.SEND_ALERT_CREATED_OWNER_NOTIFICATION]:
82
90
  WhatsAppTemplateIds.AlertCreatedOwnerNotification,
83
91
  [NotificationSettingEventType.SEND_ALERT_NOTE_POSTED_OWNER_NOTIFICATION]:
@@ -124,6 +132,8 @@ const templateIdByEventType: Record<
124
132
  WhatsAppTemplateIds.ScheduledMaintenanceOwnerAddedNotification,
125
133
  [NotificationSettingEventType.SEND_SCHEDULED_MAINTENANCE_STATE_CHANGED_OWNER_NOTIFICATION]:
126
134
  WhatsAppTemplateIds.ScheduledMaintenanceStateChangedOwnerNotification,
135
+ [NotificationSettingEventType.SEND_SCHEDULED_MAINTENANCE_REMINDER_OWNER_NOTIFICATION]:
136
+ WhatsAppTemplateIds.ScheduledMaintenanceReminderOwnerNotification,
127
137
  [NotificationSettingEventType.SEND_STATUS_PAGE_ANNOUNCEMENT_CREATED_OWNER_NOTIFICATION]:
128
138
  WhatsAppTemplateIds.StatusPageAnnouncementCreatedOwnerNotification,
129
139
  [NotificationSettingEventType.SEND_STATUS_PAGE_CREATED_OWNER_NOTIFICATION]:
package/ServiceRoute.ts CHANGED
@@ -39,3 +39,7 @@ export const DocsRoute: Route = new Route("/docs");
39
39
  export const StatusPageApiRoute: Route = new Route("/status-page-api");
40
40
 
41
41
  export const PublicDashboardRoute: Route = new Route("/public-dashboard");
42
+
43
+ export const PublicDashboardApiRoute: Route = new Route(
44
+ "/public-dashboard-api",
45
+ );
@@ -0,0 +1,280 @@
1
+ import IoTDeviceService, {
2
+ IoTDeviceLatestMetric,
3
+ ParsedIoTDevice,
4
+ } from "../../../Server/Services/IoTDeviceService";
5
+ import { IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES } from "../../../Server/Utils/Telemetry/IoTSnapshotScan";
6
+ import ObjectID from "../../../Types/ObjectID";
7
+
8
+ /*
9
+ * Malformed-device hardening for the IoTDevice inventory write path.
10
+ * The service builds raw parameterized SQL against the TypeORM manager
11
+ * — these tests mock the query runner (no Postgres) and lock in:
12
+ *
13
+ * - ShortText values (externalId and friends) truncate to the
14
+ * 100-char column limit BEFORE the chunk is built, so one bad
15
+ * device can never fail the whole 500-row INSERT and drop the
16
+ * other 499 with it,
17
+ * - identities that collide after truncation dedupe to the newest
18
+ * row (two VALUES rows on one conflict target abort the statement),
19
+ * - a future-skewed lastSeenAt / observedAt clamps to ingest time so
20
+ * the `>=` dominance guards can't get wedged by one bogus device
21
+ * clock,
22
+ * - the latest-metric mirror truncates identity the SAME way the
23
+ * upsert does, so the UPDATE still finds the row the upsert wrote.
24
+ */
25
+
26
+ const PROJECT_ID: ObjectID = ObjectID.generate();
27
+ const FLEET_ID: ObjectID = ObjectID.generate();
28
+ const SHORT_TEXT_LIMIT: number = 100;
29
+ const UPSERT_PARAMS_PER_ROW: number = 11;
30
+
31
+ type QueryCall = [string, Array<unknown>];
32
+
33
+ function mockQueryRunner(result: unknown = []): jest.Mock {
34
+ const query: jest.Mock = jest.fn().mockResolvedValue(result);
35
+ jest
36
+ .spyOn(IoTDeviceService, "getRepository")
37
+ .mockReturnValue({ manager: { query } } as any);
38
+ return query;
39
+ }
40
+
41
+ function parsedDevice(
42
+ overrides: Partial<ParsedIoTDevice> = {},
43
+ ): ParsedIoTDevice {
44
+ return {
45
+ kind: "Sensor",
46
+ externalId: "sensor-1",
47
+ name: "greenhouse-sensor",
48
+ deviceType: "temperature",
49
+ firmwareVersion: "1.2.3",
50
+ isUp: true,
51
+ uptimeSeconds: 3600,
52
+ lastSeenAt: new Date("2026-06-13T00:00:00.000Z"),
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ function latestMetric(
58
+ overrides: Partial<IoTDeviceLatestMetric> = {},
59
+ ): IoTDeviceLatestMetric {
60
+ return {
61
+ kind: "Sensor",
62
+ externalId: "sensor-1",
63
+ cpuPercent: 12.5,
64
+ memoryBytes: 1024,
65
+ maxMemoryBytes: 4096,
66
+ memoryPercent: 25,
67
+ batteryPercent: 88,
68
+ signalStrengthDbm: -61,
69
+ temperatureCelsius: 21.4,
70
+ observedAt: new Date("2026-06-13T00:00:00.000Z"),
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ afterEach(() => {
76
+ jest.restoreAllMocks();
77
+ });
78
+
79
+ describe("IoTDeviceService.bulkUpsert — ShortText sanitation", () => {
80
+ test("truncates an over-long externalId instead of failing the chunk", async () => {
81
+ const query: jest.Mock = mockQueryRunner();
82
+ const longId: string = "x".repeat(150);
83
+
84
+ await IoTDeviceService.bulkUpsert({
85
+ projectId: PROJECT_ID,
86
+ iotFleetId: FLEET_ID,
87
+ devices: [
88
+ parsedDevice({ externalId: longId }),
89
+ parsedDevice({ externalId: "sensor-good" }),
90
+ ],
91
+ });
92
+
93
+ expect(query).toHaveBeenCalledTimes(1);
94
+ const [, params] = query.mock.calls[0] as QueryCall;
95
+
96
+ // Both rows made it into the one chunk — the bad id dropped nobody.
97
+ expect(params).toHaveLength(2 * UPSERT_PARAMS_PER_ROW);
98
+ expect(params).toContain("x".repeat(SHORT_TEXT_LIMIT));
99
+ expect(params).toContain("sensor-good");
100
+ expect(params).not.toContain(longId);
101
+ });
102
+
103
+ test("truncates over-long kind/name/deviceType/firmwareVersion values", async () => {
104
+ const query: jest.Mock = mockQueryRunner();
105
+
106
+ await IoTDeviceService.bulkUpsert({
107
+ projectId: PROJECT_ID,
108
+ iotFleetId: FLEET_ID,
109
+ devices: [
110
+ parsedDevice({
111
+ kind: "k".repeat(140),
112
+ name: "n".repeat(140),
113
+ deviceType: "t".repeat(140),
114
+ firmwareVersion: "f".repeat(140),
115
+ }),
116
+ ],
117
+ });
118
+
119
+ const [, params] = query.mock.calls[0] as QueryCall;
120
+ expect(params).toContain("k".repeat(SHORT_TEXT_LIMIT));
121
+ expect(params).toContain("n".repeat(SHORT_TEXT_LIMIT));
122
+ expect(params).toContain("t".repeat(SHORT_TEXT_LIMIT));
123
+ expect(params).toContain("f".repeat(SHORT_TEXT_LIMIT));
124
+ });
125
+
126
+ test("identities colliding after truncation dedupe to the newest row", async () => {
127
+ const query: jest.Mock = mockQueryRunner();
128
+ const shared: string = "z".repeat(SHORT_TEXT_LIMIT);
129
+ const older: Date = new Date("2026-06-13T00:00:00.000Z");
130
+ const newer: Date = new Date("2026-06-13T00:05:00.000Z");
131
+
132
+ await IoTDeviceService.bulkUpsert({
133
+ projectId: PROJECT_ID,
134
+ iotFleetId: FLEET_ID,
135
+ devices: [
136
+ parsedDevice({
137
+ externalId: `${shared}-alpha`,
138
+ name: "older-device",
139
+ lastSeenAt: older,
140
+ }),
141
+ parsedDevice({
142
+ externalId: `${shared}-bravo`,
143
+ name: "newer-device",
144
+ lastSeenAt: newer,
145
+ }),
146
+ ],
147
+ });
148
+
149
+ const [, params] = query.mock.calls[0] as QueryCall;
150
+
151
+ // One conflict target → exactly one VALUES row, the newest one.
152
+ expect(params).toHaveLength(UPSERT_PARAMS_PER_ROW);
153
+ expect(params).toContain(shared);
154
+ expect(params).toContain("newer-device");
155
+ expect(params).not.toContain("older-device");
156
+ });
157
+
158
+ test("skips rows whose externalId is empty", async () => {
159
+ const query: jest.Mock = mockQueryRunner();
160
+
161
+ await IoTDeviceService.bulkUpsert({
162
+ projectId: PROJECT_ID,
163
+ iotFleetId: FLEET_ID,
164
+ devices: [parsedDevice({ externalId: "" })],
165
+ });
166
+
167
+ expect(query).not.toHaveBeenCalled();
168
+ });
169
+ });
170
+
171
+ describe("IoTDeviceService.bulkUpsert — future clock-skew clamping", () => {
172
+ test("clamps a lastSeenAt beyond the skew tolerance to ingest time", async () => {
173
+ const query: jest.Mock = mockQueryRunner();
174
+ const before: number = Date.now();
175
+
176
+ await IoTDeviceService.bulkUpsert({
177
+ projectId: PROJECT_ID,
178
+ iotFleetId: FLEET_ID,
179
+ devices: [
180
+ parsedDevice({
181
+ lastSeenAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
182
+ }),
183
+ ],
184
+ });
185
+
186
+ const after: number = Date.now();
187
+ const [, params] = query.mock.calls[0] as QueryCall;
188
+ const lastSeenAt: Date = params[9] as Date;
189
+
190
+ expect(lastSeenAt.getTime()).toBeGreaterThanOrEqual(before);
191
+ expect(lastSeenAt.getTime()).toBeLessThanOrEqual(after);
192
+ });
193
+
194
+ test("keeps a lastSeenAt within the skew tolerance untouched", async () => {
195
+ const query: jest.Mock = mockQueryRunner();
196
+ const slightlyAhead: Date = new Date(
197
+ Date.now() + (IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES - 1) * 60 * 1000,
198
+ );
199
+
200
+ await IoTDeviceService.bulkUpsert({
201
+ projectId: PROJECT_ID,
202
+ iotFleetId: FLEET_ID,
203
+ devices: [parsedDevice({ lastSeenAt: slightlyAhead })],
204
+ });
205
+
206
+ const [, params] = query.mock.calls[0] as QueryCall;
207
+ expect(params[9]).toEqual(slightlyAhead);
208
+ });
209
+ });
210
+
211
+ describe("IoTDeviceService.bulkUpdateLatestMetrics — sanitation matches the upsert", () => {
212
+ test("truncates an over-long externalId to the same identity the upsert wrote", async () => {
213
+ const query: jest.Mock = mockQueryRunner();
214
+ const longId: string = "x".repeat(150);
215
+
216
+ await IoTDeviceService.bulkUpdateLatestMetrics({
217
+ projectId: PROJECT_ID,
218
+ iotFleetId: FLEET_ID,
219
+ metrics: [latestMetric({ externalId: longId })],
220
+ });
221
+
222
+ expect(query).toHaveBeenCalledTimes(1);
223
+ const [, params] = query.mock.calls[0] as QueryCall;
224
+
225
+ // Params: projectId, fleetId, then kind, externalId, ... per row.
226
+ expect(params[3]).toBe("x".repeat(SHORT_TEXT_LIMIT));
227
+ });
228
+
229
+ test("clamps a future observedAt beyond the skew tolerance", async () => {
230
+ const query: jest.Mock = mockQueryRunner();
231
+ const before: number = Date.now();
232
+
233
+ await IoTDeviceService.bulkUpdateLatestMetrics({
234
+ projectId: PROJECT_ID,
235
+ iotFleetId: FLEET_ID,
236
+ metrics: [
237
+ latestMetric({
238
+ observedAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
239
+ }),
240
+ ],
241
+ });
242
+
243
+ const after: number = Date.now();
244
+ const [, params] = query.mock.calls[0] as QueryCall;
245
+ const observedAt: Date = params[11] as Date;
246
+
247
+ expect(observedAt.getTime()).toBeGreaterThanOrEqual(before);
248
+ expect(observedAt.getTime()).toBeLessThanOrEqual(after);
249
+ });
250
+
251
+ test("identities colliding after truncation dedupe to the newest observation", async () => {
252
+ const query: jest.Mock = mockQueryRunner();
253
+ const shared: string = "z".repeat(SHORT_TEXT_LIMIT);
254
+
255
+ await IoTDeviceService.bulkUpdateLatestMetrics({
256
+ projectId: PROJECT_ID,
257
+ iotFleetId: FLEET_ID,
258
+ metrics: [
259
+ latestMetric({
260
+ externalId: `${shared}-alpha`,
261
+ batteryPercent: 10,
262
+ observedAt: new Date("2026-06-13T00:00:00.000Z"),
263
+ }),
264
+ latestMetric({
265
+ externalId: `${shared}-bravo`,
266
+ batteryPercent: 90,
267
+ observedAt: new Date("2026-06-13T00:05:00.000Z"),
268
+ }),
269
+ ],
270
+ });
271
+
272
+ const [, params] = query.mock.calls[0] as QueryCall;
273
+
274
+ // 2 shared params + one 9-param row (kind..temp) + observedAt.
275
+ expect(params).toHaveLength(2 + 10);
276
+ expect(params[3]).toBe(shared);
277
+ expect(params).toContain(90);
278
+ expect(params).not.toContain(10);
279
+ });
280
+ });
@@ -672,6 +672,55 @@ describe("StatementGenerator", () => {
672
672
  expect(statement.query).toBe("");
673
673
  expect(statement.query_params).toStrictEqual({});
674
674
  });
675
+
676
+ test("emits NOT hasAny(...) for IncludesNone on an Array(String) column", () => {
677
+ const statement: Statement = arrayGenerator.toWhereStatement({
678
+ entityKeys: new IncludesNone(["210dac24142f1baa"]),
679
+ } as any);
680
+ expect(statement.query).toBe(
681
+ "AND NOT hasAny({p0:Identifier}, {p1:Array(String)})",
682
+ );
683
+ expect(statement.query_params).toStrictEqual({
684
+ p0: "entityKeys",
685
+ p1: ["210dac24142f1baa"],
686
+ });
687
+ });
688
+
689
+ test("drops empty IncludesNone instead of NOT hasAny(col, [])", () => {
690
+ const statement: Statement = arrayGenerator.toWhereStatement({
691
+ entityKeys: new IncludesNone([]),
692
+ } as any);
693
+ expect(statement.query).toBe("");
694
+ expect(statement.query_params).toStrictEqual({});
695
+ });
696
+ });
697
+
698
+ describe("scalar IncludesNone exclusion", () => {
699
+ /*
700
+ * "is none of" on a scalar column compiles to `col NOT IN (...)` —
701
+ * the exception monitor uses this to exclude occurrences of
702
+ * resolved/archived exception groups by fingerprint.
703
+ */
704
+ test("emits NOT IN (...) for IncludesNone on a Text column", () => {
705
+ const statement: Statement = generator.toWhereStatement({
706
+ column_1: new IncludesNone(["<fingerprint-1>", "<fingerprint-2>"]),
707
+ } as any);
708
+ expect(statement.query).toBe(
709
+ "AND {p0:Identifier} NOT IN {p1:Array(String)}",
710
+ );
711
+ expect(statement.query_params).toStrictEqual({
712
+ p0: "column_1",
713
+ p1: ["<fingerprint-1>", "<fingerprint-2>"],
714
+ });
715
+ });
716
+
717
+ test("drops empty IncludesNone instead of producing NOT IN ()", () => {
718
+ const statement: Statement = generator.toWhereStatement({
719
+ column_1: new IncludesNone([]),
720
+ } as any);
721
+ expect(statement.query).toBe("");
722
+ expect(statement.query_params).toStrictEqual({});
723
+ });
675
724
  });
676
725
 
677
726
  describe("entityScope synthetic key", () => {