@oneuptime/common 10.0.88 → 10.0.90
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/Server/API/MetricAPI.ts +149 -0
- package/Server/Services/AnalyticsDatabaseService.ts +21 -0
- package/Server/Services/MetricService.ts +193 -1
- package/Types/Dashboard/DashboardComponentType.ts +3 -0
- package/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.ts +13 -0
- package/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.ts +13 -0
- package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +13 -0
- package/Types/JSONFunctions.ts +61 -1
- package/Utils/Dashboard/Components/DashboardAlertListComponent.ts +86 -0
- package/Utils/Dashboard/Components/DashboardIncidentListComponent.ts +86 -0
- package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +85 -0
- package/Utils/Dashboard/Components/Index.ts +21 -0
- package/build/dist/Server/API/MetricAPI.js +123 -0
- package/build/dist/Server/API/MetricAPI.js.map +1 -0
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +18 -0
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +151 -1
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponentType.js +3 -0
- package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardAlertListComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardIncidentListComponent.js.map +1 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.js.map +1 -0
- package/build/dist/Types/JSONFunctions.js +47 -1
- package/build/dist/Types/JSONFunctions.js.map +1 -1
- package/build/dist/Utils/Dashboard/Components/DashboardAlertListComponent.js +70 -0
- package/build/dist/Utils/Dashboard/Components/DashboardAlertListComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardIncidentListComponent.js +70 -0
- package/build/dist/Utils/Dashboard/Components/DashboardIncidentListComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +69 -0
- package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/Index.js +12 -0
- package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
- 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();
|
|
@@ -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
|
+
}
|
package/Types/JSONFunctions.ts
CHANGED
|
@@ -20,7 +20,67 @@ export default class JSONFunctions {
|
|
|
20
20
|
obj1: GenericObject,
|
|
21
21
|
obj2: GenericObject,
|
|
22
22
|
): boolean {
|
|
23
|
-
return
|
|
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
|
+
}
|