@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.
Files changed (64) hide show
  1. package/Models/AnalyticsModels/ExceptionInstance.ts +98 -0
  2. package/Models/DatabaseModels/TelemetryException.ts +105 -0
  3. package/Models/DatabaseModels/User.ts +27 -0
  4. package/Server/API/UserWebAuthnAPI.ts +0 -2
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.ts +41 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.ts +23 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  8. package/Server/Services/MonitorTestService.ts +102 -1
  9. package/Server/Services/UserWebAuthnService.ts +116 -8
  10. package/Server/Utils/Browser.ts +0 -1
  11. package/Server/Utils/Memory.ts +81 -0
  12. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +4 -1
  13. package/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.ts +5 -1
  14. package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +3 -15
  15. package/Server/Utils/Telemetry.ts +7 -2
  16. package/Server/Utils/VM/VMAPI.ts +204 -0
  17. package/Tests/Server/Utils/VM/VMAPI.test.ts +314 -0
  18. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +2 -2
  19. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +136 -11
  20. package/UI/Components/TextArea/TextArea.tsx +2 -1
  21. package/Utils/Number.ts +27 -0
  22. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +87 -0
  23. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  24. package/build/dist/Models/DatabaseModels/TelemetryException.js +108 -0
  25. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  26. package/build/dist/Models/DatabaseModels/User.js +31 -0
  27. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  28. package/build/dist/Server/API/UserWebAuthnAPI.js +0 -2
  29. package/build/dist/Server/API/UserWebAuthnAPI.js.map +1 -1
  30. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.js +20 -0
  31. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772111896988-MigrationName.js.map +1 -0
  32. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.js +14 -0
  33. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.js.map +1 -0
  34. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  35. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  36. package/build/dist/Server/Services/MonitorTestService.js +74 -0
  37. package/build/dist/Server/Services/MonitorTestService.js.map +1 -1
  38. package/build/dist/Server/Services/UserWebAuthnService.js +83 -5
  39. package/build/dist/Server/Services/UserWebAuthnService.js.map +1 -1
  40. package/build/dist/Server/Utils/Browser.js +0 -1
  41. package/build/dist/Server/Utils/Browser.js.map +1 -1
  42. package/build/dist/Server/Utils/Memory.js +55 -0
  43. package/build/dist/Server/Utils/Memory.js.map +1 -0
  44. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +3 -1
  45. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  46. package/build/dist/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.js +4 -1
  47. package/build/dist/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.js.map +1 -1
  48. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +3 -9
  49. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
  50. package/build/dist/Server/Utils/Telemetry.js +4 -2
  51. package/build/dist/Server/Utils/Telemetry.js.map +1 -1
  52. package/build/dist/Server/Utils/VM/VMAPI.js +153 -0
  53. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  54. package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js +218 -0
  55. package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js.map +1 -0
  56. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +2 -2
  57. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  58. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +72 -5
  59. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  60. package/build/dist/UI/Components/TextArea/TextArea.js +2 -2
  61. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  62. package/build/dist/Utils/Number.js +16 -0
  63. package/build/dist/Utils/Number.js.map +1 -1
  64. 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
- return `${data.criteriaFilter.checkOn} is not empty.`;
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
- return `${fieldName} is not empty.`;
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 { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
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
 
@@ -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
+ });