mirror of https://github.com/grafana/grafana.git
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:
parent
3afc20fae9
commit
1a857552a1
File diff suppressed because it is too large
Load Diff
|
|
@ -54,7 +54,7 @@ export interface GraphNGProps extends Themeable2 {
|
||||||
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
|
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
|
||||||
prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder;
|
prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder;
|
||||||
propsToDiff?: Array<string | PropDiffFn>;
|
propsToDiff?: Array<string | PropDiffFn>;
|
||||||
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame;
|
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null;
|
||||||
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
|
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
type InsertMode = (prev: number, next: number, threshold: number) => number;
|
||||||
|
|
||||||
|
|
@ -29,10 +31,7 @@ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame {
|
||||||
insertMode = INSERT_MODES.threshold;
|
insertMode = INSERT_MODES.threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refField = frame.fields.find((field) => {
|
const refField = getRefField(frame, refFieldName);
|
||||||
// note: getFieldDisplayName() would require full DF[]
|
|
||||||
return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refField == null) {
|
if (refField == null) {
|
||||||
return frame;
|
return frame;
|
||||||
|
|
|
||||||
|
|
@ -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
|
// will mutate the DataFrame's fields' values
|
||||||
function applySpanNullsThresholds(frame: DataFrame) {
|
function applySpanNullsThresholds(frame: DataFrame, refFieldName?: string | null) {
|
||||||
let refField = frame.fields.find((field) => field.type === FieldType.time); // this doesnt need to be time, just any numeric/asc join field
|
const refField = getRefField(frame, refFieldName);
|
||||||
|
|
||||||
let refValues = refField?.values as any[];
|
let refValues = refField?.values as any[];
|
||||||
|
|
||||||
for (let i = 0; i < frame.fields.length; i++) {
|
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) {
|
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
|
// apply null insertions at interval
|
||||||
frames = frames.map((frame) => {
|
frames = frames.map((frame) => {
|
||||||
if (!frame.fields[0].state?.nullThresholdApplied) {
|
if (!xField?.state?.nullThresholdApplied) {
|
||||||
return applyNullInsertThreshold({
|
return applyNullInsertThreshold({
|
||||||
frame,
|
frame,
|
||||||
refFieldName: null,
|
refFieldName: xField.name,
|
||||||
refFieldPseudoMin: timeRange?.from.valueOf(),
|
refFieldPseudoMin: timeRange?.from.valueOf(),
|
||||||
refFieldPseudoMax: timeRange?.to.valueOf(),
|
refFieldPseudoMax: timeRange?.to.valueOf(),
|
||||||
});
|
});
|
||||||
|
|
@ -84,7 +102,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const xVals = frame.fields[0].values;
|
const xVals = xField.values;
|
||||||
|
|
||||||
for (let i = 0; i < xVals.length; i++) {
|
for (let i = 0; i < xVals.length; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
|
|
@ -102,7 +120,7 @@ export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers
|
||||||
});
|
});
|
||||||
|
|
||||||
if (alignedFrame) {
|
if (alignedFrame) {
|
||||||
alignedFrame = applySpanNullsThresholds(alignedFrame);
|
alignedFrame = applySpanNullsThresholds(alignedFrame, xField!.name);
|
||||||
|
|
||||||
// append 2 null vals at minXDelta to bar series
|
// append 2 null vals at minXDelta to bar series
|
||||||
if (minXDelta !== Infinity) {
|
if (minXDelta !== Infinity) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { commonOptionsBuilder } from '@grafana/ui';
|
||||||
|
|
||||||
import { InsertNullsEditor } from '../timeseries/InsertNullsEditor';
|
import { InsertNullsEditor } from '../timeseries/InsertNullsEditor';
|
||||||
import { SpanNullsEditor } from '../timeseries/SpanNullsEditor';
|
import { SpanNullsEditor } from '../timeseries/SpanNullsEditor';
|
||||||
|
import { NullEditorSettings } from '../timeseries/config';
|
||||||
|
|
||||||
import { StateTimelinePanel } from './StateTimelinePanel';
|
import { StateTimelinePanel } from './StateTimelinePanel';
|
||||||
import { timelinePanelChangedHandler } from './migrations';
|
import { timelinePanelChangedHandler } from './migrations';
|
||||||
|
|
@ -51,7 +52,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.addCustomEditor<void, boolean>({
|
.addCustomEditor<NullEditorSettings, boolean>({
|
||||||
id: 'spanNulls',
|
id: 'spanNulls',
|
||||||
path: 'spanNulls',
|
path: 'spanNulls',
|
||||||
name: 'Connect null values',
|
name: 'Connect null values',
|
||||||
|
|
@ -60,8 +61,9 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
|
||||||
override: SpanNullsEditor,
|
override: SpanNullsEditor,
|
||||||
shouldApply: (field) => field.type !== FieldType.time,
|
shouldApply: (field) => field.type !== FieldType.time,
|
||||||
process: identityOverrideProcessor,
|
process: identityOverrideProcessor,
|
||||||
|
settings: { isTime: true },
|
||||||
})
|
})
|
||||||
.addCustomEditor<void, boolean>({
|
.addCustomEditor<NullEditorSettings, boolean>({
|
||||||
id: 'insertNulls',
|
id: 'insertNulls',
|
||||||
path: 'insertNulls',
|
path: 'insertNulls',
|
||||||
name: 'Disconnect values',
|
name: 'Disconnect values',
|
||||||
|
|
@ -70,6 +72,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel)
|
||||||
override: InsertNullsEditor,
|
override: InsertNullsEditor,
|
||||||
shouldApply: (field) => field.type !== FieldType.time,
|
shouldApply: (field) => field.type !== FieldType.time,
|
||||||
process: identityOverrideProcessor,
|
process: identityOverrideProcessor,
|
||||||
|
settings: { isTime: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
commonOptionsBuilder.addHideFrom(builder);
|
commonOptionsBuilder.addHideFrom(builder);
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,21 @@ const DISCONNECT_OPTIONS: Array<SelectableValue<boolean | number>> = [
|
||||||
|
|
||||||
type Props = FieldOverrideEditorProps<boolean | number, unknown>;
|
type Props = FieldOverrideEditorProps<boolean | number, unknown>;
|
||||||
|
|
||||||
export const InsertNullsEditor = ({ value, onChange }: Props) => {
|
export const InsertNullsEditor = ({ value, onChange, item }: Props) => {
|
||||||
const isThreshold = typeof value === 'number';
|
const isThreshold = typeof value === 'number';
|
||||||
DISCONNECT_OPTIONS[1].value = isThreshold ? value : 3600000; // 1h
|
DISCONNECT_OPTIONS[1].value = isThreshold ? value : 3600000; // 1h
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<RadioButtonGroup value={value} options={DISCONNECT_OPTIONS} onChange={onChange} />
|
<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>
|
</HorizontalGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,27 @@ export enum InputPrefix {
|
||||||
GreaterThan = 'greaterthan',
|
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) => {
|
export const NullsThresholdInput = ({ value, onChange, inputPrefix, isTime }: Props) => {
|
||||||
const formattedTime = rangeUtil.secondsToHms(value / 1000);
|
let defaultValue = rangeUtil.secondsToHms(value / 1000);
|
||||||
|
if (!isTime) {
|
||||||
|
defaultValue = '10';
|
||||||
|
}
|
||||||
const checkAndUpdate = (txt: string) => {
|
const checkAndUpdate = (txt: string) => {
|
||||||
let val: boolean | number = false;
|
let val: boolean | number = false;
|
||||||
if (txt) {
|
if (txt) {
|
||||||
try {
|
try {
|
||||||
val = rangeUtil.intervalToMs(txt);
|
if (isTime && rangeUtil.isValidTimeSpan(txt)) {
|
||||||
|
val = rangeUtil.intervalToMs(txt);
|
||||||
|
} else {
|
||||||
|
val = Number(txt);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('ERROR', err);
|
console.warn('ERROR', err);
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +59,7 @@ export const NullsThresholdInput = ({ value, onChange, inputPrefix }: Props) =>
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
placeholder="never"
|
placeholder="never"
|
||||||
width={10}
|
width={10}
|
||||||
defaultValue={formattedTime}
|
defaultValue={defaultValue}
|
||||||
onKeyDown={handleEnterKey}
|
onKeyDown={handleEnterKey}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,21 @@ const GAPS_OPTIONS: Array<SelectableValue<boolean | number>> = [
|
||||||
|
|
||||||
type Props = FieldOverrideEditorProps<boolean | number, unknown>;
|
type Props = FieldOverrideEditorProps<boolean | number, unknown>;
|
||||||
|
|
||||||
export const SpanNullsEditor = ({ value, onChange }: Props) => {
|
export const SpanNullsEditor = ({ value, onChange, item }: Props) => {
|
||||||
const isThreshold = typeof value === 'number';
|
const isThreshold = typeof value === 'number';
|
||||||
GAPS_OPTIONS[2].value = isThreshold ? value : 3600000; // 1h
|
GAPS_OPTIONS[2].value = isThreshold ? value : 3600000; // 1h
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<RadioButtonGroup value={value} options={GAPS_OPTIONS} onChange={onChange} />
|
<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>
|
</HorizontalGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,9 @@ export const defaultGraphConfig: GraphFieldConfig = {
|
||||||
|
|
||||||
const categoryStyles = ['Graph styles'];
|
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 {
|
return {
|
||||||
standardOptions: {
|
standardOptions: {
|
||||||
[FieldConfigProperty.Color]: {
|
[FieldConfigProperty.Color]: {
|
||||||
|
|
@ -143,7 +145,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
|
||||||
process: identityOverrideProcessor,
|
process: identityOverrideProcessor,
|
||||||
shouldApply: (field) => field.type === FieldType.number,
|
shouldApply: (field) => field.type === FieldType.number,
|
||||||
})
|
})
|
||||||
.addCustomEditor<void, boolean>({
|
.addCustomEditor<NullEditorSettings, boolean>({
|
||||||
id: 'spanNulls',
|
id: 'spanNulls',
|
||||||
path: 'spanNulls',
|
path: 'spanNulls',
|
||||||
name: 'Connect null values',
|
name: 'Connect null values',
|
||||||
|
|
@ -154,8 +156,9 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
|
||||||
showIf: (config) => config.drawStyle === GraphDrawStyle.Line,
|
showIf: (config) => config.drawStyle === GraphDrawStyle.Line,
|
||||||
shouldApply: (field) => field.type !== FieldType.time,
|
shouldApply: (field) => field.type !== FieldType.time,
|
||||||
process: identityOverrideProcessor,
|
process: identityOverrideProcessor,
|
||||||
|
settings: { isTime },
|
||||||
})
|
})
|
||||||
.addCustomEditor<void, boolean>({
|
.addCustomEditor<NullEditorSettings, boolean>({
|
||||||
id: 'insertNulls',
|
id: 'insertNulls',
|
||||||
path: 'insertNulls',
|
path: 'insertNulls',
|
||||||
name: 'Disconnect values',
|
name: 'Disconnect values',
|
||||||
|
|
@ -166,6 +169,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
|
||||||
showIf: (config) => config.drawStyle === GraphDrawStyle.Line,
|
showIf: (config) => config.drawStyle === GraphDrawStyle.Line,
|
||||||
shouldApply: (field) => field.type !== FieldType.time,
|
shouldApply: (field) => field.type !== FieldType.time,
|
||||||
process: identityOverrideProcessor,
|
process: identityOverrideProcessor,
|
||||||
|
settings: { isTime },
|
||||||
})
|
})
|
||||||
.addRadio({
|
.addRadio({
|
||||||
path: 'showPoints',
|
path: 'showPoints',
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import React, { useMemo } from 'react';
|
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 { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames';
|
||||||
import { config, PanelDataErrorView } from '@grafana/runtime';
|
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 { findFieldIndex } from 'app/features/dimensions';
|
||||||
|
|
||||||
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
|
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
|
||||||
|
|
@ -23,6 +31,20 @@ export const TrendPanel = ({
|
||||||
id,
|
id,
|
||||||
}: PanelProps<Options>) => {
|
}: PanelProps<Options>) => {
|
||||||
const { sync } = usePanelContext();
|
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(() => {
|
const info = useMemo(() => {
|
||||||
if (data.series.length > 1) {
|
if (data.series.length > 1) {
|
||||||
|
|
@ -90,6 +112,7 @@ export const TrendPanel = ({
|
||||||
height={height}
|
height={height}
|
||||||
legend={options.legend}
|
legend={options.legend}
|
||||||
options={options}
|
options={options}
|
||||||
|
preparePlotFrame={preparePlotFrameTimeless}
|
||||||
>
|
>
|
||||||
{(config, alignedDataFrame) => {
|
{(config, alignedDataFrame) => {
|
||||||
if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) {
|
if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { FieldConfig, Options } from './panelcfg.gen';
|
||||||
import { TrendSuggestionsSupplier } from './suggestions';
|
import { TrendSuggestionsSupplier } from './suggestions';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
|
export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
|
||||||
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig))
|
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig, false))
|
||||||
.setPanelOptions((builder) => {
|
.setPanelOptions((builder) => {
|
||||||
const category = ['X Axis'];
|
const category = ['X Axis'];
|
||||||
builder.addFieldNamePicker({
|
builder.addFieldNamePicker({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue