@oneuptime/common 10.0.11 → 10.0.15
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/AnalyticsModels/ExceptionInstance.ts +98 -0
- package/Models/DatabaseModels/TelemetryException.ts +105 -0
- package/Models/DatabaseModels/User.ts +27 -0
- package/Server/API/UserWebAuthnAPI.ts +0 -2
- package/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.ts +41 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.ts +23 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/MonitorTestService.ts +102 -1
- package/Server/Services/UserWebAuthnService.ts +116 -8
- package/Server/Utils/Browser.ts +0 -1
- package/Server/Utils/Memory.ts +81 -0
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +4 -1
- package/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.ts +5 -1
- package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +3 -15
- package/Server/Utils/Telemetry.ts +7 -2
- package/Server/Utils/VM/VMAPI.ts +204 -0
- package/Tests/Server/Utils/VM/VMAPI.test.ts +314 -0
- package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +2 -2
- package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +136 -11
- package/UI/Components/TextArea/TextArea.tsx +2 -1
- package/Utils/Number.ts +27 -0
- package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +87 -0
- package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryException.js +108 -0
- package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
- package/build/dist/Models/DatabaseModels/User.js +31 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Server/API/UserWebAuthnAPI.js +0 -2
- package/build/dist/Server/API/UserWebAuthnAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.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/MonitorTestService.js +74 -0
- package/build/dist/Server/Services/MonitorTestService.js.map +1 -1
- package/build/dist/Server/Services/UserWebAuthnService.js +83 -5
- package/build/dist/Server/Services/UserWebAuthnService.js.map +1 -1
- package/build/dist/Server/Utils/Browser.js +0 -1
- package/build/dist/Server/Utils/Browser.js.map +1 -1
- package/build/dist/Server/Utils/Memory.js +55 -0
- package/build/dist/Server/Utils/Memory.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +3 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.js +4 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +3 -9
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry.js +4 -2
- package/build/dist/Server/Utils/Telemetry.js.map +1 -1
- package/build/dist/Server/Utils/VM/VMAPI.js +153 -0
- package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
- package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js +218 -0
- package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js.map +1 -0
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +2 -2
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +72 -5
- package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js +2 -2
- package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
- package/build/dist/Utils/Number.js +16 -0
- package/build/dist/Utils/Number.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
|
|
4
|
+
const UNLIMITED_CGROUP_THRESHOLD_BYTES: number = 1 << 60; // treat absurdly large cgroup limit as "unlimited"
|
|
5
|
+
|
|
6
|
+
export default class MemoryUtil {
|
|
7
|
+
public static getHostFreeMemoryInBytes(): number {
|
|
8
|
+
return os.freemem();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public static getContainerAwareAvailableMemoryInBytes(): number {
|
|
12
|
+
const hostFreeMemory: number = this.getHostFreeMemoryInBytes();
|
|
13
|
+
const cgroupAvailableMemory: number | null =
|
|
14
|
+
this.getCgroupAvailableMemoryInBytes();
|
|
15
|
+
|
|
16
|
+
if (cgroupAvailableMemory === null) {
|
|
17
|
+
return hostFreeMemory;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Be conservative: never exceed container-available memory.
|
|
21
|
+
return Math.min(hostFreeMemory, cgroupAvailableMemory);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public static getCgroupAvailableMemoryInBytes(): number | null {
|
|
25
|
+
// cgroup v2
|
|
26
|
+
const v2Limit: number | null = this.readNumericFile(
|
|
27
|
+
"/sys/fs/cgroup/memory.max",
|
|
28
|
+
);
|
|
29
|
+
const v2Usage: number | null = this.readNumericFile(
|
|
30
|
+
"/sys/fs/cgroup/memory.current",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
v2Limit &&
|
|
35
|
+
v2Usage !== null &&
|
|
36
|
+
v2Limit > 0 &&
|
|
37
|
+
v2Limit < UNLIMITED_CGROUP_THRESHOLD_BYTES
|
|
38
|
+
) {
|
|
39
|
+
return Math.max(v2Limit - v2Usage, 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// cgroup v1
|
|
43
|
+
const v1Limit: number | null = this.readNumericFile(
|
|
44
|
+
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
|
45
|
+
);
|
|
46
|
+
const v1Usage: number | null = this.readNumericFile(
|
|
47
|
+
"/sys/fs/cgroup/memory/memory.usage_in_bytes",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
v1Limit &&
|
|
52
|
+
v1Usage !== null &&
|
|
53
|
+
v1Limit > 0 &&
|
|
54
|
+
v1Limit < UNLIMITED_CGROUP_THRESHOLD_BYTES
|
|
55
|
+
) {
|
|
56
|
+
return Math.max(v1Limit - v1Usage, 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private static readNumericFile(path: string): number | null {
|
|
63
|
+
try {
|
|
64
|
+
const rawValue: string = fs.readFileSync(path, "utf8").trim();
|
|
65
|
+
|
|
66
|
+
if (!rawValue || rawValue === "max") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const value: number = Number(rawValue);
|
|
71
|
+
|
|
72
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return value;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -235,7 +235,10 @@ export default class CompareCriteria {
|
|
|
235
235
|
|
|
236
236
|
if (data.criteriaFilter.filterType === FilterType.IsNotEmpty) {
|
|
237
237
|
if (data.value !== null && data.value !== undefined) {
|
|
238
|
-
|
|
238
|
+
const valueStr: string = String(data.value);
|
|
239
|
+
const truncatedValue: string =
|
|
240
|
+
valueStr.length > 500 ? valueStr.substring(0, 500) + "..." : valueStr;
|
|
241
|
+
return `${data.criteriaFilter.checkOn} is not empty. Value: ${truncatedValue}`;
|
|
239
242
|
}
|
|
240
243
|
|
|
241
244
|
return null;
|
|
@@ -238,7 +238,11 @@ export default class IncomingEmailCriteria {
|
|
|
238
238
|
|
|
239
239
|
if (criteriaFilter.filterType === FilterType.IsNotEmpty) {
|
|
240
240
|
if (fieldValue && fieldValue.trim() !== "") {
|
|
241
|
-
|
|
241
|
+
const truncatedValue: string =
|
|
242
|
+
fieldValue.length > 500
|
|
243
|
+
? fieldValue.substring(0, 500) + "..."
|
|
244
|
+
: fieldValue;
|
|
245
|
+
return `${fieldName} is not empty. Value: ${truncatedValue}`;
|
|
242
246
|
}
|
|
243
247
|
return null;
|
|
244
248
|
}
|
|
@@ -159,11 +159,7 @@ export default class IncomingRequestCriteria {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
if (
|
|
163
|
-
input.criteriaFilter.checkOn === CheckOn.RequestBody &&
|
|
164
|
-
!(input.dataToProcess as IncomingMonitorRequest)
|
|
165
|
-
.onlyCheckForIncomingRequestReceivedAt
|
|
166
|
-
) {
|
|
162
|
+
if (input.criteriaFilter.checkOn === CheckOn.RequestBody) {
|
|
167
163
|
let responseBody: string | JSONObject | undefined = (
|
|
168
164
|
input.dataToProcess as IncomingMonitorRequest
|
|
169
165
|
).requestBody;
|
|
@@ -200,11 +196,7 @@ export default class IncomingRequestCriteria {
|
|
|
200
196
|
}
|
|
201
197
|
}
|
|
202
198
|
|
|
203
|
-
if (
|
|
204
|
-
input.criteriaFilter.checkOn === CheckOn.RequestHeader &&
|
|
205
|
-
!(input.dataToProcess as IncomingMonitorRequest)
|
|
206
|
-
.onlyCheckForIncomingRequestReceivedAt
|
|
207
|
-
) {
|
|
199
|
+
if (input.criteriaFilter.checkOn === CheckOn.RequestHeader) {
|
|
208
200
|
const headerKeys: Array<string> = Object.keys(
|
|
209
201
|
(input.dataToProcess as IncomingMonitorRequest).requestHeaders || {},
|
|
210
202
|
).map((key: string) => {
|
|
@@ -227,11 +219,7 @@ export default class IncomingRequestCriteria {
|
|
|
227
219
|
}
|
|
228
220
|
}
|
|
229
221
|
|
|
230
|
-
if (
|
|
231
|
-
input.criteriaFilter.checkOn === CheckOn.RequestHeaderValue &&
|
|
232
|
-
!(input.dataToProcess as IncomingMonitorRequest)
|
|
233
|
-
.onlyCheckForIncomingRequestReceivedAt
|
|
234
|
-
) {
|
|
222
|
+
if (input.criteriaFilter.checkOn === CheckOn.RequestHeaderValue) {
|
|
235
223
|
const headerValues: Array<string> = Object.values(
|
|
236
224
|
(input.dataToProcess as IncomingMonitorRequest).requestHeaders || {},
|
|
237
225
|
).map((key: string) => {
|
|
@@ -34,10 +34,13 @@ import {
|
|
|
34
34
|
import type { PushMetricExporter } from "@opentelemetry/sdk-metrics/build/src/export/MetricExporter";
|
|
35
35
|
import * as opentelemetry from "@opentelemetry/sdk-node";
|
|
36
36
|
import { SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
ATTR_SERVICE_NAME,
|
|
39
|
+
ATTR_SERVICE_VERSION,
|
|
40
|
+
} from "@opentelemetry/semantic-conventions";
|
|
38
41
|
import URL from "../../Types/API/URL";
|
|
39
42
|
import Dictionary from "../../Types/Dictionary";
|
|
40
|
-
import { DisableTelemetry } from "../EnvironmentConfig";
|
|
43
|
+
import { AppVersion, Env, DisableTelemetry } from "../EnvironmentConfig";
|
|
41
44
|
import logger from "./Logger";
|
|
42
45
|
|
|
43
46
|
type ResourceWithRawAttributes = LogsResource & {
|
|
@@ -141,6 +144,8 @@ export default class Telemetry {
|
|
|
141
144
|
public static getResource(data: { serviceName: string }): Resource {
|
|
142
145
|
return new Resource({
|
|
143
146
|
[ATTR_SERVICE_NAME]: data.serviceName,
|
|
147
|
+
[ATTR_SERVICE_VERSION]: AppVersion,
|
|
148
|
+
["deployment.environment"]: Env,
|
|
144
149
|
});
|
|
145
150
|
}
|
|
146
151
|
|
package/Server/Utils/VM/VMAPI.ts
CHANGED
|
@@ -69,6 +69,14 @@ export default class VMUtil {
|
|
|
69
69
|
valueToReplaceInPlace.toString().includes("}}")
|
|
70
70
|
) {
|
|
71
71
|
let valueToReplaceInPlaceCopy: string = valueToReplaceInPlace.toString();
|
|
72
|
+
|
|
73
|
+
// First, expand {{#each path}}...{{/each}} loops before variable substitution
|
|
74
|
+
valueToReplaceInPlaceCopy = VMUtil.expandEachLoops(
|
|
75
|
+
storageMap,
|
|
76
|
+
valueToReplaceInPlaceCopy,
|
|
77
|
+
isJSON,
|
|
78
|
+
);
|
|
79
|
+
|
|
72
80
|
const variablesInArgument: Array<string> = [];
|
|
73
81
|
|
|
74
82
|
const regex: RegExp = /{{(.*?)}}/g; // Find all matches of the regular expression and capture the word between the braces {{x}} => x
|
|
@@ -128,6 +136,202 @@ export default class VMUtil {
|
|
|
128
136
|
return valueToReplaceInPlace;
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Expand {{#each path}}...{{/each}} loop blocks by iterating over arrays.
|
|
141
|
+
*
|
|
142
|
+
* Supports:
|
|
143
|
+
* - {{variableName}} inside the loop body resolves relative to the current array element
|
|
144
|
+
* - {{@index}} resolves to the 0-based index of the current iteration
|
|
145
|
+
* - {{this}} resolves to the current element value (useful for primitive arrays)
|
|
146
|
+
* - Nested {{#each}} blocks for multi-level array traversal
|
|
147
|
+
* - If the resolved path is not an array, the block is removed (replaced with empty string)
|
|
148
|
+
*
|
|
149
|
+
* Example:
|
|
150
|
+
* {{#each requestBody.alerts}}
|
|
151
|
+
* Alert {{@index}}: {{labels.label}} - {{status}}
|
|
152
|
+
* {{/each}}
|
|
153
|
+
*/
|
|
154
|
+
@CaptureSpan()
|
|
155
|
+
public static expandEachLoops(
|
|
156
|
+
storageMap: JSONObject,
|
|
157
|
+
template: string,
|
|
158
|
+
isJSON: boolean | undefined,
|
|
159
|
+
): string {
|
|
160
|
+
let result: string = template;
|
|
161
|
+
const maxIterations: number = 100; // safety limit to prevent infinite loops
|
|
162
|
+
let iterations: number = 0;
|
|
163
|
+
|
|
164
|
+
while (iterations < maxIterations) {
|
|
165
|
+
iterations++;
|
|
166
|
+
|
|
167
|
+
// Find the first (outermost) {{#each ...}} tag
|
|
168
|
+
const openTag: RegExp = /\{\{#each\s+(.*?)\}\}/;
|
|
169
|
+
const openMatch: RegExpExecArray | null = openTag.exec(result);
|
|
170
|
+
|
|
171
|
+
if (!openMatch) {
|
|
172
|
+
break; // no more {{#each}} blocks
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const blockStart: number = openMatch.index!;
|
|
176
|
+
const arrayPath: string = openMatch[1]!.trim();
|
|
177
|
+
const bodyStart: number = blockStart + openMatch[0]!.length;
|
|
178
|
+
|
|
179
|
+
// Find the matching {{/each}} by counting nesting depth
|
|
180
|
+
let depth: number = 1;
|
|
181
|
+
let searchPos: number = bodyStart;
|
|
182
|
+
let matchEnd: number = -1;
|
|
183
|
+
let bodyEnd: number = -1;
|
|
184
|
+
|
|
185
|
+
while (depth > 0 && searchPos < result.length) {
|
|
186
|
+
const nextOpen: number = result.indexOf("{{#each ", searchPos);
|
|
187
|
+
const nextClose: number = result.indexOf("{{/each}}", searchPos);
|
|
188
|
+
|
|
189
|
+
if (nextClose === -1) {
|
|
190
|
+
// Unmatched {{#each}} — break out to avoid infinite loop
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
195
|
+
// Found a nested {{#each}} before the next {{/each}}
|
|
196
|
+
depth++;
|
|
197
|
+
searchPos = nextOpen + 8; // skip past "{{#each "
|
|
198
|
+
} else {
|
|
199
|
+
// Found {{/each}}
|
|
200
|
+
depth--;
|
|
201
|
+
if (depth === 0) {
|
|
202
|
+
bodyEnd = nextClose;
|
|
203
|
+
matchEnd = nextClose + "{{/each}}".length;
|
|
204
|
+
}
|
|
205
|
+
searchPos = nextClose + "{{/each}}".length;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (matchEnd === -1 || bodyEnd === -1) {
|
|
210
|
+
// Unmatched {{#each}} — remove it to prevent infinite loop
|
|
211
|
+
result =
|
|
212
|
+
result.slice(0, blockStart) +
|
|
213
|
+
result.slice(blockStart + openMatch[0]!.length);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const loopBody: string = result.slice(bodyStart, bodyEnd);
|
|
218
|
+
|
|
219
|
+
// Resolve the array from the storage map
|
|
220
|
+
const arrayValue: JSONValue = VMUtil.deepFind(storageMap, arrayPath);
|
|
221
|
+
|
|
222
|
+
if (!Array.isArray(arrayValue)) {
|
|
223
|
+
// Not an array — remove the block entirely
|
|
224
|
+
result = result.slice(0, blockStart) + result.slice(matchEnd);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Expand the loop body for each element in the array
|
|
229
|
+
const expandedParts: Array<string> = [];
|
|
230
|
+
|
|
231
|
+
for (let i: number = 0; i < arrayValue.length; i++) {
|
|
232
|
+
const element: JSONValue = arrayValue[i]!;
|
|
233
|
+
let iterationBody: string = loopBody;
|
|
234
|
+
|
|
235
|
+
// Replace {{@index}} with the current index
|
|
236
|
+
iterationBody = iterationBody.replace(/\{\{@index\}\}/g, i.toString());
|
|
237
|
+
|
|
238
|
+
if (typeof element === "object" && element !== null) {
|
|
239
|
+
/*
|
|
240
|
+
* Merge element properties into a scoped storageMap so that:
|
|
241
|
+
* 1. Element properties can be accessed directly (e.g., {{status}})
|
|
242
|
+
* 2. Parent storageMap properties are still accessible (e.g., {{requestBody.receiver}})
|
|
243
|
+
*/
|
|
244
|
+
const scopedStorageMap: JSONObject = {
|
|
245
|
+
...storageMap,
|
|
246
|
+
...(element as JSONObject),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Recursively expand any nested {{#each}} blocks within the iteration body
|
|
250
|
+
iterationBody = VMUtil.expandEachLoops(
|
|
251
|
+
scopedStorageMap,
|
|
252
|
+
iterationBody,
|
|
253
|
+
isJSON,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Replace remaining {{variable}} placeholders
|
|
257
|
+
iterationBody = VMUtil.replaceLoopVariables(
|
|
258
|
+
element as JSONObject,
|
|
259
|
+
storageMap,
|
|
260
|
+
iterationBody,
|
|
261
|
+
isJSON,
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
// For primitive array elements, replace {{this}} with the value
|
|
265
|
+
iterationBody = iterationBody.replace(
|
|
266
|
+
/\{\{this\}\}/g,
|
|
267
|
+
isJSON ? VMUtil.serializeValueForJSON(`${element}`) : `${element}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
expandedParts.push(iterationBody);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
result =
|
|
275
|
+
result.slice(0, blockStart) +
|
|
276
|
+
expandedParts.join("") +
|
|
277
|
+
result.slice(matchEnd);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Replace {{variable}} placeholders inside a loop body.
|
|
285
|
+
* Variables are resolved first against the current element (scoped),
|
|
286
|
+
* then fall back to the parent storageMap.
|
|
287
|
+
*/
|
|
288
|
+
@CaptureSpan()
|
|
289
|
+
private static replaceLoopVariables(
|
|
290
|
+
element: JSONObject,
|
|
291
|
+
parentStorageMap: JSONObject,
|
|
292
|
+
body: string,
|
|
293
|
+
isJSON: boolean | undefined,
|
|
294
|
+
): string {
|
|
295
|
+
const variableRegex: RegExp = /\{\{((?!#each\b|\/each\b|@index\b).*?)\}\}/g;
|
|
296
|
+
let match: RegExpExecArray | null = null;
|
|
297
|
+
const variables: Array<string> = [];
|
|
298
|
+
|
|
299
|
+
while ((match = variableRegex.exec(body)) !== null) {
|
|
300
|
+
if (match[1]) {
|
|
301
|
+
variables.push(match[1]);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const variable of variables) {
|
|
306
|
+
// First try resolving relative to the current element
|
|
307
|
+
let foundValue: JSONValue = VMUtil.deepFind(element, variable.trim());
|
|
308
|
+
|
|
309
|
+
// Fall back to the parent storage map (for absolute paths)
|
|
310
|
+
if (foundValue === undefined) {
|
|
311
|
+
foundValue = VMUtil.deepFind(parentStorageMap, variable.trim());
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (foundValue === undefined) {
|
|
315
|
+
continue; // leave unresolved
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let replacement: string;
|
|
319
|
+
|
|
320
|
+
if (typeof foundValue === "object" && foundValue !== null) {
|
|
321
|
+
replacement = JSON.stringify(foundValue, null, 2);
|
|
322
|
+
} else {
|
|
323
|
+
replacement = `${foundValue}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
body = body.replace(
|
|
327
|
+
"{{" + variable + "}}",
|
|
328
|
+
isJSON ? VMUtil.serializeValueForJSON(replacement) : replacement,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return body;
|
|
333
|
+
}
|
|
334
|
+
|
|
131
335
|
@CaptureSpan()
|
|
132
336
|
public static serializeValueForJSON(value: string): string {
|
|
133
337
|
if (!value) {
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Mock all heavy dependencies so the test focuses on template logic only
|
|
2
|
+
jest.mock("../../../../Server/EnvironmentConfig", () => {
|
|
3
|
+
return {
|
|
4
|
+
IsolatedVMHostname: "localhost",
|
|
5
|
+
};
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
jest.mock("../../../../Server/Middleware/ClusterKeyAuthorization", () => {
|
|
9
|
+
return {
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: {
|
|
12
|
+
getClusterKeyHeaders: () => {
|
|
13
|
+
return {};
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
jest.mock("../../../../Utils/API", () => {
|
|
20
|
+
return {
|
|
21
|
+
__esModule: true,
|
|
22
|
+
default: { post: jest.fn() },
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
jest.mock("../../../../Server/Utils/Logger", () => {
|
|
27
|
+
return {
|
|
28
|
+
__esModule: true,
|
|
29
|
+
default: {
|
|
30
|
+
error: jest.fn(),
|
|
31
|
+
debug: jest.fn(),
|
|
32
|
+
info: jest.fn(),
|
|
33
|
+
warn: jest.fn(),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
jest.mock("../../../../Server/Utils/Telemetry/CaptureSpan", () => {
|
|
39
|
+
return {
|
|
40
|
+
__esModule: true,
|
|
41
|
+
default: () => {
|
|
42
|
+
return (
|
|
43
|
+
_target: any,
|
|
44
|
+
_propertyKey: string,
|
|
45
|
+
descriptor: PropertyDescriptor,
|
|
46
|
+
) => {
|
|
47
|
+
return descriptor;
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
import VMUtil from "../../../../Server/Utils/VM/VMAPI";
|
|
54
|
+
import { describe, expect, it } from "@jest/globals";
|
|
55
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
56
|
+
|
|
57
|
+
describe("VMUtil", () => {
|
|
58
|
+
describe("deepFind", () => {
|
|
59
|
+
it("should find top-level keys", () => {
|
|
60
|
+
const obj: JSONObject = { status: "firing", receiver: "test" };
|
|
61
|
+
expect(VMUtil.deepFind(obj, "status")).toBe("firing");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should find nested keys with dot notation", () => {
|
|
65
|
+
const obj: JSONObject = { data: { nested: { value: 42 } } };
|
|
66
|
+
expect(VMUtil.deepFind(obj, "data.nested.value")).toBe(42);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should find array elements with bracket notation", () => {
|
|
70
|
+
const obj: JSONObject = { items: ["a", "b", "c"] };
|
|
71
|
+
expect(VMUtil.deepFind(obj, "items[0]")).toBe("a");
|
|
72
|
+
expect(VMUtil.deepFind(obj, "items[2]")).toBe("c");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should find last array element with [last]", () => {
|
|
76
|
+
const obj: JSONObject = { items: [1, 2, 3] };
|
|
77
|
+
expect(VMUtil.deepFind(obj, "items[last]")).toBe(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should return undefined for missing paths", () => {
|
|
81
|
+
const obj: JSONObject = { a: { b: 1 } };
|
|
82
|
+
expect(VMUtil.deepFind(obj, "a.c")).toBeUndefined();
|
|
83
|
+
expect(VMUtil.deepFind(obj, "x.y.z")).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("replaceValueInPlace", () => {
|
|
88
|
+
it("should replace simple variables", () => {
|
|
89
|
+
const storageMap: JSONObject = { name: "test", status: "firing" };
|
|
90
|
+
const result: string = VMUtil.replaceValueInPlace(
|
|
91
|
+
storageMap,
|
|
92
|
+
"Alert: {{name}} is {{status}}",
|
|
93
|
+
false,
|
|
94
|
+
);
|
|
95
|
+
expect(result).toBe("Alert: test is firing");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should replace nested dotted path variables", () => {
|
|
99
|
+
const storageMap: JSONObject = {
|
|
100
|
+
requestBody: { title: "My Alert", data: { severity: "high" } },
|
|
101
|
+
};
|
|
102
|
+
const result: string = VMUtil.replaceValueInPlace(
|
|
103
|
+
storageMap,
|
|
104
|
+
"Title: {{requestBody.title}}, Severity: {{requestBody.data.severity}}",
|
|
105
|
+
false,
|
|
106
|
+
);
|
|
107
|
+
expect(result).toBe("Title: My Alert, Severity: high");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should leave unresolved variables as-is", () => {
|
|
111
|
+
const storageMap: JSONObject = { name: "test" };
|
|
112
|
+
const result: string = VMUtil.replaceValueInPlace(
|
|
113
|
+
storageMap,
|
|
114
|
+
"{{name}} {{unknown}}",
|
|
115
|
+
false,
|
|
116
|
+
);
|
|
117
|
+
expect(result).toBe("test {{unknown}}");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("expandEachLoops", () => {
|
|
122
|
+
it("should expand a simple each loop over an array of objects", () => {
|
|
123
|
+
const storageMap: JSONObject = {
|
|
124
|
+
requestBody: {
|
|
125
|
+
alerts: [
|
|
126
|
+
{ labels: { label: "Coralpay" }, status: "firing" },
|
|
127
|
+
{ labels: { label: "capitecpay" }, status: "resolved" },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const template: string =
|
|
133
|
+
"Alerts:{{#each requestBody.alerts}} {{labels.label}}({{status}}){{/each}}";
|
|
134
|
+
const result: string = VMUtil.expandEachLoops(
|
|
135
|
+
storageMap,
|
|
136
|
+
template,
|
|
137
|
+
false,
|
|
138
|
+
);
|
|
139
|
+
expect(result).toBe("Alerts: Coralpay(firing) capitecpay(resolved)");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should support {{@index}} in loops", () => {
|
|
143
|
+
const storageMap: JSONObject = {
|
|
144
|
+
items: [{ name: "a" }, { name: "b" }, { name: "c" }],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const template: string = "{{#each items}}{{@index}}: {{name}} {{/each}}";
|
|
148
|
+
const result: string = VMUtil.expandEachLoops(
|
|
149
|
+
storageMap,
|
|
150
|
+
template,
|
|
151
|
+
false,
|
|
152
|
+
);
|
|
153
|
+
expect(result).toBe("0: a 1: b 2: c ");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should support {{this}} for primitive arrays", () => {
|
|
157
|
+
const storageMap: JSONObject = {
|
|
158
|
+
tags: ["critical", "production", "api"],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const template: string = "Tags:{{#each tags}} {{this}}{{/each}}";
|
|
162
|
+
const result: string = VMUtil.expandEachLoops(
|
|
163
|
+
storageMap,
|
|
164
|
+
template,
|
|
165
|
+
false,
|
|
166
|
+
);
|
|
167
|
+
expect(result).toBe("Tags: critical production api");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should remove the block if the path is not an array", () => {
|
|
171
|
+
const storageMap: JSONObject = { notAnArray: "hello" };
|
|
172
|
+
const template: string = "Before {{#each notAnArray}}body{{/each}} After";
|
|
173
|
+
const result: string = VMUtil.expandEachLoops(
|
|
174
|
+
storageMap,
|
|
175
|
+
template,
|
|
176
|
+
false,
|
|
177
|
+
);
|
|
178
|
+
expect(result).toBe("Before After");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should remove the block if the path does not exist", () => {
|
|
182
|
+
const storageMap: JSONObject = {};
|
|
183
|
+
const template: string =
|
|
184
|
+
"Before {{#each missing.path}}body{{/each}} After";
|
|
185
|
+
const result: string = VMUtil.expandEachLoops(
|
|
186
|
+
storageMap,
|
|
187
|
+
template,
|
|
188
|
+
false,
|
|
189
|
+
);
|
|
190
|
+
expect(result).toBe("Before After");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should handle empty arrays", () => {
|
|
194
|
+
const storageMap: JSONObject = { items: [] };
|
|
195
|
+
const template: string = "Before {{#each items}}item{{/each}} After";
|
|
196
|
+
const result: string = VMUtil.expandEachLoops(
|
|
197
|
+
storageMap,
|
|
198
|
+
template,
|
|
199
|
+
false,
|
|
200
|
+
);
|
|
201
|
+
expect(result).toBe("Before After");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should support nested each loops", () => {
|
|
205
|
+
const storageMap: JSONObject = {
|
|
206
|
+
groups: [
|
|
207
|
+
{ name: "G1", members: [{ id: 1 }, { id: 2 }] },
|
|
208
|
+
{ name: "G2", members: [{ id: 3 }] },
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const template: string =
|
|
213
|
+
"{{#each groups}}Group {{name}}: {{#each members}}{{id}} {{/each}}| {{/each}}";
|
|
214
|
+
const result: string = VMUtil.expandEachLoops(
|
|
215
|
+
storageMap,
|
|
216
|
+
template,
|
|
217
|
+
false,
|
|
218
|
+
);
|
|
219
|
+
expect(result).toBe("Group G1: 1 2 | Group G2: 3 | ");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should allow fallback to parent variables inside loops", () => {
|
|
223
|
+
const storageMap: JSONObject = {
|
|
224
|
+
globalTitle: "My Dashboard",
|
|
225
|
+
items: [{ name: "item1" }, { name: "item2" }],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const template: string =
|
|
229
|
+
"{{#each items}}{{name}} in {{globalTitle}} {{/each}}";
|
|
230
|
+
const result: string = VMUtil.expandEachLoops(
|
|
231
|
+
storageMap,
|
|
232
|
+
template,
|
|
233
|
+
false,
|
|
234
|
+
);
|
|
235
|
+
expect(result).toBe("item1 in My Dashboard item2 in My Dashboard ");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("replaceValueInPlace with each loops (end-to-end)", () => {
|
|
240
|
+
it("should expand loops and then replace remaining variables", () => {
|
|
241
|
+
const storageMap: JSONObject = {
|
|
242
|
+
requestBody: {
|
|
243
|
+
receiver: "Fundsflow",
|
|
244
|
+
alerts: [
|
|
245
|
+
{
|
|
246
|
+
status: "firing",
|
|
247
|
+
labels: { label: "Coralpay", alertname: "File Drop" },
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
status: "firing",
|
|
251
|
+
labels: { label: "capitecpay", alertname: "File Drop" },
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const template: string =
|
|
258
|
+
"Receiver: {{requestBody.receiver}}\n{{#each requestBody.alerts}}- {{labels.label}}: {{status}}\n{{/each}}";
|
|
259
|
+
const result: string = VMUtil.replaceValueInPlace(
|
|
260
|
+
storageMap,
|
|
261
|
+
template,
|
|
262
|
+
false,
|
|
263
|
+
);
|
|
264
|
+
expect(result).toBe(
|
|
265
|
+
"Receiver: Fundsflow\n- Coralpay: firing\n- capitecpay: firing\n",
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should handle the Grafana alerts use case", () => {
|
|
270
|
+
const storageMap: JSONObject = {
|
|
271
|
+
requestBody: {
|
|
272
|
+
status: "firing",
|
|
273
|
+
alerts: [
|
|
274
|
+
{
|
|
275
|
+
status: "firing",
|
|
276
|
+
labels: {
|
|
277
|
+
alertname: "Fundsflow File Drop Update",
|
|
278
|
+
label: "Coralpay",
|
|
279
|
+
},
|
|
280
|
+
valueString: "A=0, C=1",
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
status: "firing",
|
|
284
|
+
labels: {
|
|
285
|
+
alertname: "Fundsflow File Drop Update",
|
|
286
|
+
label: "capitecpay",
|
|
287
|
+
},
|
|
288
|
+
valueString: "A=0, C=1",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
status: "firing",
|
|
292
|
+
labels: {
|
|
293
|
+
alertname: "Fundsflow File Drop Update",
|
|
294
|
+
label: "capricorn",
|
|
295
|
+
},
|
|
296
|
+
valueString: "A=0, C=1",
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const template: string =
|
|
303
|
+
"Alert Labels:\n{{#each requestBody.alerts}}- {{labels.label}}\n{{/each}}";
|
|
304
|
+
const result: string = VMUtil.replaceValueInPlace(
|
|
305
|
+
storageMap,
|
|
306
|
+
template,
|
|
307
|
+
false,
|
|
308
|
+
);
|
|
309
|
+
expect(result).toBe(
|
|
310
|
+
"Alert Labels:\n- Coralpay\n- capitecpay\n- capricorn\n",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|