@oneuptime/common 11.3.19 → 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.
- package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +173 -0
- package/Models/DatabaseModels/GlobalConfig.ts +39 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Server/API/DashboardAPI.ts +221 -0
- package/Server/API/EnterpriseLicenseAPI.ts +279 -12
- package/Server/API/GlobalConfigAPI.ts +81 -16
- package/Server/API/StatusPageAPI.ts +894 -791
- package/Server/EnvironmentConfig.ts +9 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783161406136-AddInstanceIdToGlobalConfig.ts +23 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/EnterpriseLicenseInstanceService.ts +10 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/IoTDeviceService.ts +99 -4
- package/Server/Services/StatusPageService.ts +104 -45
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +6 -4
- package/Server/Utils/Monitor/MonitorCriteriaDataExtractor.ts +7 -6
- package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +6 -4
- package/Server/Utils/Telemetry/IoTSnapshotScan.ts +45 -7
- package/ServiceRoute.ts +4 -0
- package/Tests/Server/Services/IoTDeviceService.test.ts +280 -0
- package/Tests/Server/Utils/Monitor/Criteria/TelemetryStepMetricAlias.test.ts +390 -0
- package/Tests/Server/Utils/Telemetry/IoTSnapshotScan.test.ts +202 -0
- package/Tests/Utils/EnterpriseLicenseUsage.test.ts +165 -0
- package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +18 -0
- package/Types/Monitor/MonitorStep.ts +32 -0
- package/UI/Components/EditionLabel/EditionLabel.tsx +172 -0
- package/Utils/EnterpriseLicense/EnterpriseLicenseUsage.ts +125 -0
- package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +197 -0
- package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -0
- package/build/dist/Models/DatabaseModels/GlobalConfig.js +39 -0
- package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +166 -16
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +203 -13
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/API/GlobalConfigAPI.js +62 -15
- package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +74 -31
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +6 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783161406136-AddInstanceIdToGlobalConfig.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783161406136-AddInstanceIdToGlobalConfig.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783226868418-AddEnterpriseLicenseInstance.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/EnterpriseLicenseInstanceService.js +9 -0
- package/build/dist/Server/Services/EnterpriseLicenseInstanceService.js.map +1 -0
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceService.js +66 -4
- package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +91 -43
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +4 -5
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaDataExtractor.js +6 -5
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaDataExtractor.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +9 -9
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +33 -2
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
- package/build/dist/ServiceRoute.js +1 -0
- package/build/dist/ServiceRoute.js.map +1 -1
- package/build/dist/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.js +2 -0
- package/build/dist/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.js.map +1 -0
- package/build/dist/Types/Monitor/MonitorStep.js +25 -0
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js +92 -0
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js +83 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseUsage.js.map +1 -0
- 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,
|
package/Server/Infrastructure/Postgres/SchemaMigrations/1783161406136-AddInstanceIdToGlobalConfig.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
2
|
+
|
|
3
|
+
export class AddInstanceIdToGlobalConfig1783161406136
|
|
4
|
+
implements MigrationInterface
|
|
5
|
+
{
|
|
6
|
+
public name = "AddInstanceIdToGlobalConfig1783161406136";
|
|
7
|
+
|
|
8
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
9
|
+
await queryRunner.query(`ALTER TABLE "GlobalConfig" ADD "instanceId" uuid`);
|
|
10
|
+
await queryRunner.query(
|
|
11
|
+
`ALTER TABLE "GlobalConfig" ADD CONSTRAINT "UQ_d80c0e0c7aeb62d6f87f5781035" UNIQUE ("instanceId")`,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
16
|
+
await queryRunner.query(
|
|
17
|
+
`ALTER TABLE "GlobalConfig" DROP CONSTRAINT "UQ_d80c0e0c7aeb62d6f87f5781035"`,
|
|
18
|
+
);
|
|
19
|
+
await queryRunner.query(
|
|
20
|
+
`ALTER TABLE "GlobalConfig" DROP COLUMN "instanceId"`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -403,6 +403,8 @@ import { WidenDockerResourceImageColumns1782800000000 } from "./1782800000000-Wi
|
|
|
403
403
|
import { AddIoTFleetAndDeviceTables1782900000000 } from "./1782900000000-AddIoTFleetAndDeviceTables";
|
|
404
404
|
import { AddIoTFleetActivityJoinTables1783000000000 } from "./1783000000000-AddIoTFleetActivityJoinTables";
|
|
405
405
|
import { MigrationName1783010000000 } from "./1783010000000-MigrationName";
|
|
406
|
+
import { AddInstanceIdToGlobalConfig1783161406136 } from "./1783161406136-AddInstanceIdToGlobalConfig";
|
|
407
|
+
import { AddEnterpriseLicenseInstance1783226868418 } from "./1783226868418-AddEnterpriseLicenseInstance";
|
|
406
408
|
|
|
407
409
|
export default [
|
|
408
410
|
InitialMigration,
|
|
@@ -810,4 +812,6 @@ export default [
|
|
|
810
812
|
AddIoTFleetAndDeviceTables1782900000000,
|
|
811
813
|
AddIoTFleetActivityJoinTables1783000000000,
|
|
812
814
|
MigrationName1783010000000,
|
|
815
|
+
AddInstanceIdToGlobalConfig1783161406136,
|
|
816
|
+
AddEnterpriseLicenseInstance1783226868418,
|
|
813
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();
|
package/Server/Services/Index.ts
CHANGED
|
@@ -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 <
|
|
108
|
-
const chunk: Array<ParsedIoTDevice> =
|
|
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
|
-
|
|
185
|
-
|
|
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`.
|
|
100
|
-
*
|
|
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
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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
|
|
728
|
-
await
|
|
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 (
|
|
747
|
-
const
|
|
806
|
+
if (domain?.fullDomain) {
|
|
807
|
+
const protocol: Protocol = await this.getProtocolForCustomDomain(domain);
|
|
808
|
+
return `${protocol}${domain.fullDomain}`;
|
|
809
|
+
}
|
|
748
810
|
|
|
749
|
-
|
|
811
|
+
const host: Hostname = await DatabaseConfig.getHost();
|
|
750
812
|
|
|
751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
[];
|
|
122
|
+
metricViewConfig?.queryConfigs || [];
|
|
120
123
|
const formulaConfigs: Array<MetricFormulaConfigData> =
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1213
|
-
[];
|
|
1216
|
+
metricViewConfig?.queryConfigs || [];
|
|
1214
1217
|
const formulaConfigs: Array<MetricFormulaConfigData> =
|
|
1215
|
-
|
|
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 =
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
+
);
|