diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts
index f62da205690..eef454cbfb2 100644
--- a/packages/grafana-data/src/transformations/transformers/ids.ts
+++ b/packages/grafana-data/src/transformations/transformers/ids.ts
@@ -23,4 +23,5 @@ export enum DataTransformerID {
groupBy = 'groupBy',
sortBy = 'sortBy',
histogram = 'histogram',
+ prepareTimeSeries = 'prepareTimeSeries',
}
diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor.tsx b/public/app/core/components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor.tsx
new file mode 100644
index 00000000000..91b60917f17
--- /dev/null
+++ b/public/app/core/components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor.tsx
@@ -0,0 +1,87 @@
+import React, { useCallback } from 'react';
+import { GrafanaTheme2, SelectableValue, TransformerRegistryItem, TransformerUIProps } from '@grafana/data';
+import { prepareTimeSeriesTransformer, PrepareTimeSeriesOptions, timeSeriesFormat } from './prepareTimeSeries';
+import { InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui';
+import { css } from '@emotion/css';
+
+const wideInfo = {
+ label: 'Wide time series',
+ value: timeSeriesFormat.TimeSeriesWide,
+ description: 'Creates a single frame joined by time',
+ info: (
+
+ - Single frame
+ - 1st field is shared time field
+ - Time in ascending order
+ - Multiple value fields of any type
+
+ ),
+};
+
+const manyInfo = {
+ label: 'Multi-frame time series',
+ value: timeSeriesFormat.TimeSeriesMany,
+ description: 'Creates a new frame for each time/number pair',
+ info: (
+
+ - Multiple frames
+ - Each frame has two fields: time, value
+ - Time in ascending order
+ - All values are numeric
+
+ ),
+};
+
+const formats: Array> = [wideInfo, manyInfo];
+
+export function PrepareTimeSeriesEditor(props: TransformerUIProps): React.ReactElement {
+ const { options, onChange } = props;
+ const styles = useStyles2(getStyles);
+
+ const onSelectFormat = useCallback(
+ (value: SelectableValue) => {
+ onChange({
+ ...options,
+ format: value.value!,
+ });
+ },
+ [onChange, options]
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {options.format === timeSeriesFormat.TimeSeriesMany ? manyInfo.info : wideInfo.info}
+
+
+
+ >
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ info: css`
+ margin-left: 20px;
+ `,
+});
+
+export const prepareTimeseriesTransformerRegistryItem: TransformerRegistryItem = {
+ id: prepareTimeSeriesTransformer.id,
+ editor: PrepareTimeSeriesEditor,
+ transformation: prepareTimeSeriesTransformer,
+ name: prepareTimeSeriesTransformer.name,
+ description: prepareTimeSeriesTransformer.description,
+};
diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.test.ts b/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.test.ts
new file mode 100644
index 00000000000..cc043766fd4
--- /dev/null
+++ b/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.test.ts
@@ -0,0 +1,214 @@
+import { toDataFrame, ArrayVector, DataFrame, FieldType, toDataFrameDTO, DataFrameDTO } from '@grafana/data';
+import { prepareTimeSeries, PrepareTimeSeriesOptions, timeSeriesFormat } from './prepareTimeSeries';
+
+describe('Prepair time series transformer', () => {
+ it('should transform wide to many', () => {
+ const source = [
+ toDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ { name: 'more', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ }),
+ ];
+
+ const config: PrepareTimeSeriesOptions = {
+ format: timeSeriesFormat.TimeSeriesMany,
+ };
+
+ expect(prepareTimeSeries(source, config)).toEqual([
+ toEquableDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ ],
+ length: 6,
+ }),
+ toEquableDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'more', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ length: 6,
+ }),
+ ]);
+ });
+
+ it('should remove string fields since time series format is expected to be time/number fields', () => {
+ const source = [
+ toDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ { name: 'more', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ }),
+ ];
+
+ const config: PrepareTimeSeriesOptions = {
+ format: timeSeriesFormat.TimeSeriesMany,
+ };
+
+ expect(prepareTimeSeries(source, config)).toEqual([
+ toEquableDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ ],
+ length: 6,
+ }),
+ toEquableDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'more', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ length: 6,
+ }),
+ ]);
+ });
+
+ it('should transform all wide to many when mixed', () => {
+ const source = [
+ toDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ { name: 'another', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ }),
+ toDataFrame({
+ name: 'long',
+ refId: 'B',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [100, 90, 80, 70, 60, 50] },
+ { name: 'value', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ }),
+ ];
+
+ const config: PrepareTimeSeriesOptions = {
+ format: timeSeriesFormat.TimeSeriesMany,
+ };
+
+ expect(prepareTimeSeries(source, config)).toEqual([
+ toEquableDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ ],
+ length: 6,
+ }),
+ toEquableDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'another', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ length: 6,
+ }),
+ toEquableDataFrame({
+ name: 'long',
+ refId: 'B',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [100, 90, 80, 70, 60, 50] },
+ { name: 'value', type: FieldType.number, values: [2, 3, 4, 5, 6, 7] },
+ ],
+ length: 6,
+ }),
+ ]);
+ });
+
+ it('should transform none when source only has long frames', () => {
+ const source = [
+ toDataFrame({
+ name: 'long',
+ refId: 'A',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ ],
+ }),
+ toDataFrame({
+ name: 'long',
+ refId: 'B',
+ fields: [
+ { name: 'time', type: FieldType.time, values: [10, 9, 8, 7, 6, 5] },
+ { name: 'count', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
+ ],
+ }),
+ ];
+
+ const config: PrepareTimeSeriesOptions = {
+ format: timeSeriesFormat.TimeSeriesMany,
+ };
+
+ expect(toEquableDataFrames(prepareTimeSeries(source, config))).toEqual(toEquableDataFrames(source));
+ });
+
+ it('should return empty array when no timeseries exist', () => {
+ const source = [
+ toDataFrame({
+ name: 'wide',
+ refId: 'A',
+ fields: [
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ ],
+ }),
+ toDataFrame({
+ name: 'wide',
+ refId: 'B',
+ fields: [
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ { name: 'text', type: FieldType.string, values: ['a', 'z', 'b', 'x', 'c', 'b'] },
+ ],
+ }),
+ ];
+
+ const config: PrepareTimeSeriesOptions = {
+ format: timeSeriesFormat.TimeSeriesMany,
+ };
+
+ expect(prepareTimeSeries(source, config)).toEqual([]);
+ });
+});
+
+function toEquableDataFrame(source: any): DataFrame {
+ return toDataFrame({
+ meta: undefined,
+ ...source,
+ fields: source.fields.map((field: any) => {
+ return {
+ ...field,
+ values: new ArrayVector(field.values),
+ config: {},
+ };
+ }),
+ });
+}
+
+function toEquableDataFrames(data: DataFrame[]): DataFrameDTO[] {
+ return data.map((frame) => toDataFrameDTO(frame));
+}
diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.ts b/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.ts
new file mode 100644
index 00000000000..197dc77c131
--- /dev/null
+++ b/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.ts
@@ -0,0 +1,93 @@
+import {
+ DataTransformerInfo,
+ DataFrame,
+ FieldType,
+ DataTransformerID,
+ outerJoinDataFrames,
+ fieldMatchers,
+ FieldMatcherID,
+} from '@grafana/data';
+import { map } from 'rxjs/operators';
+
+/**
+ * There is currently an effort to figure out consistent names
+ * for the various formats/types we produce and use.
+ *
+ * This transformer will eventually include the required metadata that can assert
+ * a DataFrame[] is of a given type
+ *
+ * @internal -- TBD
+ */
+export enum timeSeriesFormat {
+ TimeSeriesWide = 'wide', // [time,...values]
+ TimeSeriesMany = 'many', // All frames have [time,number]
+ // TimeSeriesLong = 'long',
+}
+
+export type PrepareTimeSeriesOptions = {
+ format: timeSeriesFormat;
+};
+
+/**
+ * Convert to [][time,number]
+ */
+export function toTimeSeriesMany(data: DataFrame[]): DataFrame[] {
+ if (!Array.isArray(data) || data.length === 0) {
+ return data;
+ }
+
+ const result: DataFrame[] = [];
+ for (const frame of data) {
+ const timeField = frame.fields.find((field) => {
+ return field.type === FieldType.time;
+ });
+
+ if (!timeField) {
+ continue;
+ }
+
+ for (const field of frame.fields) {
+ if (field.type !== FieldType.number) {
+ continue;
+ }
+
+ result.push({
+ name: frame.name,
+ refId: frame.refId,
+ meta: frame.meta,
+ fields: [timeField, field],
+ length: frame.length,
+ });
+ }
+ }
+ return result;
+}
+
+export function prepareTimeSeries(data: DataFrame[], options: PrepareTimeSeriesOptions): DataFrame[] {
+ const format = options?.format ?? timeSeriesFormat.TimeSeriesWide;
+ if (format === timeSeriesFormat.TimeSeriesMany) {
+ return toTimeSeriesMany(data);
+ }
+
+ // Join by the first frame
+ const frame = outerJoinDataFrames({
+ frames: data,
+ joinBy: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
+ enforceSort: true,
+ keepOriginIndices: true,
+ });
+ return frame ? [frame] : [];
+}
+
+export const prepareTimeSeriesTransformer: DataTransformerInfo = {
+ id: DataTransformerID.prepareTimeSeries,
+ name: 'Prepare time series',
+ description: `Will stretch data frames from the wide format into the long format. This is really helpful to be able to keep backwards compatability for panels not supporting the new wide format.`,
+ defaultOptions: {},
+
+ /**
+ * Return a modified copy of the series. If the transform is not or should not
+ * be applied, just return the input series
+ */
+ operator: (options) => (source) => source.pipe(map((data) => prepareTimeSeries(data, options))),
+};
diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts
index 584ed687472..595a53b68ee 100644
--- a/public/app/core/utils/standardTransformers.ts
+++ b/public/app/core/utils/standardTransformers.ts
@@ -14,6 +14,7 @@ import { seriesToRowsTransformerRegistryItem } from '../components/TransformersU
import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor';
import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer';
import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor';
+import { prepareTimeseriesTransformerRegistryItem } from '../components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor';
export const getStandardTransformers = (): Array> => {
return [
@@ -32,5 +33,6 @@ export const getStandardTransformers = (): Array> =
sortByTransformRegistryItem,
mergeTransformerRegistryItem,
histogramTransformRegistryItem,
+ prepareTimeseriesTransformerRegistryItem,
];
};