@oneuptime/common 11.3.20 → 11.3.21

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 (74) hide show
  1. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +173 -0
  2. package/Models/DatabaseModels/GlobalConfig.ts +19 -0
  3. package/Models/DatabaseModels/Index.ts +2 -0
  4. package/Server/API/DashboardAPI.ts +221 -0
  5. package/Server/API/EnterpriseLicenseAPI.ts +279 -12
  6. package/Server/API/GlobalConfigAPI.ts +81 -16
  7. package/Server/API/StatusPageAPI.ts +894 -791
  8. package/Server/EnvironmentConfig.ts +9 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.ts +47 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  11. package/Server/Services/EnterpriseLicenseInstanceService.ts +10 -0
  12. package/Server/Services/Index.ts +2 -0
  13. package/Server/Services/IoTDeviceService.ts +99 -4
  14. package/Server/Services/StatusPageService.ts +104 -45
  15. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +6 -4
  16. package/Server/Utils/Monitor/MonitorCriteriaDataExtractor.ts +7 -6
  17. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +6 -4
  18. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +45 -7
  19. package/ServiceRoute.ts +4 -0
  20. package/Tests/Server/Services/IoTDeviceService.test.ts +280 -0
  21. package/Tests/Server/Utils/Monitor/Criteria/TelemetryStepMetricAlias.test.ts +390 -0
  22. package/Tests/Server/Utils/Telemetry/IoTSnapshotScan.test.ts +202 -0
  23. package/Tests/Utils/EnterpriseLicenseUsage.test.ts +165 -0
  24. package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +18 -0
  25. package/Types/Monitor/MonitorStep.ts +32 -0
  26. package/UI/Components/EditionLabel/EditionLabel.tsx +172 -0
  27. package/Utils/EnterpriseLicense/EnterpriseLicenseUsage.ts +125 -0
  28. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +197 -0
  29. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -0
  30. package/build/dist/Models/DatabaseModels/GlobalConfig.js +18 -0
  31. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  32. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  33. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  34. package/build/dist/Server/API/DashboardAPI.js +166 -16
  35. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  36. package/build/dist/Server/API/EnterpriseLicenseAPI.js +203 -13
  37. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  38. package/build/dist/Server/API/GlobalConfigAPI.js +62 -15
  39. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  40. package/build/dist/Server/API/StatusPageAPI.js +74 -31
  41. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  42. package/build/dist/Server/EnvironmentConfig.js +6 -0
  43. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  44. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.js +22 -0
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.js.map +1 -0
  46. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  48. package/build/dist/Server/Services/EnterpriseLicenseInstanceService.js +9 -0
  49. package/build/dist/Server/Services/EnterpriseLicenseInstanceService.js.map +1 -0
  50. package/build/dist/Server/Services/Index.js +2 -0
  51. package/build/dist/Server/Services/Index.js.map +1 -1
  52. package/build/dist/Server/Services/IoTDeviceService.js +66 -4
  53. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  54. package/build/dist/Server/Services/StatusPageService.js +91 -43
  55. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  56. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +4 -5
  57. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  58. package/build/dist/Server/Utils/Monitor/MonitorCriteriaDataExtractor.js +6 -5
  59. package/build/dist/Server/Utils/Monitor/MonitorCriteriaDataExtractor.js.map +1 -1
  60. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +9 -9
  61. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  62. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +33 -2
  63. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
  64. package/build/dist/ServiceRoute.js +1 -0
  65. package/build/dist/ServiceRoute.js.map +1 -1
  66. package/build/dist/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.js +2 -0
  67. package/build/dist/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.js.map +1 -0
  68. package/build/dist/Types/Monitor/MonitorStep.js +25 -0
  69. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  70. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +92 -0
  71. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  72. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js +83 -0
  73. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js.map +1 -0
  74. package/package.json +1 -1
@@ -543,6 +543,15 @@ export const StatusPageApiInternalUrl: URL = URL.fromString(
543
543
  AppApiClientUrl.toString(),
544
544
  ).addRoute(new Route("/status-page"));
545
545
 
546
+ /*
547
+ *Internal URL for server-to-server communication with the Dashboard API.
548
+ *Note: The internal path is /api/dashboard (not /public-dashboard-api) because
549
+ * /public-dashboard-api is the external route that Nginx rewrites to /api/dashboard
550
+ */
551
+ export const DashboardApiInternalUrl: URL = URL.fromString(
552
+ AppApiClientUrl.toString(),
553
+ ).addRoute(new Route("/dashboard"));
554
+
546
555
  export const DashboardClientUrl: URL = new URL(
547
556
  HttpProtocol,
548
557
  Host,
@@ -0,0 +1,47 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class AddEnterpriseLicenseInstance1783226868418
4
+ implements MigrationInterface
5
+ {
6
+ public name = "AddEnterpriseLicenseInstance1783226868418";
7
+
8
+ public async up(queryRunner: QueryRunner): Promise<void> {
9
+ await queryRunner.query(
10
+ `CREATE TABLE "EnterpriseLicenseInstance" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "enterpriseLicenseId" uuid NOT NULL, "instanceId" character varying(100) NOT NULL, "host" character varying(100), "userCount" integer, "userEmailHashes" jsonb, "lastReportedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_af49c1c72bebf72915b9d38721d" PRIMARY KEY ("_id"))`,
11
+ );
12
+ await queryRunner.query(
13
+ `CREATE INDEX "IDX_743f3cfb5c7170f50de2159c3f" ON "EnterpriseLicenseInstance" ("enterpriseLicenseId") `,
14
+ );
15
+ await queryRunner.query(
16
+ `CREATE INDEX "IDX_25feed2a867b2a3d81d0633878" ON "EnterpriseLicenseInstance" ("instanceId") `,
17
+ );
18
+ await queryRunner.query(
19
+ `CREATE UNIQUE INDEX "IDX_4e293ff0c754f702657b1d3abf" ON "EnterpriseLicenseInstance" ("enterpriseLicenseId", "instanceId") `,
20
+ );
21
+ await queryRunner.query(
22
+ `ALTER TABLE "GlobalConfig" ADD "enterpriseLicenseInstances" jsonb`,
23
+ );
24
+ await queryRunner.query(
25
+ `ALTER TABLE "EnterpriseLicenseInstance" ADD CONSTRAINT "FK_743f3cfb5c7170f50de2159c3f8" FOREIGN KEY ("enterpriseLicenseId") REFERENCES "EnterpriseLicense"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
26
+ );
27
+ }
28
+
29
+ public async down(queryRunner: QueryRunner): Promise<void> {
30
+ await queryRunner.query(
31
+ `ALTER TABLE "EnterpriseLicenseInstance" DROP CONSTRAINT "FK_743f3cfb5c7170f50de2159c3f8"`,
32
+ );
33
+ await queryRunner.query(
34
+ `ALTER TABLE "GlobalConfig" DROP COLUMN "enterpriseLicenseInstances"`,
35
+ );
36
+ await queryRunner.query(
37
+ `DROP INDEX "public"."IDX_4e293ff0c754f702657b1d3abf"`,
38
+ );
39
+ await queryRunner.query(
40
+ `DROP INDEX "public"."IDX_25feed2a867b2a3d81d0633878"`,
41
+ );
42
+ await queryRunner.query(
43
+ `DROP INDEX "public"."IDX_743f3cfb5c7170f50de2159c3f"`,
44
+ );
45
+ await queryRunner.query(`DROP TABLE "EnterpriseLicenseInstance"`);
46
+ }
47
+ }
@@ -404,6 +404,7 @@ import { AddIoTFleetAndDeviceTables1782900000000 } from "./1782900000000-AddIoTF
404
404
  import { AddIoTFleetActivityJoinTables1783000000000 } from "./1783000000000-AddIoTFleetActivityJoinTables";
405
405
  import { MigrationName1783010000000 } from "./1783010000000-MigrationName";
406
406
  import { AddInstanceIdToGlobalConfig1783161406136 } from "./1783161406136-AddInstanceIdToGlobalConfig";
407
+ import { AddEnterpriseLicenseInstance1783226868418 } from "./1783226868418-AddEnterpriseLicenseInstance";
407
408
 
408
409
  export default [
409
410
  InitialMigration,
@@ -812,4 +813,5 @@ export default [
812
813
  AddIoTFleetActivityJoinTables1783000000000,
813
814
  MigrationName1783010000000,
814
815
  AddInstanceIdToGlobalConfig1783161406136,
816
+ AddEnterpriseLicenseInstance1783226868418,
815
817
  ];
@@ -0,0 +1,10 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import EnterpriseLicenseInstance from "../../Models/DatabaseModels/EnterpriseLicenseInstance";
3
+
4
+ export class Service extends DatabaseService<EnterpriseLicenseInstance> {
5
+ public constructor() {
6
+ super(EnterpriseLicenseInstance);
7
+ }
8
+ }
9
+
10
+ export default new Service();
@@ -113,6 +113,7 @@ import GlobalSsoProjectService from "./GlobalSsoProjectService";
113
113
  import GlobalOidcProjectService from "./GlobalOidcProjectService";
114
114
  import PromoCodeService from "./PromoCodeService";
115
115
  import EnterpriseLicenseService from "./EnterpriseLicenseService";
116
+ import EnterpriseLicenseInstanceService from "./EnterpriseLicenseInstanceService";
116
117
  import OpenSourceDeploymentService from "./OpenSourceDeploymentService";
117
118
  import ResellerPlanService from "./ResellerPlanService";
118
119
  import ResellerService from "./ResellerService";
@@ -251,6 +252,7 @@ const services: Array<BaseService> = [
251
252
  AcmeCertificateService,
252
253
  PromoCodeService,
253
254
  EnterpriseLicenseService,
255
+ EnterpriseLicenseInstanceService,
254
256
  OpenSourceDeploymentService,
255
257
 
256
258
  ResellerService,
@@ -1,8 +1,10 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/IoTDevice";
3
3
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
4
+ import ColumnLength from "../../Types/Database/ColumnLength";
4
5
  import ObjectID from "../../Types/ObjectID";
5
6
  import OneUptimeDate from "../../Types/Date";
7
+ import { clampIoTTimestamp } from "../Utils/Telemetry/IoTSnapshotScan";
6
8
  import logger from "../Utils/Logger";
7
9
 
8
10
  /*
@@ -57,6 +59,23 @@ export interface IoTInventorySummary {
57
59
  const UPSERT_BATCH_SIZE: number = 500;
58
60
  const STALE_DELETE_WARN_THRESHOLD: number = 100;
59
61
 
62
+ /*
63
+ * The identity/text columns are ShortText (100 chars). A single value
64
+ * over the column limit fails the whole multi-row INSERT chunk it rides
65
+ * in — truncate per value instead so one malformed device can never
66
+ * drop the other rows of its chunk.
67
+ */
68
+ function truncateShortText(value: string): string;
69
+ function truncateShortText(value: string | null): string | null;
70
+ function truncateShortText(value: string | null): string | null {
71
+ if (value === null) {
72
+ return value;
73
+ }
74
+ return value.length > ColumnLength.ShortText
75
+ ? value.substring(0, ColumnLength.ShortText)
76
+ : value;
77
+ }
78
+
60
79
  /*
61
80
  * Column order used by bulkUpsert() and its generated parameter tuples.
62
81
  * Keep this and the INSERT column list in perfect sync.
@@ -103,9 +122,52 @@ export class Service extends DatabaseService<Model> {
103
122
  return;
104
123
  }
105
124
 
125
+ /*
126
+ * Sanitize before building chunks: ShortText columns truncate to
127
+ * the column limit (one over-long device.id must not fail the
128
+ * whole 500-row chunk), lastSeenAt clamps so a future-skewed
129
+ * device clock can't wedge the `>=` dominance guard, and rows that
130
+ * collide on the conflict target after truncation dedupe to the
131
+ * newest observation (two VALUES rows hitting the same conflict
132
+ * target would abort the statement).
133
+ */
134
+ const dedupedByIdentity: Map<string, ParsedIoTDevice> = new Map();
135
+ for (const r of data.devices) {
136
+ const externalId: string = truncateShortText(r.externalId);
137
+ if (!externalId) {
138
+ continue;
139
+ }
140
+ if (externalId !== r.externalId) {
141
+ logger.warn(
142
+ `IoTDevice externalId exceeds ${ColumnLength.ShortText} chars; truncated to "${externalId}" (fleet ${data.iotFleetId.toString()}).`,
143
+ );
144
+ }
145
+ const device: ParsedIoTDevice = {
146
+ ...r,
147
+ kind: truncateShortText(r.kind),
148
+ externalId,
149
+ name: truncateShortText(r.name),
150
+ deviceType: truncateShortText(r.deviceType),
151
+ firmwareVersion: truncateShortText(r.firmwareVersion),
152
+ lastSeenAt: clampIoTTimestamp(r.lastSeenAt),
153
+ };
154
+ const key: string = `${device.kind}|${device.externalId}`;
155
+ const existing: ParsedIoTDevice | undefined = dedupedByIdentity.get(key);
156
+ if (!existing || device.lastSeenAt >= existing.lastSeenAt) {
157
+ dedupedByIdentity.set(key, device);
158
+ }
159
+ }
160
+
161
+ const devices: Array<ParsedIoTDevice> = Array.from(
162
+ dedupedByIdentity.values(),
163
+ );
164
+ if (devices.length === 0) {
165
+ return;
166
+ }
167
+
106
168
  // Chunk to keep individual statement parameter counts reasonable.
107
- for (let i: number = 0; i < data.devices.length; i += UPSERT_BATCH_SIZE) {
108
- const chunk: Array<ParsedIoTDevice> = data.devices.slice(
169
+ for (let i: number = 0; i < devices.length; i += UPSERT_BATCH_SIZE) {
170
+ const chunk: Array<ParsedIoTDevice> = devices.slice(
109
171
  i,
110
172
  i + UPSERT_BATCH_SIZE,
111
173
  );
@@ -181,8 +243,41 @@ export class Service extends DatabaseService<Model> {
181
243
  return;
182
244
  }
183
245
 
184
- for (let i: number = 0; i < data.metrics.length; i += UPSERT_BATCH_SIZE) {
185
- const chunk: Array<IoTDeviceLatestMetric> = data.metrics.slice(
246
+ /*
247
+ * Same sanitation as bulkUpsert — and the identity truncation MUST
248
+ * match it, or the mirror UPDATE would silently miss the row the
249
+ * upsert just wrote under the truncated id. Dedupe keeps the UPDATE
250
+ * deterministic when two rows collapse onto one identity.
251
+ */
252
+ const dedupedByIdentity: Map<string, IoTDeviceLatestMetric> = new Map();
253
+ for (const m of data.metrics) {
254
+ const externalId: string = truncateShortText(m.externalId);
255
+ if (!externalId) {
256
+ continue;
257
+ }
258
+ const metric: IoTDeviceLatestMetric = {
259
+ ...m,
260
+ kind: truncateShortText(m.kind),
261
+ externalId,
262
+ observedAt: clampIoTTimestamp(m.observedAt),
263
+ };
264
+ const key: string = `${metric.kind}|${metric.externalId}`;
265
+ const existing: IoTDeviceLatestMetric | undefined =
266
+ dedupedByIdentity.get(key);
267
+ if (!existing || metric.observedAt >= existing.observedAt) {
268
+ dedupedByIdentity.set(key, metric);
269
+ }
270
+ }
271
+
272
+ const metrics: Array<IoTDeviceLatestMetric> = Array.from(
273
+ dedupedByIdentity.values(),
274
+ );
275
+ if (metrics.length === 0) {
276
+ return;
277
+ }
278
+
279
+ for (let i: number = 0; i < metrics.length; i += UPSERT_BATCH_SIZE) {
280
+ const chunk: Array<IoTDeviceLatestMetric> = metrics.slice(
186
281
  i,
187
282
  i + UPSERT_BATCH_SIZE,
188
283
  );
@@ -37,6 +37,7 @@ import User from "../../Models/DatabaseModels/User";
37
37
  import {
38
38
  AllowedStatusPageCountInFreePlan,
39
39
  IsBillingEnabled,
40
+ LetsEncryptAccountKey,
40
41
  } from "../EnvironmentConfig";
41
42
  import { PlanType } from "../../Types/Billing/SubscriptionPlan";
42
43
  import Recurring from "../../Types/Events/Recurring";
@@ -96,9 +97,9 @@ export class Service extends DatabaseService<StatusPage> {
96
97
  * Caches the resolved status page URL per statusPageId. `getStatusPageURL`
97
98
  * is called inside per-subscriber notification loops (see
98
99
  * `StatusPageSubscriberService`), where a single batch can fire N×
99
- * Postgres lookups against `StatusPageDomain`. SSL-provisioned custom
100
- * domains change rarely (provisioning takes minutes), so a 60s staleness
101
- * window is acceptable. Callers that need stronger consistency can call
100
+ * Postgres lookups against `StatusPageDomain`. Usable custom domains
101
+ * change rarely (provisioning/verification takes minutes), so a 60s
102
+ * staleness window is acceptable. Callers that need stronger consistency can call
102
103
  * `clearStatusPageUrlCache` after writes to the underlying domain.
103
104
  */
104
105
  private statusPageUrlCache: InMemoryTTLCache<string> = new InMemoryTTLCache(
@@ -679,32 +680,107 @@ export class Service extends DatabaseService<StatusPage> {
679
680
  return monitorStatusTimelines;
680
681
  }
681
682
 
683
+ /*
684
+ * Picks the custom domain to use when linking to this status page from
685
+ * subscriber notifications (email/SMS/Slack/Teams/webhooks) and SSO
686
+ * redirects. A domain is usable when any of these holds, in order of
687
+ * preference:
688
+ * 1. `isSslProvisioned` — OneUptime ordered the certificate and confirmed
689
+ * the domain serves HTTPS.
690
+ * 2. `isCustomCertificate` + `isCnameVerified` — the user uploaded their
691
+ * own certificate (the SSL provisioning jobs skip these domains, so
692
+ * `isSslProvisioned` never becomes true for them) and the CNAME check
693
+ * confirmed the domain actually routes to this instance. CNAME alone
694
+ * guards against certs uploaded before DNS is pointed.
695
+ * 3. `isCnameVerified`, but only when this instance has no Let's Encrypt
696
+ * account key — self-hosted installs that terminate TLS at their own
697
+ * proxy can never provision SSL, so CNAME verification is the
698
+ * strongest available signal that the domain routes to this instance.
699
+ * (With Let's Encrypt configured, a verified-but-unprovisioned domain
700
+ * is just mid-provisioning and will be picked up by rule 1 shortly.)
701
+ * Without rules 2 and 3, such status pages fall back to the installation
702
+ * URL in every subscriber notification
703
+ * (https://github.com/OneUptime/oneuptime/issues/1951).
704
+ */
682
705
  @CaptureSpan()
683
- public async getStatusPageURL(statusPageId: ObjectID): Promise<string> {
684
- const cacheKey: string = statusPageId.toString();
685
- const cached: string | undefined = this.statusPageUrlCache.get(cacheKey);
686
- if (cached !== undefined) {
687
- return cached;
688
- }
689
-
690
- const domain: StatusPageDomain | null =
691
- await StatusPageDomainService.findOneBy({
706
+ public async getUsableCustomDomain(
707
+ statusPageId: ObjectID,
708
+ ): Promise<StatusPageDomain | null> {
709
+ const domains: Array<StatusPageDomain> =
710
+ await StatusPageDomainService.findBy({
692
711
  query: {
693
712
  statusPageId: statusPageId,
694
- isSslProvisioned: true,
695
713
  },
696
714
  select: {
697
715
  fullDomain: true,
716
+ isSslProvisioned: true,
717
+ isCustomCertificate: true,
718
+ isCnameVerified: true,
698
719
  },
720
+ skip: 0,
721
+ limit: LIMIT_PER_PROJECT,
699
722
  props: {
700
723
  isRoot: true,
701
724
  ignoreHooks: true,
702
725
  },
703
726
  });
704
727
 
705
- let statusPageURL: string = domain?.fullDomain
706
- ? `https://${domain.fullDomain}`
707
- : "";
728
+ const domainsWithFullDomain: Array<StatusPageDomain> = domains.filter(
729
+ (domain: StatusPageDomain) => {
730
+ return Boolean(domain.fullDomain);
731
+ },
732
+ );
733
+
734
+ return (
735
+ domainsWithFullDomain.find((domain: StatusPageDomain) => {
736
+ return domain.isSslProvisioned;
737
+ }) ||
738
+ domainsWithFullDomain.find((domain: StatusPageDomain) => {
739
+ return domain.isCustomCertificate && domain.isCnameVerified;
740
+ }) ||
741
+ (!LetsEncryptAccountKey
742
+ ? domainsWithFullDomain.find((domain: StatusPageDomain) => {
743
+ return domain.isCnameVerified;
744
+ })
745
+ : undefined) ||
746
+ null
747
+ );
748
+ }
749
+
750
+ /*
751
+ * SSL-provisioned and custom-certificate domains are known to serve
752
+ * HTTPS. A CNAME-verified-only domain (self-hosted instance behind the
753
+ * user's own proxy) follows the instance protocol instead — CNAME
754
+ * verification can succeed over plain HTTP, so an HTTP-only install
755
+ * gets http:// links that actually work.
756
+ */
757
+ private async getProtocolForCustomDomain(
758
+ domain: StatusPageDomain,
759
+ ): Promise<Protocol> {
760
+ if (domain.isSslProvisioned || domain.isCustomCertificate) {
761
+ return Protocol.HTTPS;
762
+ }
763
+
764
+ return DatabaseConfig.getHttpProtocol();
765
+ }
766
+
767
+ @CaptureSpan()
768
+ public async getStatusPageURL(statusPageId: ObjectID): Promise<string> {
769
+ const cacheKey: string = statusPageId.toString();
770
+ const cached: string | undefined = this.statusPageUrlCache.get(cacheKey);
771
+ if (cached !== undefined) {
772
+ return cached;
773
+ }
774
+
775
+ const domain: StatusPageDomain | null =
776
+ await this.getUsableCustomDomain(statusPageId);
777
+
778
+ let statusPageURL: string = "";
779
+
780
+ if (domain?.fullDomain) {
781
+ const protocol: Protocol = await this.getProtocolForCustomDomain(domain);
782
+ statusPageURL = `${protocol}${domain.fullDomain}`;
783
+ }
708
784
 
709
785
  if (!statusPageURL) {
710
786
  const host: Hostname = await DatabaseConfig.getHost();
@@ -724,39 +800,22 @@ export class Service extends DatabaseService<StatusPage> {
724
800
 
725
801
  @CaptureSpan()
726
802
  public async getStatusPageFirstURL(statusPageId: ObjectID): Promise<string> {
727
- const domains: Array<StatusPageDomain> =
728
- await StatusPageDomainService.findBy({
729
- query: {
730
- statusPageId: statusPageId,
731
- isSslProvisioned: true,
732
- },
733
- select: {
734
- fullDomain: true,
735
- },
736
- skip: 0,
737
- limit: LIMIT_PER_PROJECT,
738
- props: {
739
- isRoot: true,
740
- ignoreHooks: true,
741
- },
742
- });
743
-
744
- let statusPageURL: string = "";
803
+ const domain: StatusPageDomain | null =
804
+ await this.getUsableCustomDomain(statusPageId);
745
805
 
746
- if (domains.length === 0) {
747
- const host: Hostname = await DatabaseConfig.getHost();
806
+ if (domain?.fullDomain) {
807
+ const protocol: Protocol = await this.getProtocolForCustomDomain(domain);
808
+ return `${protocol}${domain.fullDomain}`;
809
+ }
748
810
 
749
- const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
811
+ const host: Hostname = await DatabaseConfig.getHost();
750
812
 
751
- // 'https://local.oneuptime.com/status-page/40092fb5-cc33-4995-b532-b4e49c441c98'
752
- statusPageURL = new URL(httpProtocol, host)
753
- .addRoute("/status-page/" + statusPageId.toString())
754
- .toString();
755
- } else {
756
- statusPageURL = domains[0]?.fullDomain || "";
757
- }
813
+ const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
758
814
 
759
- return statusPageURL;
815
+ // 'https://local.oneuptime.com/status-page/40092fb5-cc33-4995-b532-b4e49c441c98'
816
+ return new URL(httpProtocol, host)
817
+ .addRoute("/status-page/" + statusPageId.toString())
818
+ .toString();
760
819
  }
761
820
 
762
821
  @CaptureSpan()
@@ -3,6 +3,7 @@ import AggregatedResult from "../../../../Types/BaseDatabase/AggregatedResult";
3
3
  import MetricFormulaConfigData from "../../../../Types/Metrics/MetricFormulaConfigData";
4
4
  import MetricQueryConfigData from "../../../../Types/Metrics/MetricQueryConfigData";
5
5
  import MetricsAggregationType from "../../../../Types/Metrics/MetricsAggregationType";
6
+ import MetricsViewConfig from "../../../../Types/Metrics/MetricsViewConfig";
6
7
  import MetricMonitorResponse from "../../../../Types/Monitor/MetricMonitor/MetricMonitorResponse";
7
8
  import MetricCriteriaContext, {
8
9
  MetricAnomalyBaseline,
@@ -114,12 +115,13 @@ export default class MetricMonitorCriteria {
114
115
  const seriesBreakdown: Array<MetricSeriesResult> | undefined =
115
116
  metricResponse.seriesBreakdown;
116
117
 
118
+ const metricViewConfig: MetricsViewConfig | undefined =
119
+ MonitorStep.getMetricsViewConfig(input.monitorStep);
120
+
117
121
  const queryConfigs: Array<MetricQueryConfigData> =
118
- input.monitorStep.data?.metricMonitor?.metricViewConfig?.queryConfigs ||
119
- [];
122
+ metricViewConfig?.queryConfigs || [];
120
123
  const formulaConfigs: Array<MetricFormulaConfigData> =
121
- input.monitorStep.data?.metricMonitor?.metricViewConfig?.formulaConfigs ||
122
- [];
124
+ metricViewConfig?.formulaConfigs || [];
123
125
 
124
126
  const nativeUnitsByMetricName: { [key: string]: string } | undefined =
125
127
  metricResponse.nativeUnitsByMetricName;
@@ -14,6 +14,7 @@ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
14
14
  import AggregateModel from "../../../Types/BaseDatabase/AggregatedModel";
15
15
  import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
16
16
  import MetricFormulaConfigData from "../../../Types/Metrics/MetricFormulaConfigData";
17
+ import MetricsViewConfig from "../../../Types/Metrics/MetricsViewConfig";
17
18
  import ExceptionMonitorResponse from "../../../Types/Monitor/ExceptionMonitor/ExceptionMonitorResponse";
18
19
 
19
20
  export default class MonitorCriteriaDataExtractor {
@@ -156,10 +157,12 @@ export default class MonitorCriteriaDataExtractor {
156
157
 
157
158
  let result: AggregatedResult | undefined;
158
159
 
160
+ const metricViewConfig: MetricsViewConfig | undefined =
161
+ MonitorStep.getMetricsViewConfig(input.monitorStep);
162
+
159
163
  if (alias) {
160
164
  const queryConfigs: Array<MetricQueryConfigData> =
161
- input.monitorStep.data?.metricMonitor?.metricViewConfig?.queryConfigs ||
162
- [];
165
+ metricViewConfig?.queryConfigs || [];
163
166
 
164
167
  let aliasIndex: number = queryConfigs.findIndex(
165
168
  (queryConfig: MetricQueryConfigData) => {
@@ -169,8 +172,7 @@ export default class MonitorCriteriaDataExtractor {
169
172
 
170
173
  if (aliasIndex < 0) {
171
174
  const formulaConfigs: Array<MetricFormulaConfigData> =
172
- input.monitorStep.data?.metricMonitor?.metricViewConfig
173
- ?.formulaConfigs || [];
175
+ metricViewConfig?.formulaConfigs || [];
174
176
 
175
177
  const formulaIndex: number = formulaConfigs.findIndex(
176
178
  (formulaConfig: MetricFormulaConfigData) => {
@@ -192,8 +194,7 @@ export default class MonitorCriteriaDataExtractor {
192
194
  result = aggregatedResults[0];
193
195
  if (!alias) {
194
196
  const defaultAlias: string | undefined =
195
- input.monitorStep.data?.metricMonitor?.metricViewConfig
196
- ?.queryConfigs?.[0]?.metricAliasData?.metricVariable;
197
+ metricViewConfig?.queryConfigs?.[0]?.metricAliasData?.metricVariable;
197
198
  alias = defaultAlias || null;
198
199
  }
199
200
  }
@@ -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
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
+ );