@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.
- package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +173 -0
- package/Models/DatabaseModels/GlobalConfig.ts +19 -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/1783226868418-AddEnterpriseLicenseInstance.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -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 +18 -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/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 +2 -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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import IoTDeviceService, {
|
|
2
|
+
IoTDeviceLatestMetric,
|
|
3
|
+
ParsedIoTDevice,
|
|
4
|
+
} from "../../../Server/Services/IoTDeviceService";
|
|
5
|
+
import { IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES } from "../../../Server/Utils/Telemetry/IoTSnapshotScan";
|
|
6
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Malformed-device hardening for the IoTDevice inventory write path.
|
|
10
|
+
* The service builds raw parameterized SQL against the TypeORM manager
|
|
11
|
+
* — these tests mock the query runner (no Postgres) and lock in:
|
|
12
|
+
*
|
|
13
|
+
* - ShortText values (externalId and friends) truncate to the
|
|
14
|
+
* 100-char column limit BEFORE the chunk is built, so one bad
|
|
15
|
+
* device can never fail the whole 500-row INSERT and drop the
|
|
16
|
+
* other 499 with it,
|
|
17
|
+
* - identities that collide after truncation dedupe to the newest
|
|
18
|
+
* row (two VALUES rows on one conflict target abort the statement),
|
|
19
|
+
* - a future-skewed lastSeenAt / observedAt clamps to ingest time so
|
|
20
|
+
* the `>=` dominance guards can't get wedged by one bogus device
|
|
21
|
+
* clock,
|
|
22
|
+
* - the latest-metric mirror truncates identity the SAME way the
|
|
23
|
+
* upsert does, so the UPDATE still finds the row the upsert wrote.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const PROJECT_ID: ObjectID = ObjectID.generate();
|
|
27
|
+
const FLEET_ID: ObjectID = ObjectID.generate();
|
|
28
|
+
const SHORT_TEXT_LIMIT: number = 100;
|
|
29
|
+
const UPSERT_PARAMS_PER_ROW: number = 11;
|
|
30
|
+
|
|
31
|
+
type QueryCall = [string, Array<unknown>];
|
|
32
|
+
|
|
33
|
+
function mockQueryRunner(result: unknown = []): jest.Mock {
|
|
34
|
+
const query: jest.Mock = jest.fn().mockResolvedValue(result);
|
|
35
|
+
jest
|
|
36
|
+
.spyOn(IoTDeviceService, "getRepository")
|
|
37
|
+
.mockReturnValue({ manager: { query } } as any);
|
|
38
|
+
return query;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parsedDevice(
|
|
42
|
+
overrides: Partial<ParsedIoTDevice> = {},
|
|
43
|
+
): ParsedIoTDevice {
|
|
44
|
+
return {
|
|
45
|
+
kind: "Sensor",
|
|
46
|
+
externalId: "sensor-1",
|
|
47
|
+
name: "greenhouse-sensor",
|
|
48
|
+
deviceType: "temperature",
|
|
49
|
+
firmwareVersion: "1.2.3",
|
|
50
|
+
isUp: true,
|
|
51
|
+
uptimeSeconds: 3600,
|
|
52
|
+
lastSeenAt: new Date("2026-06-13T00:00:00.000Z"),
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function latestMetric(
|
|
58
|
+
overrides: Partial<IoTDeviceLatestMetric> = {},
|
|
59
|
+
): IoTDeviceLatestMetric {
|
|
60
|
+
return {
|
|
61
|
+
kind: "Sensor",
|
|
62
|
+
externalId: "sensor-1",
|
|
63
|
+
cpuPercent: 12.5,
|
|
64
|
+
memoryBytes: 1024,
|
|
65
|
+
maxMemoryBytes: 4096,
|
|
66
|
+
memoryPercent: 25,
|
|
67
|
+
batteryPercent: 88,
|
|
68
|
+
signalStrengthDbm: -61,
|
|
69
|
+
temperatureCelsius: 21.4,
|
|
70
|
+
observedAt: new Date("2026-06-13T00:00:00.000Z"),
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
jest.restoreAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("IoTDeviceService.bulkUpsert — ShortText sanitation", () => {
|
|
80
|
+
test("truncates an over-long externalId instead of failing the chunk", async () => {
|
|
81
|
+
const query: jest.Mock = mockQueryRunner();
|
|
82
|
+
const longId: string = "x".repeat(150);
|
|
83
|
+
|
|
84
|
+
await IoTDeviceService.bulkUpsert({
|
|
85
|
+
projectId: PROJECT_ID,
|
|
86
|
+
iotFleetId: FLEET_ID,
|
|
87
|
+
devices: [
|
|
88
|
+
parsedDevice({ externalId: longId }),
|
|
89
|
+
parsedDevice({ externalId: "sensor-good" }),
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(query).toHaveBeenCalledTimes(1);
|
|
94
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
95
|
+
|
|
96
|
+
// Both rows made it into the one chunk — the bad id dropped nobody.
|
|
97
|
+
expect(params).toHaveLength(2 * UPSERT_PARAMS_PER_ROW);
|
|
98
|
+
expect(params).toContain("x".repeat(SHORT_TEXT_LIMIT));
|
|
99
|
+
expect(params).toContain("sensor-good");
|
|
100
|
+
expect(params).not.toContain(longId);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("truncates over-long kind/name/deviceType/firmwareVersion values", async () => {
|
|
104
|
+
const query: jest.Mock = mockQueryRunner();
|
|
105
|
+
|
|
106
|
+
await IoTDeviceService.bulkUpsert({
|
|
107
|
+
projectId: PROJECT_ID,
|
|
108
|
+
iotFleetId: FLEET_ID,
|
|
109
|
+
devices: [
|
|
110
|
+
parsedDevice({
|
|
111
|
+
kind: "k".repeat(140),
|
|
112
|
+
name: "n".repeat(140),
|
|
113
|
+
deviceType: "t".repeat(140),
|
|
114
|
+
firmwareVersion: "f".repeat(140),
|
|
115
|
+
}),
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
120
|
+
expect(params).toContain("k".repeat(SHORT_TEXT_LIMIT));
|
|
121
|
+
expect(params).toContain("n".repeat(SHORT_TEXT_LIMIT));
|
|
122
|
+
expect(params).toContain("t".repeat(SHORT_TEXT_LIMIT));
|
|
123
|
+
expect(params).toContain("f".repeat(SHORT_TEXT_LIMIT));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("identities colliding after truncation dedupe to the newest row", async () => {
|
|
127
|
+
const query: jest.Mock = mockQueryRunner();
|
|
128
|
+
const shared: string = "z".repeat(SHORT_TEXT_LIMIT);
|
|
129
|
+
const older: Date = new Date("2026-06-13T00:00:00.000Z");
|
|
130
|
+
const newer: Date = new Date("2026-06-13T00:05:00.000Z");
|
|
131
|
+
|
|
132
|
+
await IoTDeviceService.bulkUpsert({
|
|
133
|
+
projectId: PROJECT_ID,
|
|
134
|
+
iotFleetId: FLEET_ID,
|
|
135
|
+
devices: [
|
|
136
|
+
parsedDevice({
|
|
137
|
+
externalId: `${shared}-alpha`,
|
|
138
|
+
name: "older-device",
|
|
139
|
+
lastSeenAt: older,
|
|
140
|
+
}),
|
|
141
|
+
parsedDevice({
|
|
142
|
+
externalId: `${shared}-bravo`,
|
|
143
|
+
name: "newer-device",
|
|
144
|
+
lastSeenAt: newer,
|
|
145
|
+
}),
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
150
|
+
|
|
151
|
+
// One conflict target → exactly one VALUES row, the newest one.
|
|
152
|
+
expect(params).toHaveLength(UPSERT_PARAMS_PER_ROW);
|
|
153
|
+
expect(params).toContain(shared);
|
|
154
|
+
expect(params).toContain("newer-device");
|
|
155
|
+
expect(params).not.toContain("older-device");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("skips rows whose externalId is empty", async () => {
|
|
159
|
+
const query: jest.Mock = mockQueryRunner();
|
|
160
|
+
|
|
161
|
+
await IoTDeviceService.bulkUpsert({
|
|
162
|
+
projectId: PROJECT_ID,
|
|
163
|
+
iotFleetId: FLEET_ID,
|
|
164
|
+
devices: [parsedDevice({ externalId: "" })],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(query).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("IoTDeviceService.bulkUpsert — future clock-skew clamping", () => {
|
|
172
|
+
test("clamps a lastSeenAt beyond the skew tolerance to ingest time", async () => {
|
|
173
|
+
const query: jest.Mock = mockQueryRunner();
|
|
174
|
+
const before: number = Date.now();
|
|
175
|
+
|
|
176
|
+
await IoTDeviceService.bulkUpsert({
|
|
177
|
+
projectId: PROJECT_ID,
|
|
178
|
+
iotFleetId: FLEET_ID,
|
|
179
|
+
devices: [
|
|
180
|
+
parsedDevice({
|
|
181
|
+
lastSeenAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
182
|
+
}),
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const after: number = Date.now();
|
|
187
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
188
|
+
const lastSeenAt: Date = params[9] as Date;
|
|
189
|
+
|
|
190
|
+
expect(lastSeenAt.getTime()).toBeGreaterThanOrEqual(before);
|
|
191
|
+
expect(lastSeenAt.getTime()).toBeLessThanOrEqual(after);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("keeps a lastSeenAt within the skew tolerance untouched", async () => {
|
|
195
|
+
const query: jest.Mock = mockQueryRunner();
|
|
196
|
+
const slightlyAhead: Date = new Date(
|
|
197
|
+
Date.now() + (IOT_MAX_FUTURE_CLOCK_SKEW_MINUTES - 1) * 60 * 1000,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await IoTDeviceService.bulkUpsert({
|
|
201
|
+
projectId: PROJECT_ID,
|
|
202
|
+
iotFleetId: FLEET_ID,
|
|
203
|
+
devices: [parsedDevice({ lastSeenAt: slightlyAhead })],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
207
|
+
expect(params[9]).toEqual(slightlyAhead);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("IoTDeviceService.bulkUpdateLatestMetrics — sanitation matches the upsert", () => {
|
|
212
|
+
test("truncates an over-long externalId to the same identity the upsert wrote", async () => {
|
|
213
|
+
const query: jest.Mock = mockQueryRunner();
|
|
214
|
+
const longId: string = "x".repeat(150);
|
|
215
|
+
|
|
216
|
+
await IoTDeviceService.bulkUpdateLatestMetrics({
|
|
217
|
+
projectId: PROJECT_ID,
|
|
218
|
+
iotFleetId: FLEET_ID,
|
|
219
|
+
metrics: [latestMetric({ externalId: longId })],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(query).toHaveBeenCalledTimes(1);
|
|
223
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
224
|
+
|
|
225
|
+
// Params: projectId, fleetId, then kind, externalId, ... per row.
|
|
226
|
+
expect(params[3]).toBe("x".repeat(SHORT_TEXT_LIMIT));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("clamps a future observedAt beyond the skew tolerance", async () => {
|
|
230
|
+
const query: jest.Mock = mockQueryRunner();
|
|
231
|
+
const before: number = Date.now();
|
|
232
|
+
|
|
233
|
+
await IoTDeviceService.bulkUpdateLatestMetrics({
|
|
234
|
+
projectId: PROJECT_ID,
|
|
235
|
+
iotFleetId: FLEET_ID,
|
|
236
|
+
metrics: [
|
|
237
|
+
latestMetric({
|
|
238
|
+
observedAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
239
|
+
}),
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const after: number = Date.now();
|
|
244
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
245
|
+
const observedAt: Date = params[11] as Date;
|
|
246
|
+
|
|
247
|
+
expect(observedAt.getTime()).toBeGreaterThanOrEqual(before);
|
|
248
|
+
expect(observedAt.getTime()).toBeLessThanOrEqual(after);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("identities colliding after truncation dedupe to the newest observation", async () => {
|
|
252
|
+
const query: jest.Mock = mockQueryRunner();
|
|
253
|
+
const shared: string = "z".repeat(SHORT_TEXT_LIMIT);
|
|
254
|
+
|
|
255
|
+
await IoTDeviceService.bulkUpdateLatestMetrics({
|
|
256
|
+
projectId: PROJECT_ID,
|
|
257
|
+
iotFleetId: FLEET_ID,
|
|
258
|
+
metrics: [
|
|
259
|
+
latestMetric({
|
|
260
|
+
externalId: `${shared}-alpha`,
|
|
261
|
+
batteryPercent: 10,
|
|
262
|
+
observedAt: new Date("2026-06-13T00:00:00.000Z"),
|
|
263
|
+
}),
|
|
264
|
+
latestMetric({
|
|
265
|
+
externalId: `${shared}-bravo`,
|
|
266
|
+
batteryPercent: 90,
|
|
267
|
+
observedAt: new Date("2026-06-13T00:05:00.000Z"),
|
|
268
|
+
}),
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const [, params] = query.mock.calls[0] as QueryCall;
|
|
273
|
+
|
|
274
|
+
// 2 shared params + one 9-param row (kind..temp) + observedAt.
|
|
275
|
+
expect(params).toHaveLength(2 + 10);
|
|
276
|
+
expect(params[3]).toBe(shared);
|
|
277
|
+
expect(params).toContain(90);
|
|
278
|
+
expect(params).not.toContain(10);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import MetricMonitorCriteria from "../../../../../Server/Utils/Monitor/Criteria/MetricMonitorCriteria";
|
|
2
|
+
import MonitorCriteriaDataExtractor from "../../../../../Server/Utils/Monitor/MonitorCriteriaDataExtractor";
|
|
3
|
+
import AggregateModel from "../../../../../Types/BaseDatabase/AggregatedModel";
|
|
4
|
+
import AggregatedResult from "../../../../../Types/BaseDatabase/AggregatedResult";
|
|
5
|
+
import {
|
|
6
|
+
CheckOn,
|
|
7
|
+
CriteriaFilter,
|
|
8
|
+
EvaluateOverTimeType,
|
|
9
|
+
FilterType,
|
|
10
|
+
} from "../../../../../Types/Monitor/CriteriaFilter";
|
|
11
|
+
import MetricFormulaConfigData from "../../../../../Types/Metrics/MetricFormulaConfigData";
|
|
12
|
+
import MetricQueryConfigData from "../../../../../Types/Metrics/MetricQueryConfigData";
|
|
13
|
+
import MetricQueryData from "../../../../../Types/Metrics/MetricQueryData";
|
|
14
|
+
import MetricsViewConfig from "../../../../../Types/Metrics/MetricsViewConfig";
|
|
15
|
+
import MetricMonitorResponse from "../../../../../Types/Monitor/MetricMonitor/MetricMonitorResponse";
|
|
16
|
+
import MonitorStep, {
|
|
17
|
+
MonitorStepType,
|
|
18
|
+
} from "../../../../../Types/Monitor/MonitorStep";
|
|
19
|
+
import { MonitorStepCephMonitorUtil } from "../../../../../Types/Monitor/MonitorStepCephMonitor";
|
|
20
|
+
import { MonitorStepDockerMonitorUtil } from "../../../../../Types/Monitor/MonitorStepDockerMonitor";
|
|
21
|
+
import { MonitorStepDockerSwarmMonitorUtil } from "../../../../../Types/Monitor/MonitorStepDockerSwarmMonitor";
|
|
22
|
+
import { MonitorStepHostMonitorUtil } from "../../../../../Types/Monitor/MonitorStepHostMonitor";
|
|
23
|
+
import { MonitorStepKubernetesMonitorUtil } from "../../../../../Types/Monitor/MonitorStepKubernetesMonitor";
|
|
24
|
+
import { MonitorStepPodmanMonitorUtil } from "../../../../../Types/Monitor/MonitorStepPodmanMonitor";
|
|
25
|
+
import { MonitorStepProxmoxMonitorUtil } from "../../../../../Types/Monitor/MonitorStepProxmoxMonitor";
|
|
26
|
+
import RollingTime from "../../../../../Types/RollingTime/RollingTime";
|
|
27
|
+
import ObjectID from "../../../../../Types/ObjectID";
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* Criteria evaluation resolves `metricAlias` → result slot via the query
|
|
31
|
+
* configs on the monitor step. Telemetry monitors other than the plain
|
|
32
|
+
* Metric monitor (IoT, Kubernetes, Docker, ...) store their query configs
|
|
33
|
+
* under their own step shape (e.g. `data.iotMonitor.metricViewConfig`), so
|
|
34
|
+
* alias resolution must read from whichever shape the step carries — not
|
|
35
|
+
* just `data.metricMonitor`. These tests pin that behavior: a criteria
|
|
36
|
+
* referencing the second query's alias must evaluate the second result
|
|
37
|
+
* slot, never silently fall back to slot 0.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
function buildQueryConfig(input: {
|
|
41
|
+
alias: string;
|
|
42
|
+
metricName: string;
|
|
43
|
+
}): MetricQueryConfigData {
|
|
44
|
+
return {
|
|
45
|
+
metricAliasData: {
|
|
46
|
+
metricVariable: input.alias,
|
|
47
|
+
title: input.metricName,
|
|
48
|
+
description: undefined,
|
|
49
|
+
legend: undefined,
|
|
50
|
+
legendUnit: undefined,
|
|
51
|
+
},
|
|
52
|
+
metricQueryData: {
|
|
53
|
+
filterData: {
|
|
54
|
+
metricName: input.metricName,
|
|
55
|
+
},
|
|
56
|
+
} as unknown as MetricQueryData,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Two queries — "a" (iot_battery_percent) and "b"
|
|
62
|
+
* (iot_signal_strength_dbm) — plus an optional formula "f" = a + b.
|
|
63
|
+
* Result slots: 0 → a, 1 → b, 2 → f.
|
|
64
|
+
*/
|
|
65
|
+
function buildViewConfig(input?: { withFormula?: boolean }): MetricsViewConfig {
|
|
66
|
+
const formulaConfigs: Array<MetricFormulaConfigData> = input?.withFormula
|
|
67
|
+
? [
|
|
68
|
+
{
|
|
69
|
+
metricAliasData: {
|
|
70
|
+
metricVariable: "f",
|
|
71
|
+
title: "Battery + Signal",
|
|
72
|
+
description: undefined,
|
|
73
|
+
legend: undefined,
|
|
74
|
+
legendUnit: undefined,
|
|
75
|
+
},
|
|
76
|
+
metricFormulaData: {
|
|
77
|
+
metricFormula: "a + b",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
: [];
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
queryConfigs: [
|
|
85
|
+
buildQueryConfig({ alias: "a", metricName: "iot_battery_percent" }),
|
|
86
|
+
buildQueryConfig({ alias: "b", metricName: "iot_signal_strength_dbm" }),
|
|
87
|
+
],
|
|
88
|
+
formulaConfigs,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* Bare step whose telemetry shape is attached by `assignShape` — the
|
|
94
|
+
* point of these tests is that `data.metricMonitor` stays undefined.
|
|
95
|
+
*/
|
|
96
|
+
function buildStep(
|
|
97
|
+
assignShape: (data: MonitorStepType, viewConfig: MetricsViewConfig) => void,
|
|
98
|
+
viewConfig: MetricsViewConfig,
|
|
99
|
+
): MonitorStep {
|
|
100
|
+
const monitorStep: MonitorStep = new MonitorStep();
|
|
101
|
+
monitorStep.data = {
|
|
102
|
+
id: ObjectID.generate().toString(),
|
|
103
|
+
monitorCriteria: { data: undefined } as never,
|
|
104
|
+
} as unknown as MonitorStep["data"];
|
|
105
|
+
assignShape(monitorStep.data!, viewConfig);
|
|
106
|
+
return monitorStep;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildIoTStep(viewConfig: MetricsViewConfig): MonitorStep {
|
|
110
|
+
return buildStep((data: MonitorStepType, v: MetricsViewConfig) => {
|
|
111
|
+
data.iotMonitor = {
|
|
112
|
+
fleetIdentifier: "plant-a-fleet",
|
|
113
|
+
resourceFilters: {},
|
|
114
|
+
metricViewConfig: v,
|
|
115
|
+
rollingTime: RollingTime.Past1Minute,
|
|
116
|
+
};
|
|
117
|
+
}, viewConfig);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* One aggregated result per result slot, in query/formula order.
|
|
122
|
+
*/
|
|
123
|
+
function buildResponse(input: {
|
|
124
|
+
viewConfig: MetricsViewConfig;
|
|
125
|
+
resultSlots: Array<Array<number>>;
|
|
126
|
+
}): MetricMonitorResponse {
|
|
127
|
+
const metricResult: Array<AggregatedResult> = input.resultSlots.map(
|
|
128
|
+
(values: Array<number>) => {
|
|
129
|
+
return {
|
|
130
|
+
data: values.map((v: number) => {
|
|
131
|
+
return {
|
|
132
|
+
timestamp: new Date(),
|
|
133
|
+
value: v,
|
|
134
|
+
} as AggregateModel;
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
projectId: ObjectID.generate(),
|
|
142
|
+
metricResult,
|
|
143
|
+
metricViewConfig: input.viewConfig,
|
|
144
|
+
monitorId: ObjectID.generate(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function greaterThanFilter(input: {
|
|
149
|
+
alias: string;
|
|
150
|
+
threshold: string;
|
|
151
|
+
}): CriteriaFilter {
|
|
152
|
+
return {
|
|
153
|
+
checkOn: CheckOn.MetricValue,
|
|
154
|
+
filterType: FilterType.GreaterThan,
|
|
155
|
+
value: input.threshold,
|
|
156
|
+
metricMonitorOptions: {
|
|
157
|
+
metricAlias: input.alias,
|
|
158
|
+
metricAggregationType: EvaluateOverTimeType.AnyValue,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe("MetricMonitorCriteria — IoT monitor step alias resolution", () => {
|
|
164
|
+
test("criteria on the second alias evaluates the second series", async () => {
|
|
165
|
+
const viewConfig: MetricsViewConfig = buildViewConfig();
|
|
166
|
+
const criteriaFilter: CriteriaFilter = greaterThanFilter({
|
|
167
|
+
alias: "b",
|
|
168
|
+
threshold: "30",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Slot 0 (alias a) stays under the threshold; slot 1 (alias b) breaches.
|
|
172
|
+
const message: string | null =
|
|
173
|
+
await MetricMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
|
|
174
|
+
dataToProcess: buildResponse({
|
|
175
|
+
viewConfig,
|
|
176
|
+
resultSlots: [[10, 12], [55]],
|
|
177
|
+
}),
|
|
178
|
+
criteriaFilter,
|
|
179
|
+
monitorStep: buildIoTStep(viewConfig),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(message).toBeTruthy();
|
|
183
|
+
expect(message).toContain("55");
|
|
184
|
+
expect(criteriaFilter.metricCriteriaContext?.metricName).toBe(
|
|
185
|
+
"iot_signal_strength_dbm",
|
|
186
|
+
);
|
|
187
|
+
expect(criteriaFilter.metricCriteriaContext?.breachingSample?.value).toBe(
|
|
188
|
+
55,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("criteria on the second alias ignores a breach in the first series", async () => {
|
|
193
|
+
const viewConfig: MetricsViewConfig = buildViewConfig();
|
|
194
|
+
|
|
195
|
+
// Slot 0 (alias a) breaches, but the criteria targets alias b.
|
|
196
|
+
const message: string | null =
|
|
197
|
+
await MetricMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
|
|
198
|
+
dataToProcess: buildResponse({
|
|
199
|
+
viewConfig,
|
|
200
|
+
resultSlots: [[90], [10]],
|
|
201
|
+
}),
|
|
202
|
+
criteriaFilter: greaterThanFilter({ alias: "b", threshold: "30" }),
|
|
203
|
+
monitorStep: buildIoTStep(viewConfig),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(message).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("formula alias resolves to the formula result slot", async () => {
|
|
210
|
+
const viewConfig: MetricsViewConfig = buildViewConfig({
|
|
211
|
+
withFormula: true,
|
|
212
|
+
});
|
|
213
|
+
const criteriaFilter: CriteriaFilter = greaterThanFilter({
|
|
214
|
+
alias: "f",
|
|
215
|
+
threshold: "100",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Slots: a → 10, b → 20, f (formula) → 150. Only the formula breaches.
|
|
219
|
+
const message: string | null =
|
|
220
|
+
await MetricMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
|
|
221
|
+
dataToProcess: buildResponse({
|
|
222
|
+
viewConfig,
|
|
223
|
+
resultSlots: [[10], [20], [150]],
|
|
224
|
+
}),
|
|
225
|
+
criteriaFilter,
|
|
226
|
+
monitorStep: buildIoTStep(viewConfig),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(message).toBeTruthy();
|
|
230
|
+
expect(message).toContain("150");
|
|
231
|
+
expect(criteriaFilter.metricCriteriaContext?.isFormula).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("MonitorCriteriaDataExtractor — IoT monitor step alias resolution", () => {
|
|
236
|
+
test("extractMetricValues returns the aliased series, not slot 0", () => {
|
|
237
|
+
const viewConfig: MetricsViewConfig = buildViewConfig();
|
|
238
|
+
|
|
239
|
+
const extracted: { alias: string | null; values: Array<number> } | null =
|
|
240
|
+
MonitorCriteriaDataExtractor.extractMetricValues({
|
|
241
|
+
criteriaFilter: greaterThanFilter({ alias: "b", threshold: "30" }),
|
|
242
|
+
dataToProcess: buildResponse({
|
|
243
|
+
viewConfig,
|
|
244
|
+
resultSlots: [
|
|
245
|
+
[10, 12],
|
|
246
|
+
[55, 60],
|
|
247
|
+
],
|
|
248
|
+
}),
|
|
249
|
+
monitorStep: buildIoTStep(viewConfig),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(extracted).not.toBeNull();
|
|
253
|
+
expect(extracted?.alias).toBe("b");
|
|
254
|
+
expect(extracted?.values).toEqual([55, 60]);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("MetricMonitorCriteria — other telemetry step shapes", () => {
|
|
259
|
+
type ShapeCase = {
|
|
260
|
+
name: string;
|
|
261
|
+
assignShape: (data: MonitorStepType, viewConfig: MetricsViewConfig) => void;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const shapeCases: Array<ShapeCase> = [
|
|
265
|
+
{
|
|
266
|
+
name: "kubernetesMonitor",
|
|
267
|
+
assignShape: (data: MonitorStepType, v: MetricsViewConfig) => {
|
|
268
|
+
data.kubernetesMonitor = {
|
|
269
|
+
...MonitorStepKubernetesMonitorUtil.getDefault(),
|
|
270
|
+
metricViewConfig: v,
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "dockerMonitor",
|
|
276
|
+
assignShape: (data: MonitorStepType, v: MetricsViewConfig) => {
|
|
277
|
+
data.dockerMonitor = {
|
|
278
|
+
...MonitorStepDockerMonitorUtil.getDefault(),
|
|
279
|
+
metricViewConfig: v,
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "dockerSwarmMonitor",
|
|
285
|
+
assignShape: (data: MonitorStepType, v: MetricsViewConfig) => {
|
|
286
|
+
data.dockerSwarmMonitor = {
|
|
287
|
+
...MonitorStepDockerSwarmMonitorUtil.getDefault(),
|
|
288
|
+
metricViewConfig: v,
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "hostMonitor",
|
|
294
|
+
assignShape: (data: MonitorStepType, v: MetricsViewConfig) => {
|
|
295
|
+
data.hostMonitor = {
|
|
296
|
+
...MonitorStepHostMonitorUtil.getDefault(),
|
|
297
|
+
metricViewConfig: v,
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: "podmanMonitor",
|
|
303
|
+
assignShape: (data: MonitorStepType, v: MetricsViewConfig) => {
|
|
304
|
+
data.podmanMonitor = {
|
|
305
|
+
...MonitorStepPodmanMonitorUtil.getDefault(),
|
|
306
|
+
metricViewConfig: v,
|
|
307
|
+
};
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: "proxmoxMonitor",
|
|
312
|
+
assignShape: (data: MonitorStepType, v: MetricsViewConfig) => {
|
|
313
|
+
data.proxmoxMonitor = {
|
|
314
|
+
...MonitorStepProxmoxMonitorUtil.getDefault(),
|
|
315
|
+
metricViewConfig: v,
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "cephMonitor",
|
|
321
|
+
assignShape: (data: MonitorStepType, v: MetricsViewConfig) => {
|
|
322
|
+
data.cephMonitor = {
|
|
323
|
+
...MonitorStepCephMonitorUtil.getDefault(),
|
|
324
|
+
metricViewConfig: v,
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
test.each(shapeCases)(
|
|
331
|
+
"$name: criteria on the second alias evaluates the second series",
|
|
332
|
+
async (shapeCase: ShapeCase) => {
|
|
333
|
+
const viewConfig: MetricsViewConfig = buildViewConfig();
|
|
334
|
+
const criteriaFilter: CriteriaFilter = greaterThanFilter({
|
|
335
|
+
alias: "b",
|
|
336
|
+
threshold: "30",
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const message: string | null =
|
|
340
|
+
await MetricMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
|
|
341
|
+
dataToProcess: buildResponse({
|
|
342
|
+
viewConfig,
|
|
343
|
+
resultSlots: [[10, 12], [55]],
|
|
344
|
+
}),
|
|
345
|
+
criteriaFilter,
|
|
346
|
+
monitorStep: buildStep(shapeCase.assignShape, viewConfig),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(message).toBeTruthy();
|
|
350
|
+
expect(message).toContain("55");
|
|
351
|
+
expect(criteriaFilter.metricCriteriaContext?.metricName).toBe(
|
|
352
|
+
"iot_signal_strength_dbm",
|
|
353
|
+
);
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("MetricMonitorCriteria — metric monitor step (regression)", () => {
|
|
359
|
+
test("alias resolution still reads data.metricMonitor", async () => {
|
|
360
|
+
const viewConfig: MetricsViewConfig = buildViewConfig();
|
|
361
|
+
const criteriaFilter: CriteriaFilter = greaterThanFilter({
|
|
362
|
+
alias: "b",
|
|
363
|
+
threshold: "30",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const message: string | null =
|
|
367
|
+
await MetricMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
|
|
368
|
+
dataToProcess: buildResponse({
|
|
369
|
+
viewConfig,
|
|
370
|
+
resultSlots: [[10, 12], [55]],
|
|
371
|
+
}),
|
|
372
|
+
criteriaFilter,
|
|
373
|
+
monitorStep: buildStep(
|
|
374
|
+
(data: MonitorStepType, v: MetricsViewConfig) => {
|
|
375
|
+
data.metricMonitor = {
|
|
376
|
+
metricViewConfig: v,
|
|
377
|
+
rollingTime: RollingTime.Past1Minute,
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
viewConfig,
|
|
381
|
+
),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(message).toBeTruthy();
|
|
385
|
+
expect(message).toContain("55");
|
|
386
|
+
expect(criteriaFilter.metricCriteriaContext?.metricName).toBe(
|
|
387
|
+
"iot_signal_strength_dbm",
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
});
|