mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 03:54:29 +08:00
Compare commits
21 Commits
docs/add-t
...
kristina/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74dfaa4b7c | ||
|
|
96b2ed367e | ||
|
|
09b7d424d4 | ||
|
|
7622a81158 | ||
|
|
7454997166 | ||
|
|
caa1202d54 | ||
|
|
1410180aed | ||
|
|
23e97f9a4a | ||
|
|
0db9227da8 | ||
|
|
84deb76b21 | ||
|
|
84126470de | ||
|
|
3644e00d7f | ||
|
|
705646c8f9 | ||
|
|
0c25bc2b27 | ||
|
|
c368196e35 | ||
|
|
c1da8e2dff | ||
|
|
883fb2de83 | ||
|
|
86faef83c2 | ||
|
|
ddca5c0a0a | ||
|
|
8f7ddd3c4c | ||
|
|
141885acae |
@@ -127,6 +127,8 @@ DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames
|
||||
DataTransformerConfig: {
|
||||
// Unique identifier of transformer
|
||||
id: string
|
||||
// Unique identifier of the instance of the transformer
|
||||
refId?: string
|
||||
// Disabled transformations are skipped
|
||||
disabled?: bool
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -516,6 +516,8 @@ lineage: schemas: [{
|
||||
#DataTransformerConfig: {
|
||||
// Unique identifier of transformer
|
||||
id: string
|
||||
// Unique identifier of the instance of the transformer
|
||||
refId?: string
|
||||
// Disabled transformations are skipped
|
||||
disabled?: bool
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -516,6 +516,8 @@ lineage: schemas: [{
|
||||
#DataTransformerConfig: {
|
||||
// Unique identifier of transformer
|
||||
id: string
|
||||
// Unique identifier of the instance of the transformer
|
||||
refId?: string
|
||||
// Disabled transformations are skipped
|
||||
disabled?: bool
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -131,6 +131,8 @@ DataTopic: "series" | "annotations" | "alertStates" @cog(kind="enum",memberNames
|
||||
DataTransformerConfig: {
|
||||
// Unique identifier of transformer
|
||||
id: string
|
||||
// Unique identifier of the instance of the transformer
|
||||
refId?: string
|
||||
// Disabled transformations are skipped
|
||||
disabled?: bool
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -225,6 +225,8 @@ func NewDashboardTransformationKind() *DashboardTransformationKind {
|
||||
type DashboardDataTransformerConfig struct {
|
||||
// Unique identifier of transformer
|
||||
Id string `json:"id"`
|
||||
// Unique identifier of the instance of the transformer
|
||||
RefId *string `json:"refId,omitempty"`
|
||||
// Disabled transformations are skipped
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -1707,6 +1707,13 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardDataTransformerConfig(ref common
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"refId": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Unique identifier of the instance of the transformer",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"disabled": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Disabled transformations are skipped",
|
||||
|
||||
@@ -512,6 +512,8 @@ lineage: schemas: [{
|
||||
#DataTransformerConfig: {
|
||||
// Unique identifier of transformer
|
||||
id: string
|
||||
// Unique identifier of the instance of the transformer
|
||||
refId?: string
|
||||
// Disabled transformations are skipped
|
||||
disabled?: bool
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -268,6 +268,7 @@ export { fuzzySearch } from './utils/fuzzySearch';
|
||||
|
||||
// Transformations
|
||||
export { standardTransformers } from './transformations/transformers';
|
||||
export { getTransformationDynamicRefId } from './transformations/transformers/utils';
|
||||
export {
|
||||
fieldMatchers,
|
||||
frameMatchers,
|
||||
|
||||
@@ -23,7 +23,7 @@ const getOperator =
|
||||
}
|
||||
|
||||
const defaultOptions = info.transformation.defaultOptions ?? {};
|
||||
const options = { ...defaultOptions, ...config.options };
|
||||
const options = { ...defaultOptions, refId: config.refId, ...config.options };
|
||||
|
||||
// when running within Scenes, we can skip var interpolation, since it's already handled upstream
|
||||
const isScenes = window.__grafanaSceneContext != null;
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('JOIN Transformer', () => {
|
||||
|
||||
describe('outer join', () => {
|
||||
const everySecondSeries = toDataFrame({
|
||||
refId: 'A',
|
||||
name: 'even',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
|
||||
@@ -23,6 +24,7 @@ describe('JOIN Transformer', () => {
|
||||
});
|
||||
|
||||
const everyOtherSecondSeries = toDataFrame({
|
||||
refId: 'B',
|
||||
name: 'odd',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
|
||||
@@ -31,18 +33,20 @@ describe('JOIN Transformer', () => {
|
||||
],
|
||||
});
|
||||
|
||||
it('joins by time field', async () => {
|
||||
it('joins by time field with defined refId', async () => {
|
||||
const cfg: DataTransformerConfig<JoinByFieldOptions> = {
|
||||
id: DataTransformerID.seriesToColumns,
|
||||
options: {
|
||||
byField: 'time',
|
||||
},
|
||||
refId: 'test',
|
||||
};
|
||||
|
||||
await expect(transformDataFrame([cfg], [everySecondSeries, everyOtherSecondSeries])).toEmitValuesWith(
|
||||
(received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
expect(filtered.refId).toBe('test');
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
@@ -133,7 +137,7 @@ describe('JOIN Transformer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('joins by temperature field', async () => {
|
||||
it('joins by temperature field with dynamic refId', async () => {
|
||||
const cfg: DataTransformerConfig<JoinByFieldOptions> = {
|
||||
id: DataTransformerID.seriesToColumns,
|
||||
options: {
|
||||
@@ -145,6 +149,7 @@ describe('JOIN Transformer', () => {
|
||||
(received) => {
|
||||
const data = received[0];
|
||||
const filtered = data[0];
|
||||
expect(filtered.refId).toBe('joinByField-A-B');
|
||||
expect(filtered.fields).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FieldMatcherID } from '../matchers/ids';
|
||||
|
||||
import { DataTransformerID } from './ids';
|
||||
import { joinDataFrames } from './joinDataFrames';
|
||||
import { getTransformationDynamicRefId } from './utils';
|
||||
|
||||
export enum JoinMode {
|
||||
outer = 'outer', // best for time series, non duplicated join on values
|
||||
@@ -17,6 +18,7 @@ export enum JoinMode {
|
||||
export interface JoinByFieldOptions {
|
||||
byField?: string; // empty will pick the field automatically
|
||||
mode?: JoinMode;
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
export const joinByFieldTransformer: SynchronousDataTransformerInfo<JoinByFieldOptions> = {
|
||||
@@ -42,11 +44,13 @@ export const joinByFieldTransformer: SynchronousDataTransformerInfo<JoinByFieldO
|
||||
}
|
||||
const joined = joinDataFrames({ frames: data, joinBy, mode: options.mode });
|
||||
if (joined) {
|
||||
joined.refId = `${DataTransformerID.joinByField}-${data.map((frame) => frame.refId).join('-')}`;
|
||||
joined.refId = options.refId ?? getTransformationDynamicRefId(DataTransformerID.joinByField, data);
|
||||
return [joined];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
},
|
||||
|
||||
usesDynamicRefId: true,
|
||||
};
|
||||
|
||||
@@ -32,8 +32,9 @@ describe('Merge multiple to single', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('combine two series into one', async () => {
|
||||
it('combine two series into one with dynamic refId', async () => {
|
||||
const seriesA = toDataFrame({
|
||||
refId: 'A',
|
||||
name: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1000] },
|
||||
@@ -42,6 +43,7 @@ describe('Merge multiple to single', () => {
|
||||
});
|
||||
|
||||
const seriesB = toDataFrame({
|
||||
refId: 'B',
|
||||
name: 'B',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [2000] },
|
||||
@@ -55,7 +57,7 @@ describe('Merge multiple to single', () => {
|
||||
createField('Time', FieldType.time, [1000, 2000]),
|
||||
createField('Temp', FieldType.number, [1, -1]),
|
||||
];
|
||||
|
||||
expect(result[0].refId).toBe('merge-A-B');
|
||||
expect(unwrap(result[0].fields)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -77,13 +79,15 @@ describe('Merge multiple to single', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await expect(transformDataFrame([cfg], [seriesA, seriesB])).toEmitValuesWith((received) => {
|
||||
const config = { ...cfg, refId: 'test' };
|
||||
|
||||
await expect(transformDataFrame([config], [seriesA, seriesB])).toEmitValuesWith((received) => {
|
||||
const result = received[0];
|
||||
const expected: Field[] = [
|
||||
createField('Time', FieldType.time, [100, 150, 200, 100, 125, 126]),
|
||||
createField('Temp', FieldType.number, [1, 4, 5, -1, 2, 3]),
|
||||
];
|
||||
|
||||
expect(result[0].refId).toBe('test');
|
||||
expect(unwrap(result[0].fields)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,16 @@ import { DataFrame, Field } from '../../types/dataFrame';
|
||||
import { DataTransformerInfo, TransformationApplicabilityLevels } from '../../types/transformations';
|
||||
|
||||
import { DataTransformerID } from './ids';
|
||||
import { getTransformationDynamicRefId } from './utils';
|
||||
|
||||
interface ValuePointer {
|
||||
key: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface MergeTransformerOptions {}
|
||||
export interface MergeTransformerOptions {
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
export const mergeTransformer: DataTransformerInfo<MergeTransformerOptions> = {
|
||||
id: DataTransformerID.merge,
|
||||
@@ -44,7 +47,7 @@ export const mergeTransformer: DataTransformerInfo<MergeTransformerOptions> = {
|
||||
const fieldIndexByName: Record<string, Record<number, number>> = {};
|
||||
const fieldNamesForKey: string[] = [];
|
||||
const dataFrame = new MutableDataFrame({
|
||||
refId: `${DataTransformerID.merge}-${data.map((frame) => frame.refId).join('-')}`,
|
||||
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.merge, data),
|
||||
fields: [],
|
||||
});
|
||||
|
||||
@@ -122,6 +125,7 @@ export const mergeTransformer: DataTransformerInfo<MergeTransformerOptions> = {
|
||||
return [dataFrame];
|
||||
})
|
||||
),
|
||||
usesDynamicRefId: true,
|
||||
};
|
||||
|
||||
const copyFieldStructure = (field: Field): Field => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DataTransformerID } from './ids';
|
||||
import { reduceFields, reduceTransformer, ReduceTransformerMode, ReduceTransformerOptions } from './reduce';
|
||||
|
||||
const seriesAWithSingleField = toDataFrame({
|
||||
refId: 'A',
|
||||
name: 'A',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
|
||||
@@ -19,6 +20,7 @@ const seriesAWithSingleField = toDataFrame({
|
||||
});
|
||||
|
||||
const seriesAWithMultipleFields = toDataFrame({
|
||||
refId: 'B',
|
||||
name: 'A',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
|
||||
@@ -28,6 +30,7 @@ const seriesAWithMultipleFields = toDataFrame({
|
||||
});
|
||||
|
||||
const seriesAWithAllNulls = toDataFrame({
|
||||
refId: 'C',
|
||||
name: 'A',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
|
||||
@@ -36,6 +39,7 @@ const seriesAWithAllNulls = toDataFrame({
|
||||
});
|
||||
|
||||
const seriesBWithSingleField = toDataFrame({
|
||||
refId: 'D',
|
||||
name: 'B',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
|
||||
@@ -44,6 +48,7 @@ const seriesBWithSingleField = toDataFrame({
|
||||
});
|
||||
|
||||
const seriesBWithMultipleFields = toDataFrame({
|
||||
refId: 'E',
|
||||
name: 'B',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
|
||||
@@ -53,6 +58,7 @@ const seriesBWithMultipleFields = toDataFrame({
|
||||
});
|
||||
|
||||
const seriesBWithAllNulls = toDataFrame({
|
||||
refId: 'F',
|
||||
name: 'B',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
|
||||
@@ -65,7 +71,7 @@ describe('Reducer Transformer', () => {
|
||||
mockTransformationsRegistry([reduceTransformer]);
|
||||
});
|
||||
|
||||
it('reduces multiple data frames with many fields', async () => {
|
||||
it('reduces multiple data frames with many fields and dynamic refId', async () => {
|
||||
const cfg = {
|
||||
id: DataTransformerID.reduce,
|
||||
options: {
|
||||
@@ -135,6 +141,7 @@ describe('Reducer Transformer', () => {
|
||||
},
|
||||
];
|
||||
|
||||
expect(processed[0].refId).toBe('reduce-B-E');
|
||||
expect(processed.length).toEqual(1);
|
||||
expect(processed[0].length).toEqual(4);
|
||||
expect(processed[0].fields).toEqual(expected);
|
||||
@@ -142,12 +149,13 @@ describe('Reducer Transformer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reduces multiple data frames with single field', async () => {
|
||||
it('reduces multiple data frames with single field and static refId', async () => {
|
||||
const cfg = {
|
||||
id: DataTransformerID.reduce,
|
||||
options: {
|
||||
reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last],
|
||||
},
|
||||
refId: 'test',
|
||||
};
|
||||
|
||||
await expect(transformDataFrame([cfg], [seriesAWithSingleField, seriesBWithSingleField])).toEmitValuesWith(
|
||||
@@ -187,6 +195,7 @@ describe('Reducer Transformer', () => {
|
||||
];
|
||||
|
||||
expect(processed.length).toEqual(1);
|
||||
expect(processed[0].refId).toBe('test');
|
||||
expect(processed[0].length).toEqual(2);
|
||||
expect(processed[0].fields).toEqual(expected);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getFieldMatcher } from '../matchers';
|
||||
import { alwaysFieldMatcher, notTimeFieldMatcher } from '../matchers/predicates';
|
||||
|
||||
import { DataTransformerID } from './ids';
|
||||
import { getTransformationDynamicRefId } from './utils';
|
||||
|
||||
export enum ReduceTransformerMode {
|
||||
SeriesToRows = 'seriesToRows', // default
|
||||
@@ -22,6 +23,7 @@ export interface ReduceTransformerOptions {
|
||||
mode?: ReduceTransformerMode;
|
||||
includeTimeField?: boolean;
|
||||
labelsToFields?: boolean;
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = {
|
||||
@@ -57,10 +59,16 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> =
|
||||
// Add a row for each series
|
||||
const res = reduceSeriesToRows(data, matcher, options.reducers, options.labelsToFields);
|
||||
return res
|
||||
? [{ ...res, refId: `${DataTransformerID.reduce}-${data.map((frame) => frame.refId).join('-')}` }]
|
||||
? [
|
||||
{
|
||||
...res,
|
||||
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.reduce, data),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
),
|
||||
usesDynamicRefId: true,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,10 +39,11 @@ describe('Series to rows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('combine two series into one', async () => {
|
||||
it('combine two series into one with static refId', async () => {
|
||||
const cfg: DataTransformerConfig<SeriesToRowsTransformerOptions> = {
|
||||
id: DataTransformerID.seriesToRows,
|
||||
options: {},
|
||||
refId: 'test',
|
||||
};
|
||||
|
||||
const seriesA = toDataFrame({
|
||||
@@ -69,18 +70,19 @@ describe('Series to rows', () => {
|
||||
createField('Metric', FieldType.string, ['B', 'A']),
|
||||
createField('Value', FieldType.number, [-1, 1]),
|
||||
];
|
||||
|
||||
expect(result[0].refId).toBe('test');
|
||||
expect(unwrap(result[0].fields)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('combine two series with multiple values into one', async () => {
|
||||
it('combine two series with multiple values into one with dynamic refid', async () => {
|
||||
const cfg: DataTransformerConfig<SeriesToRowsTransformerOptions> = {
|
||||
id: DataTransformerID.seriesToRows,
|
||||
options: {},
|
||||
};
|
||||
|
||||
const seriesA = toDataFrame({
|
||||
refId: 'A',
|
||||
name: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [100, 150, 200] },
|
||||
@@ -89,6 +91,7 @@ describe('Series to rows', () => {
|
||||
});
|
||||
|
||||
const seriesB = toDataFrame({
|
||||
refId: 'B',
|
||||
name: 'B',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [100, 125, 126] },
|
||||
@@ -104,7 +107,7 @@ describe('Series to rows', () => {
|
||||
createField('Metric', FieldType.string, ['A', 'A', 'B', 'B', 'A', 'B']),
|
||||
createField('Value', FieldType.number, [5, 4, 3, 2, 1, -1]),
|
||||
];
|
||||
|
||||
expect(result[0].refId).toBe('seriesToRows-A-B');
|
||||
expect(unwrap(result[0].fields)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,11 @@ import {
|
||||
import { DataTransformerInfo } from '../../types/transformations';
|
||||
|
||||
import { DataTransformerID } from './ids';
|
||||
import { getTransformationDynamicRefId } from './utils';
|
||||
|
||||
export interface SeriesToRowsTransformerOptions {}
|
||||
export interface SeriesToRowsTransformerOptions {
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransformerOptions> = {
|
||||
id: DataTransformerID.seriesToRows,
|
||||
@@ -38,7 +41,7 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme
|
||||
const timeFieldByIndex: Record<number, number> = {};
|
||||
const targetFields = new Set<string>();
|
||||
const dataFrame = new MutableDataFrame({
|
||||
refId: `${DataTransformerID.seriesToRows}-${data.map((frame) => frame.refId).join('-')}`,
|
||||
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.seriesToRows, data),
|
||||
fields: [],
|
||||
});
|
||||
const metricField: Field = {
|
||||
@@ -90,6 +93,7 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme
|
||||
return [sortDataFrame(dataFrame, 0, true)];
|
||||
})
|
||||
),
|
||||
usesDynamicRefId: true,
|
||||
};
|
||||
|
||||
const copyFieldStructure = (field: Field, name: string): Field => {
|
||||
|
||||
@@ -33,3 +33,7 @@ export function getSpecialValue(specialValue: SpecialValue) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const getTransformationDynamicRefId = (transformationId: string, data: DataFrame[]) => {
|
||||
return `${transformationId}-${data.map((frame) => frame.refId).join('-')}`;
|
||||
};
|
||||
|
||||
@@ -55,6 +55,10 @@ export interface DataTransformerInfo<TOptions = any> extends RegistryItemWithOpt
|
||||
* This way descriptions can be tailored relative to the underlying data.
|
||||
*/
|
||||
isApplicableDescription?: string | ((data: DataFrame[]) => string);
|
||||
/**
|
||||
* Does the transformation generate a dataframe and thus generates a refID automatically based on incoming data
|
||||
*/
|
||||
usesDynamicRefId?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -746,6 +746,10 @@ export interface DataTransformerConfig {
|
||||
* Valid options depend on the transformer id
|
||||
*/
|
||||
options: unknown;
|
||||
/**
|
||||
* Unique identifier of the instance of the transformer
|
||||
*/
|
||||
refId?: string;
|
||||
/**
|
||||
* Where to pull DataFrames from as input to transformation
|
||||
*/
|
||||
|
||||
@@ -176,6 +176,8 @@ export const defaultTransformationKind = (): TransformationKind => ({
|
||||
export interface DataTransformerConfig {
|
||||
// Unique identifier of transformer
|
||||
id: string;
|
||||
// Unique identifier of the instance of the transformer
|
||||
refId?: string;
|
||||
// Disabled transformations are skipped
|
||||
disabled?: boolean;
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -312,6 +312,8 @@ const DashboardLinkPlacement = "inControlsMenu"
|
||||
type DataTransformerConfig struct {
|
||||
// Unique identifier of transformer
|
||||
Id string `json:"id"`
|
||||
// Unique identifier of the instance of the transformer
|
||||
RefId *string `json:"refId,omitempty"`
|
||||
// Disabled transformations are skipped
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getFrameMatchers,
|
||||
transformDataFrame,
|
||||
DataFrame,
|
||||
getTransformationDynamicRefId,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
@@ -26,6 +27,7 @@ import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo
|
||||
import { TransformationEditor } from './TransformationEditor';
|
||||
import { TransformationEditorHelpDisplay } from './TransformationEditorHelpDisplay';
|
||||
import { TransformationFilter } from './TransformationFilter';
|
||||
import { TransformationOperationRowHeader } from './TransformationOperationRowHeader';
|
||||
import { TransformationData } from './TransformationsEditor';
|
||||
import { TransformationsEditorTransformation } from './types';
|
||||
|
||||
@@ -60,6 +62,8 @@ export const TransformationOperationRow = ({
|
||||
// output of previous transformation
|
||||
const [prevOutput, setPrevOutput] = useState<DataFrame[]>([]);
|
||||
|
||||
const dynamicRefId = getTransformationDynamicRefId(uiConfig.id, data.series);
|
||||
|
||||
const onDisableToggle = useCallback(
|
||||
(index: number) => {
|
||||
const current = configs[index].transformation;
|
||||
@@ -157,6 +161,20 @@ export const TransformationOperationRow = ({
|
||||
};
|
||||
}, [index, data, configs]);
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<TransformationOperationRowHeader
|
||||
index={index}
|
||||
transformation={configs[index].transformation}
|
||||
transformations={configs.map((config) => config.transformation)}
|
||||
transformationTypeName={`${index + 1} - ${uiConfig.name}`}
|
||||
disabled
|
||||
onChange={onChange}
|
||||
dynamicRefId={uiConfig.transformation.usesDynamicRefId ? dynamicRefId : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = () => {
|
||||
return (
|
||||
<>
|
||||
@@ -228,10 +246,9 @@ export const TransformationOperationRow = ({
|
||||
<QueryOperationRow
|
||||
id={id}
|
||||
index={index}
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
title={`${index + 1} - ${uiConfig.name}`}
|
||||
draggable
|
||||
actions={renderActions}
|
||||
headerElement={renderHeader}
|
||||
disabled={disabled}
|
||||
expanderMessages={{
|
||||
close: 'Collapse transformation row',
|
||||
@@ -247,7 +264,6 @@ export const TransformationOperationRow = ({
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TransformationEditor
|
||||
input={input}
|
||||
output={output}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { DataTransformerConfig, DataTransformerID } from '@grafana/data';
|
||||
import { LabelsToFieldsMode, LabelsToFieldsOptions, MergeTransformerOptions } from '@grafana/data/internal';
|
||||
|
||||
import { TransformationOperationRowHeader } from './TransformationOperationRowHeader';
|
||||
|
||||
const mergeTransform: DataTransformerConfig<MergeTransformerOptions> = {
|
||||
id: DataTransformerID.merge,
|
||||
options: {},
|
||||
};
|
||||
|
||||
const labelsToFieldsTransform: DataTransformerConfig<LabelsToFieldsOptions> = {
|
||||
id: DataTransformerID.labelsToFields,
|
||||
options: {
|
||||
mode: LabelsToFieldsMode.Rows,
|
||||
},
|
||||
};
|
||||
|
||||
const labelsToFieldsRefId = { ...labelsToFieldsTransform, refId: 'test' };
|
||||
|
||||
describe('TransformationOperationRowHeader', () => {
|
||||
it('renders the modal with the title and empty refId for ', () => {
|
||||
const { unmount } = render(
|
||||
<TransformationOperationRowHeader
|
||||
index={0}
|
||||
transformation={mergeTransform}
|
||||
transformations={[mergeTransform, labelsToFieldsTransform]}
|
||||
transformationTypeName="1 - Labels to fields"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the modal title is rendered with the correct text
|
||||
expect(screen.getByText('1 - Labels to fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Auto)')).toBeInTheDocument();
|
||||
|
||||
// Unmount the component to clean up
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onChange when the the refId is changed', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const { unmount } = render(
|
||||
<TransformationOperationRowHeader
|
||||
index={0}
|
||||
transformation={mergeTransform}
|
||||
transformations={[mergeTransform, labelsToFieldsTransform]}
|
||||
transformationTypeName="1 - Labels to fields"
|
||||
onChange={mockOnChange}
|
||||
dynamicRefId=""
|
||||
/>
|
||||
);
|
||||
|
||||
// Find and click the modal's close button
|
||||
const beforeEditField = screen.getByTestId('transformation-refid-div');
|
||||
await userEvent.click(beforeEditField);
|
||||
const refIdInput = screen.getByTestId('transformation-refid-input');
|
||||
await userEvent.click(refIdInput);
|
||||
await userEvent.type(refIdInput, 'test refid');
|
||||
|
||||
// blur the field
|
||||
await userEvent.click(document.body);
|
||||
expect(mockOnChange).toHaveBeenCalledWith(0, { id: 'merge', options: {}, refId: 'test refid' });
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an error message if the refID is already used', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<TransformationOperationRowHeader
|
||||
index={0}
|
||||
transformation={mergeTransform}
|
||||
transformations={[mergeTransform, labelsToFieldsRefId]}
|
||||
transformationTypeName="1 - Labels to fields"
|
||||
onChange={mockOnChange}
|
||||
dynamicRefId=""
|
||||
/>
|
||||
);
|
||||
|
||||
// Find and click the modal's close button
|
||||
const beforeEditField = screen.getByTestId('transformation-refid-div');
|
||||
await userEvent.click(beforeEditField);
|
||||
const refIdInput = screen.getByTestId('transformation-refid-input');
|
||||
await userEvent.click(refIdInput);
|
||||
await userEvent.type(refIdInput, 'test');
|
||||
expect(screen.getByText('Transformation name already exists')).toBeInTheDocument();
|
||||
// blur the field
|
||||
await userEvent.click(document.body);
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('displays the dynamic id if provided', async () => {
|
||||
const { unmount } = render(
|
||||
<TransformationOperationRowHeader
|
||||
index={0}
|
||||
transformation={mergeTransform}
|
||||
transformations={[mergeTransform, labelsToFieldsTransform]}
|
||||
transformationTypeName="1 - Labels to fields"
|
||||
onChange={() => {}}
|
||||
dynamicRefId="test-A-B"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('test-A-B')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('displays the static id if provided, even if dynamic ref id is also provided', async () => {
|
||||
const { unmount } = render(
|
||||
<TransformationOperationRowHeader
|
||||
index={0}
|
||||
transformation={labelsToFieldsRefId}
|
||||
transformations={[labelsToFieldsRefId, mergeTransform]}
|
||||
transformationTypeName="1 - Labels to fields"
|
||||
onChange={() => {}}
|
||||
dynamicRefId="refId"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('test')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, DataTransformerConfig } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { FieldValidationMessage, Icon, Input, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface Props {
|
||||
index: number;
|
||||
transformation: DataTransformerConfig;
|
||||
transformations: DataTransformerConfig[];
|
||||
transformationTypeName: string;
|
||||
disabled?: boolean;
|
||||
onChange: (index: number, config: DataTransformerConfig) => void;
|
||||
dynamicRefId?: string;
|
||||
}
|
||||
|
||||
export const TransformationOperationRowHeader = (props: Props) => {
|
||||
const { index, transformation, transformations, onChange, disabled, transformationTypeName, dynamicRefId } = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isRefIdEditing, toggleIsRefIdEditing] = useToggle(false);
|
||||
const [isStaticRefId, setIsStaticRefId] = useState(transformation.refId !== undefined);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const onEndEditRefId = (newRefId: string) => {
|
||||
const trimmedNewRefId = newRefId.trim();
|
||||
toggleIsRefIdEditing(false);
|
||||
|
||||
// Ignore change if invalid
|
||||
if (validationError) {
|
||||
setValidationError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (transformation.refId !== trimmedNewRefId && trimmedNewRefId !== '') {
|
||||
setIsStaticRefId(true);
|
||||
onChange(index, {
|
||||
...transformation,
|
||||
refId: trimmedNewRefId,
|
||||
});
|
||||
} else if (trimmedNewRefId === '') {
|
||||
// if it was previously custom and is now empty, it is being cleared out and we want to save it as undefined
|
||||
if (isStaticRefId) {
|
||||
onChange(index, {
|
||||
...transformation,
|
||||
refId: undefined,
|
||||
});
|
||||
}
|
||||
// either way, if it is empty, we want it to display as not static
|
||||
setIsStaticRefId(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
const newRefId = event.currentTarget.value.trim();
|
||||
|
||||
for (const otherTransformation of transformations) {
|
||||
if (otherTransformation !== transformation && newRefId === otherTransformation.refId) {
|
||||
setValidationError('Transformation name already exists');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (validationError) {
|
||||
setValidationError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onEditRefIdBlur = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onEndEditRefId(event.currentTarget.value.trim());
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
onEndEditRefId(event.currentTarget.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onFocus = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
event.target.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{!isRefIdEditing && (
|
||||
<button
|
||||
className={styles.refIdWrapper}
|
||||
title={t(
|
||||
'dashboard.transformation-operation-row.transformation-editor-row-header.edit-refId',
|
||||
'Edit transformation name'
|
||||
)}
|
||||
onClick={() => toggleIsRefIdEditing()}
|
||||
data-testid="transformation-refid-div"
|
||||
type="button"
|
||||
>
|
||||
<span className={cx(styles.refIdStyle, !isStaticRefId && styles.placeholderText)}>
|
||||
{transformation.refId ||
|
||||
dynamicRefId ||
|
||||
t(
|
||||
'dashboard.transformation-operation-row.transformation-editor-row-header.edit-refId-placeholder',
|
||||
'(Auto)'
|
||||
)}
|
||||
</span>
|
||||
<Icon name="pen" className={styles.refIdEditIcon} size="sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRefIdEditing && (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
defaultValue={transformation.refId}
|
||||
onBlur={onEditRefIdBlur}
|
||||
autoFocus
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
invalid={validationError !== null}
|
||||
onChange={onInputChange}
|
||||
className={styles.refIdInput}
|
||||
data-testid="transformation-refid-input"
|
||||
/>
|
||||
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<div className={cx(styles.title, disabled && styles.disabled)}>{transformationTypeName}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrapper: css({
|
||||
label: 'Wrapper',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
refIdWrapper: css({
|
||||
display: 'flex',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
alignItems: 'center',
|
||||
margin: 0,
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
|
||||
'&:hover': {
|
||||
background: theme.colors.action.hover,
|
||||
border: `1px dashed ${theme.colors.border.strong}`,
|
||||
},
|
||||
|
||||
'&:focus': {
|
||||
border: `2px solid ${theme.colors.primary.border}`,
|
||||
},
|
||||
|
||||
'&:hover, &:focus': {
|
||||
'.transformation-refid-edit-icon': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
}),
|
||||
refIdStyle: css({
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
color: theme.colors.primary.text,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
refIdEditIcon: cx(
|
||||
css({
|
||||
marginLeft: theme.spacing(1),
|
||||
visibility: 'hidden',
|
||||
}),
|
||||
'transformation-refid-edit-icon'
|
||||
),
|
||||
refIdInput: css({
|
||||
maxWidth: '300px',
|
||||
margin: '-4px 0',
|
||||
}),
|
||||
placeholderText: css({
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
color: theme.colors.text.secondary,
|
||||
alignItems: 'center',
|
||||
fontStyle: 'italic',
|
||||
textOverflow: 'ellipsis',
|
||||
}),
|
||||
title: css({
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
color: theme.colors.text.link,
|
||||
marginLeft: theme.spacing(0.5),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}),
|
||||
disabled: css({
|
||||
color: theme.colors.text.disabled,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -276,6 +276,16 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
renderTransformationEditors = () => {
|
||||
const { data, transformations } = this.state;
|
||||
|
||||
const transformationNoRefIdIdxs = transformations
|
||||
.map((t, i) => {
|
||||
return t.refId === undefined ? i : undefined;
|
||||
})
|
||||
.filter((idx) => idx !== undefined);
|
||||
|
||||
transformationNoRefIdIdxs.forEach((tIdx, i) => {
|
||||
transformations[tIdx].refId = `T-${i}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||
<Droppable droppableId="transformations-list" direction="vertical">
|
||||
|
||||
@@ -2,5 +2,6 @@ import { DataTransformerConfig } from '@grafana/data';
|
||||
|
||||
export interface TransformationsEditorTransformation {
|
||||
transformation: DataTransformerConfig;
|
||||
refId?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -3,58 +3,62 @@ import { toDataFrame, FieldType, DataFrame } from '@grafana/data';
|
||||
import { joinByLabels } from './joinByLabels';
|
||||
|
||||
describe('Join by labels', () => {
|
||||
it('Simple join', () => {
|
||||
const input = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 2] },
|
||||
{
|
||||
name: 'Value',
|
||||
type: FieldType.number,
|
||||
config: {
|
||||
displayNameFromDS: '111',
|
||||
},
|
||||
values: [10, 200],
|
||||
labels: { what: 'Temp', cluster: 'A', job: 'J1' },
|
||||
const input = [
|
||||
toDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 2] },
|
||||
{
|
||||
name: 'Value',
|
||||
type: FieldType.number,
|
||||
config: {
|
||||
displayNameFromDS: '111',
|
||||
},
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 2] },
|
||||
{
|
||||
name: 'Value',
|
||||
type: FieldType.number,
|
||||
config: {
|
||||
displayNameFromDS: '222',
|
||||
},
|
||||
values: [10, 200],
|
||||
labels: { what: 'Temp', cluster: 'B', job: 'J1' },
|
||||
values: [10, 200],
|
||||
labels: { what: 'Temp', cluster: 'A', job: 'J1' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
refId: 'B',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [1, 2] },
|
||||
{
|
||||
name: 'Value',
|
||||
type: FieldType.number,
|
||||
config: {
|
||||
displayNameFromDS: '222',
|
||||
},
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [22, 28] },
|
||||
{
|
||||
name: 'Value',
|
||||
type: FieldType.number,
|
||||
config: {
|
||||
displayNameFromDS: '333',
|
||||
},
|
||||
values: [22, 77],
|
||||
labels: { what: 'Speed', cluster: 'B', job: 'J1' },
|
||||
values: [10, 200],
|
||||
labels: { what: 'Temp', cluster: 'B', job: 'J1' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
toDataFrame({
|
||||
refId: 'C',
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: [22, 28] },
|
||||
{
|
||||
name: 'Value',
|
||||
type: FieldType.number,
|
||||
config: {
|
||||
displayNameFromDS: '333',
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
values: [22, 77],
|
||||
labels: { what: 'Speed', cluster: 'B', job: 'J1' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
it('Simple join with dynamic refId', () => {
|
||||
const result = joinByLabels(
|
||||
{
|
||||
value: 'what',
|
||||
},
|
||||
input
|
||||
);
|
||||
expect(result.refId).toBe('joinByLabels-A-B-C');
|
||||
expect(result.fields[result.fields.length - 1].config).toMatchInlineSnapshot(`{}`);
|
||||
expect(toRowsSnapshow(result)).toMatchInlineSnapshot(`
|
||||
{
|
||||
@@ -94,6 +98,17 @@ describe('Join by labels', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('Simple join with static refId', () => {
|
||||
const result = joinByLabels(
|
||||
{
|
||||
value: 'what',
|
||||
refId: 'test',
|
||||
},
|
||||
input
|
||||
);
|
||||
expect(result.refId).toBe('test');
|
||||
});
|
||||
|
||||
it('Error handling (no labels)', () => {
|
||||
const input = [
|
||||
toDataFrame({
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { DataFrame, DataTransformerID, Field, FieldType, SynchronousDataTransformerInfo } from '@grafana/data';
|
||||
import {
|
||||
DataFrame,
|
||||
DataTransformerID,
|
||||
Field,
|
||||
FieldType,
|
||||
getTransformationDynamicRefId,
|
||||
SynchronousDataTransformerInfo,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { getDistinctLabels } from '../utils';
|
||||
@@ -8,6 +15,7 @@ import { getDistinctLabels } from '../utils';
|
||||
export interface JoinByLabelsTransformOptions {
|
||||
value: string; // something must be defined
|
||||
join?: string[];
|
||||
refId?: string;
|
||||
}
|
||||
|
||||
export const getJoinByLabelsTransformer: () => SynchronousDataTransformerInfo<JoinByLabelsTransformOptions> = () => ({
|
||||
@@ -30,6 +38,7 @@ export const getJoinByLabelsTransformer: () => SynchronousDataTransformerInfo<Jo
|
||||
return [joinByLabels(options, data)];
|
||||
};
|
||||
},
|
||||
usesDynamicRefId: true,
|
||||
});
|
||||
|
||||
interface JoinValues {
|
||||
@@ -111,7 +120,7 @@ export function joinByLabels(options: JoinByLabelsTransformOptions, data: DataFr
|
||||
const frame: DataFrame = {
|
||||
fields: [],
|
||||
length: nameValues[0].length,
|
||||
refId: `${DataTransformerID.joinByLabels}-${data.map((frame) => frame.refId).join('-')}`,
|
||||
refId: options.refId ?? getTransformationDynamicRefId(DataTransformerID.joinByLabels, data),
|
||||
};
|
||||
for (let i = 0; i < join.length; i++) {
|
||||
frame.fields.push({
|
||||
|
||||
@@ -5447,7 +5447,11 @@
|
||||
"title-remove": "Remove",
|
||||
"title-show-transform-help": "Show transform help"
|
||||
},
|
||||
"title-delete": "Delete {{name}}?"
|
||||
"title-delete": "Delete {{name}}?",
|
||||
"transformation-editor-row-header": {
|
||||
"edit-refId": "Edit transformation name",
|
||||
"edit-refId-placeholder": "(Auto)"
|
||||
}
|
||||
},
|
||||
"transformation-picker": {
|
||||
"info": "Transformations allow you to join, calculate, re-order, hide, and rename your query results before they are visualized.",
|
||||
|
||||
Reference in New Issue
Block a user