Trend: Support disconnect values and connect nulls options (#70616)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Drew Slobodnjak 2023-07-13 19:28:58 -07:00 committed by GitHub
parent 3afc20fae9
commit 1a857552a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1336 additions and 489 deletions

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,7 @@ export interface GraphNGProps extends Themeable2 {
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder;
propsToDiff?: Array<string | PropDiffFn>;
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame;
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null;
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
/**

View File

@ -1,4 +1,6 @@
import { DataFrame, FieldType } from '@grafana/data';
import { DataFrame } from '@grafana/data';
import { getRefField } from './utils';
type InsertMode = (prev: number, next: number, threshold: number) => number;
@ -29,10 +31,7 @@ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame {
insertMode = INSERT_MODES.threshold;
}
const refField = frame.fields.find((field) => {
// note: getFieldDisplayName() would require full DF[]
return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time;
});
const refField = getRefField(frame, refFieldName);
if (refField == null) {
return frame;

View File

@ -18,9 +18,17 @@ function isVisibleBarField(f: Field) {
);
}
export function getRefField(frame: DataFrame, refFieldName?: string | null) {
return frame.fields.find((field) => {
// note: getFieldDisplayName() would require full DF[]
return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time;
});
}
// will mutate the DataFrame's fields' values
function applySpanNullsThresholds(frame: DataFrame) {
let refField = frame.fields.find((field) => field.type === FieldType.time); // this doesnt need to be time, just any numeric/asc join field
function applySpanNullsThresholds(frame: DataFrame, refFieldName?: string | null) {
const refField = getRefField(frame, refFieldName);
let refValues = refField?.values as any[];
for (let i = 0; i < frame.fields.length; i++) {
@ -43,12 +51,22 @@ function applySpanNullsThresholds(frame: DataFrame) {
}
export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) {
let xField: Field;
loop: for (let frame of frames) {
for (let field of frame.fields) {
if (dimFields.x(field, frame, frames)) {
xField = field;
break loop;
}
}
}
// apply null insertions at interval
frames = frames.map((frame) => {
if (!frame.fields[0].state?.nullThresholdApplied) {
if (!xField?.state?.nullThresholdApplied) {
return applyNullInsertThreshold({
frame,
refFieldName: null,
refFieldName: xField.name,
refFieldPseudoMin: timeRange?.from.valueOf(),
refFieldPseudoMax: timeRange?.to.valueOf(),
});
@ -84,7 +102,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers
return;
}
const xVals = frame.fields[0].values;
const xVals = xField.values;
for (let i = 0; i < xVals.length; i++) {
if (i > 0) {
@ -102,7 +120,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers
});
if (alignedFrame) {
alignedFrame = applySpanNullsThresholds(alignedFrame);
alignedFrame = applySpanNullsThresholds(alignedFrame, xField!.name);
// append 2 null vals at minXDelta to bar series
if (minXDelta !== Infinity) {

View File

@ -10,6 +10,7 @@ import { commonOptionsBuilder } from '@grafana/ui';
import { InsertNullsEditor } from '../timeseries/InsertNullsEditor';
import { SpanNullsEditor } from '../timeseries/SpanNullsEditor';
import { NullEditorSettings } from '../timeseries/config';
import { StateTimelinePanel } from './StateTimelinePanel';
import { timelinePanelChangedHandler } from './migrations';
@ -51,7 +52,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
step: 1,
},
})
.addCustomEditor<void, boolean>({
.addCustomEditor<NullEditorSettings, boolean>({
id: 'spanNulls',
path: 'spanNulls',
name: 'Connect null values',
@ -60,8 +61,9 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
override: SpanNullsEditor,
shouldApply: (field) => field.type !== FieldType.time,
process: identityOverrideProcessor,
settings: { isTime: true },
})
.addCustomEditor<void, boolean>({
.addCustomEditor<NullEditorSettings, boolean>({
id: 'insertNulls',
path: 'insertNulls',
name: 'Disconnect values',
@ -70,6 +72,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
override: InsertNullsEditor,
shouldApply: (field) => field.type !== FieldType.time,
process: identityOverrideProcessor,
settings: { isTime: true },
});
commonOptionsBuilder.addHideFrom(builder);

View File

@ -18,14 +18,21 @@ const DISCONNECT_OPTIONS: Array<SelectableValue<boolean | number>> = [
type Props = FieldOverrideEditorProps<boolean | number, unknown>;
export const InsertNullsEditor = ({ value, onChange }: Props) => {
export const InsertNullsEditor = ({ value, onChange, item }: Props) => {
const isThreshold = typeof value === 'number';
DISCONNECT_OPTIONS[1].value = isThreshold ? value : 3600000; // 1h
return (
<HorizontalGroup>
<RadioButtonGroup value={value} options={DISCONNECT_OPTIONS} onChange={onChange} />
{isThreshold && <NullsThresholdInput value={value} onChange={onChange} inputPrefix={InputPrefix.GreaterThan} />}
{isThreshold && (
<NullsThresholdInput
value={value}
onChange={onChange}
inputPrefix={InputPrefix.GreaterThan}
isTime={item.settings.isTime}
/>
)}
</HorizontalGroup>
);
};

View File

@ -8,15 +8,27 @@ export enum InputPrefix {
GreaterThan = 'greaterthan',
}
type Props = { value: number; onChange: (value?: number | boolean | undefined) => void; inputPrefix?: InputPrefix };
type Props = {
value: number;
onChange: (value?: number | boolean | undefined) => void;
inputPrefix?: InputPrefix;
isTime: boolean;
};
export const NullsThresholdInput = ({ value, onChange, inputPrefix }: Props) => {
const formattedTime = rangeUtil.secondsToHms(value / 1000);
export const NullsThresholdInput = ({ value, onChange, inputPrefix, isTime }: Props) => {
let defaultValue = rangeUtil.secondsToHms(value / 1000);
if (!isTime) {
defaultValue = '10';
}
const checkAndUpdate = (txt: string) => {
let val: boolean | number = false;
if (txt) {
try {
val = rangeUtil.intervalToMs(txt);
if (isTime && rangeUtil.isValidTimeSpan(txt)) {
val = rangeUtil.intervalToMs(txt);
} else {
val = Number(txt);
}
} catch (err) {
console.warn('ERROR', err);
}
@ -47,7 +59,7 @@ export const NullsThresholdInput = ({ value, onChange, inputPrefix }: Props) =>
autoFocus={false}
placeholder="never"
width={10}
defaultValue={formattedTime}
defaultValue={defaultValue}
onKeyDown={handleEnterKey}
onBlur={handleBlur}
prefix={prefix}

View File

@ -22,14 +22,21 @@ const GAPS_OPTIONS: Array<SelectableValue<boolean | number>> = [
type Props = FieldOverrideEditorProps<boolean | number, unknown>;
export const SpanNullsEditor = ({ value, onChange }: Props) => {
export const SpanNullsEditor = ({ value, onChange, item }: Props) => {
const isThreshold = typeof value === 'number';
GAPS_OPTIONS[2].value = isThreshold ? value : 3600000; // 1h
return (
<HorizontalGroup>
<RadioButtonGroup value={value} options={GAPS_OPTIONS} onChange={onChange} />
{isThreshold && <NullsThresholdInput value={value} onChange={onChange} inputPrefix={InputPrefix.LessThan} />}
{isThreshold && (
<NullsThresholdInput
value={value}
onChange={onChange}
inputPrefix={InputPrefix.LessThan}
isTime={item.settings.isTime}
/>
)}
</HorizontalGroup>
);
};

View File

@ -42,7 +42,9 @@ export const defaultGraphConfig: GraphFieldConfig = {
const categoryStyles = ['Graph styles'];
export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<GraphFieldConfig> {
export type NullEditorSettings = { isTime: boolean };
export function getGraphFieldConfig(cfg: GraphFieldConfig, isTime = true): SetFieldConfigOptionsArgs<GraphFieldConfig> {
return {
standardOptions: {
[FieldConfigProperty.Color]: {
@ -143,7 +145,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
process: identityOverrideProcessor,
shouldApply: (field) => field.type === FieldType.number,
})
.addCustomEditor<void, boolean>({
.addCustomEditor<NullEditorSettings, boolean>({
id: 'spanNulls',
path: 'spanNulls',
name: 'Connect null values',
@ -154,8 +156,9 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
showIf: (config) => config.drawStyle === GraphDrawStyle.Line,
shouldApply: (field) => field.type !== FieldType.time,
process: identityOverrideProcessor,
settings: { isTime },
})
.addCustomEditor<void, boolean>({
.addCustomEditor<NullEditorSettings, boolean>({
id: 'insertNulls',
path: 'insertNulls',
name: 'Disconnect values',
@ -166,6 +169,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
showIf: (config) => config.drawStyle === GraphDrawStyle.Line,
shouldApply: (field) => field.type !== FieldType.time,
process: identityOverrideProcessor,
settings: { isTime },
})
.addRadio({
path: 'showPoints',

View File

@ -1,9 +1,17 @@
import React, { useMemo } from 'react';
import { FieldType, PanelProps } from '@grafana/data';
import { DataFrame, FieldType, getFieldDisplayName, PanelProps, TimeRange } from '@grafana/data';
import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { KeyboardPlugin, TimeSeries, TooltipDisplayMode, TooltipPlugin, usePanelContext } from '@grafana/ui';
import {
KeyboardPlugin,
preparePlotFrame,
TimeSeries,
TooltipDisplayMode,
TooltipPlugin,
usePanelContext,
} from '@grafana/ui';
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types';
import { findFieldIndex } from 'app/features/dimensions';
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
@ -23,6 +31,20 @@ export const TrendPanel = ({
id,
}: PanelProps<Options>) => {
const { sync } = usePanelContext();
// Need to fallback to first number field if no xField is set in options otherwise panel crashes 😬
const trendXFieldName =
options.xField ?? data.series[0].fields.find((field) => field.type === FieldType.number)?.name;
const preparePlotFrameTimeless = (frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) => {
dimFields = {
...dimFields,
x: (field, frame, frames) => {
return getFieldDisplayName(field, frame, frames) === trendXFieldName;
},
};
return preparePlotFrame(frames, dimFields);
};
const info = useMemo(() => {
if (data.series.length > 1) {
@ -90,6 +112,7 @@ export const TrendPanel = ({
height={height}
legend={options.legend}
options={options}
preparePlotFrame={preparePlotFrameTimeless}
>
{(config, alignedDataFrame) => {
if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) {

View File

@ -8,7 +8,7 @@ import { FieldConfig, Options } from './panelcfg.gen';
import { TrendSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig))
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig, false))
.setPanelOptions((builder) => {
const category = ['X Axis'];
builder.addFieldNamePicker({