Transformers: changes reduce transformer (#23611)

* Transformers: changes reduce transformer

* Refactor: fixes lenght of frame

* Minor tweaks and polish

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Hugo Häggmark 2020-04-17 16:27:00 +02:00 committed by GitHub
parent ed8c3430c4
commit 97c5e2971d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 299 additions and 209 deletions

View File

@ -1,73 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Reducer Transformer filters by include 1`] = `
Object {
"fields": Array [
Object {
"config": Object {},
"labels": undefined,
"name": "Field",
"type": "string",
"values": Array [
"A",
"B",
],
},
Object {
"config": Object {
"title": "First",
},
"labels": undefined,
"name": "first",
"type": "number",
"values": Array [
1,
"a",
],
},
Object {
"config": Object {
"title": "Min",
},
"labels": undefined,
"name": "min",
"type": "number",
"values": Array [
1,
null,
],
},
Object {
"config": Object {
"title": "Max",
},
"labels": undefined,
"name": "max",
"type": "number",
"values": Array [
4,
null,
],
},
Object {
"config": Object {
"title": "Delta",
},
"labels": undefined,
"name": "delta",
"type": "number",
"values": Array [
3,
0,
],
},
],
"meta": Object {
"transformations": Array [
"reduce",
],
},
"name": undefined,
"refId": undefined,
}
`;

View File

@ -1,14 +1,43 @@
import { ReducerID } from '../fieldReducer';
import { DataTransformerID } from './ids';
import { toDataFrame, toDataFrameDTO } from '../../dataframe/processDataFrame';
import { toDataFrame } from '../../dataframe/processDataFrame';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { reduceTransformer } from './reduce';
import { transformDataFrame } from '../transformDataFrame';
import { Field, FieldType } from '../../types';
import { ArrayVector } from '../../vector';
const seriesWithValues = toDataFrame({
const seriesAWithSingleField = toDataFrame({
name: 'A',
fields: [
{ name: 'A', values: [1, 2, 3, 4] }, // Numbers
{ name: 'B', values: ['a', 'b', 'c', 'd'] }, // Strings
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6] },
],
});
const seriesAWithMultipleFields = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'temperature', type: FieldType.number, values: [3, 4, 5, 6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
const seriesBWithSingleField = toDataFrame({
name: 'B',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
],
});
const seriesBWithMultipleFields = toDataFrame({
name: 'B',
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 3000, 5000, 7000] },
{ name: 'temperature', type: FieldType.number, values: [1, 3, 5, 7] },
{ name: 'humidity', type: FieldType.number, values: [11000.1, 11000.3, 11000.5, 11000.7] },
],
});
@ -16,15 +45,188 @@ describe('Reducer Transformer', () => {
beforeAll(() => {
mockTransformationsRegistry([reduceTransformer]);
});
it('filters by include', () => {
it('reduces multiple data frames with many fields', () => {
const cfg = {
id: DataTransformerID.reduce,
options: {
reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.delta],
reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last],
},
};
const processed = transformDataFrame([cfg], [seriesWithValues])[0];
expect(processed.fields.length).toBe(5);
expect(toDataFrameDTO(processed)).toMatchSnapshot();
const processed = transformDataFrame([cfg], [seriesAWithMultipleFields, seriesBWithMultipleFields]);
const expected: Field[] = [
{
name: 'Field',
type: FieldType.string,
values: new ArrayVector(['temperature {A}', 'humidity {A}', 'temperature {B}', 'humidity {B}']),
config: {},
},
{
name: 'first',
type: FieldType.number,
values: new ArrayVector([3, 10000.3, 1, 11000.1]),
config: { title: 'First' },
},
{
name: 'min',
type: FieldType.number,
values: new ArrayVector([3, 10000.3, 1, 11000.1]),
config: { title: 'Min' },
},
{
name: 'max',
type: FieldType.number,
values: new ArrayVector([6, 10000.6, 7, 11000.7]),
config: { title: 'Max' },
},
{
name: 'last',
type: FieldType.number,
values: new ArrayVector([6, 10000.6, 7, 11000.7]),
config: { title: 'Last' },
},
];
expect(processed.length).toEqual(1);
expect(processed[0].length).toEqual(4);
expect(processed[0].fields).toEqual(expected);
});
it('reduces multiple data frames with single field', () => {
const cfg = {
id: DataTransformerID.reduce,
options: {
reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last],
},
};
const processed = transformDataFrame([cfg], [seriesAWithSingleField, seriesBWithSingleField]);
const expected: Field[] = [
{
name: 'Field',
type: FieldType.string,
values: new ArrayVector(['temperature {A}', 'temperature {B}']),
config: {},
},
{
name: 'first',
type: FieldType.number,
values: new ArrayVector([3, 1]),
config: { title: 'First' },
},
{
name: 'min',
type: FieldType.number,
values: new ArrayVector([3, 1]),
config: { title: 'Min' },
},
{
name: 'max',
type: FieldType.number,
values: new ArrayVector([6, 7]),
config: { title: 'Max' },
},
{
name: 'last',
type: FieldType.number,
values: new ArrayVector([6, 7]),
config: { title: 'Last' },
},
];
expect(processed.length).toEqual(1);
expect(processed[0].length).toEqual(2);
expect(processed[0].fields).toEqual(expected);
});
it('reduces single data frame with many fields', () => {
const cfg = {
id: DataTransformerID.reduce,
options: {
reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last],
},
};
const processed = transformDataFrame([cfg], [seriesAWithMultipleFields]);
const expected: Field[] = [
{
name: 'Field',
type: FieldType.string,
values: new ArrayVector(['temperature', 'humidity']),
config: {},
},
{
name: 'first',
type: FieldType.number,
values: new ArrayVector([3, 10000.3]),
config: { title: 'First' },
},
{
name: 'min',
type: FieldType.number,
values: new ArrayVector([3, 10000.3]),
config: { title: 'Min' },
},
{
name: 'max',
type: FieldType.number,
values: new ArrayVector([6, 10000.6]),
config: { title: 'Max' },
},
{
name: 'last',
type: FieldType.number,
values: new ArrayVector([6, 10000.6]),
config: { title: 'Last' },
},
];
expect(processed.length).toEqual(1);
expect(processed[0].length).toEqual(2);
expect(processed[0].fields).toEqual(expected);
});
it('reduces single data frame with single field', () => {
const cfg = {
id: DataTransformerID.reduce,
options: {
reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.last],
},
};
const processed = transformDataFrame([cfg], [seriesAWithSingleField]);
const expected: Field[] = [
{
name: 'Field',
type: FieldType.string,
values: new ArrayVector(['temperature']),
config: {},
},
{
name: 'first',
type: FieldType.number,
values: new ArrayVector([3]),
config: { title: 'First' },
},
{
name: 'min',
type: FieldType.number,
values: new ArrayVector([3]),
config: { title: 'Min' },
},
{
name: 'max',
type: FieldType.number,
values: new ArrayVector([6]),
config: { title: 'Max' },
},
{
name: 'last',
type: FieldType.number,
values: new ArrayVector([6]),
config: { title: 'Last' },
},
];
expect(processed.length).toEqual(1);
expect(processed[0].length).toEqual(1);
expect(processed[0].fields).toEqual(expected);
});
});

View File

@ -1,12 +1,14 @@
import { DataTransformerID } from './ids';
import { MatcherConfig, DataTransformerInfo } from '../../types/transformations';
import { ReducerID, fieldReducers, reduceField } from '../fieldReducer';
import { DataTransformerInfo, MatcherConfig } from '../../types/transformations';
import { fieldReducers, reduceField, ReducerID } from '../fieldReducer';
import { alwaysFieldMatcher } from '../matchers/predicates';
import { DataFrame, Field, FieldType } from '../../types/dataFrame';
import { ArrayVector } from '../../vector/ArrayVector';
import { KeyValue } from '../../types/data';
import { guessFieldTypeForField } from '../../dataframe/processDataFrame';
import { getFieldMatcher } from '../matchers';
import { FieldMatcherID } from '../matchers/ids';
import { filterFieldsTransformer } from './filter';
export interface ReduceTransformerOptions {
reducers: ReducerID[];
@ -15,10 +17,10 @@ export interface ReduceTransformerOptions {
export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = {
id: DataTransformerID.reduce,
name: 'Reducer',
description: 'Return a DataFrame with the reduction results',
name: 'Reduce',
description: 'Reduce all rows to a single row and concatenate all results',
defaultOptions: {
reducers: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last],
reducers: [ReducerID.max],
},
/**
@ -32,10 +34,13 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> =
return (data: DataFrame[]) => {
const processed: DataFrame[] = [];
for (const series of data) {
for (let seriesIndex = 0; seriesIndex < data.length; seriesIndex++) {
const series = data[seriesIndex];
const values: ArrayVector[] = [];
const fields: Field[] = [];
const byId: KeyValue<ArrayVector> = {};
values.push(new ArrayVector()); // The name
fields.push({
name: 'Field',
@ -43,10 +48,12 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> =
values: values[0],
config: {},
});
for (const info of calculators) {
const vals = new ArrayVector();
byId[info.id] = vals;
values.push(vals);
fields.push({
name: info.id,
type: FieldType.other, // UNKNOWN until after we call the functions
@ -57,34 +64,80 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> =
},
});
}
for (let i = 0; i < series.fields.length; i++) {
const field = series.fields[i];
if (field.type === FieldType.time) {
continue;
}
if (matcher(field)) {
const results = reduceField({
field,
reducers,
});
// Update the name list
values[0].buffer.push(field.name);
const seriesName = series.name ?? series.refId ?? seriesIndex;
const fieldName =
field.name === seriesName || data.length === 1 ? field.name : `${field.name} {${seriesName}}`;
values[0].buffer.push(fieldName);
for (const info of calculators) {
const v = results[info.id];
byId[info.id].buffer.push(v);
}
}
}
for (const f of fields) {
const t = guessFieldTypeForField(f);
if (t) {
f.type = t;
}
}
processed.push({
...series, // Same properties, different fields
fields,
length: values[0].length,
});
}
return processed;
const withoutTime = filterFieldsTransformer.transformer({ exclude: { id: FieldMatcherID.time } })(processed);
return mergeResults(withoutTime);
};
},
};
const mergeResults = (data: DataFrame[]) => {
if (data.length <= 1) {
return data;
}
const baseFrame = data[0];
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
const series = data[seriesIndex];
for (const baseField of baseFrame.fields) {
for (const field of series.fields) {
if (baseField.type !== field.type || baseField.name !== field.name) {
continue;
}
const baseValues: any[] = ((baseField.values as unknown) as ArrayVector).buffer;
const values: any[] = ((field.values as unknown) as ArrayVector).buffer;
((baseField.values as unknown) as ArrayVector).buffer = baseValues.concat(values);
}
}
}
baseFrame.name = undefined;
baseFrame.length = baseFrame.fields[0].values.length;
return [baseFrame];
};

View File

@ -102,6 +102,7 @@ export function SelectBase<T>({
renderControl,
size = 'auto',
tabSelectsValue = true,
className,
value,
width,
}: SelectBaseProps<T>) {
@ -277,7 +278,7 @@ export function SelectBase<T>({
width: inputSizesPixels(size),
}),
}}
className={cx('select-container', widthClass)}
className={cx('select-container', widthClass, className)}
{...commonSelectProps}
{...creatableProps}
{...asyncSelectProps}

View File

@ -1,97 +0,0 @@
// Libraries
import React, { PureComponent, ChangeEvent } from 'react';
// Components
import { FormLabel } from '../FormLabel/FormLabel';
import { FormField } from '../FormField/FormField';
import { StatsPicker } from '../StatsPicker/StatsPicker';
// Types
import Select from '../Forms/Legacy/Select/Select';
import {
ReduceDataOptions,
DEFAULT_FIELD_DISPLAY_VALUES_LIMIT,
ReducerID,
toNumberString,
toIntegerOrUndefined,
SelectableValue,
} from '@grafana/data';
const showOptions: Array<SelectableValue<boolean>> = [
{
value: true,
label: 'All Values',
description: 'Each row in the response data',
},
{
value: false,
label: 'Calculation',
description: 'Calculate a value based on the response',
},
];
export interface Props {
labelWidth?: number;
value: ReduceDataOptions;
onChange: (value: ReduceDataOptions, event?: React.SyntheticEvent<HTMLElement>) => void;
}
export class FieldDisplayEditor extends PureComponent<Props> {
onShowValuesChange = (item: SelectableValue<boolean>) => {
const val = item.value === true;
this.props.onChange({ ...this.props.value, values: val });
};
onCalcsChange = (calcs: string[]) => {
this.props.onChange({ ...this.props.value, calcs });
};
onLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onChange({
...this.props.value,
limit: toIntegerOrUndefined(event.target.value),
});
};
render() {
const { value } = this.props;
const { calcs, values, limit } = value;
const labelWidth = this.props.labelWidth || 5;
return (
<>
<div className="gf-form">
<FormLabel width={labelWidth}>Show</FormLabel>
<Select
options={showOptions}
value={values ? showOptions[0] : showOptions[1]}
onChange={this.onShowValuesChange}
/>
</div>
{values ? (
<FormField
label="Limit"
labelWidth={labelWidth}
placeholder={`${DEFAULT_FIELD_DISPLAY_VALUES_LIMIT}`}
onChange={this.onLimitChange}
value={toNumberString(limit)}
type="number"
/>
) : (
<div className="gf-form">
<FormLabel width={labelWidth}>Calc</FormLabel>
<StatsPicker
width={12}
placeholder="Choose Stat"
defaultStat={ReducerID.mean}
allowMultiple={false}
stats={calcs}
onChange={this.onCalcsChange}
/>
</div>
)}
</>
);
}
}

View File

@ -1,8 +1,6 @@
export { FieldDisplayEditor } from './FieldDisplayEditor';
export {
SingleStatBaseOptions,
sharedSingleStatPanelChangedHandler,
sharedSingleStatMigrationHandler,
convertOldAngularValueMapping,
sharedSingleStatPanelChangedHandler,
} from './SingleStatBaseOptions';

View File

@ -13,11 +13,11 @@ interface Props {
stats: string[];
allowMultiple?: boolean;
defaultStat?: string;
className?: string;
}
export class StatsPicker extends PureComponent<Props> {
static defaultProps = {
width: 12,
static defaultProps: Partial<Props> = {
allowMultiple: false,
};
@ -62,12 +62,13 @@ export class StatsPicker extends PureComponent<Props> {
};
render() {
const { stats, allowMultiple, defaultStat, placeholder } = this.props;
const { stats, allowMultiple, defaultStat, placeholder, className } = this.props;
const select = fieldReducers.selectOptions(stats);
return (
<Select
value={select.current}
className={className}
isClearable={!defaultStat}
isMulti={allowMultiple}
isSearchable={true}

View File

@ -64,7 +64,7 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid
columns.push({
Cell,
id: field.name,
Header: field.name,
Header: field.config.title ?? field.name,
accessor: field.name,
width: fieldTableOptions.width,
});

View File

@ -15,8 +15,11 @@ export const ReduceTransformerEditor: React.FC<TransformerUIProps<ReduceTransfor
onChange,
}) => {
return (
<div className="gf-form-inline">
<div className="gf-form gf-form--grow">
<div className="gf-form-label width-8">Calculations</div>
<StatsPicker
width={25}
className="flex-grow-1"
placeholder="Choose Stat"
allowMultiple
stats={options.reducers || []}
@ -27,6 +30,8 @@ export const ReduceTransformerEditor: React.FC<TransformerUIProps<ReduceTransfor
});
}}
/>
</div>
</div>
);
};
@ -34,6 +39,6 @@ export const reduceTransformRegistryItem: TransformerRegistyItem<ReduceTransform
id: DataTransformerID.reduce,
editor: ReduceTransformerEditor,
transformation: standardTransformers.reduceTransformer,
name: 'Reduce',
description: 'Return a DataFrame with the reduction results',
name: standardTransformers.reduceTransformer.name,
description: standardTransformers.reduceTransformer.description,
};