@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
@@ -0,0 +1,202 @@
1
+ import {
2
+ IoTDeviceBufferEntry,
3
+ IoTFleetSnapshotBufferEntry,
4
+ IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES,
5
+ bufferIoTSnapshotMetric,
6
+ clampIoTTimestamp,
7
+ } from "../../../../Server/Utils/Telemetry/IoTSnapshotScan";
8
+ import { JSONObject } from "../../../../Types/JSON";
9
+
10
+ /*
11
+ * Malformed-device hardening for the IoT snapshot scan:
12
+ *
13
+ * - a `device.id` longer than the ShortText column limit (100) is
14
+ * truncated at ingest, so one bad device can never fail the
15
+ * 500-row inventory INSERT chunk downstream — and two ids sharing
16
+ * the truncated prefix fold into ONE entry (a duplicate conflict
17
+ * target inside a single INSERT would abort the statement),
18
+ * - a future-skewed device clock is clamped to ingest time, so the
19
+ * `>=` lastSeenAt dominance guard can't get wedged by one bogus
20
+ * future timestamp and then reject every legitimate update.
21
+ */
22
+
23
+ const FLEET: string = "0a1b2c3d-0000-0000-0000-000000000001";
24
+ const SHORT_TEXT_LIMIT: number = 100;
25
+
26
+ type LabelMap = Record<string, string>;
27
+
28
+ function toNano(ms: number): string {
29
+ return `${ms}000000`;
30
+ }
31
+
32
+ function datapoint(data: {
33
+ value: number;
34
+ atMs?: number;
35
+ labels?: LabelMap;
36
+ asDouble?: boolean;
37
+ }): JSONObject {
38
+ const attributes: Array<JSONObject> = Object.entries(data.labels || {}).map(
39
+ ([key, value]: [string, string]) => {
40
+ return { key, value: { stringValue: value } };
41
+ },
42
+ );
43
+ return {
44
+ ...(data.asDouble ? { asDouble: data.value } : { asInt: data.value }),
45
+ timeUnixNano: toNano(data.atMs ?? Date.now()),
46
+ attributes,
47
+ };
48
+ }
49
+
50
+ interface IoTBuffers {
51
+ resourceBuffer: Map<string, Map<string, IoTDeviceBufferEntry>>;
52
+ fleetBuffer: Map<string, IoTFleetSnapshotBufferEntry>;
53
+ }
54
+
55
+ function iotBuffers(): IoTBuffers {
56
+ return { resourceBuffer: new Map(), fleetBuffer: new Map() };
57
+ }
58
+
59
+ function feed(buffers: IoTBuffers, metricName: string, dp: JSONObject): void {
60
+ bufferIoTSnapshotMetric({
61
+ fleetIdStr: FLEET,
62
+ metricName,
63
+ datapoint: dp,
64
+ resourceBuffer: buffers.resourceBuffer,
65
+ fleetBuffer: buffers.fleetBuffer,
66
+ });
67
+ }
68
+
69
+ function entries(buffers: IoTBuffers): Array<IoTDeviceBufferEntry> {
70
+ return Array.from(buffers.resourceBuffer.get(FLEET)?.values() || []);
71
+ }
72
+
73
+ describe("clampIoTTimestamp", () => {
74
+ const now: Date = new Date("2026-07-02T12:00:00.000Z");
75
+
76
+ test("keeps a past timestamp untouched", () => {
77
+ const past: Date = new Date("2026-07-02T11:00:00.000Z");
78
+ expect(clampIoTTimestamp(past, now)).toEqual(past);
79
+ });
80
+
81
+ test("keeps a future timestamp within the skew tolerance", () => {
82
+ const slightlyAhead: Date = new Date(
83
+ now.getTime() + (IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES - 1) * 60 * 1000,
84
+ );
85
+ expect(clampIoTTimestamp(slightlyAhead, now)).toEqual(slightlyAhead);
86
+ });
87
+
88
+ test("clamps a timestamp beyond the skew tolerance back to now", () => {
89
+ const farAhead: Date = new Date(now.getTime() + 60 * 60 * 1000);
90
+ expect(clampIoTTimestamp(farAhead, now)).toEqual(now);
91
+ });
92
+ });
93
+
94
+ describe("bufferIoTSnapshotMetric — device.id length hardening", () => {
95
+ test("truncates a device.id longer than the 100-char column limit", () => {
96
+ const longId: string = "x".repeat(150);
97
+ const buffers: IoTBuffers = iotBuffers();
98
+
99
+ feed(buffers, "iot_device_up", {
100
+ ...datapoint({ value: 1, labels: { "device.id": longId } }),
101
+ });
102
+
103
+ const buffered: Array<IoTDeviceBufferEntry> = entries(buffers);
104
+ expect(buffered).toHaveLength(1);
105
+ expect(buffered[0]!.externalId).toBe("x".repeat(SHORT_TEXT_LIMIT));
106
+ expect(buffered[0]!.externalId.length).toBe(SHORT_TEXT_LIMIT);
107
+ });
108
+
109
+ test("keeps a device.id exactly at the column limit untouched", () => {
110
+ const exactId: string = "y".repeat(SHORT_TEXT_LIMIT);
111
+ const buffers: IoTBuffers = iotBuffers();
112
+
113
+ feed(buffers, "iot_device_up", {
114
+ ...datapoint({ value: 1, labels: { "device.id": exactId } }),
115
+ });
116
+
117
+ expect(entries(buffers)[0]!.externalId).toBe(exactId);
118
+ });
119
+
120
+ test("two over-long ids sharing the truncated prefix fold into one entry", () => {
121
+ const shared: string = "z".repeat(SHORT_TEXT_LIMIT);
122
+ const buffers: IoTBuffers = iotBuffers();
123
+
124
+ feed(buffers, "iot_device_up", {
125
+ ...datapoint({ value: 1, labels: { "device.id": `${shared}-alpha` } }),
126
+ });
127
+ feed(buffers, "iot_device_up", {
128
+ ...datapoint({ value: 0, labels: { "device.id": `${shared}-bravo` } }),
129
+ });
130
+
131
+ // One folded identity — never two rows hitting one conflict target.
132
+ const buffered: Array<IoTDeviceBufferEntry> = entries(buffers);
133
+ expect(buffered).toHaveLength(1);
134
+ expect(buffered[0]!.externalId).toBe(shared);
135
+ });
136
+ });
137
+
138
+ describe("bufferIoTSnapshotMetric — future clock-skew hardening", () => {
139
+ test("clamps an observedAt beyond the skew tolerance to ingest time", () => {
140
+ const buffers: IoTBuffers = iotBuffers();
141
+ const before: number = Date.now();
142
+
143
+ feed(buffers, "iot_battery_percent", {
144
+ ...datapoint({
145
+ value: 55,
146
+ asDouble: true,
147
+ atMs: Date.now() + 60 * 60 * 1000, // device clock 1h ahead
148
+ labels: { "device.id": "sensor-1" },
149
+ }),
150
+ });
151
+
152
+ const after: number = Date.now();
153
+ const observedAt: Date = entries(buffers)[0]!.observedAt;
154
+ expect(observedAt.getTime()).toBeGreaterThanOrEqual(before);
155
+ expect(observedAt.getTime()).toBeLessThanOrEqual(after);
156
+ });
157
+
158
+ test("keeps an observedAt within the skew tolerance untouched", () => {
159
+ const buffers: IoTBuffers = iotBuffers();
160
+ const slightlyAheadMs: number =
161
+ Date.now() + (IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES - 1) * 60 * 1000;
162
+
163
+ feed(buffers, "iot_battery_percent", {
164
+ ...datapoint({
165
+ value: 55,
166
+ asDouble: true,
167
+ atMs: slightlyAheadMs,
168
+ labels: { "device.id": "sensor-1" },
169
+ }),
170
+ });
171
+
172
+ expect(entries(buffers)[0]!.observedAt.getTime()).toBe(
173
+ Math.trunc(slightlyAheadMs),
174
+ );
175
+ });
176
+
177
+ test("a clamped future point no longer wedges newest-wins folding", () => {
178
+ const buffers: IoTBuffers = iotBuffers();
179
+
180
+ // Skewed device stamps 1h ahead — clamped to ~now at ingest.
181
+ feed(buffers, "iot_battery_percent", {
182
+ ...datapoint({
183
+ value: 50,
184
+ asDouble: true,
185
+ atMs: Date.now() + 60 * 60 * 1000,
186
+ labels: { "device.id": "sensor-1" },
187
+ }),
188
+ });
189
+
190
+ // A legitimate later reading must still win the fold.
191
+ feed(buffers, "iot_battery_percent", {
192
+ ...datapoint({
193
+ value: 80,
194
+ asDouble: true,
195
+ atMs: Date.now() + 1000,
196
+ labels: { "device.id": "sensor-1" },
197
+ }),
198
+ });
199
+
200
+ expect(entries(buffers)[0]!.latestBatteryPercent).toBe(80);
201
+ });
202
+ });
@@ -0,0 +1,165 @@
1
+ import EnterpriseLicenseUsageUtil from "../../Utils/EnterpriseLicense/EnterpriseLicenseUsage";
2
+ import OneUptimeDate from "../../Types/Date";
3
+ import { describe, expect, it } from "@jest/globals";
4
+
5
+ type HashFunction = (seed: string) => string;
6
+
7
+ // Deterministic fake SHA-256-shaped hex string for tests.
8
+ const fakeHash: HashFunction = (seed: string): string => {
9
+ return seed
10
+ .repeat(64)
11
+ .replace(/[^a-f0-9]/g, "a")
12
+ .substring(0, 64);
13
+ };
14
+
15
+ describe("EnterpriseLicenseUsageUtil.sanitizeUserEmailHashes", () => {
16
+ it("returns empty array for non-array input", () => {
17
+ expect(EnterpriseLicenseUsageUtil.sanitizeUserEmailHashes(null)).toEqual(
18
+ [],
19
+ );
20
+ expect(
21
+ EnterpriseLicenseUsageUtil.sanitizeUserEmailHashes("not-an-array"),
22
+ ).toEqual([]);
23
+ expect(EnterpriseLicenseUsageUtil.sanitizeUserEmailHashes({})).toEqual([]);
24
+ });
25
+
26
+ it("keeps only valid sha256 hex strings and normalizes case", () => {
27
+ const valid: string = fakeHash("1");
28
+
29
+ const result: Array<string> =
30
+ EnterpriseLicenseUsageUtil.sanitizeUserEmailHashes([
31
+ valid.toUpperCase(),
32
+ ` ${valid} `,
33
+ "short",
34
+ 12345,
35
+ "z".repeat(64), // not hex
36
+ null,
37
+ ]);
38
+
39
+ expect(result).toEqual([valid]);
40
+ });
41
+
42
+ it("removes duplicates", () => {
43
+ const a: string = fakeHash("a");
44
+ const b: string = fakeHash("b");
45
+
46
+ const result: Array<string> =
47
+ EnterpriseLicenseUsageUtil.sanitizeUserEmailHashes([a, b, a, b, a]);
48
+
49
+ expect(result.sort()).toEqual([a, b].sort());
50
+ });
51
+ });
52
+
53
+ describe("EnterpriseLicenseUsageUtil.getUniqueUserCount", () => {
54
+ const now: Date = OneUptimeDate.getCurrentDate();
55
+ const fresh: Date = OneUptimeDate.addRemoveDays(now, -1);
56
+ const stale: Date = OneUptimeDate.addRemoveDays(now, -60);
57
+
58
+ it("counts the same user on multiple instances once", () => {
59
+ const sharedUser: string = fakeHash("shared");
60
+ const stagingOnlyUser: string = fakeHash("staging");
61
+ const prodOnlyUser: string = fakeHash("prod");
62
+
63
+ const count: number = EnterpriseLicenseUsageUtil.getUniqueUserCount(
64
+ [
65
+ {
66
+ userCount: 2,
67
+ userEmailHashes: [sharedUser, stagingOnlyUser],
68
+ lastReportedAt: fresh,
69
+ },
70
+ {
71
+ userCount: 2,
72
+ userEmailHashes: [sharedUser, prodOnlyUser],
73
+ lastReportedAt: fresh,
74
+ },
75
+ ],
76
+ now,
77
+ );
78
+
79
+ expect(count).toBe(3);
80
+ });
81
+
82
+ it("adds plain user counts for instances without hashes", () => {
83
+ const count: number = EnterpriseLicenseUsageUtil.getUniqueUserCount(
84
+ [
85
+ {
86
+ userCount: 2,
87
+ userEmailHashes: [fakeHash("a"), fakeHash("b")],
88
+ lastReportedAt: fresh,
89
+ },
90
+ {
91
+ // Older installation that reports a count but no hashes.
92
+ userCount: 7,
93
+ userEmailHashes: undefined,
94
+ lastReportedAt: fresh,
95
+ },
96
+ ],
97
+ now,
98
+ );
99
+
100
+ expect(count).toBe(9);
101
+ });
102
+
103
+ it("counts overflow users when the hash list was capped", () => {
104
+ /*
105
+ * An instance with more users than the hash cap: userCount is larger
106
+ * than the number of hashes stored. The overflow must still count.
107
+ */
108
+ const count: number = EnterpriseLicenseUsageUtil.getUniqueUserCount(
109
+ [
110
+ {
111
+ userCount: 250_000,
112
+ userEmailHashes: [fakeHash("a"), fakeHash("b")], // capped list
113
+ lastReportedAt: fresh,
114
+ },
115
+ ],
116
+ now,
117
+ );
118
+
119
+ expect(count).toBe(250_000);
120
+ });
121
+
122
+ it("ignores instances that stopped reporting", () => {
123
+ const count: number = EnterpriseLicenseUsageUtil.getUniqueUserCount(
124
+ [
125
+ {
126
+ userCount: 4,
127
+ userEmailHashes: [
128
+ fakeHash("a"),
129
+ fakeHash("b"),
130
+ fakeHash("c"),
131
+ fakeHash("d"),
132
+ ],
133
+ lastReportedAt: fresh,
134
+ },
135
+ {
136
+ userCount: 100,
137
+ userEmailHashes: [fakeHash("e")],
138
+ lastReportedAt: stale,
139
+ },
140
+ ],
141
+ now,
142
+ );
143
+
144
+ expect(count).toBe(4);
145
+ });
146
+
147
+ it("ignores instances that never reported", () => {
148
+ const count: number = EnterpriseLicenseUsageUtil.getUniqueUserCount(
149
+ [
150
+ {
151
+ userCount: undefined,
152
+ userEmailHashes: undefined,
153
+ lastReportedAt: undefined,
154
+ },
155
+ ],
156
+ now,
157
+ );
158
+
159
+ expect(count).toBe(0);
160
+ });
161
+
162
+ it("returns 0 for no instances", () => {
163
+ expect(EnterpriseLicenseUsageUtil.getUniqueUserCount([], now)).toBe(0);
164
+ });
165
+ });
@@ -0,0 +1,18 @@
1
+ /*
2
+ * Summary of a self-hosted OneUptime instance that uses an enterprise
3
+ * license. Returned by the license server (oneuptime.com) on validate and
4
+ * report-user-count calls, stored in GlobalConfig on the customer's
5
+ * instances, and shown in the license modal.
6
+ *
7
+ * Kept as a `type` of plain primitives (not an interface) so it stays
8
+ * structurally compatible with JSONObject and safe to store in JSON columns.
9
+ */
10
+ type EnterpriseLicenseInstanceSummary = {
11
+ instanceId: string;
12
+ host: string | null;
13
+ userCount: number | null;
14
+ // ISO date string of the most recent usage report from this instance.
15
+ lastReportedAt: string | null;
16
+ };
17
+
18
+ export default EnterpriseLicenseInstanceSummary;
@@ -68,6 +68,7 @@ import MonitorStepCephMonitor, {
68
68
  import MonitorStepIoTMonitor, {
69
69
  MonitorStepIoTMonitorUtil,
70
70
  } from "./MonitorStepIoTMonitor";
71
+ import MetricsViewConfig from "../Metrics/MetricsViewConfig";
71
72
  import Zod, { ZodSchema } from "../../Utils/Schema/Zod";
72
73
 
73
74
  /*
@@ -305,6 +306,37 @@ export default class MonitorStep extends DatabaseProperty {
305
306
  return monitorStep;
306
307
  }
307
308
 
309
+ /**
310
+ * Telemetry monitor steps store their metric query configs under their
311
+ * own step shape (metricMonitor, iotMonitor, kubernetesMonitor, ...),
312
+ * yet all of them produce a MetricMonitorResponse whose result slots
313
+ * are ordered by these configs. Criteria evaluation must resolve
314
+ * metric aliases through whichever shape the step carries — reading
315
+ * only `metricMonitor` silently falls back to result slot 0 for every
316
+ * other telemetry monitor type.
317
+ */
318
+ public static getMetricsViewConfig(
319
+ monitorStep: MonitorStep | undefined,
320
+ ): MetricsViewConfig | undefined {
321
+ const data: MonitorStepType | undefined = monitorStep?.data;
322
+
323
+ if (!data) {
324
+ return undefined;
325
+ }
326
+
327
+ return (
328
+ data.metricMonitor?.metricViewConfig ||
329
+ data.iotMonitor?.metricViewConfig ||
330
+ data.kubernetesMonitor?.metricViewConfig ||
331
+ data.dockerMonitor?.metricViewConfig ||
332
+ data.dockerSwarmMonitor?.metricViewConfig ||
333
+ data.hostMonitor?.metricViewConfig ||
334
+ data.podmanMonitor?.metricViewConfig ||
335
+ data.proxmoxMonitor?.metricViewConfig ||
336
+ data.cephMonitor?.metricViewConfig
337
+ );
338
+ }
339
+
308
340
  public get id(): ObjectID {
309
341
  return new ObjectID(this.data?.id as string);
310
342
  }
@@ -27,6 +27,7 @@ import {
27
27
  IS_ENTERPRISE_EDITION,
28
28
  } from "../../Config";
29
29
  import Alert, { AlertType } from "../Alerts/Alert";
30
+ import EnterpriseLicenseInstanceSummary from "../../../Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary";
30
31
 
31
32
  export interface ComponentProps {
32
33
  className?: string | undefined;
@@ -34,6 +35,69 @@ export interface ComponentProps {
34
35
 
35
36
  const ENTERPRISE_URL: string = "https://oneuptime.com/enterprise/demo";
36
37
 
38
+ type ParseLicenseInstancesFunction = (
39
+ value: unknown,
40
+ ) => Array<EnterpriseLicenseInstanceSummary>;
41
+
42
+ const parseLicenseInstances: ParseLicenseInstancesFunction = (
43
+ value: unknown,
44
+ ): Array<EnterpriseLicenseInstanceSummary> => {
45
+ if (!Array.isArray(value)) {
46
+ return [];
47
+ }
48
+
49
+ const instances: Array<EnterpriseLicenseInstanceSummary> = [];
50
+
51
+ for (const item of value) {
52
+ if (!item || typeof item !== "object") {
53
+ continue;
54
+ }
55
+
56
+ const instance: JSONObject = item as JSONObject;
57
+
58
+ instances.push({
59
+ instanceId:
60
+ typeof instance["instanceId"] === "string"
61
+ ? instance["instanceId"]
62
+ : "",
63
+ host:
64
+ typeof instance["host"] === "string" && instance["host"]
65
+ ? instance["host"]
66
+ : null,
67
+ userCount:
68
+ typeof instance["userCount"] === "number"
69
+ ? instance["userCount"]
70
+ : null,
71
+ lastReportedAt:
72
+ typeof instance["lastReportedAt"] === "string"
73
+ ? instance["lastReportedAt"]
74
+ : null,
75
+ });
76
+ }
77
+
78
+ return instances;
79
+ };
80
+
81
+ type FormatInstanceReportedAtFunction = (
82
+ lastReportedAt: string | null,
83
+ ) => string;
84
+
85
+ const formatInstanceReportedAt: FormatInstanceReportedAtFunction = (
86
+ lastReportedAt: string | null,
87
+ ): string => {
88
+ if (!lastReportedAt) {
89
+ return "No usage reported yet.";
90
+ }
91
+
92
+ const reportedAt: Date = OneUptimeDate.fromString(lastReportedAt);
93
+
94
+ if (Number.isNaN(reportedAt.getTime())) {
95
+ return "No usage reported yet.";
96
+ }
97
+
98
+ return `Last reported ${reportedAt.toLocaleString()}.`;
99
+ };
100
+
37
101
  const EditionLabel: FunctionComponent<ComponentProps> = (
38
102
  props: ComponentProps,
39
103
  ): ReactElement => {
@@ -46,6 +110,18 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
46
110
  const [successMessage, setSuccessMessage] = useState<string>("");
47
111
  const [isValidating, setIsValidating] = useState<boolean>(false);
48
112
  const [isChangingLicense, setIsChangingLicense] = useState<boolean>(false);
113
+ const [licenseInstances, setLicenseInstances] = useState<
114
+ Array<EnterpriseLicenseInstanceSummary>
115
+ >([]);
116
+ const [thisInstanceId, setThisInstanceId] = useState<string>("");
117
+ /*
118
+ * Validity as computed by the server. The server redacts the license
119
+ * token for signed-out visitors (e.g. on the login page), so the client
120
+ * cannot always derive validity from the token itself.
121
+ */
122
+ const [serverLicenseValid, setServerLicenseValid] = useState<boolean | null>(
123
+ null,
124
+ );
49
125
  const licenseInputEditedRef: React.MutableRefObject<boolean> =
50
126
  useRef<boolean>(false);
51
127
 
@@ -117,12 +193,25 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
117
193
  }
118
194
 
119
195
  setGlobalConfig(configModel);
196
+ setLicenseInstances(parseLicenseInstances(payload["instances"]));
197
+ setThisInstanceId(
198
+ typeof payload["instanceId"] === "string"
199
+ ? payload["instanceId"]
200
+ : "",
201
+ );
202
+ setServerLicenseValid(
203
+ typeof payload["licenseValid"] === "boolean"
204
+ ? payload["licenseValid"]
205
+ : null,
206
+ );
120
207
 
121
208
  if (!licenseInputEditedRef.current) {
122
209
  setLicenseKeyInput(configModel.enterpriseLicenseKey || "");
123
210
  }
124
211
  } catch (err) {
125
212
  setGlobalConfig(null);
213
+ setLicenseInstances([]);
214
+ setServerLicenseValid(null);
126
215
  setConfigError(API.getFriendlyMessage(err));
127
216
  } finally {
128
217
  setIsConfigLoading(false);
@@ -138,6 +227,11 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
138
227
  return false;
139
228
  }
140
229
 
230
+ // Prefer the server's verdict (works even when the token is redacted).
231
+ if (serverLicenseValid !== null) {
232
+ return serverLicenseValid;
233
+ }
234
+
141
235
  if (
142
236
  !globalConfig?.enterpriseLicenseToken ||
143
237
  !globalConfig.enterpriseLicenseExpiresAt
@@ -153,6 +247,7 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
153
247
  }, [
154
248
  globalConfig?.enterpriseLicenseExpiresAt,
155
249
  globalConfig?.enterpriseLicenseToken,
250
+ serverLicenseValid,
156
251
  ]);
157
252
 
158
253
  const licenseExpiresAtText: string | null = useMemo(() => {
@@ -626,6 +721,15 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
626
721
  </p>
627
722
  )}
628
723
 
724
+ {licenseInstances.length > 1 && (
725
+ <p className="mt-3 text-xs text-gray-500">
726
+ Users are counted uniquely across all{" "}
727
+ {licenseInstances.length} instances that share this
728
+ license — the same user on multiple instances uses
729
+ one seat.
730
+ </p>
731
+ )}
732
+
629
733
  <p className="mt-3 text-xs text-gray-500">
630
734
  {userCountUpdatedAtText
631
735
  ? `Last reported to OneUptime on ${userCountUpdatedAtText}.`
@@ -636,6 +740,74 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
636
740
  </div>
637
741
  )}
638
742
 
743
+ {!configError &&
744
+ !isConfigLoading &&
745
+ licenseValid &&
746
+ licenseInstances.length > 0 && (
747
+ <div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
748
+ <div className="flex items-center justify-between">
749
+ <h3 className="text-sm font-semibold text-gray-900">
750
+ Instances using this license
751
+ </h3>
752
+ <span className="text-xs text-gray-500">
753
+ {licenseInstances.length}{" "}
754
+ {licenseInstances.length === 1
755
+ ? "instance"
756
+ : "instances"}
757
+ </span>
758
+ </div>
759
+ <p className="mt-1 text-xs text-gray-500">
760
+ Use the same license key on every instance you deploy
761
+ (staging, production, and so on). Each instance reports
762
+ its usage daily.
763
+ </p>
764
+ <ul className="mt-3 divide-y divide-gray-100">
765
+ {licenseInstances.map(
766
+ (
767
+ instance: EnterpriseLicenseInstanceSummary,
768
+ index: number,
769
+ ) => {
770
+ const isThisInstance: boolean =
771
+ Boolean(thisInstanceId) &&
772
+ instance.instanceId === thisInstanceId;
773
+
774
+ return (
775
+ <li
776
+ key={instance.instanceId || index}
777
+ className="flex items-center justify-between gap-3 py-2"
778
+ >
779
+ <div className="min-w-0">
780
+ <p className="truncate text-sm font-medium text-gray-900">
781
+ {instance.host || "Unknown host"}
782
+ {isThisInstance && (
783
+ <span className="ml-2 inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">
784
+ This instance
785
+ </span>
786
+ )}
787
+ </p>
788
+ <p className="text-xs text-gray-500">
789
+ {formatInstanceReportedAt(
790
+ instance.lastReportedAt,
791
+ )}
792
+ </p>
793
+ </div>
794
+ <div className="shrink-0 text-right text-sm text-gray-700">
795
+ {typeof instance.userCount === "number"
796
+ ? `${instance.userCount.toLocaleString()} ${
797
+ instance.userCount === 1
798
+ ? "user"
799
+ : "users"
800
+ }`
801
+ : "—"}
802
+ </div>
803
+ </li>
804
+ );
805
+ },
806
+ )}
807
+ </ul>
808
+ </div>
809
+ )}
810
+
639
811
  {!configError &&
640
812
  !isConfigLoading &&
641
813
  licenseValid &&