From 86b785d039aa2a1d0e4ac91765ad5b37bfe1e020 Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Fri, 17 Jun 2022 14:38:59 -0500 Subject: [PATCH] Time Series Panel: Add Null Filling and "No Value" Support (#50907) * Use nullInsertThreshold and nullToValue in time series * Allow for undefined timeRange to support certain candlestick uses of prepareGraphableFields * Make sure null to value doesn't modify initial data * Do a shallow values copy and avoid Array.push() * Clean up null to value transformation. * Add basic tests * Remove redunant null threshold application flagging * set nullThresholdApplied flag even when no null inserts were done * Include nullThresholdApplied in test snapshot Co-authored-by: Leon Sorokin --- .../components/GraphNG/nullInsertThreshold.ts | 24 ++++------ .../src/components/GraphNG/nullToValue.ts | 36 +++++++++------ .../src/components/GraphNG/utils.test.ts | 1 + .../panel/candlestick/CandlestickPanel.tsx | 4 +- .../app/plugins/panel/candlestick/fields.ts | 6 ++- .../panel/timeseries/TimeSeriesPanel.tsx | 2 +- .../plugins/panel/timeseries/utils.test.ts | 44 +++++++++++++++++++ public/app/plugins/panel/timeseries/utils.ts | 17 ++++++- 8 files changed, 100 insertions(+), 34 deletions(-) diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts index 3b6cdb0e6c8..cc49b264751 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts +++ b/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts @@ -38,6 +38,11 @@ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame { return frame; } + refField.state = { + ...refField.state, + nullThresholdApplied: true, + }; + const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls ?? refField.config.interval ?? null); const uniqueThresholds = new Set(thresholds); @@ -76,21 +81,10 @@ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame { return { ...frame, length: filledFieldValues[0].length, - fields: frame.fields.map((field, i) => { - let f = { - ...field, - values: new ArrayVector(filledFieldValues[i]), - }; - - if (i === 0) { - f.state = { - ...field.state, - nullThresholdApplied: true, - }; - } - - return f; - }), + fields: frame.fields.map((field, i) => ({ + ...field, + values: new ArrayVector(filledFieldValues[i]), + })), }; } diff --git a/packages/grafana-ui/src/components/GraphNG/nullToValue.ts b/packages/grafana-ui/src/components/GraphNG/nullToValue.ts index b79ac200214..f6772d9766d 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullToValue.ts +++ b/packages/grafana-ui/src/components/GraphNG/nullToValue.ts @@ -1,17 +1,27 @@ -import { DataFrame } from '@grafana/data'; +import { ArrayVector, DataFrame } from '@grafana/data'; export function nullToValue(frame: DataFrame) { - frame.fields.forEach((f) => { - const noValue = +f.config?.noValue!; - if (!Number.isNaN(noValue)) { - const values = f.values.toArray(); - for (let i = 0; i < values.length; i++) { - if (values[i] === null) { - values[i] = noValue; - } - } - } - }); + return { + ...frame, + fields: frame.fields.map((field) => { + const noValue = +field.config?.noValue!; - return frame; + if (!Number.isNaN(noValue)) { + const transformedVals = field.values.toArray().slice(); + + for (let i = 0; i < transformedVals.length; i++) { + if (transformedVals[i] === null) { + transformedVals[i] = noValue; + } + } + + return { + ...field, + values: new ArrayVector(transformedVals), + }; + } else { + return field; + } + }), + }; } diff --git a/packages/grafana-ui/src/components/GraphNG/utils.test.ts b/packages/grafana-ui/src/components/GraphNG/utils.test.ts index 71b4f7a673e..bb99a583c53 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.test.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.test.ts @@ -328,6 +328,7 @@ describe('GraphNG utils', () => { "config": Object {}, "name": "time", "state": Object { + "nullThresholdApplied": true, "origin": Object { "fieldIndex": 0, "frameIndex": 0, diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index c42b7a2fdf2..aa4544ebb71 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -46,7 +46,9 @@ export const CandlestickPanel: React.FC = ({ const theme = useTheme2(); - const info = useMemo(() => prepareCandlestickFields(data?.series, options, theme), [data, options, theme]); + const info = useMemo(() => { + return prepareCandlestickFields(data?.series, options, theme, timeRange); + }, [data, options, theme, timeRange]); const { renderers, tweakScale, tweakAxis } = useMemo(() => { let tweakScale = (opts: ScaleProps, forField: Field) => opts; diff --git a/public/app/plugins/panel/candlestick/fields.ts b/public/app/plugins/panel/candlestick/fields.ts index d4c3e17e111..415e70e3200 100644 --- a/public/app/plugins/panel/candlestick/fields.ts +++ b/public/app/plugins/panel/candlestick/fields.ts @@ -6,6 +6,7 @@ import { getFieldDisplayName, GrafanaTheme2, outerJoinDataFrames, + TimeRange, } from '@grafana/data'; import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { findField } from 'app/features/dimensions'; @@ -97,7 +98,8 @@ function findFieldOrAuto(frame: DataFrame, info: FieldPickerInfo, options: Candl export function prepareCandlestickFields( series: DataFrame[] | undefined, options: CandlestickOptions, - theme: GrafanaTheme2 + theme: GrafanaTheme2, + timeRange?: TimeRange ): CandlestickData | null { if (!series?.length) { return null; @@ -119,7 +121,7 @@ export function prepareCandlestickFields( const data: CandlestickData = { aligned, frame: aligned, names: {} }; // Apply same filter as everythign else in timeseries - const timeSeriesFrames = prepareGraphableFields([aligned], theme); + const timeSeriesFrames = prepareGraphableFields([aligned], theme, timeRange); if (!timeSeriesFrames) { return null; } diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index e727764fa16..dfa3861e293 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -36,7 +36,7 @@ export const TimeSeriesPanel: React.FC = ({ return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); }; - const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2), [data]); + const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data, timeRange]); if (!frames) { return ( diff --git a/public/app/plugins/panel/timeseries/utils.test.ts b/public/app/plugins/panel/timeseries/utils.test.ts index 3064163f1b2..25239a24685 100644 --- a/public/app/plugins/panel/timeseries/utils.test.ts +++ b/public/app/plugins/panel/timeseries/utils.test.ts @@ -79,4 +79,48 @@ describe('prepare timeseries graph', () => { ] `); }); + + it('will insert nulls given an interval value', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 6] }, + { name: 'a', values: [1, 2, 3] }, + ], + }); + const frames = prepareGraphableFields([df], createTheme()); + + const field = frames![0].fields.find((f) => f.name === 'a'); + expect(field!.values.toArray()).toMatchInlineSnapshot(` + Array [ + 1, + null, + 2, + null, + null, + 3, + ] + `); + }); + + it('will insert and convert nulls to a configure "no value" value', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 6] }, + { name: 'a', config: { noValue: '20' }, values: [1, 2, 3] }, + ], + }); + const frames = prepareGraphableFields([df], createTheme()); + + const field = frames![0].fields.find((f) => f.name === 'a'); + expect(field!.values.toArray()).toMatchInlineSnapshot(` + Array [ + 1, + 20, + 2, + 20, + 20, + 3, + ] + `); + }); }); diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index b38e34cd238..9d720a321ba 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -6,13 +6,20 @@ import { getDisplayProcessor, GrafanaTheme2, isBooleanUnit, + TimeRange, } from '@grafana/data'; import { GraphFieldConfig, LineInterpolation } from '@grafana/schema'; +import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold'; +import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue'; /** * Returns null if there are no graphable fields */ -export function prepareGraphableFields(series: DataFrame[], theme: GrafanaTheme2): DataFrame[] | null { +export function prepareGraphableFields( + series: DataFrame[], + theme: GrafanaTheme2, + timeRange?: TimeRange +): DataFrame[] | null { if (!series?.length) { return null; } @@ -27,7 +34,13 @@ export function prepareGraphableFields(series: DataFrame[], theme: GrafanaTheme2 let hasTimeField = false; let hasValueField = false; - for (const field of frame.fields) { + let nulledFrame = applyNullInsertThreshold({ + frame, + refFieldPseudoMin: timeRange?.from.valueOf(), + refFieldPseudoMax: timeRange?.to.valueOf(), + }); + + for (const field of nullToValue(nulledFrame).fields) { switch (field.type) { case FieldType.time: hasTimeField = true;