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 <leeoniya@gmail.com>
This commit is contained in:
Kyle Cunningham
2022-06-17 14:38:59 -05:00
committed by GitHub
parent 81089b956a
commit 86b785d039
8 changed files with 100 additions and 34 deletions

View File

@@ -38,6 +38,11 @@ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame {
return frame; return frame;
} }
refField.state = {
...refField.state,
nullThresholdApplied: true,
};
const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls ?? refField.config.interval ?? null); const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls ?? refField.config.interval ?? null);
const uniqueThresholds = new Set<number>(thresholds); const uniqueThresholds = new Set<number>(thresholds);
@@ -76,21 +81,10 @@ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame {
return { return {
...frame, ...frame,
length: filledFieldValues[0].length, length: filledFieldValues[0].length,
fields: frame.fields.map((field, i) => { fields: frame.fields.map((field, i) => ({
let f = { ...field,
...field, values: new ArrayVector(filledFieldValues[i]),
values: new ArrayVector(filledFieldValues[i]), })),
};
if (i === 0) {
f.state = {
...field.state,
nullThresholdApplied: true,
};
}
return f;
}),
}; };
} }

View File

@@ -1,17 +1,27 @@
import { DataFrame } from '@grafana/data'; import { ArrayVector, DataFrame } from '@grafana/data';
export function nullToValue(frame: DataFrame) { export function nullToValue(frame: DataFrame) {
frame.fields.forEach((f) => { return {
const noValue = +f.config?.noValue!; ...frame,
if (!Number.isNaN(noValue)) { fields: frame.fields.map((field) => {
const values = f.values.toArray(); const noValue = +field.config?.noValue!;
for (let i = 0; i < values.length; i++) {
if (values[i] === null) {
values[i] = 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;
}
}),
};
} }

View File

@@ -328,6 +328,7 @@ describe('GraphNG utils', () => {
"config": Object {}, "config": Object {},
"name": "time", "name": "time",
"state": Object { "state": Object {
"nullThresholdApplied": true,
"origin": Object { "origin": Object {
"fieldIndex": 0, "fieldIndex": 0,
"frameIndex": 0, "frameIndex": 0,

View File

@@ -46,7 +46,9 @@ export const CandlestickPanel: React.FC<CandlestickPanelProps> = ({
const theme = useTheme2(); 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(() => { const { renderers, tweakScale, tweakAxis } = useMemo(() => {
let tweakScale = (opts: ScaleProps, forField: Field) => opts; let tweakScale = (opts: ScaleProps, forField: Field) => opts;

View File

@@ -6,6 +6,7 @@ import {
getFieldDisplayName, getFieldDisplayName,
GrafanaTheme2, GrafanaTheme2,
outerJoinDataFrames, outerJoinDataFrames,
TimeRange,
} from '@grafana/data'; } from '@grafana/data';
import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
import { findField } from 'app/features/dimensions'; import { findField } from 'app/features/dimensions';
@@ -97,7 +98,8 @@ function findFieldOrAuto(frame: DataFrame, info: FieldPickerInfo, options: Candl
export function prepareCandlestickFields( export function prepareCandlestickFields(
series: DataFrame[] | undefined, series: DataFrame[] | undefined,
options: CandlestickOptions, options: CandlestickOptions,
theme: GrafanaTheme2 theme: GrafanaTheme2,
timeRange?: TimeRange
): CandlestickData | null { ): CandlestickData | null {
if (!series?.length) { if (!series?.length) {
return null; return null;
@@ -119,7 +121,7 @@ export function prepareCandlestickFields(
const data: CandlestickData = { aligned, frame: aligned, names: {} }; const data: CandlestickData = { aligned, frame: aligned, names: {} };
// Apply same filter as everythign else in timeseries // Apply same filter as everythign else in timeseries
const timeSeriesFrames = prepareGraphableFields([aligned], theme); const timeSeriesFrames = prepareGraphableFields([aligned], theme, timeRange);
if (!timeSeriesFrames) { if (!timeSeriesFrames) {
return null; return null;
} }

View File

@@ -36,7 +36,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); 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) { if (!frames) {
return ( return (

View File

@@ -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,
]
`);
});
}); });

View File

@@ -6,13 +6,20 @@ import {
getDisplayProcessor, getDisplayProcessor,
GrafanaTheme2, GrafanaTheme2,
isBooleanUnit, isBooleanUnit,
TimeRange,
} from '@grafana/data'; } from '@grafana/data';
import { GraphFieldConfig, LineInterpolation } from '@grafana/schema'; 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 * 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) { if (!series?.length) {
return null; return null;
} }
@@ -27,7 +34,13 @@ export function prepareGraphableFields(series: DataFrame[], theme: GrafanaTheme2
let hasTimeField = false; let hasTimeField = false;
let hasValueField = 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) { switch (field.type) {
case FieldType.time: case FieldType.time:
hasTimeField = true; hasTimeField = true;