@oneuptime/common 10.0.88 → 10.0.89

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 (37) hide show
  1. package/Server/API/MetricAPI.ts +149 -0
  2. package/Server/Services/AnalyticsDatabaseService.ts +21 -0
  3. package/Server/Services/MetricService.ts +193 -1
  4. package/Types/Dashboard/DashboardComponentType.ts +3 -0
  5. package/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.ts +13 -0
  6. package/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.ts +13 -0
  7. package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +13 -0
  8. package/Types/JSONFunctions.ts +61 -1
  9. package/Utils/Dashboard/Components/DashboardAlertListComponent.ts +86 -0
  10. package/Utils/Dashboard/Components/DashboardIncidentListComponent.ts +86 -0
  11. package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +85 -0
  12. package/Utils/Dashboard/Components/Index.ts +21 -0
  13. package/build/dist/Server/API/MetricAPI.js +123 -0
  14. package/build/dist/Server/API/MetricAPI.js.map +1 -0
  15. package/build/dist/Server/Services/AnalyticsDatabaseService.js +18 -0
  16. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  17. package/build/dist/Server/Services/MetricService.js +151 -1
  18. package/build/dist/Server/Services/MetricService.js.map +1 -1
  19. package/build/dist/Types/Dashboard/DashboardComponentType.js +3 -0
  20. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  21. package/build/dist/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.js +2 -0
  22. package/build/dist/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.js.map +1 -0
  23. package/build/dist/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.js +2 -0
  24. package/build/dist/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.js.map +1 -0
  25. package/build/dist/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.js +2 -0
  26. package/build/dist/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.js.map +1 -0
  27. package/build/dist/Types/JSONFunctions.js +47 -1
  28. package/build/dist/Types/JSONFunctions.js.map +1 -1
  29. package/build/dist/Utils/Dashboard/Components/DashboardAlertListComponent.js +70 -0
  30. package/build/dist/Utils/Dashboard/Components/DashboardAlertListComponent.js.map +1 -0
  31. package/build/dist/Utils/Dashboard/Components/DashboardIncidentListComponent.js +70 -0
  32. package/build/dist/Utils/Dashboard/Components/DashboardIncidentListComponent.js.map +1 -0
  33. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +69 -0
  34. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -0
  35. package/build/dist/Utils/Dashboard/Components/Index.js +12 -0
  36. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  37. package/package.json +1 -1
@@ -0,0 +1,149 @@
1
+ import AggregateBy from "../../Types/BaseDatabase/AggregateBy";
2
+ import AggregatedResult from "../../Types/BaseDatabase/AggregatedResult";
3
+ import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
4
+ import BadRequestException from "../../Types/Exception/BadRequestException";
5
+ import { JSONObject } from "../../Types/JSON";
6
+ import JSONFunctions from "../../Types/JSONFunctions";
7
+ import Metric from "../../Models/AnalyticsModels/Metric";
8
+ import { MetricService } from "../Services/MetricService";
9
+ import GlobalCache from "../Infrastructure/GlobalCache";
10
+ import logger from "../Utils/Logger";
11
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
12
+ import { ExpressRequest, ExpressResponse } from "../Utils/Express";
13
+ import Response from "../Utils/Response";
14
+ import CommonAPI from "./CommonAPI";
15
+ import BaseAnalyticsAPI from "./BaseAnalyticsAPI";
16
+
17
+ /*
18
+ * Aggregate cache TTL. Dashboards typically auto-refresh every 30s+, so
19
+ * an 8s window collapses bursts of identical requests (e.g. 12 widgets
20
+ * loading on the same page) onto a single ClickHouse query while still
21
+ * looking real-time to humans.
22
+ */
23
+ const AGGREGATE_CACHE_NAMESPACE: string = "metric-aggregate";
24
+ const AGGREGATE_CACHE_TTL_SECONDS: number = 8;
25
+
26
+ export default class MetricAPI extends BaseAnalyticsAPI<Metric, MetricService> {
27
+ public constructor(service: MetricService) {
28
+ super(Metric, service);
29
+ }
30
+
31
+ /*
32
+ * Cached override of BaseAnalyticsAPI.getAggregate.
33
+ *
34
+ * Why a cache: each chart/value/gauge/table widget on a dashboard
35
+ * issues its own /aggregate call. With 10+ widgets and a small group
36
+ * of users hitting the same dashboard the underlying ClickHouse
37
+ * cluster sees the same heavy aggregation many times in close
38
+ * succession. Aggregations are read-only and pure (same input ->
39
+ * same output for the bucket interval), so a brief result cache is
40
+ * safe.
41
+ *
42
+ * Cache key: tenant project + the deserialized aggregateBy payload.
43
+ * We must include the project so cross-tenant collisions cannot
44
+ * leak data; we deliberately do NOT key on user id, because the
45
+ * service layer applies project-scoped read permissions and metric
46
+ * data is project-wide.
47
+ *
48
+ * Cache miss / Redis down: we fall through to the live query, so
49
+ * cache outages degrade to today's behavior, never error.
50
+ */
51
+ @CaptureSpan()
52
+ public override async getAggregate(
53
+ req: ExpressRequest,
54
+ res: ExpressResponse,
55
+ ): Promise<void> {
56
+ await this.onBeforeList(req, res);
57
+
58
+ let aggregateBy: AggregateBy<Metric> | null = null;
59
+
60
+ if (req.body && req.body["aggregateBy"]) {
61
+ aggregateBy = JSONFunctions.deserialize(
62
+ req.body["aggregateBy"] as JSONObject,
63
+ ) as any;
64
+ }
65
+
66
+ if (!aggregateBy) {
67
+ throw new BadRequestException("AggregateBy is required");
68
+ }
69
+
70
+ const databaseProps: DatabaseCommonInteractionProps =
71
+ await CommonAPI.getDatabaseCommonInteractionProps(req);
72
+
73
+ const projectId: string | undefined = databaseProps.tenantId?.toString();
74
+ const cacheKey: string | null = projectId
75
+ ? `${projectId}:${this.buildCacheKey(aggregateBy)}`
76
+ : null;
77
+
78
+ if (cacheKey) {
79
+ try {
80
+ const cached: JSONObject | null = await GlobalCache.getJSONObject(
81
+ AGGREGATE_CACHE_NAMESPACE,
82
+ cacheKey,
83
+ );
84
+ if (cached) {
85
+ return Response.sendJsonObjectResponse(req, res, cached);
86
+ }
87
+ } catch (err) {
88
+ // Cache fetch failed — fall through to a live query.
89
+ logger.debug("MetricAPI aggregate cache read failed");
90
+ logger.debug(err);
91
+ }
92
+ }
93
+
94
+ const aggregateResult: AggregatedResult = await this.service.aggregateBy({
95
+ ...aggregateBy,
96
+ props: databaseProps,
97
+ });
98
+
99
+ const responseBody: JSONObject = { ...(aggregateResult as any) };
100
+
101
+ if (cacheKey) {
102
+ try {
103
+ await GlobalCache.setJSON(
104
+ AGGREGATE_CACHE_NAMESPACE,
105
+ cacheKey,
106
+ responseBody,
107
+ { expiresInSeconds: AGGREGATE_CACHE_TTL_SECONDS },
108
+ );
109
+ } catch (err) {
110
+ logger.debug("MetricAPI aggregate cache write failed");
111
+ logger.debug(err);
112
+ }
113
+ }
114
+
115
+ return Response.sendJsonObjectResponse(req, res, responseBody);
116
+ }
117
+
118
+ private buildCacheKey(aggregateBy: AggregateBy<Metric>): string {
119
+ /*
120
+ * Stable serialization. Date instances are normalized to ISO so two
121
+ * logically-equal time windows hit the same cache slot, and we sort
122
+ * keys via JSON.stringify replacer to keep ordering deterministic
123
+ * across clients and across versions of V8.
124
+ */
125
+ return JSON.stringify(
126
+ aggregateBy,
127
+ (_key: string, value: unknown): unknown => {
128
+ if (value instanceof Date) {
129
+ return value.toISOString();
130
+ }
131
+ if (
132
+ value &&
133
+ typeof value === "object" &&
134
+ !Array.isArray(value) &&
135
+ (value as Record<string, unknown>).constructor === Object
136
+ ) {
137
+ const sorted: Record<string, unknown> = {};
138
+ for (const k of Object.keys(
139
+ value as Record<string, unknown>,
140
+ ).sort()) {
141
+ sorted[k] = (value as Record<string, unknown>)[k];
142
+ }
143
+ return sorted;
144
+ }
145
+ return value;
146
+ },
147
+ );
148
+ }
149
+ }
@@ -787,6 +787,27 @@ export default class AnalyticsDatabaseService<
787
787
  }}
788
788
  `);
789
789
 
790
+ /*
791
+ * Aggregation read-path settings.
792
+ *
793
+ * - optimize_aggregation_in_order: when GROUP BY is a prefix of the
794
+ * sort key (we always group by a time bucket and the time column
795
+ * is at the tail of every analytics primary key), ClickHouse can
796
+ * stream rows in order and emit aggregates without an in-memory
797
+ * sort, which is a large speedup on wide time ranges.
798
+ * - optimize_move_to_prewhere: PREWHERE is a default-on optimizer
799
+ * pass; we set it explicitly so the behavior is independent of
800
+ * server-side defaults.
801
+ * - max_threads=4: caps per-query parallelism so a single dashboard
802
+ * load (which fans out to many aggregate calls) does not starve
803
+ * other tenants on the cluster. Per-query latency is essentially
804
+ * unchanged at 4 threads for the usual dashboard widget time
805
+ * ranges, but cluster headroom is preserved under burst.
806
+ */
807
+ statement.append(
808
+ ` SETTINGS optimize_aggregation_in_order=1, optimize_move_to_prewhere=1, max_threads=4`,
809
+ );
810
+
790
811
  logger.debug(`${this.model.tableName} Aggregate Statement`, { tableName: this.model.tableName } as LogAttributes);
791
812
  logger.debug(statement, { tableName: this.model.tableName } as LogAttributes);
792
813
 
@@ -5,10 +5,11 @@ import AggregateBy, {
5
5
  AggregateUtil,
6
6
  } from "../Types/AnalyticsDatabase/AggregateBy";
7
7
  import { SQL, Statement } from "../Utils/AnalyticsDatabase/Statement";
8
- import {
8
+ import AggregationType, {
9
9
  getPercentileLevel,
10
10
  isPercentileAggregation,
11
11
  } from "../../Types/BaseDatabase/AggregationType";
12
+ import AggregationInterval from "../../Types/BaseDatabase/AggregationInterval";
12
13
  import TableColumnType from "../../Types/AnalyticsDatabase/TableColumnType";
13
14
  import logger, { LogAttributes } from "../Utils/Logger";
14
15
 
@@ -56,6 +57,13 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
56
57
  columns: Array<string>;
57
58
  } {
58
59
  if (!isPercentileAggregation(aggregateBy.aggregationType)) {
60
+ const mvStatement: {
61
+ statement: Statement;
62
+ columns: Array<string>;
63
+ } | null = this.tryBuildMinuteAggregateMVStatement(aggregateBy);
64
+ if (mvStatement) {
65
+ return mvStatement;
66
+ }
59
67
  return super.toAggregateStatement(aggregateBy);
60
68
  }
61
69
 
@@ -216,6 +224,16 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
216
224
  }} `,
217
225
  );
218
226
 
227
+ /*
228
+ * Match the read-path settings the base aggregator now appends (see
229
+ * AnalyticsDatabaseService.toAggregateStatement). The percentile
230
+ * path bypasses the base method, so we mirror them here to keep
231
+ * cluster behavior consistent across aggregation kinds.
232
+ */
233
+ statement.append(
234
+ ` SETTINGS optimize_aggregation_in_order=1, optimize_move_to_prewhere=1, max_threads=4`,
235
+ );
236
+
219
237
  const columns: Array<string> = [
220
238
  aggregationColumn,
221
239
  aggregationTimestampColumn,
@@ -231,6 +249,180 @@ export class MetricService extends AnalyticsDatabaseService<Metric> {
231
249
 
232
250
  return { statement, columns };
233
251
  }
252
+
253
+ /*
254
+ * Materialized-view fast path for scalar aggregations.
255
+ *
256
+ * Returns a statement that reads from MetricItemAggMV1m (the
257
+ * 1-minute pre-aggregate created by
258
+ * AddMetricMinuteAggregateMaterializedView) when:
259
+ *
260
+ * - The aggregation is Sum/Avg/Min/Max/Count over `value`.
261
+ * - The dashboard's effective bucket interval is >= 1 minute (the
262
+ * MV stores 1-minute states; sub-minute requests need raw rows).
263
+ * - The query carries no per-attribute filter or group-by, since
264
+ * the MV is keyed by (projectId, name, serviceId, bucketTime)
265
+ * only — it does not preserve attribute breakdowns.
266
+ * - The query carries no group-by other than the time bucket.
267
+ *
268
+ * Returns `null` if any condition fails so the caller falls back to
269
+ * the base table. The result row shape (columns: aggregateColumn,
270
+ * timestampColumn) matches the base statement so downstream code
271
+ * needs no changes.
272
+ */
273
+ private tryBuildMinuteAggregateMVStatement(
274
+ aggregateBy: AggregateBy<Metric>,
275
+ ): { statement: Statement; columns: Array<string> } | null {
276
+ const aggType: AggregationType = aggregateBy.aggregationType;
277
+ const supported: ReadonlyArray<AggregationType> = [
278
+ AggregationType.Sum,
279
+ AggregationType.Avg,
280
+ AggregationType.Min,
281
+ AggregationType.Max,
282
+ AggregationType.Count,
283
+ ];
284
+ if (!supported.includes(aggType)) {
285
+ return null;
286
+ }
287
+
288
+ if (
289
+ aggregateBy.aggregateColumnName.toString() !== "value" ||
290
+ aggregateBy.aggregationTimestampColumnName.toString() !== "time"
291
+ ) {
292
+ return null;
293
+ }
294
+
295
+ const interval: AggregationInterval = AggregateUtil.getAggregationInterval({
296
+ startDate: aggregateBy.startTimestamp!,
297
+ endDate: aggregateBy.endTimestamp!,
298
+ });
299
+ /*
300
+ * The MV is bucketed at 1 minute, so all values of AggregationInterval
301
+ * (Minute / Hour / Day / Week / Month / Year) are >= MV resolution
302
+ * and acceptable. Kept as a no-op read so the dependency on
303
+ * AggregateUtil makes the intent obvious.
304
+ */
305
+ void interval;
306
+
307
+ const queryRecord: Record<string, unknown> =
308
+ (aggregateBy.query as unknown as Record<string, unknown>) || {};
309
+ const attrs: unknown = queryRecord["attributes"];
310
+ if (
311
+ attrs !== undefined &&
312
+ attrs !== null &&
313
+ !(
314
+ typeof attrs === "object" &&
315
+ Object.keys(attrs as Record<string, unknown>).length === 0
316
+ )
317
+ ) {
318
+ return null;
319
+ }
320
+
321
+ if (aggregateBy.groupBy && Object.keys(aggregateBy.groupBy).length > 0) {
322
+ return null;
323
+ }
324
+
325
+ if (!this.database) {
326
+ this.useDefaultDatabase();
327
+ }
328
+ const databaseName: string = this.database.getDatasourceOptions().database!;
329
+
330
+ const intervalLower: string = interval.toLowerCase();
331
+
332
+ let mergedExpr: string;
333
+ if (aggType === AggregationType.Sum) {
334
+ mergedExpr = `sumMerge(valueSumState)`;
335
+ } else if (aggType === AggregationType.Count) {
336
+ mergedExpr = `countMerge(valueCountState)`;
337
+ } else if (aggType === AggregationType.Min) {
338
+ mergedExpr = `minMerge(valueMinState)`;
339
+ } else if (aggType === AggregationType.Max) {
340
+ mergedExpr = `maxMerge(valueMaxState)`;
341
+ } else {
342
+ // Avg = sum / count, derived from the two stored states.
343
+ mergedExpr = `if(countMerge(valueCountState) = 0, 0, sumMerge(valueSumState) / countMerge(valueCountState))`;
344
+ }
345
+
346
+ /*
347
+ * Build the WHERE on a copy of the query with `time` removed so
348
+ * the generator never references a column that doesn't exist on
349
+ * the MV. We then add an explicit `bucketTime` range from
350
+ * startTimestamp/endTimestamp.
351
+ */
352
+ const nonTimeWhere: Statement = this.statementGenerator.toWhereStatement(
353
+ this.stripTimeFromQuery(aggregateBy.query) as typeof aggregateBy.query,
354
+ );
355
+ const sortStatement: Statement = this.statementGenerator.toSortStatement(
356
+ aggregateBy.sort!,
357
+ );
358
+
359
+ const statement: Statement = SQL``;
360
+
361
+ statement.append(
362
+ `SELECT ${mergedExpr} as value, date_trunc('${intervalLower}', toStartOfInterval(bucketTime, INTERVAL 1 ${intervalLower})) as time`,
363
+ );
364
+ statement.append(SQL` FROM ${databaseName}.MetricItemAggMV1m`);
365
+ statement.append(
366
+ ` WHERE bucketTime >= toDateTime('${this.formatDateTime(aggregateBy.startTimestamp!)}') AND bucketTime <= toDateTime('${this.formatDateTime(aggregateBy.endTimestamp!)}')`,
367
+ );
368
+ statement.append(SQL` `).append(nonTimeWhere);
369
+
370
+ statement.append(SQL` GROUP BY `).append(`time`);
371
+ statement.append(SQL` ORDER BY `).append(sortStatement);
372
+ statement.append(
373
+ SQL` LIMIT ${{
374
+ value: Number(aggregateBy.limit),
375
+ type: TableColumnType.Number,
376
+ }}`,
377
+ );
378
+ statement.append(
379
+ SQL` OFFSET ${{
380
+ value: Number(aggregateBy.skip),
381
+ type: TableColumnType.Number,
382
+ }} `,
383
+ );
384
+ statement.append(
385
+ ` SETTINGS optimize_aggregation_in_order=1, optimize_move_to_prewhere=1, max_threads=4`,
386
+ );
387
+
388
+ logger.debug(`${this.model.tableName} MV Aggregate Statement`, {
389
+ tableName: this.model.tableName,
390
+ } as LogAttributes);
391
+ logger.debug(statement, {
392
+ tableName: this.model.tableName,
393
+ } as LogAttributes);
394
+
395
+ return {
396
+ statement,
397
+ columns: [
398
+ aggregateBy.aggregateColumnName.toString(),
399
+ aggregateBy.aggregationTimestampColumnName.toString(),
400
+ ],
401
+ };
402
+ }
403
+
404
+ private stripTimeFromQuery(query: unknown): typeof query {
405
+ if (!query || typeof query !== "object") {
406
+ return query;
407
+ }
408
+ const out: Record<string, unknown> = {};
409
+ for (const [k, v] of Object.entries(query as Record<string, unknown>)) {
410
+ if (k === "time") {
411
+ continue;
412
+ }
413
+ out[k] = v;
414
+ }
415
+ return out as typeof query;
416
+ }
417
+
418
+ private formatDateTime(d: Date): string {
419
+ /*
420
+ * ClickHouse's DateTime parser accepts 'YYYY-MM-DD HH:MM:SS'.
421
+ * toISOString gives 'YYYY-MM-DDTHH:MM:SS.sssZ'; trim the milliseconds
422
+ * and the trailing 'Z' and replace 'T' with a space.
423
+ */
424
+ return new Date(d).toISOString().replace("T", " ").substring(0, 19);
425
+ }
234
426
  }
235
427
 
236
428
  export default new MetricService();
@@ -6,6 +6,9 @@ enum DashboardComponentType {
6
6
  Gauge = `Gauge`,
7
7
  LogStream = `LogStream`,
8
8
  TraceList = `TraceList`,
9
+ IncidentList = `IncidentList`,
10
+ AlertList = `AlertList`,
11
+ MonitorList = `MonitorList`,
9
12
  }
10
13
 
11
14
  export default DashboardComponentType;
@@ -0,0 +1,13 @@
1
+ import ObjectID from "../../ObjectID";
2
+ import DashboardComponentType from "../DashboardComponentType";
3
+ import BaseComponent from "./DashboardBaseComponent";
4
+
5
+ export default interface DashboardAlertListComponent extends BaseComponent {
6
+ componentType: DashboardComponentType.AlertList;
7
+ componentId: ObjectID;
8
+ arguments: {
9
+ title?: string | undefined;
10
+ maxRows?: number | undefined;
11
+ stateFilter?: string | undefined;
12
+ };
13
+ }
@@ -0,0 +1,13 @@
1
+ import ObjectID from "../../ObjectID";
2
+ import DashboardComponentType from "../DashboardComponentType";
3
+ import BaseComponent from "./DashboardBaseComponent";
4
+
5
+ export default interface DashboardIncidentListComponent extends BaseComponent {
6
+ componentType: DashboardComponentType.IncidentList;
7
+ componentId: ObjectID;
8
+ arguments: {
9
+ title?: string | undefined;
10
+ maxRows?: number | undefined;
11
+ stateFilter?: string | undefined;
12
+ };
13
+ }
@@ -0,0 +1,13 @@
1
+ import ObjectID from "../../ObjectID";
2
+ import DashboardComponentType from "../DashboardComponentType";
3
+ import BaseComponent from "./DashboardBaseComponent";
4
+
5
+ export default interface DashboardMonitorListComponent extends BaseComponent {
6
+ componentType: DashboardComponentType.MonitorList;
7
+ componentId: ObjectID;
8
+ arguments: {
9
+ title?: string | undefined;
10
+ maxRows?: number | undefined;
11
+ statusFilter?: string | undefined;
12
+ };
13
+ }
@@ -20,7 +20,67 @@ export default class JSONFunctions {
20
20
  obj1: GenericObject,
21
21
  obj2: GenericObject,
22
22
  ): boolean {
23
- return JSON.stringify(obj1) !== JSON.stringify(obj2);
23
+ return !JSONFunctions.deepEqual(obj1, obj2);
24
+ }
25
+
26
+ /*
27
+ * Structural deep-equal that short-circuits at the first mismatch. The
28
+ * dashboard widget hot path was previously running JSON.stringify on
29
+ * both sides of nested metricQueryConfig objects every render, which
30
+ * dominated CPU when many widgets were on screen. This visits the
31
+ * tree once and bails on the first divergence.
32
+ */
33
+ public static deepEqual(a: unknown, b: unknown): boolean {
34
+ if (a === b) {
35
+ return true;
36
+ }
37
+
38
+ if (
39
+ a === null ||
40
+ b === null ||
41
+ typeof a !== "object" ||
42
+ typeof b !== "object"
43
+ ) {
44
+ return false;
45
+ }
46
+
47
+ if (a instanceof Date || b instanceof Date) {
48
+ return (
49
+ a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
50
+ );
51
+ }
52
+
53
+ if (Array.isArray(a) || Array.isArray(b)) {
54
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
55
+ return false;
56
+ }
57
+ for (let i: number = 0; i < a.length; i++) {
58
+ if (!JSONFunctions.deepEqual(a[i], b[i])) {
59
+ return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+
65
+ const keysA: Array<string> = Object.keys(a as Record<string, unknown>);
66
+ const keysB: Array<string> = Object.keys(b as Record<string, unknown>);
67
+ if (keysA.length !== keysB.length) {
68
+ return false;
69
+ }
70
+ for (const key of keysA) {
71
+ if (!Object.prototype.hasOwnProperty.call(b, key)) {
72
+ return false;
73
+ }
74
+ if (
75
+ !JSONFunctions.deepEqual(
76
+ (a as Record<string, unknown>)[key],
77
+ (b as Record<string, unknown>)[key],
78
+ )
79
+ ) {
80
+ return false;
81
+ }
82
+ }
83
+ return true;
24
84
  }
25
85
 
26
86
  public static nestJson(obj: JSONObject): JSONObject {
@@ -0,0 +1,86 @@
1
+ import DashboardAlertListComponent from "../../../Types/Dashboard/DashboardComponents/DashboardAlertListComponent";
2
+ import { ObjectType } from "../../../Types/JSON";
3
+ import ObjectID from "../../../Types/ObjectID";
4
+ import DashboardBaseComponentUtil from "./DashboardBaseComponent";
5
+ import {
6
+ ComponentArgument,
7
+ ComponentArgumentSection,
8
+ ComponentInputType,
9
+ } from "../../../Types/Dashboard/DashboardComponents/ComponentArgument";
10
+ import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
11
+
12
+ const DisplaySection: ComponentArgumentSection = {
13
+ name: "Display Options",
14
+ description: "Configure the widget title and row limit",
15
+ order: 1,
16
+ };
17
+
18
+ const FiltersSection: ComponentArgumentSection = {
19
+ name: "Filters",
20
+ description: "Narrow down which alerts are shown",
21
+ order: 2,
22
+ defaultCollapsed: true,
23
+ };
24
+
25
+ export default class DashboardAlertListComponentUtil extends DashboardBaseComponentUtil {
26
+ public static override getDefaultComponent(): DashboardAlertListComponent {
27
+ return {
28
+ _type: ObjectType.DashboardComponent,
29
+ componentType: DashboardComponentType.AlertList,
30
+ widthInDashboardUnits: 6,
31
+ heightInDashboardUnits: 4,
32
+ topInDashboardUnits: 0,
33
+ leftInDashboardUnits: 0,
34
+ componentId: ObjectID.generate(),
35
+ minHeightInDashboardUnits: 3,
36
+ minWidthInDashboardUnits: 6,
37
+ arguments: {
38
+ maxRows: 25,
39
+ },
40
+ };
41
+ }
42
+
43
+ public static override getComponentConfigArguments(): Array<
44
+ ComponentArgument<DashboardAlertListComponent>
45
+ > {
46
+ const componentArguments: Array<
47
+ ComponentArgument<DashboardAlertListComponent>
48
+ > = [];
49
+
50
+ componentArguments.push({
51
+ name: "Title",
52
+ description: "Header shown above the alert list",
53
+ required: false,
54
+ type: ComponentInputType.Text,
55
+ id: "title",
56
+ section: DisplaySection,
57
+ });
58
+
59
+ componentArguments.push({
60
+ name: "Max Rows",
61
+ description: "Maximum number of alerts to show",
62
+ required: false,
63
+ type: ComponentInputType.Number,
64
+ id: "maxRows",
65
+ placeholder: "25",
66
+ section: DisplaySection,
67
+ });
68
+
69
+ componentArguments.push({
70
+ name: "State",
71
+ description: "Filter alerts by lifecycle state",
72
+ required: false,
73
+ type: ComponentInputType.Dropdown,
74
+ id: "stateFilter",
75
+ section: FiltersSection,
76
+ dropdownOptions: [
77
+ { label: "All", value: "" },
78
+ { label: "Unresolved (open)", value: "unresolved" },
79
+ { label: "Acknowledged", value: "acknowledged" },
80
+ { label: "Resolved", value: "resolved" },
81
+ ],
82
+ });
83
+
84
+ return componentArguments;
85
+ }
86
+ }
@@ -0,0 +1,86 @@
1
+ import DashboardIncidentListComponent from "../../../Types/Dashboard/DashboardComponents/DashboardIncidentListComponent";
2
+ import { ObjectType } from "../../../Types/JSON";
3
+ import ObjectID from "../../../Types/ObjectID";
4
+ import DashboardBaseComponentUtil from "./DashboardBaseComponent";
5
+ import {
6
+ ComponentArgument,
7
+ ComponentArgumentSection,
8
+ ComponentInputType,
9
+ } from "../../../Types/Dashboard/DashboardComponents/ComponentArgument";
10
+ import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
11
+
12
+ const DisplaySection: ComponentArgumentSection = {
13
+ name: "Display Options",
14
+ description: "Configure the widget title and row limit",
15
+ order: 1,
16
+ };
17
+
18
+ const FiltersSection: ComponentArgumentSection = {
19
+ name: "Filters",
20
+ description: "Narrow down which incidents are shown",
21
+ order: 2,
22
+ defaultCollapsed: true,
23
+ };
24
+
25
+ export default class DashboardIncidentListComponentUtil extends DashboardBaseComponentUtil {
26
+ public static override getDefaultComponent(): DashboardIncidentListComponent {
27
+ return {
28
+ _type: ObjectType.DashboardComponent,
29
+ componentType: DashboardComponentType.IncidentList,
30
+ widthInDashboardUnits: 6,
31
+ heightInDashboardUnits: 4,
32
+ topInDashboardUnits: 0,
33
+ leftInDashboardUnits: 0,
34
+ componentId: ObjectID.generate(),
35
+ minHeightInDashboardUnits: 3,
36
+ minWidthInDashboardUnits: 6,
37
+ arguments: {
38
+ maxRows: 25,
39
+ },
40
+ };
41
+ }
42
+
43
+ public static override getComponentConfigArguments(): Array<
44
+ ComponentArgument<DashboardIncidentListComponent>
45
+ > {
46
+ const componentArguments: Array<
47
+ ComponentArgument<DashboardIncidentListComponent>
48
+ > = [];
49
+
50
+ componentArguments.push({
51
+ name: "Title",
52
+ description: "Header shown above the incident list",
53
+ required: false,
54
+ type: ComponentInputType.Text,
55
+ id: "title",
56
+ section: DisplaySection,
57
+ });
58
+
59
+ componentArguments.push({
60
+ name: "Max Rows",
61
+ description: "Maximum number of incidents to show",
62
+ required: false,
63
+ type: ComponentInputType.Number,
64
+ id: "maxRows",
65
+ placeholder: "25",
66
+ section: DisplaySection,
67
+ });
68
+
69
+ componentArguments.push({
70
+ name: "State",
71
+ description: "Filter incidents by lifecycle state",
72
+ required: false,
73
+ type: ComponentInputType.Dropdown,
74
+ id: "stateFilter",
75
+ section: FiltersSection,
76
+ dropdownOptions: [
77
+ { label: "All", value: "" },
78
+ { label: "Unresolved (open)", value: "unresolved" },
79
+ { label: "Acknowledged", value: "acknowledged" },
80
+ { label: "Resolved", value: "resolved" },
81
+ ],
82
+ });
83
+
84
+ return componentArguments;
85
+ }
86
+ }