mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Transformations: support a rows mode in labels to fields (#41020)
This commit is contained in:
parent
aac3f88603
commit
70e92ef987
@ -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.
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user