@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.
Files changed (23) hide show
  1. package/Server/API/DashboardAPI.ts +96 -0
  2. package/Server/Services/TelemetryAttributeService.ts +68 -66
  3. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +29 -0
  4. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +36 -0
  5. package/Types/Dashboard/DashboardVariable.ts +17 -0
  6. package/Types/Metrics/MetricsQuery.ts +2 -1
  7. package/Utils/Dashboard/VariableInterpolation.ts +169 -0
  8. package/Utils/Dashboard/VariableUrlState.ts +128 -0
  9. package/build/dist/Server/API/DashboardAPI.js +68 -2
  10. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  11. package/build/dist/Server/Services/TelemetryAttributeService.js +52 -48
  12. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  13. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +23 -0
  14. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  15. package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js +32 -0
  16. package/build/dist/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.js.map +1 -1
  17. package/build/dist/Types/Dashboard/DashboardVariable.js +1 -0
  18. package/build/dist/Types/Dashboard/DashboardVariable.js.map +1 -1
  19. package/build/dist/Utils/Dashboard/VariableInterpolation.js +101 -0
  20. package/build/dist/Utils/Dashboard/VariableInterpolation.js.map +1 -0
  21. package/build/dist/Utils/Dashboard/VariableUrlState.js +101 -0
  22. package/build/dist/Utils/Dashboard/VariableUrlState.js.map +1 -0
  23. 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
- private static readonly CACHE_STALE_AFTER_MINUTES: number = 5;
41
- private static readonly LOOKBACK_WINDOW_IN_DAYS: number = 30;
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
- * If the source has a denormalized attributeKeys array column, prefer it
251
- * (avoids materializing every row's map). Otherwise fall back to
252
- * mapKeys(attributes) slower but works for tables that don't carry
253
- * the precomputed array (e.g. ExceptionInstance).
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
- WITH filtered AS (
258
- SELECT arrayJoin(
259
- if(
260
- empty(${data.attributeKeysColumn}),
261
- mapKeys(${data.attributesColumn}),
262
- ${data.attributeKeysColumn}
263
- )
264
- ) AS attribute
265
- FROM ${data.tableName}
266
- WHERE projectId = ${{
267
- type: TableColumnType.ObjectID,
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
- WITH filtered AS (
280
- SELECT arrayJoin(mapKeys(${data.attributesColumn})) AS attribute
281
- FROM ${data.tableName}
282
- WHERE projectId = ${{
283
- type: TableColumnType.ObjectID,
284
- value: data.projectId,
285
- }}
286
- AND NOT empty(${data.attributesColumn})
287
- AND ${data.timeColumn} >= ${{
288
- type: TableColumnType.Date,
289
- value: lookbackStartDate,
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
- AND name = ${{
296
- type: TableColumnType.Text,
297
- value: data.metricName,
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
- const attributeKeys: Array<string> = rows
346
- .map((row: JSONObject) => {
347
- const attribute: unknown = row["attribute"];
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
- return Array.from(new Set(attributeKeys));
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
+ }