Transformations: Add support for dashboard variable in limit, sort by, filter by value, heatmap and histogram (#75372)

* variables for filterforvalue

* use datalinkinput for basic matcher

* fix user select issue

* heatmap transformation variable interpolation

* clean code

* interpolate sort by

* add options interpolation in histogram transformation

* interpolation for limit

* Add suggestions UI to Filter by data value Transformation

Co-authored-by: oscarkilhed <oscar.kilhed@grafana.com>

* add validation for number/variable fields

* Add variables to add field from calculation

* Add validator to limit transformation

* Refactor validator

* Refactor suggestionInput styles

* Add variable support in heatmap calculate options to be in sync with tranform

* Refactor SuggestionsInput

* Fix histogram, limit and filter by value matchers

* clean up weird state ref

* Only interpolate when the feature toggle is set

* Add feature toggle to ui

* Fix number of variable test

* Fix issue with characters typed after opening suggestions still remains after selecting a suggestion

* Clean up from review

* Add more tests for numberOrVariableValidator

---------

Co-authored-by: Victor Marin <victor.marin@grafana.com>
This commit is contained in:
Oscar Kilhed
2023-10-04 17:28:46 +03:00
committed by GitHub
co-authored by Victor Marin
parent 027028d9a0
commit 40cdb30336
33 changed files with 1020 additions and 97 deletions
@@ -4,7 +4,7 @@ import { ValueMatcherID } from '../ids';
import { BasicValueMatcherOptions } from './types';
const isGreaterValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isGreaterValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
id: ValueMatcherID.greater,
name: 'Is greater',
description: 'Match when field value is greater than option.',
@@ -24,7 +24,7 @@ const isGreaterValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>>
getDefaultOptions: () => ({ value: 0 }),
};
const isGreaterOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isGreaterOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
id: ValueMatcherID.greaterOrEqual,
name: 'Is greater or equal',
description: 'Match when field value is greater than or equal to option.',
@@ -44,7 +44,7 @@ const isGreaterOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<nu
getDefaultOptions: () => ({ value: 0 }),
};
const isLowerValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isLowerValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
id: ValueMatcherID.lower,
name: 'Is lower',
description: 'Match when field value is lower than option.',
@@ -64,7 +64,7 @@ const isLowerValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> =
getDefaultOptions: () => ({ value: 0 }),
};
const isLowerOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions<number>> = {
const isLowerOrEqualValueMatcher: ValueMatcherInfo<BasicValueMatcherOptions> = {
id: ValueMatcherID.lowerOrEqual,
name: 'Is lower or equal',
description: 'Match when field value is lower or equal than option.',
@@ -4,7 +4,7 @@ import { ValueMatcherID } from '../ids';
import { RangeValueMatcherOptions } from './types';
const isBetweenValueMatcher: ValueMatcherInfo<RangeValueMatcherOptions<number>> = {
const isBetweenValueMatcher: ValueMatcherInfo<RangeValueMatcherOptions> = {
id: ValueMatcherID.between,
name: 'Is between',
description: 'Match when field value is between given option values.',
@@ -13,6 +13,18 @@ import {
} from './filterByValue';
import { DataTransformerID } from './ids';
let transformationSupport = false;
jest.mock('./utils', () => {
const actual = jest.requireActual('./utils');
return {
...actual,
transformationsVariableSupport: () => {
return transformationSupport;
},
};
});
const seriesAWithSingleField = toDataFrame({
name: 'A',
length: 7,
@@ -109,6 +121,93 @@ describe('FilterByValue transformer', () => {
});
});
it('should interpolate dashboard variables', async () => {
transformationSupport = true;
const lower: MatcherConfig<BasicValueMatcherOptions<string | number>> = {
id: ValueMatcherID.lower,
options: { value: 'thiswillinterpolateto6' },
};
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = {
id: DataTransformerID.filterByValue,
options: {
type: FilterByValueType.exclude,
match: FilterByValueMatch.all,
filters: [
{
fieldName: 'numbers',
config: lower,
},
],
},
};
const ctxmock = { interpolate: jest.fn(() => '6') };
await expect(transformDataFrame([cfg], [seriesAWithSingleField], ctxmock)).toEmitValuesWith((received) => {
const processed = received[0];
expect(processed.length).toEqual(1);
expect(processed[0].fields).toEqual([
{
name: 'time',
type: FieldType.time,
values: [6000, 7000],
state: {},
},
{
name: 'numbers',
type: FieldType.number,
values: [6, 7],
state: {},
},
]);
});
transformationSupport = false;
});
it('should not interpolate dashboard variables when feature toggle is off', async () => {
const lower: MatcherConfig<BasicValueMatcherOptions<number | string>> = {
id: ValueMatcherID.lower,
options: { value: 'notinterpolating' },
};
const cfg: DataTransformerConfig<FilterByValueTransformerOptions> = {
id: DataTransformerID.filterByValue,
options: {
type: FilterByValueType.exclude,
match: FilterByValueMatch.all,
filters: [
{
fieldName: 'numbers',
config: lower,
},
],
},
};
await expect(transformDataFrame([cfg], [seriesAWithSingleField])).toEmitValuesWith((received) => {
const processed = received[0];
expect(processed.length).toEqual(1);
expect(processed[0].fields).toEqual([
{
name: 'time',
type: FieldType.time,
values: [1000, 2000, 3000, 4000, 5000, 6000, 7000],
state: {},
},
{
name: 'numbers',
type: FieldType.number,
values: [1, 2, 3, 4, 5, 6, 7],
state: {},
},
]);
});
});
it('should match any condition', async () => {
const lowerOrEqual: MatcherConfig<BasicValueMatcherOptions<number>> = {
id: ValueMatcherID.lowerOrEqual,
@@ -4,9 +4,11 @@ import { getFieldDisplayName } from '../../field/fieldState';
import { DataFrame, Field } from '../../types/dataFrame';
import { DataTransformerInfo, MatcherConfig } from '../../types/transformations';
import { getValueMatcher } from '../matchers';
import { ValueMatcherID } from '../matchers/ids';
import { DataTransformerID } from './ids';
import { noopTransformer } from './noop';
import { transformationsVariableSupport } from './utils';
export enum FilterByValueType {
exclude = 'exclude',
@@ -48,6 +50,46 @@ export const filterByValueTransformer: DataTransformerInfo<FilterByValueTransfor
return source.pipe(noopTransformer.operator({}, ctx));
}
const interpolatedFilters: FilterByValueFilter[] = [];
if (transformationsVariableSupport()) {
interpolatedFilters.push(
...filters.map((filter) => {
if (filter.config.id === ValueMatcherID.between) {
const interpolatedFrom = ctx.interpolate(filter.config.options.from);
const interpolatedTo = ctx.interpolate(filter.config.options.to);
const newFilter = {
...filter,
config: {
...filter.config,
options: {
...filter.config.options,
to: interpolatedTo,
from: interpolatedFrom,
},
},
};
return newFilter;
} else if (filter.config.id === ValueMatcherID.regex) {
// Due to colliding syntaxes, interpolating regex filters will cause issues.
return filter;
} else if (filter.config.options.value) {
const interpolatedValue = ctx.interpolate(filter.config.options.value);
const newFilter = {
...filter,
config: { ...filter.config, options: { ...filter.config.options, value: interpolatedValue } },
};
newFilter.config.options.value! = interpolatedValue;
return newFilter;
}
return filter;
})
);
}
return source.pipe(
map((data) => {
if (!Array.isArray(data) || data.length === 0) {
@@ -58,7 +100,13 @@ export const filterByValueTransformer: DataTransformerInfo<FilterByValueTransfor
for (const frame of data) {
const fieldIndexByName = groupFieldIndexByName(frame, data);
const matchers = createFilterValueMatchers(filters, fieldIndexByName);
let matchers;
if (transformationsVariableSupport()) {
matchers = createFilterValueMatchers(interpolatedFilters, fieldIndexByName);
} else {
matchers = createFilterValueMatchers(filters, fieldIndexByName);
}
for (let index = 0; index < frame.length; index++) {
if (rows.has(index)) {
@@ -2,12 +2,13 @@ import { map } from 'rxjs/operators';
import { getDisplayProcessor } from '../../field';
import { createTheme, GrafanaTheme2 } from '../../themes';
import { DataFrameType, SynchronousDataTransformerInfo } from '../../types';
import { DataFrameType, DataTransformContext, SynchronousDataTransformerInfo } from '../../types';
import { DataFrame, Field, FieldConfig, FieldType } from '../../types/dataFrame';
import { roundDecimals } from '../../utils';
import { DataTransformerID } from './ids';
import { AlignedData, join } from './joinDataFrames';
import { transformationsVariableSupport } from './utils';
/**
* @internal
@@ -40,6 +41,12 @@ export const histogramBucketSizes = [
const histFilter = [null];
const histSort = (a: number, b: number) => a - b;
export interface HistogramTransformerInputs {
bucketSize?: string | number;
bucketOffset?: string | number;
combine?: boolean;
}
/**
* @alpha
*/
@@ -74,7 +81,7 @@ export const histogramFieldInfo = {
/**
* @alpha
*/
export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTransformerOptions> = {
export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTransformerInputs> = {
id: DataTransformerID.histogram,
name: 'Histogram',
description: 'Calculate a histogram from input data.',
@@ -85,11 +92,51 @@ export const histogramTransformer: SynchronousDataTransformerInfo<HistogramTrans
operator: (options, ctx) => (source) =>
source.pipe(map((data) => histogramTransformer.transformer(options, ctx)(data))),
transformer: (options: HistogramTransformerOptions) => (data: DataFrame[]) => {
transformer: (options: HistogramTransformerInputs, ctx: DataTransformContext) => (data: DataFrame[]) => {
if (!Array.isArray(data) || data.length === 0) {
return data;
}
const hist = buildHistogram(data, options);
let bucketSize,
bucketOffset: number | undefined = undefined;
if (options.bucketSize) {
if (transformationsVariableSupport()) {
options.bucketSize = ctx.interpolate(options.bucketSize.toString());
}
if (typeof options.bucketSize === 'string') {
bucketSize = parseFloat(options.bucketSize);
} else {
bucketSize = options.bucketSize;
}
if (isNaN(bucketSize)) {
bucketSize = undefined;
}
}
if (options.bucketOffset) {
if (transformationsVariableSupport()) {
options.bucketOffset = ctx.interpolate(options.bucketOffset.toString());
}
if (typeof options.bucketOffset === 'string') {
bucketOffset = parseFloat(options.bucketOffset);
} else {
bucketOffset = options.bucketOffset;
}
if (isNaN(bucketOffset)) {
bucketOffset = undefined;
}
}
const interpolatedOptions: HistogramTransformerOptions = {
bucketSize: bucketSize,
bucketOffset: bucketOffset,
combine: options.combine,
};
const hist = buildHistogram(data, interpolatedOptions);
if (hist == null) {
return [];
}
@@ -3,9 +3,10 @@ import { map } from 'rxjs/operators';
import { DataTransformerInfo } from '../../types';
import { DataTransformerID } from './ids';
import { transformationsVariableSupport } from './utils';
export interface LimitTransformerOptions {
limitField?: number;
limitField?: number | string;
}
const DEFAULT_LIMIT_FIELD = 10;
@@ -18,21 +19,32 @@ export const limitTransformer: DataTransformerInfo<LimitTransformerOptions> = {
limitField: DEFAULT_LIMIT_FIELD,
},
operator: (options) => (source) =>
operator: (options, ctx) => (source) =>
source.pipe(
map((data) => {
const limitFieldMatch = options.limitField || DEFAULT_LIMIT_FIELD;
let limit = DEFAULT_LIMIT_FIELD;
if (options.limitField !== undefined) {
if (typeof options.limitField === 'string') {
if (transformationsVariableSupport()) {
limit = parseInt(ctx.interpolate(options.limitField), 10);
} else {
limit = parseInt(options.limitField, 10);
}
} else {
limit = options.limitField;
}
}
return data.map((frame) => {
if (frame.length > limitFieldMatch) {
if (frame.length > limit) {
return {
...frame,
fields: frame.fields.map((f) => {
return {
...f,
values: f.values.slice(0, limitFieldMatch),
values: f.values.slice(0, limit),
};
}),
length: limitFieldMatch,
length: limit,
};
}
@@ -3,9 +3,10 @@ import { map } from 'rxjs/operators';
import { sortDataFrame } from '../../dataframe';
import { getFieldDisplayName } from '../../field';
import { DataFrame } from '../../types';
import { DataTransformerInfo } from '../../types/transformations';
import { DataTransformContext, DataTransformerInfo } from '../../types/transformations';
import { DataTransformerID } from './ids';
import { transformationsVariableSupport } from './utils';
export interface SortByField {
field: string;
@@ -31,20 +32,20 @@ export const sortByTransformer: DataTransformerInfo<SortByTransformerOptions> =
* Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series
*/
operator: (options) => (source) =>
operator: (options, ctx) => (source) =>
source.pipe(
map((data) => {
if (!Array.isArray(data) || data.length === 0 || !options?.sort?.length) {
return data;
}
return sortDataFrames(data, options.sort);
return sortDataFrames(data, options.sort, ctx);
})
),
};
export function sortDataFrames(data: DataFrame[], sort: SortByField[]): DataFrame[] {
export function sortDataFrames(data: DataFrame[], sort: SortByField[], ctx: DataTransformContext): DataFrame[] {
return data.map((frame) => {
const s = attachFieldIndex(frame, sort);
const s = attachFieldIndex(frame, sort, ctx);
if (s.length && s[0].index != null) {
return sortDataFrame(frame, s[0].index, s[0].desc);
}
@@ -52,12 +53,18 @@ export function sortDataFrames(data: DataFrame[], sort: SortByField[]): DataFram
});
}
function attachFieldIndex(frame: DataFrame, sort: SortByField[]): SortByField[] {
function attachFieldIndex(frame: DataFrame, sort: SortByField[], ctx: DataTransformContext): SortByField[] {
return sort.map((s) => {
if (s.index != null) {
// null or undefined
return s;
}
if (transformationsVariableSupport()) {
return {
...s,
index: frame.fields.findIndex((f) => ctx.interpolate(s.field) === getFieldDisplayName(f, frame)),
};
}
return {
...s,
index: frame.fields.findIndex((f) => s.field === getFieldDisplayName(f, frame)),
@@ -0,0 +1,3 @@
export const transformationsVariableSupport = () => {
return (window as any)?.grafanaBootData?.settings?.featureToggles?.transformationsVariableSupport;
};
@@ -136,4 +136,5 @@ export interface FeatureToggles {
externalServiceAccounts?: boolean;
alertingModifiedExport?: boolean;
enableNativeHTTPHistogram?: boolean;
transformationsVariableSupport?: boolean;
}
@@ -31,6 +31,7 @@ const getStyles = (theme: GrafanaTheme2) => {
item: css({
background: 'none',
padding: '2px 8px',
userSelect: 'none',
color: theme.colors.text.primary,
cursor: 'pointer',
'&:hover': {