mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Transformations: Allow Timeseries to table transformation to handle multiple time series (#76801)
* Initial fix up of getLabelFields * Update time series to table code * Cleanup and allow merging * Support merge and non-merge scenarios * Update editor * Fix case with merge true for multiple queries * Update time series detection, fix tests * Remove spurious console.log * Prettier plus remove test console.log * Remove type assertion * Add options migration * Add type export * Sentence casing * Make sure current options are preserved when making changes * Add disabled image * DashboardMigrator prettier * Add type assertion explanation and exception * Fix schema version test * Prettier * Fix genAI tests and make them more robust so they dont break on every new schema version --------- Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
parent
470d879c80
commit
71e3814c46
@ -3359,17 +3359,18 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "19"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "20"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "21"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "22"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "22"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "23"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "24"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "25"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "26"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "27"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "28"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "28"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "29"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "30"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "30"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "31"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "32"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "32"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "33"]
|
||||
],
|
||||
"public/app/features/dashboard/state/DashboardModel.repeat.test.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
|
@ -3,10 +3,16 @@ import { DataFrame, FieldType } from '../types/dataFrame';
|
||||
import { getTimeField } from './processDataFrame';
|
||||
|
||||
export function isTimeSeriesFrame(frame: DataFrame) {
|
||||
if (frame.fields.length > 2) {
|
||||
// If we have less than two frames we can't have a timeseries
|
||||
if (frame.fields.length < 2) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(frame.fields.find((field) => field.type === FieldType.time));
|
||||
|
||||
// In order to have a time series we need a time field
|
||||
// and at least one number field
|
||||
const timeField = frame.fields.find((field) => field.type === FieldType.time);
|
||||
const numberField = frame.fields.find((field) => field.type === FieldType.number);
|
||||
return timeField !== undefined && numberField !== undefined;
|
||||
}
|
||||
|
||||
export function isTimeSeriesFrames(data: DataFrame[]) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { DASHBOARD_SCHEMA_VERSION } from '../../state/DashboardMigrator';
|
||||
import { createDashboardModelFixture, createPanelSaveModel } from '../../state/__fixtures__/dashboardFixtures';
|
||||
|
||||
import { orderProperties, JSONArray, JSONValue, isObject, getDashboardStringDiff } from './jsonDiffText';
|
||||
@ -239,7 +240,7 @@ describe('isObject', () => {
|
||||
describe('getDashboardStringDiff', () => {
|
||||
const dashboard = {
|
||||
title: 'Original Title',
|
||||
schemaVersion: 38,
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
panels: [
|
||||
createPanelSaveModel({
|
||||
id: 1,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { llms } from '@grafana/experimental';
|
||||
|
||||
import { DASHBOARD_SCHEMA_VERSION } from '../../state/DashboardMigrator';
|
||||
import { createDashboardModelFixture, createPanelSaveModel } from '../../state/__fixtures__/dashboardFixtures';
|
||||
|
||||
import { getDashboardChanges, isLLMPluginEnabled, sanitizeReply } from './utils';
|
||||
@ -21,7 +22,7 @@ describe('getDashboardChanges', () => {
|
||||
const deprecatedOptions = {
|
||||
legend: { displayMode: 'hidden', showLegend: false },
|
||||
};
|
||||
const deprecatedVersion = 37;
|
||||
const deprecatedVersion = DASHBOARD_SCHEMA_VERSION - 1;
|
||||
const dashboard = createDashboardModelFixture({
|
||||
schemaVersion: deprecatedVersion,
|
||||
panels: [createPanelSaveModel({ title: 'Panel 1', options: deprecatedOptions })],
|
||||
@ -48,8 +49,8 @@ describe('getDashboardChanges', () => {
|
||||
' {\n' +
|
||||
' "editable": true,\n' +
|
||||
' "graphTooltip": 0,\n' +
|
||||
'- "schemaVersion": 37,\n' +
|
||||
'+ "schemaVersion": 38,\n' +
|
||||
`- "schemaVersion": ${deprecatedVersion},\n` +
|
||||
`+ "schemaVersion": ${DASHBOARD_SCHEMA_VERSION},\n` +
|
||||
' "timezone": "",\n' +
|
||||
' "panels": [\n' +
|
||||
' {\n' +
|
||||
@ -62,7 +63,7 @@ describe('getDashboardChanges', () => {
|
||||
'+++ After user changes\t\n' +
|
||||
'@@ -3,16 +3,17 @@\n' +
|
||||
' "graphTooltip": 0,\n' +
|
||||
' "schemaVersion": 38,\n' +
|
||||
` "schemaVersion": ${DASHBOARD_SCHEMA_VERSION},\n` +
|
||||
' "timezone": "",\n' +
|
||||
' "panels": [\n' +
|
||||
' {\n' +
|
||||
|
@ -13,6 +13,8 @@ import { VariableHide } from '../../variables/types';
|
||||
import { DashboardModel } from '../state/DashboardModel';
|
||||
import { PanelModel } from '../state/PanelModel';
|
||||
|
||||
import { DASHBOARD_SCHEMA_VERSION } from './DashboardMigrator';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({}));
|
||||
|
||||
const dataSources = {
|
||||
@ -228,7 +230,7 @@ describe('DashboardModel', () => {
|
||||
});
|
||||
|
||||
it('dashboard schema version should be set to latest', () => {
|
||||
expect(model.schemaVersion).toBe(38);
|
||||
expect(model.schemaVersion).toBe(DASHBOARD_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it('graph thresholds should be migrated', () => {
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
isEmptyObject,
|
||||
MappingType,
|
||||
PanelPlugin,
|
||||
ReducerID,
|
||||
SpecialValueMatch,
|
||||
standardEditorsRegistry,
|
||||
standardFieldConfigEditorRegistry,
|
||||
@ -42,6 +43,10 @@ import {
|
||||
import getFactors from 'app/core/utils/factors';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import {
|
||||
RefIdTransformerOptions,
|
||||
TimeSeriesTableTransformerOptions,
|
||||
} from 'app/features/transformers/timeSeriesTable/timeSeriesTableTransformer';
|
||||
import { isConstant, isMulti } from 'app/features/variables/guard';
|
||||
import { alignCurrentWithMulti } from 'app/features/variables/shared/multiOptions';
|
||||
import { CloudWatchMetricsQuery, LegacyAnnotationQuery } from 'app/plugins/datasource/cloudwatch/types';
|
||||
@ -63,6 +68,14 @@ standardEditorsRegistry.setInit(getAllOptionEditors);
|
||||
standardFieldConfigEditorRegistry.setInit(getAllStandardFieldConfigs);
|
||||
|
||||
type PanelSchemeUpgradeHandler = (panel: PanelModel) => PanelModel;
|
||||
|
||||
/**
|
||||
* The current version of the dashboard schema.
|
||||
* To add a dashboard migration increment this number
|
||||
* and then add your migration at the bottom of 'updateSchema'
|
||||
* hint: search "Add migration here"
|
||||
*/
|
||||
export const DASHBOARD_SCHEMA_VERSION = 39;
|
||||
export class DashboardMigrator {
|
||||
dashboard: DashboardModel;
|
||||
|
||||
@ -79,7 +92,7 @@ export class DashboardMigrator {
|
||||
let i, j, k, n;
|
||||
const oldVersion = this.dashboard.schemaVersion;
|
||||
const panelUpgrades: PanelSchemeUpgradeHandler[] = [];
|
||||
this.dashboard.schemaVersion = 38;
|
||||
this.dashboard.schemaVersion = DASHBOARD_SCHEMA_VERSION;
|
||||
|
||||
if (oldVersion === this.dashboard.schemaVersion) {
|
||||
return;
|
||||
@ -849,6 +862,46 @@ export class DashboardMigrator {
|
||||
});
|
||||
}
|
||||
|
||||
// Update the configuration of the Timeseries to table transformation
|
||||
// to support multiple options per query
|
||||
if (oldVersion < 39) {
|
||||
panelUpgrades.push((panel: PanelModel) => {
|
||||
panel.transformations?.forEach((transformation) => {
|
||||
// If we run into a timeSeriesTable transformation
|
||||
// and it doesn't have undefined options then we migrate
|
||||
if (
|
||||
transformation.id === 'timeSeriesTable' &&
|
||||
transformation.options !== undefined &&
|
||||
transformation.options.refIdToStat !== undefined
|
||||
) {
|
||||
let tableTransformOptions: TimeSeriesTableTransformerOptions = {};
|
||||
|
||||
// For each {refIdtoStat} record which maps refId to a statistic
|
||||
// we add that to the stat property of the the new
|
||||
// RefIdTransformerOptions interface which includes multiple settings
|
||||
for (const [refId, stat] of Object.entries(transformation.options.refIdToStat)) {
|
||||
let newSettings: RefIdTransformerOptions = {};
|
||||
// In this case the easiest way is just to do a type
|
||||
// assertion as iterated entries have unknown types
|
||||
newSettings.stat = stat as ReducerID;
|
||||
tableTransformOptions[refId] = newSettings;
|
||||
}
|
||||
|
||||
// Update the options
|
||||
transformation.options = tableTransformOptions;
|
||||
}
|
||||
});
|
||||
|
||||
return panel;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* -==- Add migration here -==-
|
||||
* Your migration should go below the previous
|
||||
* block and above this (hopefully) helpful message.
|
||||
*/
|
||||
|
||||
if (panelUpgrades.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -1,30 +1,63 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { PluginState, TransformerRegistryItem, TransformerUIProps, ReducerID, isReducerID } from '@grafana/data';
|
||||
import { InlineFieldRow, InlineField, StatsPicker } from '@grafana/ui';
|
||||
import {
|
||||
PluginState,
|
||||
TransformerRegistryItem,
|
||||
TransformerUIProps,
|
||||
ReducerID,
|
||||
isReducerID,
|
||||
SelectableValue,
|
||||
getFieldDisplayName,
|
||||
} from '@grafana/data';
|
||||
import { InlineFieldRow, InlineField, StatsPicker, InlineSwitch, Select } from '@grafana/ui';
|
||||
|
||||
import { timeSeriesTableTransformer, TimeSeriesTableTransformerOptions } from './timeSeriesTableTransformer';
|
||||
import {
|
||||
timeSeriesTableTransformer,
|
||||
TimeSeriesTableTransformerOptions,
|
||||
getRefData,
|
||||
} from './timeSeriesTableTransformer';
|
||||
|
||||
export function TimeSeriesTableTransformEditor({
|
||||
input,
|
||||
options,
|
||||
onChange,
|
||||
}: TransformerUIProps<TimeSeriesTableTransformerOptions>) {
|
||||
const refIds: string[] = input.reduce<string[]>((acc, frame) => {
|
||||
if (frame.refId && !acc.includes(frame.refId)) {
|
||||
return [...acc, frame.refId];
|
||||
const timeFields: Array<SelectableValue<string>> = [];
|
||||
const refIdMap = getRefData(input);
|
||||
|
||||
// Retrieve time fields
|
||||
for (const frame of input) {
|
||||
for (const field of frame.fields) {
|
||||
if (field.type === 'time') {
|
||||
const name = getFieldDisplayName(field, frame, input);
|
||||
timeFields.push({ label: name, value: name });
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
const onSelectTimefield = useCallback(
|
||||
(refId: string, value: SelectableValue<string>) => {
|
||||
const val = value?.value !== undefined ? value.value : '';
|
||||
onChange({
|
||||
...options,
|
||||
[refId]: {
|
||||
...options[refId],
|
||||
timeField: val,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
const onSelectStat = useCallback(
|
||||
(refId: string, stats: string[]) => {
|
||||
const reducerID = stats[0];
|
||||
if (reducerID && isReducerID(reducerID)) {
|
||||
onChange({
|
||||
refIdToStat: {
|
||||
...options.refIdToStat,
|
||||
[refId]: reducerID,
|
||||
...options,
|
||||
[refId]: {
|
||||
...options[refId],
|
||||
stat: reducerID,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -32,25 +65,55 @@ export function TimeSeriesTableTransformEditor({
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{refIds.map((refId) => {
|
||||
return (
|
||||
<div key={refId}>
|
||||
<InlineFieldRow>
|
||||
<InlineField label={`Trend ${refIds.length > 1 ? ` #${refId}` : ''} value`}>
|
||||
<StatsPicker
|
||||
stats={[options.refIdToStat?.[refId] ?? ReducerID.lastNotNull]}
|
||||
onChange={onSelectStat.bind(null, refId)}
|
||||
filterOptions={(ext) => ext.id !== ReducerID.allValues && ext.id !== ReducerID.uniqueValues}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
const onMergeSeriesToggle = useCallback(
|
||||
(refId: string) => {
|
||||
const mergeSeries = options[refId]?.mergeSeries !== undefined ? !options[refId].mergeSeries : false;
|
||||
onChange({
|
||||
...options,
|
||||
[refId]: {
|
||||
...options[refId],
|
||||
mergeSeries,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
let configRows = [];
|
||||
for (const refId of Object.keys(refIdMap)) {
|
||||
configRows.push(
|
||||
<InlineFieldRow key={refId}>
|
||||
<InlineField
|
||||
label="Time field"
|
||||
tooltip="The time field that will be used for the time series. If not selected the first found will be used."
|
||||
>
|
||||
<Select
|
||||
onChange={onSelectTimefield.bind(null, refId)}
|
||||
options={timeFields}
|
||||
value={options[refId]?.timeField}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Stat" tooltip="The statistic that should be calculated for this time series.">
|
||||
<StatsPicker
|
||||
stats={[options[refId]?.stat ?? ReducerID.lastNotNull]}
|
||||
onChange={onSelectStat.bind(null, refId)}
|
||||
filterOptions={(ext) => ext.id !== ReducerID.allValues && ext.id !== ReducerID.uniqueValues}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField
|
||||
label="Merge series"
|
||||
tooltip="If selected, multiple series from a single datasource will be merged into one series."
|
||||
>
|
||||
<InlineSwitch
|
||||
value={options[refId]?.mergeSeries !== undefined ? options[refId]?.mergeSeries : true}
|
||||
onChange={onMergeSeriesToggle.bind(null, refId)}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{configRows}</>;
|
||||
}
|
||||
|
||||
export const timeSeriesTableTransformRegistryItem: TransformerRegistryItem<TimeSeriesTableTransformerOptions> = {
|
||||
|
@ -15,9 +15,12 @@ describe('timeSeriesTableTransformer', () => {
|
||||
const result = results[0];
|
||||
expect(result.refId).toBe('A');
|
||||
expect(result.fields).toHaveLength(3);
|
||||
expect(result.fields[0].values).toEqual(['A', 'A', 'A']);
|
||||
expect(result.fields[1].values).toEqual(['B', 'C', 'D']);
|
||||
assertDataFrameField(result.fields[2], series);
|
||||
expect(result.fields[0].values).toEqual([
|
||||
'Value : instance=A : pod=B',
|
||||
'Value : instance=A : pod=C',
|
||||
'Value : instance=A : pod=D',
|
||||
]);
|
||||
assertDataFrameField(result.fields[1], series);
|
||||
});
|
||||
|
||||
it('Will pass through non time series frames', () => {
|
||||
@ -33,8 +36,6 @@ describe('timeSeriesTableTransformer', () => {
|
||||
expect(results[0]).toEqual(series[0]);
|
||||
expect(results[1].refId).toBe('A');
|
||||
expect(results[1].fields).toHaveLength(3);
|
||||
expect(results[1].fields[0].values).toEqual(['A', 'A']);
|
||||
expect(results[1].fields[1].values).toEqual(['B', 'C']);
|
||||
expect(results[2]).toEqual(series[3]);
|
||||
});
|
||||
|
||||
@ -51,15 +52,23 @@ describe('timeSeriesTableTransformer', () => {
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].refId).toBe('A');
|
||||
expect(results[0].fields).toHaveLength(3);
|
||||
expect(results[0].fields[0].values).toEqual(['A', 'A', 'A']);
|
||||
expect(results[0].fields[1].values).toEqual(['B', 'C', 'D']);
|
||||
assertDataFrameField(results[0].fields[2], series.slice(0, 3));
|
||||
expect(results[0].fields[0].values).toEqual([
|
||||
'Value : instance=A : pod=B',
|
||||
'Value : instance=A : pod=C',
|
||||
'Value : instance=A : pod=D',
|
||||
]);
|
||||
assertDataFrameField(results[0].fields[1], series.slice(0, 3));
|
||||
expect(results[1].refId).toBe('B');
|
||||
expect(results[1].fields).toHaveLength(4);
|
||||
expect(results[1].fields[0].values).toEqual(['B', 'B']);
|
||||
expect(results[1].fields[1].values).toEqual(['F', 'G']);
|
||||
expect(results[1].fields[2].values).toEqual(['A', 'B']);
|
||||
assertDataFrameField(results[1].fields[3], series.slice(3, 5));
|
||||
expect(results[1].fields).toHaveLength(3);
|
||||
expect(results[1].fields[0].values).toEqual([
|
||||
'Value : instance=B : pod=F : cluster=A',
|
||||
'Value : instance=B : pod=G : cluster=B',
|
||||
]);
|
||||
expect(results[1].fields[0].values).toEqual([
|
||||
'Value : instance=B : pod=F : cluster=A',
|
||||
'Value : instance=B : pod=G : cluster=B',
|
||||
]);
|
||||
assertDataFrameField(results[1].fields[1], series.slice(3, 5));
|
||||
});
|
||||
|
||||
it('Will include last value by deault', () => {
|
||||
@ -69,8 +78,8 @@ describe('timeSeriesTableTransformer', () => {
|
||||
];
|
||||
|
||||
const results = timeSeriesToTableTransform({}, series);
|
||||
expect(results[0].fields[2].values[0].value).toEqual(3);
|
||||
expect(results[0].fields[2].values[1].value).toEqual(5);
|
||||
expect(results[0].fields[1].values[0].fields[1].values[2]).toEqual(3);
|
||||
expect(results[0].fields[1].values[1].fields[1].values[2]).toEqual(5);
|
||||
});
|
||||
|
||||
it('Will calculate average value if configured', () => {
|
||||
@ -79,16 +88,9 @@ describe('timeSeriesTableTransformer', () => {
|
||||
getTimeSeries('B', { instance: 'A', pod: 'C' }, [3, 4, 5]),
|
||||
];
|
||||
|
||||
const results = timeSeriesToTableTransform(
|
||||
{
|
||||
refIdToStat: {
|
||||
B: ReducerID.mean,
|
||||
},
|
||||
},
|
||||
series
|
||||
);
|
||||
expect(results[0].fields[2].values[0].value).toEqual(3);
|
||||
expect(results[1].fields[2].values[0].value).toEqual(4);
|
||||
const results = timeSeriesToTableTransform({ B: { stat: ReducerID.mean } }, series);
|
||||
expect(results[0].fields[2].values[0]).toEqual(3);
|
||||
expect(results[1].fields[2].values[0]).toEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { map } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
DataFrameWithValue,
|
||||
DataTransformerID,
|
||||
DataTransformerInfo,
|
||||
Field,
|
||||
@ -11,18 +10,72 @@ import {
|
||||
isTimeSeriesFrame,
|
||||
ReducerID,
|
||||
reduceField,
|
||||
TransformationApplicabilityLevels,
|
||||
} from '@grafana/data';
|
||||
|
||||
const MERGE_DEFAULT = true;
|
||||
|
||||
/**
|
||||
* Maps a refId to a Field which can contain
|
||||
* different types of data. In our case we
|
||||
* care about DataFrame, number, and string.
|
||||
*/
|
||||
interface RefFieldMap<T> {
|
||||
[index: string]: Field<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* For options we have a set of options
|
||||
* for each refId. So we map the refId
|
||||
* for each setting.
|
||||
*/
|
||||
export interface TimeSeriesTableTransformerOptions {
|
||||
refIdToStat?: Record<string, ReducerID>;
|
||||
[index: string]: RefIdTransformerOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of refId frames in
|
||||
* a given frame array. i.e.
|
||||
* {
|
||||
* A: 10
|
||||
* B: 20
|
||||
* C: 12
|
||||
* }
|
||||
*/
|
||||
interface RefCount {
|
||||
[index: string]: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each refId we allow the following to
|
||||
* be configured:
|
||||
*
|
||||
* - stat: A stat to calculate for the refId
|
||||
* - mergeSeries: Whether separate series should be merged into one
|
||||
* - timeField: The time field that should be used for the time series
|
||||
*/
|
||||
export interface RefIdTransformerOptions {
|
||||
stat?: ReducerID;
|
||||
mergeSeries?: boolean;
|
||||
timeField?: string;
|
||||
}
|
||||
|
||||
export const timeSeriesTableTransformer: DataTransformerInfo<TimeSeriesTableTransformerOptions> = {
|
||||
id: DataTransformerID.timeSeriesTable,
|
||||
name: 'Time series to table transform',
|
||||
description: 'Time series to table rows.',
|
||||
name: 'Time series to table',
|
||||
description: 'Convert time series data to table rows so that they can be viewed in tabular or sparkline format.',
|
||||
defaultOptions: {},
|
||||
isApplicable: (data) => {
|
||||
for (const frame of data) {
|
||||
if (isTimeSeriesFrame(frame)) {
|
||||
return TransformationApplicabilityLevels.Applicable;
|
||||
}
|
||||
}
|
||||
|
||||
return TransformationApplicabilityLevels.NotApplicable;
|
||||
},
|
||||
isApplicableDescription:
|
||||
'The Time series to table transformation requires at least one time series frame to function. You currently have none.',
|
||||
operator: (options) => (source) =>
|
||||
source.pipe(
|
||||
map((data) => {
|
||||
@ -37,101 +90,179 @@ export const timeSeriesTableTransformer: DataTransformerInfo<TimeSeriesTableTran
|
||||
* @remarks
|
||||
* For each refId (queryName) convert all time series frames into a single table frame, adding each series
|
||||
* as values of a "Trend" frame field. This allows "Trend" to be rendered as area chart type.
|
||||
* Any non time series frames are returned as is.
|
||||
*
|
||||
* Any non time series frames are returned unmodified.
|
||||
*
|
||||
* @param options - Transform options, currently not used
|
||||
* @param data - Array of data frames to transform
|
||||
* @returns Array of transformed data frames
|
||||
*
|
||||
* @alpha
|
||||
* @beta
|
||||
*/
|
||||
export function timeSeriesToTableTransform(options: TimeSeriesTableTransformerOptions, data: DataFrame[]): DataFrame[] {
|
||||
// initialize fields from labels for each refId
|
||||
const refId2LabelFields = getLabelFields(data);
|
||||
|
||||
const refId2frameField: Record<string, Field<DataFrameWithValue>> = {};
|
||||
// Initialize maps for labels, sparklines, and reduced values
|
||||
const refId2LabelField: RefFieldMap<string> = {};
|
||||
const refId2FrameField: RefFieldMap<DataFrame> = {};
|
||||
const refId2ValueField: RefFieldMap<number> = {};
|
||||
|
||||
// Accumulator for our final value
|
||||
// which we'll return
|
||||
const result: DataFrame[] = [];
|
||||
|
||||
for (const frame of data) {
|
||||
if (!isTimeSeriesFrame(frame)) {
|
||||
result.push(frame);
|
||||
continue;
|
||||
}
|
||||
// Retreive the refIds of all the data
|
||||
let refIdMap = getRefData(data);
|
||||
|
||||
const refId = frame.refId ?? '';
|
||||
// If we're merging data then rather
|
||||
// than creating a series per source
|
||||
// series we initialize fields here
|
||||
// so we end up with one
|
||||
for (const refId of Object.keys(refIdMap)) {
|
||||
const merge = options[refId]?.mergeSeries !== undefined ? options[refId].mergeSeries : MERGE_DEFAULT;
|
||||
|
||||
const labelFields = refId2LabelFields[refId] ?? {};
|
||||
// initialize a new frame for this refId with fields per label and a Trend frame field, if it doesn't exist yet
|
||||
let frameField = refId2frameField[refId];
|
||||
if (!frameField) {
|
||||
frameField = {
|
||||
name: 'Trend' + (refId && Object.keys(refId2LabelFields).length > 1 ? ` #${refId}` : ''),
|
||||
type: FieldType.frame,
|
||||
config: {},
|
||||
values: [],
|
||||
};
|
||||
refId2frameField[refId] = frameField;
|
||||
// Get the frames with the current refId
|
||||
const framesForRef = data.filter((frame) => frame.refId === refId);
|
||||
|
||||
const table = new MutableDataFrame();
|
||||
for (const label of Object.values(labelFields)) {
|
||||
table.addField(label);
|
||||
}
|
||||
table.addField(frameField);
|
||||
table.refId = refId;
|
||||
result.push(table);
|
||||
}
|
||||
for (let i = 0; i < framesForRef.length; i++) {
|
||||
const frame = framesForRef[i];
|
||||
|
||||
// add values to each label based field of this frame
|
||||
const labels = frame.fields[1].labels;
|
||||
for (const labelKey of Object.keys(labelFields)) {
|
||||
const labelValue = labels?.[labelKey] ?? null;
|
||||
labelFields[labelKey].values.push(labelValue!);
|
||||
}
|
||||
const reducerId = options.refIdToStat?.[refId] ?? ReducerID.lastNotNull;
|
||||
const valueField = frame.fields.find((f) => f.type === FieldType.number);
|
||||
const value = (valueField && reduceField({ field: valueField, reducers: [reducerId] })[reducerId]) || null;
|
||||
frameField.values.push({
|
||||
...frame,
|
||||
value,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For each refId, initialize a field for each label name
|
||||
function getLabelFields(frames: DataFrame[]): Record<string, Record<string, Field<string>>> {
|
||||
// refId -> label name -> field
|
||||
const labelFields: Record<string, Record<string, Field<string>>> = {};
|
||||
|
||||
for (const frame of frames) {
|
||||
if (!isTimeSeriesFrame(frame)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const refId = frame.refId ?? '';
|
||||
|
||||
if (!labelFields[refId]) {
|
||||
labelFields[refId] = {};
|
||||
}
|
||||
|
||||
for (const field of frame.fields) {
|
||||
if (!field.labels) {
|
||||
// If it's not a time series frame we add
|
||||
// it unmodified to the result
|
||||
if (!isTimeSeriesFrame(frame)) {
|
||||
result.push(frame);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const labelName of Object.keys(field.labels)) {
|
||||
if (!labelFields[refId][labelName]) {
|
||||
labelFields[refId][labelName] = {
|
||||
name: labelName,
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: [],
|
||||
};
|
||||
// If we're not dealing with a frame
|
||||
// of the current refId skip it
|
||||
if (frame.refId !== refId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Retrieve the time field that's been configured
|
||||
// If one isn't configured then use the first found
|
||||
let timeField = null;
|
||||
if (options[refId]?.timeField !== undefined) {
|
||||
timeField = frame.fields.find((field) => field.name === options[refId]?.timeField);
|
||||
} else {
|
||||
timeField = frame.fields.find((field) => field.type === FieldType.time);
|
||||
}
|
||||
|
||||
// Initialize fields for this frame
|
||||
// if we're not merging them
|
||||
if ((merge && i === 0) || !merge) {
|
||||
refId2LabelField[refId] = newField('Label', FieldType.string);
|
||||
refId2FrameField[refId] = newField('Trend', FieldType.frame);
|
||||
refId2ValueField[refId] = newField('Trend Value', FieldType.number);
|
||||
}
|
||||
|
||||
for (const field of frame.fields) {
|
||||
// Skip non-number based fields
|
||||
// i.e. we skip time, strings, etc.
|
||||
if (field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the value for the label field
|
||||
let labelParts: string[] = [];
|
||||
|
||||
// Add the refId to the label if we have
|
||||
// more than one
|
||||
if (refIdMap.length > 1) {
|
||||
labelParts.push(refId);
|
||||
}
|
||||
|
||||
// Add the name of the field
|
||||
labelParts.push(field.name);
|
||||
|
||||
// If there is any labeled data add it here
|
||||
if (field.labels !== undefined) {
|
||||
for (const [labelKey, labelValue] of Object.entries(field.labels)) {
|
||||
labelParts.push(`${labelKey}=${labelValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the label parts to the label field
|
||||
const label = labelParts.join(' : ');
|
||||
refId2LabelField[refId].values.push(label);
|
||||
|
||||
// Calculate the reduction of the current field
|
||||
// and push the frame with reduction
|
||||
// into the the appropriate field
|
||||
const reducerId = options[refId]?.stat ?? ReducerID.lastNotNull;
|
||||
const value = reduceField({ field, reducers: [reducerId] })[reducerId] || null;
|
||||
refId2ValueField[refId].values.push(value);
|
||||
|
||||
// Push the appropriate time and value frame
|
||||
// to the trend frame for the sparkline
|
||||
const sparklineFrame = new MutableDataFrame();
|
||||
if (timeField !== undefined) {
|
||||
sparklineFrame.addField(timeField);
|
||||
sparklineFrame.addField(field);
|
||||
}
|
||||
refId2FrameField[refId].values.push(sparklineFrame);
|
||||
}
|
||||
|
||||
// If we're merging then we only add at the very
|
||||
// end that is when i has reached the end of the data
|
||||
if (merge && framesForRef.length - 1 !== i) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finally, allocate the new frame
|
||||
const table = new MutableDataFrame();
|
||||
|
||||
// Set the refId
|
||||
table.refId = refId;
|
||||
|
||||
// Add the label, sparkline, and value fields
|
||||
// into the new frame
|
||||
table.addField(refId2LabelField[refId]);
|
||||
table.addField(refId2FrameField[refId]);
|
||||
table.addField(refId2ValueField[refId]);
|
||||
|
||||
// Finaly push to the result
|
||||
result.push(table);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new field with the given label and type.
|
||||
*
|
||||
* @param label
|
||||
* The string label for the field.
|
||||
* @param type
|
||||
* The type fo the field (e.g. number, boolean, etc.)
|
||||
* @returns
|
||||
* A new Field"
|
||||
*/
|
||||
function newField(label: string, type: FieldType) {
|
||||
return {
|
||||
name: label,
|
||||
type: type,
|
||||
config: {},
|
||||
values: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the refIds contained in an array of Data frames.
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
export function getRefData(data: DataFrame[]) {
|
||||
let refMap: RefCount = {};
|
||||
for (const frame of data) {
|
||||
if (frame.refId !== undefined) {
|
||||
if (refMap[frame.refId] === undefined) {
|
||||
refMap[frame.refId] = 1;
|
||||
} else {
|
||||
refMap[frame.refId]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return labelFields;
|
||||
return refMap;
|
||||
}
|
||||
|
33
public/img/transformations/disabled/timeSeriesTable.svg
Normal file
33
public/img/transformations/disabled/timeSeriesTable.svg
Normal file
@ -0,0 +1,33 @@
|
||||
<svg width="124" height="48" viewBox="0 0 124 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1764_106195)">
|
||||
<path d="M76 0.611699V7.64012H90.394V0H76.6015C76.442 0 76.289 0.0644467 76.1762 0.179162C76.0634 0.293878 76 0.449466 76 0.611699Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M109.606 7.64012H124V0.611699C124 0.449466 123.937 0.293878 123.824 0.179162C123.711 0.0644467 123.558 0 123.398 0L109.606 0V7.64012Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M107.194 0H92.8V7.64012H107.194V0Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M107.194 10.0869H92.8V17.727H107.194V10.0869Z" fill="#CCCCDC" fill-opacity="0.12"/>
|
||||
<path d="M90.394 10.0869H76V17.727H90.394V10.0869Z" fill="#CCCCDC" fill-opacity="0.12"/>
|
||||
<path d="M124 10.0869H109.606V17.727H124V10.0869Z" fill="#CCCCDC" fill-opacity="0.12"/>
|
||||
<path d="M124 20.1799H109.606V27.8262H124V20.1799Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M107.194 20.1799H92.8V27.8262H107.194V20.1799Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M90.394 20.1799H76V27.8262H90.394V20.1799Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M107.194 30.2668H92.8V37.907H107.194V30.2668Z" fill="#CCCCDC" fill-opacity="0.12"/>
|
||||
<path d="M124 30.2668H109.606V37.907H124V30.2668Z" fill="#CCCCDC" fill-opacity="0.12"/>
|
||||
<path d="M90.394 30.2668H76V37.907H90.394V30.2668Z" fill="#CCCCDC" fill-opacity="0.12"/>
|
||||
<path d="M107.194 40.3599H92.8V48H107.194V40.3599Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M124 47.3883V40.3721H109.606V48H123.398C123.558 48 123.711 47.9355 123.824 47.8208C123.937 47.7061 124 47.5505 124 47.3883Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M90.394 48V40.3721H76V47.3883C76 47.5505 76.0634 47.7061 76.1762 47.8208C76.289 47.9355 76.442 48 76.6015 48H90.394Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
</g>
|
||||
<path d="M61.9067 30.4C62.6011 30.4 67.9327 26.1333 67.9327 24C67.9327 21.8666 62.7357 17.6 61.9067 17.6C61.0777 17.6 60.4023 18.1333 60.4023 19.1739C60.4023 20.2146 63.9067 22.8486 63.9067 22.8486C63.9067 22.8486 56.2539 22.1333 56 22.8486C55.7461 23.5639 55.7461 24.4361 56 25.1514C56.2539 25.8666 63.9067 25.1514 63.9067 25.1514C63.9067 25.1514 60.4023 28 60.4023 28.8321C60.4023 29.6643 61.2123 30.4 61.9067 30.4Z" fill="#CCCCDC" fill-opacity="0.4"/>
|
||||
<g clip-path="url(#clip1_1764_106195)">
|
||||
<path d="M20.5657 14.8473C20.3886 14.8474 20.2136 14.8094 20.0525 14.736L10.963 10.6159L1.68804 14.1483C1.38138 14.2648 1.04102 14.2546 0.741842 14.12C0.442666 13.9854 0.209179 13.7375 0.0927449 13.4307C-0.0236892 13.1238 -0.0135325 12.7833 0.120981 12.484C0.255494 12.1847 0.503345 11.9511 0.81001 11.8346L10.5796 8.12275C10.8825 7.9973 11.2228 7.9973 11.5257 8.12275L20.2813 12.0882L29.5562 2.19003C29.7339 2.01998 29.9583 1.90678 30.2006 1.86491C30.4429 1.82304 30.6922 1.85438 30.9166 1.95495L40.2843 6.01939L49.0832 0.204206C49.2187 0.111618 49.3713 0.0469507 49.5321 0.0139747C49.6929 -0.0190012 49.8586 -0.0196266 50.0197 0.0121352C50.1807 0.043897 50.3338 0.10741 50.47 0.198973C50.6063 0.290536 50.7229 0.408317 50.8132 0.545449C50.9035 0.682581 50.9656 0.836322 50.9959 0.997709C51.0262 1.1591 51.024 1.3249 50.9896 1.48546C50.9552 1.64602 50.8892 1.79812 50.7954 1.93289C50.7017 2.06766 50.582 2.18241 50.4435 2.27045L41.0881 8.45682C40.916 8.56985 40.7184 8.63811 40.5133 8.6554C40.3081 8.67268 40.1019 8.63845 39.9133 8.5558L30.7434 4.57178L21.4685 14.47C21.3516 14.591 21.2112 14.6869 21.056 14.7518C20.9008 14.8167 20.7339 14.8492 20.5657 14.8473Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M49.7632 10.9623L40.4079 20.8605L30.4342 13.4368L20.5656 20.8605L11.0062 19.0046L1.23657 27.0468H1.30459V47.9939H49.8931L49.7632 10.9623Z" fill="#CCCCDC" fill-opacity="0.04"/>
|
||||
<path d="M1.30458 28.2841H1.23657C0.98245 28.2834 0.734739 28.2043 0.527135 28.0577C0.319532 27.911 0.162116 27.704 0.0763042 27.4647C-0.00950775 27.2254 -0.0195494 26.9654 0.0475455 26.7202C0.11464 26.475 0.255614 26.2564 0.451287 26.0942L10.2209 18.0519C10.3614 17.9361 10.5257 17.8528 10.702 17.8079C10.8784 17.763 11.0625 17.7576 11.2412 17.7921L20.2626 19.5428L29.6798 12.447C29.8939 12.2864 30.1542 12.1996 30.4218 12.1996C30.6894 12.1996 30.9498 12.2864 31.1638 12.447L40.2533 19.2149L48.8481 10.1148C48.9577 9.9891 49.0916 9.88688 49.2417 9.81428C49.3918 9.74167 49.555 9.70018 49.7215 9.6923C49.8881 9.68442 50.0545 9.7103 50.2107 9.76841C50.367 9.82651 50.5099 9.91562 50.6309 10.0304C50.7519 10.1452 50.8484 10.2832 50.9147 10.4363C50.981 10.5893 51.0156 10.7542 51.0166 10.921C51.0176 11.0878 50.9848 11.2531 50.9203 11.4069C50.8558 11.5607 50.7609 11.6999 50.6413 11.816L41.3045 21.7142C41.0966 21.9331 40.8159 22.0685 40.5152 22.0947C40.2145 22.1209 39.9147 22.0362 39.6721 21.8565L30.4404 14.9835L21.3076 21.8503C21.1698 21.9547 21.0117 22.0289 20.8434 22.0684C20.6751 22.1078 20.5004 22.1115 20.3307 22.0792L11.3401 20.3347L2.31247 27.7583C2.19874 27.9202 2.04786 28.0524 1.87249 28.1439C1.69712 28.2354 1.50237 28.2835 1.30458 28.2841Z" fill="#CCCCDC" fill-opacity="0.12"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1764_106195">
|
||||
<rect width="48" height="48" fill="white" transform="translate(76)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1764_106195">
|
||||
<rect width="51" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 5.1 KiB |
Loading…
Reference in New Issue
Block a user