mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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;
|
||||
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;
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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))) {
|
||||
|
@ -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({
|
||||
|
Loading…
Reference in New Issue
Block a user