@oneuptime/common 10.2.11 → 10.2.13
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/DashboardAPI.ts +96 -0
- package/Server/Services/TelemetryAttributeService.ts +68 -66
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +29 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +36 -0
- package/Types/Dashboard/DashboardVariable.ts +17 -0
- package/Types/Metrics/MetricsQuery.ts +2 -1
- package/Utils/Dashboard/VariableInterpolation.ts +169 -0
- package/Utils/Dashboard/VariableUrlState.ts +128 -0
- package/build/dist/Server/API/DashboardAPI.js +68 -2
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/Services/TelemetryAttributeService.js +52 -48
- package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +23 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js +32 -0
- package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardVariable.js +1 -0
- package/build/dist/Types/Dashboard/DashboardVariable.js.map +1 -1
- package/build/dist/Utils/Dashboard/VariableInterpolation.js +101 -0
- package/build/dist/Utils/Dashboard/VariableInterpolation.js.map +1 -0
- package/build/dist/Utils/Dashboard/VariableUrlState.js +101 -0
- package/build/dist/Utils/Dashboard/VariableUrlState.js.map +1 -0
- package/package.json +1 -1
|
@@ -24,6 +24,8 @@ import { DASHBOARD_MASTER_PASSWORD_INVALID_MESSAGE } from "../../Types/Dashboard
|
|
|
24
24
|
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
|
|
25
25
|
import ForbiddenException from "../../Types/Exception/ForbiddenException";
|
|
26
26
|
import JSONFunctions from "../../Types/JSONFunctions";
|
|
27
|
+
import TelemetryAttributeService from "../Services/TelemetryAttributeService";
|
|
28
|
+
import TelemetryType from "../../Types/Telemetry/TelemetryType";
|
|
27
29
|
|
|
28
30
|
export default class DashboardAPI extends BaseAPI<
|
|
29
31
|
Dashboard,
|
|
@@ -302,6 +304,100 @@ export default class DashboardAPI extends BaseAPI<
|
|
|
302
304
|
},
|
|
303
305
|
);
|
|
304
306
|
|
|
307
|
+
/*
|
|
308
|
+
* Public attribute-value lookup for dashboard variables.
|
|
309
|
+
*
|
|
310
|
+
* The private `/telemetry/metrics/get-attribute-values` route requires
|
|
311
|
+
* a logged-in session; public dashboards have no session, so we mirror
|
|
312
|
+
* the behaviour here scoped to the dashboard's owning projectId.
|
|
313
|
+
* Authorization reuses DashboardService.hasReadAccess (public flag, IP
|
|
314
|
+
* whitelist, master password) — never falls back to project-wide read.
|
|
315
|
+
*/
|
|
316
|
+
this.router.post(
|
|
317
|
+
`${new this.entityType()
|
|
318
|
+
.getCrudApiPath()
|
|
319
|
+
?.toString()}/attribute-values/:dashboardId`,
|
|
320
|
+
UserMiddleware.getUserMiddleware,
|
|
321
|
+
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
|
322
|
+
try {
|
|
323
|
+
const dashboardId: ObjectID = new ObjectID(
|
|
324
|
+
req.params["dashboardId"] as string,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const accessResult: {
|
|
328
|
+
hasReadAccess: boolean;
|
|
329
|
+
error?: NotAuthenticatedException | ForbiddenException;
|
|
330
|
+
} = await DashboardService.hasReadAccess({
|
|
331
|
+
dashboardId,
|
|
332
|
+
req,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (!accessResult.hasReadAccess) {
|
|
336
|
+
throw (
|
|
337
|
+
accessResult.error ||
|
|
338
|
+
new BadDataException("Access denied to this dashboard.")
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const attributeKey: string | undefined =
|
|
343
|
+
req.body && (req.body["attributeKey"] as string);
|
|
344
|
+
|
|
345
|
+
if (!attributeKey || !attributeKey.trim()) {
|
|
346
|
+
throw new BadDataException("attributeKey is required.");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const metricNameRaw: string | undefined =
|
|
350
|
+
req.body && (req.body["metricName"] as string);
|
|
351
|
+
const metricName: string | undefined =
|
|
352
|
+
metricNameRaw && metricNameRaw.trim() ? metricNameRaw : undefined;
|
|
353
|
+
|
|
354
|
+
const telemetryTypeRaw: string | undefined =
|
|
355
|
+
req.body && (req.body["telemetryType"] as string);
|
|
356
|
+
let telemetryType: TelemetryType = TelemetryType.Metric;
|
|
357
|
+
if (telemetryTypeRaw) {
|
|
358
|
+
const match: TelemetryType | undefined = (
|
|
359
|
+
Object.values(TelemetryType) as Array<string>
|
|
360
|
+
).includes(telemetryTypeRaw)
|
|
361
|
+
? (telemetryTypeRaw as TelemetryType)
|
|
362
|
+
: undefined;
|
|
363
|
+
if (match) {
|
|
364
|
+
telemetryType = match;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const dashboard: Dashboard | null =
|
|
369
|
+
await DashboardService.findOneById({
|
|
370
|
+
id: dashboardId,
|
|
371
|
+
select: {
|
|
372
|
+
_id: true,
|
|
373
|
+
projectId: true,
|
|
374
|
+
},
|
|
375
|
+
props: {
|
|
376
|
+
isRoot: true,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if (!dashboard || !dashboard.projectId) {
|
|
381
|
+
throw new NotFoundException("Dashboard not found");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const values: Array<string> =
|
|
385
|
+
await TelemetryAttributeService.fetchAttributeValues({
|
|
386
|
+
projectId: dashboard.projectId,
|
|
387
|
+
telemetryType,
|
|
388
|
+
attributeKey: attributeKey.trim(),
|
|
389
|
+
metricName,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return Response.sendJsonObjectResponse(req, res, {
|
|
393
|
+
values,
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
next(err);
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
|
|
305
401
|
this.router.post(
|
|
306
402
|
`${new this.entityType()
|
|
307
403
|
.getCrudApiPath()
|
|
@@ -35,10 +35,20 @@ type TelemetryAttributesCacheEntry = {
|
|
|
35
35
|
|
|
36
36
|
export class TelemetryAttributeService {
|
|
37
37
|
private static readonly ATTRIBUTES_LIMIT: number = 5000;
|
|
38
|
-
private static readonly ROW_SCAN_LIMIT: number = 10000;
|
|
39
38
|
private static readonly CACHE_NAMESPACE: string = "telemetry-attributes";
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
/*
|
|
40
|
+
* Attribute keys change rarely. Cache for an hour so the (still O(seconds))
|
|
41
|
+
* ClickHouse scan only runs once per project per hour rather than on every
|
|
42
|
+
* dashboard / metrics-explorer load.
|
|
43
|
+
*/
|
|
44
|
+
private static readonly CACHE_STALE_AFTER_MINUTES: number = 60;
|
|
45
|
+
/*
|
|
46
|
+
* The previous 30-day window forced a 100M+ row scan with an in-CTE
|
|
47
|
+
* ORDER BY time DESC that pushed this query to 30-60s on busy projects.
|
|
48
|
+
* Attribute keys rotate slowly, so a 1-day window covers virtually every
|
|
49
|
+
* active key while keeping the scan tractable.
|
|
50
|
+
*/
|
|
51
|
+
private static readonly LOOKBACK_WINDOW_IN_DAYS: number = 1;
|
|
42
52
|
|
|
43
53
|
private getTelemetrySource(
|
|
44
54
|
telemetryType: TelemetryType,
|
|
@@ -247,76 +257,56 @@ export class TelemetryAttributeService {
|
|
|
247
257
|
TelemetryAttributeService.getLookbackStartDate();
|
|
248
258
|
|
|
249
259
|
/*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* the
|
|
260
|
+
* Two notable choices here:
|
|
261
|
+
*
|
|
262
|
+
* 1. We aggregate with `groupUniqArrayArray` (or `mapKeys`+`groupUniqArray`
|
|
263
|
+
* for tables that lack the denormalized array column) instead of
|
|
264
|
+
* `arrayJoin` + outer `DISTINCT`. That avoids materializing one row
|
|
265
|
+
* per attribute key across millions of source rows.
|
|
266
|
+
*
|
|
267
|
+
* 2. The previous implementation wrapped the scan in
|
|
268
|
+
* `ORDER BY time DESC LIMIT 10000` to "cap" the work. With `arrayJoin`
|
|
269
|
+
* that LIMIT applied AFTER expansion so it didn't actually bound rows
|
|
270
|
+
* read, but it did force ClickHouse to sort every matching row by
|
|
271
|
+
* time — the dominant cost on busy projects. Bounded by lookback
|
|
272
|
+
* instead, the aggregate-and-flatten approach finishes in seconds.
|
|
254
273
|
*/
|
|
255
274
|
const statement: Statement = data.attributeKeysColumn
|
|
256
275
|
? SQL`
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
value: data.projectId,
|
|
269
|
-
}}
|
|
270
|
-
AND (
|
|
271
|
-
NOT empty(${data.attributeKeysColumn}) OR
|
|
272
|
-
NOT empty(${data.attributesColumn})
|
|
273
|
-
)
|
|
274
|
-
AND ${data.timeColumn} >= ${{
|
|
275
|
-
type: TableColumnType.Date,
|
|
276
|
-
value: lookbackStartDate,
|
|
277
|
-
}}`
|
|
276
|
+
SELECT arrayDistinct(arrayFlatten(groupUniqArrayArray(${data.attributeKeysColumn}))) AS keys
|
|
277
|
+
FROM ${data.tableName}
|
|
278
|
+
WHERE projectId = ${{
|
|
279
|
+
type: TableColumnType.ObjectID,
|
|
280
|
+
value: data.projectId,
|
|
281
|
+
}}
|
|
282
|
+
AND NOT empty(${data.attributeKeysColumn})
|
|
283
|
+
AND ${data.timeColumn} >= ${{
|
|
284
|
+
type: TableColumnType.Date,
|
|
285
|
+
value: lookbackStartDate,
|
|
286
|
+
}}`
|
|
278
287
|
: SQL`
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}}`;
|
|
288
|
+
SELECT groupUniqArray(arrayJoin(mapKeys(${data.attributesColumn}))) AS keys
|
|
289
|
+
FROM ${data.tableName}
|
|
290
|
+
WHERE projectId = ${{
|
|
291
|
+
type: TableColumnType.ObjectID,
|
|
292
|
+
value: data.projectId,
|
|
293
|
+
}}
|
|
294
|
+
AND NOT empty(${data.attributesColumn})
|
|
295
|
+
AND ${data.timeColumn} >= ${{
|
|
296
|
+
type: TableColumnType.Date,
|
|
297
|
+
value: lookbackStartDate,
|
|
298
|
+
}}`;
|
|
291
299
|
|
|
292
300
|
if (data.metricName) {
|
|
293
301
|
statement.append(
|
|
294
302
|
SQL`
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
303
|
+
AND name = ${{
|
|
304
|
+
type: TableColumnType.Text,
|
|
305
|
+
value: data.metricName,
|
|
306
|
+
}}`,
|
|
299
307
|
);
|
|
300
308
|
}
|
|
301
309
|
|
|
302
|
-
statement.append(
|
|
303
|
-
SQL`
|
|
304
|
-
ORDER BY ${data.timeColumn} DESC
|
|
305
|
-
LIMIT ${{
|
|
306
|
-
type: TableColumnType.Number,
|
|
307
|
-
value: TelemetryAttributeService.ROW_SCAN_LIMIT,
|
|
308
|
-
}}
|
|
309
|
-
)
|
|
310
|
-
SELECT DISTINCT attribute
|
|
311
|
-
FROM filtered
|
|
312
|
-
WHERE attribute IS NOT NULL AND attribute != ''
|
|
313
|
-
ORDER BY attribute ASC
|
|
314
|
-
LIMIT ${{
|
|
315
|
-
type: TableColumnType.Number,
|
|
316
|
-
value: TelemetryAttributeService.ATTRIBUTES_LIMIT,
|
|
317
|
-
}}`,
|
|
318
|
-
);
|
|
319
|
-
|
|
320
310
|
return statement;
|
|
321
311
|
}
|
|
322
312
|
|
|
@@ -341,17 +331,29 @@ export class TelemetryAttributeService {
|
|
|
341
331
|
}>();
|
|
342
332
|
|
|
343
333
|
const rows: Array<JSONObject> = response.data || [];
|
|
334
|
+
const firstRow: JSONObject | undefined = rows[0];
|
|
335
|
+
const rawKeys: unknown = firstRow ? firstRow["keys"] : null;
|
|
344
336
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
337
|
+
if (!Array.isArray(rawKeys)) {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const attributeKeys: Array<string> = rawKeys
|
|
342
|
+
.map((attribute: unknown): string | null => {
|
|
348
343
|
return typeof attribute === "string" ? attribute.trim() : null;
|
|
349
344
|
})
|
|
350
345
|
.filter((attribute: string | null): attribute is string => {
|
|
351
346
|
return Boolean(attribute);
|
|
352
347
|
});
|
|
353
348
|
|
|
354
|
-
|
|
349
|
+
const distinctKeys: Array<string> = Array.from(new Set(attributeKeys));
|
|
350
|
+
distinctKeys.sort((a: string, b: string): number => {
|
|
351
|
+
return a.localeCompare(b);
|
|
352
|
+
});
|
|
353
|
+
if (distinctKeys.length > TelemetryAttributeService.ATTRIBUTES_LIMIT) {
|
|
354
|
+
distinctKeys.length = TelemetryAttributeService.ATTRIBUTES_LIMIT;
|
|
355
|
+
}
|
|
356
|
+
return distinctKeys;
|
|
355
357
|
}
|
|
356
358
|
|
|
357
359
|
private static readonly ATTRIBUTE_VALUES_LIMIT: number = 100;
|
|
@@ -20,6 +20,7 @@ import GreaterThan from "../../../Types/BaseDatabase/GreaterThan";
|
|
|
20
20
|
import GreaterThanOrEqual from "../../../Types/BaseDatabase/GreaterThanOrEqual";
|
|
21
21
|
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
22
22
|
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
23
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
23
24
|
import IsNull from "../../../Types/BaseDatabase/IsNull";
|
|
24
25
|
import LessThan from "../../../Types/BaseDatabase/LessThan";
|
|
25
26
|
import LessThanOrEqual from "../../../Types/BaseDatabase/LessThanOrEqual";
|
|
@@ -711,6 +712,34 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
|
|
|
711
712
|
continue;
|
|
712
713
|
}
|
|
713
714
|
|
|
715
|
+
/*
|
|
716
|
+
* Multi-value selection (dashboard variables, ad-hoc filters):
|
|
717
|
+
* an empty `Includes` would expand to `IN ()`, which ClickHouse
|
|
718
|
+
* treats as "match nothing" and is never the user's intent
|
|
719
|
+
* here — skip the predicate instead so a cleared multi-select
|
|
720
|
+
* behaves like "All".
|
|
721
|
+
*/
|
|
722
|
+
if (mapEntry instanceof Includes) {
|
|
723
|
+
const includesValues: Array<string> = (
|
|
724
|
+
(mapEntry as Includes).values || []
|
|
725
|
+
).map((v: string | ObjectID | number) => {
|
|
726
|
+
return String(v);
|
|
727
|
+
});
|
|
728
|
+
if (includesValues.length === 0) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
whereStatement.append(
|
|
732
|
+
SQL`AND ${key}[${{
|
|
733
|
+
value: mapKey,
|
|
734
|
+
type: TableColumnType.Text,
|
|
735
|
+
}}] IN ${{
|
|
736
|
+
value: new Includes(includesValues),
|
|
737
|
+
type: TableColumnType.Text,
|
|
738
|
+
}}`,
|
|
739
|
+
);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
714
743
|
// Bare string/number/boolean — direct Map subscript.
|
|
715
744
|
whereStatement.append(
|
|
716
745
|
SQL`AND ${key}[${{
|
|
@@ -18,6 +18,7 @@ import NotEqual from "../../../../Types/BaseDatabase/NotEqual";
|
|
|
18
18
|
import IsNull from "../../../../Types/BaseDatabase/IsNull";
|
|
19
19
|
import NotNull from "../../../../Types/BaseDatabase/NotNull";
|
|
20
20
|
import GreaterThan from "../../../../Types/BaseDatabase/GreaterThan";
|
|
21
|
+
import Includes from "../../../../Types/BaseDatabase/Includes";
|
|
21
22
|
import Search from "../../../../Types/BaseDatabase/Search";
|
|
22
23
|
import StartsWith from "../../../../Types/BaseDatabase/StartsWith";
|
|
23
24
|
|
|
@@ -323,6 +324,41 @@ describe("StatementGenerator", () => {
|
|
|
323
324
|
expect(statement.query).toContain("lowerUTF8");
|
|
324
325
|
expect(statement.query).toContain("ILIKE");
|
|
325
326
|
});
|
|
327
|
+
|
|
328
|
+
test("emits direct map subscript IN(...) for Includes wrapper", () => {
|
|
329
|
+
const statement: Statement = mapGenerator.toWhereStatement({
|
|
330
|
+
attributes: {
|
|
331
|
+
"k8s.cluster.name": new Includes(["prod-east", "prod-west"]),
|
|
332
|
+
},
|
|
333
|
+
} as any);
|
|
334
|
+
/*
|
|
335
|
+
* Multi-value dashboard variables emit Includes on a map column;
|
|
336
|
+
* the generator must produce an O(1) Map subscript followed by
|
|
337
|
+
* IN, matching the fast-path used for bare-value equality.
|
|
338
|
+
*/
|
|
339
|
+
expect(statement.query).toBe(
|
|
340
|
+
"AND {p0:Identifier}[{p1:String}] IN {p2:Array(String)}",
|
|
341
|
+
);
|
|
342
|
+
expect(statement.query_params).toStrictEqual({
|
|
343
|
+
p0: "attributes",
|
|
344
|
+
p1: "k8s.cluster.name",
|
|
345
|
+
p2: ["prod-east", "prod-west"],
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("drops empty Includes wrapper instead of producing IN ()", () => {
|
|
350
|
+
const statement: Statement = mapGenerator.toWhereStatement({
|
|
351
|
+
attributes: {
|
|
352
|
+
"k8s.cluster.name": new Includes([]),
|
|
353
|
+
},
|
|
354
|
+
} as any);
|
|
355
|
+
/*
|
|
356
|
+
* An empty multi-select is the user's "All" — must not emit
|
|
357
|
+
* `IN ()` (which ClickHouse treats as match-nothing).
|
|
358
|
+
*/
|
|
359
|
+
expect(statement.query).toBe("");
|
|
360
|
+
expect(statement.query_params).toStrictEqual({});
|
|
361
|
+
});
|
|
326
362
|
});
|
|
327
363
|
});
|
|
328
364
|
|
|
@@ -2,6 +2,7 @@ export enum DashboardVariableType {
|
|
|
2
2
|
CustomList = "Custom List",
|
|
3
3
|
Query = "Query",
|
|
4
4
|
TextInput = "Text Input",
|
|
5
|
+
TelemetryAttribute = "Telemetry Attribute",
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export default interface DashboardVariable {
|
|
@@ -13,6 +14,22 @@ export default interface DashboardVariable {
|
|
|
13
14
|
customListValues?: string | undefined;
|
|
14
15
|
// For Query: a ClickHouse query to populate options
|
|
15
16
|
query?: string | undefined;
|
|
17
|
+
/*
|
|
18
|
+
* For TelemetryAttribute: the OpenTelemetry attribute key this
|
|
19
|
+
* variable binds to (e.g. "k8s.cluster.name"). At render time the
|
|
20
|
+
* selected value is injected into any widget filter that targets
|
|
21
|
+
* this attribute key. Options are fetched from the distinct values
|
|
22
|
+
* of this attribute across the current time range.
|
|
23
|
+
*/
|
|
24
|
+
attributeKey?: string | undefined;
|
|
25
|
+
/*
|
|
26
|
+
* For TelemetryAttribute: optional metric name to scope the option
|
|
27
|
+
* lookup to. When set, the dropdown lists distinct attribute values
|
|
28
|
+
* observed on that metric only (e.g. distinct `k8s.pod.name` values
|
|
29
|
+
* emitted by `k8s.container.cpu_usage`). Leave undefined to list
|
|
30
|
+
* values across every metric in the project.
|
|
31
|
+
*/
|
|
32
|
+
metricName?: string | undefined;
|
|
16
33
|
// Current selected value(s)
|
|
17
34
|
selectedValue?: string | undefined;
|
|
18
35
|
selectedValues?: Array<string> | undefined;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import Includes from "../BaseDatabase/Includes";
|
|
1
2
|
import Search from "../BaseDatabase/Search";
|
|
2
3
|
import Dictionary from "../Dictionary";
|
|
3
4
|
import MetricsAggregationType from "./MetricsAggregationType";
|
|
4
5
|
|
|
5
6
|
export default interface MetricsQuery {
|
|
6
7
|
metricName: string;
|
|
7
|
-
attributes: Dictionary<string | boolean | number | Search<string
|
|
8
|
+
attributes: Dictionary<string | boolean | number | Search<string> | Includes>;
|
|
8
9
|
aggegationType: MetricsAggregationType;
|
|
9
10
|
aggregateBy: Dictionary<boolean>;
|
|
10
11
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import Includes from "../../Types/BaseDatabase/Includes";
|
|
2
|
+
import DashboardVariable, {
|
|
3
|
+
DashboardVariableType,
|
|
4
|
+
} from "../../Types/Dashboard/DashboardVariable";
|
|
5
|
+
import MetricQueryConfigData from "../../Types/Metrics/MetricQueryConfigData";
|
|
6
|
+
|
|
7
|
+
export interface ResolvedVariableValue {
|
|
8
|
+
/*
|
|
9
|
+
* Single-value selection. Becomes a `attributes[key] = '...'` predicate
|
|
10
|
+
* server-side.
|
|
11
|
+
*/
|
|
12
|
+
scalar?: string | undefined;
|
|
13
|
+
/*
|
|
14
|
+
* Multi-value selection. Becomes a `attributes[key] IN (...)` predicate.
|
|
15
|
+
* Always non-empty when present; an empty multi-select is treated as
|
|
16
|
+
* "All" and produces neither field.
|
|
17
|
+
*/
|
|
18
|
+
multi?: Array<string> | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Applies dashboard variables to a metric query at render time.
|
|
23
|
+
*
|
|
24
|
+
* For each TelemetryAttribute variable with a selected value, the
|
|
25
|
+
* variable's bound attribute key is injected into the query's attribute
|
|
26
|
+
* filter. An empty/unset selection means "All" — no filter is applied,
|
|
27
|
+
* and any previously-set filter on that key is removed so the widget
|
|
28
|
+
* shows the cross-cluster view by default.
|
|
29
|
+
*
|
|
30
|
+
* Multi-select variables emit an `Includes` operator on the same map
|
|
31
|
+
* key so the server's WHERE-builder produces `attributes[key] IN (...)`.
|
|
32
|
+
*/
|
|
33
|
+
export default class DashboardVariableInterpolation {
|
|
34
|
+
public static resolveValue(
|
|
35
|
+
variable: DashboardVariable,
|
|
36
|
+
): ResolvedVariableValue | undefined {
|
|
37
|
+
if (variable.isMultiSelect) {
|
|
38
|
+
const values: Array<string> = (variable.selectedValues || []).filter(
|
|
39
|
+
(v: string) => {
|
|
40
|
+
return Boolean(v);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
if (values.length > 0) {
|
|
44
|
+
return { multi: values };
|
|
45
|
+
}
|
|
46
|
+
// No multi-select picks. Fall through to default value handling.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const v: string | undefined =
|
|
50
|
+
variable.selectedValue ?? variable.defaultValue ?? undefined;
|
|
51
|
+
if (v === undefined || v === null || v === "") {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return { scalar: v };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* Generic helper: take a plain attributes map and return a new map
|
|
59
|
+
* with all TelemetryAttribute variables applied. Used by every widget
|
|
60
|
+
* type whose filter is shaped like `{ [attrKey]: value | operator }`
|
|
61
|
+
* (metric charts, log streams). Returns the original reference when
|
|
62
|
+
* nothing changes so React.memo can short-circuit.
|
|
63
|
+
*/
|
|
64
|
+
public static applyToAttributes(
|
|
65
|
+
attributes: Record<string, unknown> | undefined,
|
|
66
|
+
variables: Array<DashboardVariable> | undefined,
|
|
67
|
+
): Record<string, unknown> {
|
|
68
|
+
const source: Record<string, unknown> = attributes || {};
|
|
69
|
+
if (!variables || variables.length === 0) {
|
|
70
|
+
return source;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const telemetryAttributeVariables: Array<DashboardVariable> =
|
|
74
|
+
variables.filter((v: DashboardVariable) => {
|
|
75
|
+
return (
|
|
76
|
+
v.type === DashboardVariableType.TelemetryAttribute &&
|
|
77
|
+
Boolean(v.attributeKey)
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (telemetryAttributeVariables.length === 0) {
|
|
82
|
+
return source;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const next: Record<string, unknown> = { ...source };
|
|
86
|
+
let changed: boolean = false;
|
|
87
|
+
|
|
88
|
+
for (const variable of telemetryAttributeVariables) {
|
|
89
|
+
const key: string = variable.attributeKey as string;
|
|
90
|
+
const resolved: ResolvedVariableValue | undefined =
|
|
91
|
+
DashboardVariableInterpolation.resolveValue(variable);
|
|
92
|
+
|
|
93
|
+
if (!resolved) {
|
|
94
|
+
if (key in next) {
|
|
95
|
+
delete next[key];
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (resolved.multi && resolved.multi.length > 0) {
|
|
102
|
+
next[key] = new Includes(resolved.multi);
|
|
103
|
+
changed = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (resolved.scalar !== undefined && next[key] !== resolved.scalar) {
|
|
108
|
+
next[key] = resolved.scalar;
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return changed ? next : source;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public static applyToQueryConfig(
|
|
117
|
+
queryConfig: MetricQueryConfigData,
|
|
118
|
+
variables: Array<DashboardVariable> | undefined,
|
|
119
|
+
): MetricQueryConfigData {
|
|
120
|
+
if (!variables || variables.length === 0) {
|
|
121
|
+
return queryConfig;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const filterData: typeof queryConfig.metricQueryData.filterData =
|
|
125
|
+
queryConfig.metricQueryData?.filterData;
|
|
126
|
+
|
|
127
|
+
if (!filterData) {
|
|
128
|
+
return queryConfig;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const originalAttributes: Record<string, unknown> =
|
|
132
|
+
(filterData.attributes as Record<string, unknown> | undefined) || {};
|
|
133
|
+
const nextAttributes: Record<string, unknown> =
|
|
134
|
+
DashboardVariableInterpolation.applyToAttributes(
|
|
135
|
+
originalAttributes,
|
|
136
|
+
variables,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (nextAttributes === originalAttributes) {
|
|
140
|
+
return queryConfig;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...queryConfig,
|
|
145
|
+
metricQueryData: {
|
|
146
|
+
...queryConfig.metricQueryData,
|
|
147
|
+
filterData: {
|
|
148
|
+
...filterData,
|
|
149
|
+
attributes: nextAttributes,
|
|
150
|
+
} as typeof queryConfig.metricQueryData.filterData,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public static applyToQueryConfigs(
|
|
156
|
+
queryConfigs: Array<MetricQueryConfigData>,
|
|
157
|
+
variables: Array<DashboardVariable> | undefined,
|
|
158
|
+
): Array<MetricQueryConfigData> {
|
|
159
|
+
if (!variables || variables.length === 0) {
|
|
160
|
+
return queryConfigs;
|
|
161
|
+
}
|
|
162
|
+
return queryConfigs.map((config: MetricQueryConfigData) => {
|
|
163
|
+
return DashboardVariableInterpolation.applyToQueryConfig(
|
|
164
|
+
config,
|
|
165
|
+
variables,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|