Transformations: support a rows mode in labels to fields (#41020)

This commit is contained in:
Ryan McKinley 2021-11-04 09:12:01 -07:00 committed by GitHub
parent aac3f88603
commit 70e92ef987
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 348 additions and 37 deletions

View File

@ -192,20 +192,33 @@ In the example below, I added two fields together and named them Sum.
## Labels to fields
This transformation changes time series results that include labels or tags into to a table structure where each label becomes its own field.
This transformation changes time series results that include labels or tags into to a table structure where each label keys and values
are included in the table result. The labels can be displayed either as columns or as row values.
Given a query result of two time series:
- Series 1: labels Server=Server A, Datacenter=EU
- Series 2: labels Server=Server B, Datacenter=EU
This would result in a table like this:
In "Columns" mode, the result looks like this:
| Time | Server | Datacenter | Value |
| ------------------- | -------- | ---------- | ----- |
| 2020-07-07 11:34:20 | Server A | EU | 1 |
| 2020-07-07 11:34:20 | Server B | EU | 2 |
In "Rows" mode, the result has a table for each series and show each label value like this:
| label | value |
| ---------- | -------- |
| Server | Server A |
| Datacenter | EU |
| label | value |
| ---------- | -------- |
| Server | Server B |
| Datacenter | EU |
### Value field name
If you selected Server as the **Value field name**, then you would get one field for every value of the Server label.

View File

@ -1,5 +1,5 @@
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { LabelsToFieldsOptions, labelsToFieldsTransformer } from './labelsToFields';
import { LabelsToFieldsMode, LabelsToFieldsOptions, labelsToFieldsTransformer } from './labelsToFields';
import { DataFrame, DataTransformerConfig, FieldDTO, FieldType } from '../../types';
import { DataTransformerID } from './ids';
import { toDataFrame, toDataFrameDTO } from '../../dataframe';
@ -176,6 +176,61 @@ describe('Labels as Columns', () => {
});
});
it('filter labels from source', async () => {
const cfg: DataTransformerConfig<LabelsToFieldsOptions> = {
id: DataTransformerID.labelsToFields,
options: {
keepLabels: ['foo', 'bar'],
},
};
const source = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{ name: 'a', type: FieldType.number, values: [1, 3], labels: { foo: 'thing', x: 'hide', y: 'z' } },
{ name: 'b', type: FieldType.number, values: [2, 4], labels: { bar: 'thing', a: 'nope' } },
],
});
await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => {
expect(received[0][0].fields.map((f) => ({ [f.name]: f.values.toArray() }))).toMatchInlineSnapshot(`
Array [
Object {
"time": Array [
1000,
2000,
],
},
Object {
"a": Array [
1,
3,
],
},
Object {
"b": Array [
2,
4,
],
},
Object {
"foo": Array [
"thing",
"thing",
],
},
Object {
"bar": Array [
"thing",
"thing",
],
},
]
`);
});
});
it('data frame with labels and multiple fields with different labels', async () => {
const cfg: DataTransformerConfig<LabelsToFieldsOptions> = {
id: DataTransformerID.labelsToFields,
@ -207,6 +262,131 @@ describe('Labels as Columns', () => {
expect(result.fields).toEqual(expected);
});
});
it('Show labels as rows', async () => {
const cfg: DataTransformerConfig<LabelsToFieldsOptions> = {
id: DataTransformerID.labelsToFields,
options: {
mode: LabelsToFieldsMode.Rows,
},
};
const source = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{ name: 'a', type: FieldType.number, values: [1, 3], labels: { foo: 'thing', bar: 'a', zaz: 'xyz' } },
{ name: 'b', type: FieldType.number, values: [2, 4], labels: { foo: 'thing', bar: 'b' } },
],
});
await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => {
expect(
received[0].map((f) => ({ name: f.name, fields: f.fields.map((v) => ({ [v.name]: v.values.toArray() })) }))
).toMatchInlineSnapshot(`
Array [
Object {
"fields": Array [
Object {
"label": Array [
"foo",
"bar",
"zaz",
],
},
Object {
"value": Array [
"thing",
"a",
"xyz",
],
},
],
"name": "a {bar=\\"a\\", foo=\\"thing\\", zaz=\\"xyz\\"}",
},
Object {
"fields": Array [
Object {
"label": Array [
"foo",
"bar",
],
},
Object {
"value": Array [
"thing",
"b",
],
},
],
"name": "b {bar=\\"b\\", foo=\\"thing\\"}",
},
]
`);
});
});
it('Show labels as rows (and filter)', async () => {
const cfg: DataTransformerConfig<LabelsToFieldsOptions> = {
id: DataTransformerID.labelsToFields,
options: {
mode: LabelsToFieldsMode.Rows,
keepLabels: ['zaz', 'bar'],
},
};
const source = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{ name: 'a', type: FieldType.number, values: [1, 3], labels: { foo: 'thing', bar: 'a', zaz: 'xyz' } },
{ name: 'b', type: FieldType.number, values: [2, 4], labels: { foo: 'thing', bar: 'b' } },
],
});
await expect(transformDataFrame([cfg], [source])).toEmitValuesWith((received) => {
expect(
received[0].map((f) => ({ name: f.name, fields: f.fields.map((v) => ({ [v.name]: v.values.toArray() })) }))
).toMatchInlineSnapshot(`
Array [
Object {
"fields": Array [
Object {
"label": Array [
"zaz",
"bar",
],
},
Object {
"value": Array [
"xyz",
"a",
],
},
],
"name": "a {bar=\\"a\\", foo=\\"thing\\", zaz=\\"xyz\\"}",
},
Object {
"fields": Array [
Object {
"label": Array [
"zaz",
"bar",
],
},
Object {
"value": Array [
undefined,
"b",
],
},
],
"name": "b {bar=\\"b\\", foo=\\"thing\\"}",
},
]
`);
});
});
});
function toSimpleObject(frame: DataFrame) {

View File

@ -3,10 +3,20 @@ import { map } from 'rxjs/operators';
import { DataFrame, Field, FieldType, SynchronousDataTransformerInfo } from '../../types';
import { DataTransformerID } from './ids';
import { ArrayVector } from '../../vector';
import { getFieldDisplayName } from '../..';
export enum LabelsToFieldsMode {
Columns = 'columns', // default mode
Rows = 'rows',
}
export interface LabelsToFieldsOptions {
/*
* If set this will use this label's value as the value field name.
mode?: LabelsToFieldsMode;
/** When empty, this will keep all labels, otherise it will keep only labels matching the value */
keepLabels?: string[];
/**
* When in column mode and if set this will use this label's value as the value field name.
*/
valueLabel?: string;
}
@ -14,18 +24,23 @@ export interface LabelsToFieldsOptions {
export const labelsToFieldsTransformer: SynchronousDataTransformerInfo<LabelsToFieldsOptions> = {
id: DataTransformerID.labelsToFields,
name: 'Labels to fields',
description: 'Extract time series labels to fields (columns)',
description: 'Extract time series labels to fields (columns or rows)',
defaultOptions: {},
operator: (options) => (source) => source.pipe(map((data) => labelsToFieldsTransformer.transformer(options)(data))),
transformer: (options: LabelsToFieldsOptions) => (data: DataFrame[]) => {
// Show each label as a field row
if (options.mode === LabelsToFieldsMode.Rows) {
return convertLabelsToRows(data, options.keepLabels);
}
const result: DataFrame[] = [];
const keepLabels = options.keepLabels?.length ? new Set(options.keepLabels) : undefined;
for (const frame of data) {
const newFields: Field[] = [];
const uniqueLabels: Record<string, Set<string>> = {};
for (const field of frame.fields) {
if (!field.labels) {
newFields.push(field);
@ -45,6 +60,10 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo<LabelsToF
newFields.push(sansLabels);
for (const labelName of Object.keys(field.labels)) {
if (keepLabels && !keepLabels.has(labelName)) {
continue;
}
// if we should use this label as the value field name store it and skip adding this as a separate field
if (options.valueLabel === labelName) {
sansLabels.name = field.labels[labelName];
@ -77,3 +96,38 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo<LabelsToF
return result;
},
};
function convertLabelsToRows(data: DataFrame[], keepLabels?: string[]): DataFrame[] {
const result: DataFrame[] = [];
for (const frame of data) {
for (const field of frame.fields) {
if (field.labels) {
const keys: string[] = [];
const vals: string[] = [];
if (keepLabels) {
for (const key of keepLabels) {
keys.push(key);
vals.push(field.labels[key]);
}
} else {
for (const [key, val] of Object.entries(field.labels)) {
keys.push(key);
vals.push(val);
}
}
if (vals.length) {
result.push({
...frame,
name: getFieldDisplayName(field, frame, data),
fields: [
{ name: 'label', type: FieldType.string, config: {}, values: new ArrayVector(keys) },
{ name: 'value', type: FieldType.string, config: {}, values: new ArrayVector(vals) },
],
length: vals.length,
});
}
}
}
}
return result;
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import {
DataTransformerID,
SelectableValue,
@ -6,52 +6,116 @@ import {
TransformerRegistryItem,
TransformerUIProps,
} from '@grafana/data';
import { Select } from '@grafana/ui';
import { InlineField, InlineFieldRow, RadioButtonGroup, Select, FilterPill } from '@grafana/ui';
import { LabelsToFieldsOptions } from '@grafana/data/src/transformations/transformers/labelsToFields';
import {
LabelsToFieldsMode,
LabelsToFieldsOptions,
} from '@grafana/data/src/transformations/transformers/labelsToFields';
const modes: Array<SelectableValue<LabelsToFieldsMode>> = [
{ value: LabelsToFieldsMode.Columns, label: 'Columns' },
{ value: LabelsToFieldsMode.Rows, label: 'Rows' },
];
export const LabelsAsFieldsTransformerEditor: React.FC<TransformerUIProps<LabelsToFieldsOptions>> = ({
input,
options,
onChange,
}) => {
let labelNames: Array<SelectableValue<string>> = [];
let uniqueLabels: Record<string, boolean> = {};
const labelWidth = 20;
for (const frame of input) {
for (const field of frame.fields) {
if (!field.labels) {
continue;
}
const { labelNames, selected } = useMemo(() => {
let labelNames: Array<SelectableValue<string>> = [];
let uniqueLabels: Record<string, boolean> = {};
for (const labelName of Object.keys(field.labels)) {
if (!uniqueLabels[labelName]) {
labelNames.push({ value: labelName, label: labelName });
uniqueLabels[labelName] = true;
for (const frame of input) {
for (const field of frame.fields) {
if (!field.labels) {
continue;
}
for (const labelName of Object.keys(field.labels)) {
if (!uniqueLabels[labelName]) {
labelNames.push({ value: labelName, label: labelName });
uniqueLabels[labelName] = true;
}
}
}
}
}
const selected = new Set(options.keepLabels?.length ? options.keepLabels : Object.keys(uniqueLabels));
return { labelNames, selected };
}, [options.keepLabels, input]);
const onValueLabelChange = (value: SelectableValue<string> | null) => {
onChange({ valueLabel: value?.value });
onChange({ ...options, valueLabel: value?.value });
};
const onToggleSelection = (v: string) => {
if (selected.has(v)) {
selected.delete(v);
} else {
selected.add(v);
}
if (selected.size === labelNames.length || !selected.size) {
const { keepLabels, ...rest } = options;
onChange(rest);
} else {
onChange({ ...options, keepLabels: [...selected] });
}
};
return (
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-label width-8">Value field name</div>
<Select
menuShouldPortal
isClearable={true}
allowCustomValue={false}
placeholder="(Optional) Select label"
options={labelNames}
className="min-width-18 gf-form-spacing"
value={options?.valueLabel}
onChange={onValueLabelChange}
/>
</div>
<div>
<InlineFieldRow>
<InlineField label={'Mode'} labelWidth={labelWidth}>
<RadioButtonGroup
options={modes}
value={options.mode ?? LabelsToFieldsMode.Columns}
onChange={(v) => onChange({ ...options, mode: v })}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={'Labels'} labelWidth={labelWidth}>
<>
{labelNames.map((o, i) => {
const label = o.label!;
return (
<FilterPill
key={`${label}/${i}`}
onClick={() => onToggleSelection(label)}
label={label}
selected={selected.has(label)}
/>
);
})}
</>
</InlineField>
</InlineFieldRow>
{options.mode !== LabelsToFieldsMode.Rows && (
<InlineFieldRow>
<InlineField
label={'Value field name'}
labelWidth={labelWidth}
tooltip="Replace the value field name with a label"
htmlFor="labels-to-fields-as-name"
>
<Select
menuShouldPortal
inputId="labels-to-fields-as-name"
isClearable={true}
allowCustomValue={false}
placeholder="(Optional) Select label"
options={labelNames}
value={options?.valueLabel}
onChange={onValueLabelChange}
className="min-width-16"
/>
</InlineField>
</InlineFieldRow>
)}
</div>
);
};