Transformations: Add 'transpose' transform (#88963)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Jmdane
2024-08-10 02:57:26 +03:00
committed by GitHub
co-authored by Leon Sorokin
parent ec7a247674
commit 8bb548e17b
10 changed files with 412 additions and 0 deletions
@@ -1423,6 +1423,11 @@ For each generated **Trend** field value, a calculation function can be selected
> **Note:** This transformation is available in Grafana 9.5+ as an opt-in beta feature. Modify the Grafana [configuration file][] to use it.
### Transpose
Use this transformation to pivot the data frame, converting rows into columns and columns into rows. This transformation is particularly useful when you want to switch the orientation of your data to better suit your visualization needs.
If you have multiple types it will default to string type.
### Regression analysis
Use this transformation to create a new data frame containing values predicted by a statistical model. This is useful for finding a trend in chaotic data. It works by fitting a mathematical function to the data, using either linear or polynomial regression. The data frame can then be used in a visualization to display a trendline.
@@ -24,6 +24,7 @@ import { renameFieldsTransformer } from './transformers/rename';
import { renameByRegexTransformer } from './transformers/renameByRegex';
import { seriesToRowsTransformer } from './transformers/seriesToRows';
import { sortByTransformer } from './transformers/sortBy';
import { transposeTransformer } from './transformers/transpose';
export const standardTransformers = {
noopTransformer,
@@ -55,4 +56,5 @@ export const standardTransformers = {
groupingToMatrixTransformer,
limitTransformer,
groupToNestedTable,
transposeTransformer,
};
@@ -37,6 +37,7 @@ export enum DataTransformerID {
limit = 'limit',
partitionByValues = 'partitionByValues',
timeSeriesTable = 'timeSeriesTable',
transpose = 'transpose',
formatTime = 'formatTime',
formatString = 'formatString',
regression = 'regression',
@@ -0,0 +1,241 @@
import { DataTransformerConfig } from '@grafana/schema';
import { toDataFrame } from '../../dataframe/processDataFrame';
import { FieldType } from '../../types/dataFrame';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { transformDataFrame } from '../transformDataFrame';
import { DataTransformerID } from './ids';
import { transposeTransformer, TransposeTransformerOptions } from './transpose';
describe('Transpose transformer', () => {
beforeAll(() => {
mockTransformationsRegistry([transposeTransformer]);
});
it('should transpose full numeric values and keep numeric type', async () => {
const cfgA: DataTransformerConfig<TransposeTransformerOptions> = {
id: DataTransformerID.transpose,
options: {},
};
const seriesA = toDataFrame({
name: 'A',
fields: [
{ name: 'env', type: FieldType.string, values: ['dev', 'prod', 'staging', 'release', 'beta'] },
{ name: 'january', type: FieldType.number, values: [11, 12, 13, 14, 15] },
{ name: 'february', type: FieldType.number, values: [6, 7, 8, 9, 10] },
{ name: 'march', type: FieldType.number, values: [1, 2, 3, 4, 5] },
],
});
await expect(transformDataFrame([cfgA], [seriesA])).toEmitValuesWith((received) => {
const result = received[0];
expect(result[0].fields).toEqual([
{
name: 'Field',
type: FieldType.string,
values: ['january', 'february', 'march'],
config: {},
},
{
name: 'Value',
labels: { env: 'dev' },
type: FieldType.number,
values: [11, 6, 1],
config: {},
},
{
name: 'Value',
labels: { env: 'prod' },
type: FieldType.number,
values: [12, 7, 2],
config: {},
},
{
name: 'Value',
labels: { env: 'staging' },
type: FieldType.number,
values: [13, 8, 3],
config: {},
},
{
name: 'Value',
labels: { env: 'release' },
type: FieldType.number,
values: [14, 9, 4],
config: {},
},
{
name: 'Value',
labels: { env: 'beta' },
type: FieldType.number,
values: [15, 10, 5],
config: {},
},
]);
});
});
it('should transpose and use string field type', async () => {
const cfgB: DataTransformerConfig<TransposeTransformerOptions> = {
id: DataTransformerID.transpose,
options: {},
};
const seriesB = toDataFrame({
name: 'B',
fields: [
{ name: 'env', type: FieldType.string, values: ['dev', 'prod', 'staging', 'release', 'beta'] },
{ name: 'january', type: FieldType.number, values: [11, 12, 13, 14, 15] },
{ name: 'february', type: FieldType.number, values: [6, 7, 8, 9, 10] },
{ name: 'type', type: FieldType.string, values: ['metricA', 'metricB', 'metricC', 'metricD', 'metricE'] },
],
});
await expect(transformDataFrame([cfgB], [seriesB])).toEmitValuesWith((received) => {
const result = received[0];
expect(result[0].fields).toEqual([
{
name: 'Field',
type: FieldType.string,
values: ['january', 'february', 'type'],
config: {},
},
{
name: 'Value',
labels: { env: 'dev' },
type: FieldType.string,
values: ['11', '6', 'metricA'],
config: {},
},
{
name: 'Value',
labels: { env: 'prod' },
type: FieldType.string,
values: ['12', '7', 'metricB'],
config: {},
},
{
name: 'Value',
labels: { env: 'staging' },
type: FieldType.string,
values: ['13', '8', 'metricC'],
config: {},
},
{
name: 'Value',
labels: { env: 'release' },
type: FieldType.string,
values: ['14', '9', 'metricD'],
config: {},
},
{
name: 'Value',
labels: { env: 'beta' },
type: FieldType.string,
values: ['15', '10', 'metricE'],
config: {},
},
]);
});
});
it('should transpose and keep number types and add new headers', async () => {
const cfgC: DataTransformerConfig<TransposeTransformerOptions> = {
id: DataTransformerID.transpose,
options: {
firstFieldName: 'NewField',
},
};
const seriesC = toDataFrame({
name: 'C',
fields: [
{ name: 'A', type: FieldType.number, values: [1, 5] },
{ name: 'B', type: FieldType.number, values: [2, 6] },
{ name: 'C', type: FieldType.number, values: [3, 7] },
{ name: 'D', type: FieldType.number, values: [4, 8] },
],
});
await expect(transformDataFrame([cfgC], [seriesC])).toEmitValuesWith((received) => {
const result = received[0];
expect(result[0].fields).toEqual([
{
name: 'NewField',
type: FieldType.string,
values: ['A', 'B', 'C', 'D'],
config: {},
},
{
name: 'Value',
labels: { row: 1 },
type: FieldType.number,
values: [1, 2, 3, 4],
config: {},
},
{
name: 'Value',
labels: { row: 2 },
type: FieldType.number,
values: [5, 6, 7, 8],
config: {},
},
]);
});
});
it('should transpose and handle different types and rename first element', async () => {
const cfgD: DataTransformerConfig<TransposeTransformerOptions> = {
id: DataTransformerID.transpose,
options: {
firstFieldName: 'Field1',
},
};
const seriesD = toDataFrame({
name: 'D',
fields: [
{
name: 'time',
type: FieldType.time,
values: ['2024-06-10 08:30:00', '2024-06-10 08:31:00', '2024-06-10 08:32:00', '2024-06-10 08:33:00'],
},
{ name: 'value', type: FieldType.number, values: [1, 2, 3, 4] },
],
});
await expect(transformDataFrame([cfgD], [seriesD])).toEmitValuesWith((received) => {
const result = received[0];
expect(result[0].fields).toEqual([
{
name: 'Field1',
type: FieldType.string,
values: ['value'],
config: {},
},
{
name: 'Value',
labels: { time: '2024-06-10 08:30:00' },
type: FieldType.number,
values: [1],
config: {},
},
{
name: 'Value',
labels: { time: '2024-06-10 08:31:00' },
type: FieldType.number,
values: [2],
config: {},
},
{
name: 'Value',
labels: { time: '2024-06-10 08:32:00' },
type: FieldType.number,
values: [3],
config: {},
},
{
name: 'Value',
labels: { time: '2024-06-10 08:33:00' },
type: FieldType.number,
values: [4],
config: {},
},
]);
});
});
});
@@ -0,0 +1,105 @@
import { map } from 'rxjs/operators';
import { DataFrame, Field, FieldType } from '../../types/dataFrame';
import { DataTransformerInfo } from '../../types/transformations';
import { DataTransformerID } from './ids';
export interface TransposeTransformerOptions {
firstFieldName?: string;
restFieldsName?: string;
}
export const transposeTransformer: DataTransformerInfo<TransposeTransformerOptions> = {
id: DataTransformerID.transpose,
name: 'Transpose',
description: 'Transpose the data frame',
defaultOptions: {},
operator: (options) => (source) =>
source.pipe(
map((data) => {
if (data.length === 0) {
return data;
}
return transposeDataFrame(options, data);
})
),
};
function transposeDataFrame(options: TransposeTransformerOptions, data: DataFrame[]): DataFrame[] {
return data.map((frame) => {
const firstField = frame.fields[0];
const firstName = !options.firstFieldName ? 'Field' : options.firstFieldName;
const restName = !options.restFieldsName ? 'Value' : options.restFieldsName;
const useFirstFieldAsHeaders =
firstField.type === FieldType.string || firstField.type === FieldType.time || firstField.type === FieldType.enum;
const headers = useFirstFieldAsHeaders
? [firstName, ...fieldValuesAsStrings(firstField, firstField.values)]
: [firstName, ...firstField.values.map((_, i) => restName)];
const rows = useFirstFieldAsHeaders
? frame.fields.map((field) => field.name).slice(1)
: frame.fields.map((field) => field.name);
const fieldType = determineFieldType(
useFirstFieldAsHeaders
? frame.fields.map((field) => field.type).slice(1)
: frame.fields.map((field) => field.type)
);
const newFields = headers.map((fieldName, index) => {
if (index === 0) {
return {
name: firstName,
type: FieldType.string,
config: {},
values: rows,
};
}
const values = frame.fields.map((field) => {
if (fieldType === FieldType.string) {
return fieldValuesAsStrings(field, [field.values[index - 1]])[0];
}
return field.values[index - 1];
});
const labelName = useFirstFieldAsHeaders ? firstField.name : 'row';
const labelValue = useFirstFieldAsHeaders ? fieldName : index;
return {
name: useFirstFieldAsHeaders ? restName : fieldName,
labels: {
[labelName]: labelValue,
},
type: fieldType,
config: {},
values: useFirstFieldAsHeaders ? values.slice(1) : values,
};
});
return {
...frame,
fields: newFields,
length: Math.max(...newFields.map((field) => field.values.length)),
};
});
}
function determineFieldType(fieldTypes: FieldType[]): FieldType {
const uniqueFieldTypes = new Set(fieldTypes);
return uniqueFieldTypes.size === 1 ? [...uniqueFieldTypes][0] : FieldType.string;
}
function fieldValuesAsStrings(field: Field, values: unknown[]) {
switch (field.type) {
case FieldType.time:
case FieldType.number:
case FieldType.boolean:
case FieldType.string:
return values.map((v) => `${v}`);
case FieldType.enum:
// @ts-ignore
return values.map((v) => field.config.type!.enum!.text![v]);
default:
return values.map((v) => JSON.stringify(v));
}
}
@@ -1520,6 +1520,15 @@ ${buildImageContent(
},
],
},
transpose: {
name: 'Transpose',
getHelperDocs: function () {
return `
Use this transformation to pivot the data frame, converting rows into columns and columns into rows. This transformation is particularly useful when you want to switch the orientation of your data to better suit your visualization needs.
If you have multiple types it will default to string type.
`;
},
},
regression: {
name: 'Regression analysis',
getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) {
@@ -0,0 +1,45 @@
import {
DataTransformerID,
standardTransformers,
TransformerRegistryItem,
TransformerUIProps,
TransformerCategory,
} from '@grafana/data';
import { TransposeTransformerOptions } from '@grafana/data/src/transformations/transformers/transpose';
import { InlineField, InlineFieldRow, Input } from '@grafana/ui';
export const TransposeTransfomerEditor = ({ options, onChange }: TransformerUIProps<TransposeTransformerOptions>) => {
return (
<>
<InlineFieldRow>
<InlineField label={'First field name'} labelWidth={24}>
<Input
placeholder="Field"
value={options.firstFieldName}
onChange={(e) => onChange({ ...options, firstFieldName: e.currentTarget.value })}
width={25}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={'Remaining fields name'} tooltip={'Name for value fields'} labelWidth={24}>
<Input
placeholder="Value"
value={options.restFieldsName}
onChange={(e) => onChange({ ...options, restFieldsName: e.currentTarget.value })}
width={25}
/>
</InlineField>
</InlineFieldRow>
</>
);
};
export const transposeTransformerRegistryItem: TransformerRegistryItem<TransposeTransformerOptions> = {
id: DataTransformerID.transpose,
editor: TransposeTransfomerEditor,
transformation: standardTransformers.transposeTransformer,
name: standardTransformers.transposeTransformer.name,
description: standardTransformers.transposeTransformer.description,
categories: new Set([TransformerCategory.Reformat]),
};
@@ -24,6 +24,7 @@ import { reduceTransformRegistryItem } from './editors/ReduceTransformerEditor';
import { renameByRegexTransformRegistryItem } from './editors/RenameByRegexTransformer';
import { seriesToRowsTransformerRegistryItem } from './editors/SeriesToRowsTransformerEditor';
import { sortByTransformRegistryItem } from './editors/SortByTransformerEditor';
import { transposeTransformerRegistryItem } from './editors/TransposeTransformerEditor';
import { extractFieldsTransformRegistryItem } from './extractFields/ExtractFieldsTransformerEditor';
import { joinByLabelsTransformRegistryItem } from './joinByLabels/JoinByLabelsTransformerEditor';
import { fieldLookupTransformRegistryItem } from './lookupGazetteer/FieldLookupTransformerEditor';
@@ -68,5 +69,6 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
...(config.featureToggles.groupToNestedTableTransformation ? [groupToNestedTableTransformRegistryItem] : []),
formatTimeTransformerRegistryItem,
timeSeriesTableTransformRegistryItem,
transposeTransformerRegistryItem,
];
};
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 118 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 118 KiB