Alerting: Edit thresholds by handle in timeseries panel (#38881)

* POC/Thresholds: Allow thresholds modification directly from the time series panel

* Snapshot updates

* Optimize styles memoization

* change threshold from graph

* renames and logging

* using useeffect to update graph

* Fix react worning about setting state on unmounted component

* revert panelrenderer

* using onFieldConfig change

* use a useeffect

* simplied fieldConfig state

* Do not use plot context in ThresholdControlsPlugin

* Do not throw setState warnings when drag handle is dropped

* Update thresholds position on the graph when updating threshold drag handle

* fix issues with rerenders

* prevent thresholds on conditions with range

* only edit the first threshold

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Peter Holmberg
2021-09-15 17:35:12 +02:00
committed by GitHub
parent b3196621f1
commit 74beb9a64c
15 changed files with 457 additions and 60 deletions

View File

@@ -17,7 +17,6 @@ export interface PanelRendererProps<P extends object = any, F extends object = a
onOptionsChange?: (options: P) => void;
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
fieldConfig?: FieldConfigSource<F>;
onFieldConfigChange?: (config: FieldConfigSource<F>) => void;
timeZone?: string;
width: number;
height: number;

View File

@@ -98,6 +98,7 @@ Object {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {
"__fixed": Object {
"auto": true,

View File

@@ -1,4 +1,11 @@
import { AnnotationEventUIModel, DashboardCursorSync, EventBus, EventBusSrv, SplitOpen } from '@grafana/data';
import {
EventBusSrv,
EventBus,
DashboardCursorSync,
AnnotationEventUIModel,
ThresholdsConfig,
SplitOpen,
} from '@grafana/data';
import React from 'react';
import { SeriesVisibilityChangeMode } from '.';
@@ -22,6 +29,20 @@ export interface PanelContext {
onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void;
onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void;
onAnnotationDelete?: (id: string) => void;
/**
* Enables modifying thresholds directly from the panel
*
* @alpha -- experimental
*/
canEditThresholds?: boolean;
/**
* Called when a panel wants to change default thresholds configuration
*
* @alpha -- experimental
*/
onThresholdsChange?: (thresholds: ThresholdsConfig) => void;
/**
* onSplitOpen is used in Explore to open the split view. It can be used in panels which has intercations and used in Explore as well.
* For example TimeSeries panel.

View File

@@ -37,6 +37,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {},
"select": undefined,
"series": Array [
@@ -86,6 +87,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {
"scale-x": Object {
"auto": false,
@@ -164,6 +166,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {
"scale-y": Object {
"auto": true,
@@ -215,6 +218,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {
"scale-y": Object {
"auto": true,
@@ -267,6 +271,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {
"scale-y": Object {
"auto": true,
@@ -382,6 +387,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {},
"select": undefined,
"series": Array [
@@ -499,6 +505,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {},
"select": undefined,
"series": Array [
@@ -612,6 +619,7 @@ describe('UPlotConfigBuilder', () => {
},
},
"hooks": Object {},
"padding": undefined,
"scales": Object {},
"select": undefined,
"series": Array [

View File

@@ -1,4 +1,4 @@
import uPlot, { Cursor, Band, Hooks, Select, AlignedData } from 'uplot';
import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding } from 'uplot';
import { merge } from 'lodash';
import {
DataFrame,
@@ -50,6 +50,7 @@ export class UPlotConfigBuilder {
private thresholds: Record<string, UPlotThresholdOptions> = {};
// Custom handler for closest datapoint and series lookup
private tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
private padding?: Padding = undefined;
prepData: PrepData | undefined = undefined;
@@ -164,6 +165,10 @@ export class UPlotConfigBuilder {
return this.sync;
}
setPadding(padding: Padding) {
this.padding = padding;
}
getConfig() {
const config: PlotConfig = {
series: [
@@ -203,6 +208,7 @@ export class UPlotConfigBuilder {
});
config.tzDate = this.tzDate;
config.padding = this.padding;
if (this.isStacking) {
// Let uPlot handle bands and fills

View File

@@ -5,7 +5,7 @@ import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
export type PlotConfig = Pick<
Options,
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate'
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding'
>;
export interface PlotPluginProps {

View File

@@ -1,9 +1,17 @@
import React, { PureComponent } from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { DataQuery, DataSourceInstanceSettings, PanelData, RelativeTimeRange } from '@grafana/data';
import {
DataQuery,
DataSourceInstanceSettings,
PanelData,
RelativeTimeRange,
ThresholdsConfig,
ThresholdsMode,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryWrapper } from './QueryWrapper';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { isExpressionQuery } from 'app/features/expressions/guards';
interface Props {
// The query configuration
@@ -50,6 +58,43 @@ export class QueryRows extends PureComponent<Props, State> {
);
};
onChangeThreshold = (thresholds: ThresholdsConfig, index: number) => {
const { queries, onQueriesChange } = this.props;
const referencedRefId = queries[index].refId;
onQueriesChange(
queries.map((query) => {
if (!isExpressionQuery(query.model)) {
return query;
}
if (query.model.conditions && query.model.conditions[0].query.params[0] === referencedRefId) {
return {
...query,
model: {
...query.model,
conditions: query.model.conditions.map((condition, conditionIndex) => {
// Only update the first condition for a given refId.
if (condition.query.params[0] === referencedRefId && conditionIndex === 0) {
return {
...condition,
evaluator: {
...condition.evaluator,
params: [parseFloat(thresholds.steps[1].value.toPrecision(3))],
},
};
}
return condition;
}),
},
};
}
return query;
})
);
};
onChangeDataSource = (settings: DataSourceInstanceSettings, index: number) => {
const { queries, onQueriesChange } = this.props;
@@ -130,8 +175,53 @@ export class QueryRows extends PureComponent<Props, State> {
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
};
getThresholdsForQueries = (queries: AlertQuery[]): Record<string, ThresholdsConfig> => {
const record: Record<string, ThresholdsConfig> = {};
for (const query of queries) {
if (!isExpressionQuery(query.model)) {
continue;
}
if (!Array.isArray(query.model.conditions)) {
continue;
}
query.model.conditions.forEach((condition, index) => {
if (index > 0) {
return;
}
const threshold = condition.evaluator.params[0];
const refId = condition.query.params[0];
if (condition.evaluator.type === 'outside_range' || condition.evaluator.type === 'within_range') {
return;
}
if (!record[refId]) {
record[refId] = {
mode: ThresholdsMode.Absolute,
steps: [
{
value: -Infinity,
color: 'green',
},
],
};
}
record[refId].steps.push({
value: threshold,
color: 'red',
});
});
}
return record;
};
render() {
const { onDuplicateQuery, onRunQueries, queries } = this.props;
const thresholdByRefId = this.getThresholdsForQueries(queries);
return (
<DragDropContext onDragEnd={this.onDragEnd}>
@@ -161,6 +251,8 @@ export class QueryRows extends PureComponent<Props, State> {
onDuplicateQuery={onDuplicateQuery}
onRunQueries={onRunQueries}
onChangeTimeRange={this.onChangeTimeRange}
thresholds={thresholdByRefId[query.refId]}
onChangeThreshold={this.onChangeThreshold}
/>
);
})}

View File

@@ -4,18 +4,19 @@ import { cloneDeep } from 'lodash';
import {
DataQuery,
DataSourceInstanceSettings,
getDefaultRelativeTimeRange,
GrafanaTheme2,
PanelData,
RelativeTimeRange,
getDefaultRelativeTimeRange,
ThresholdsConfig,
} from '@grafana/data';
import { useStyles2, RelativeTimeRangePicker } from '@grafana/ui';
import { RelativeTimeRangePicker, useStyles2 } from '@grafana/ui';
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
import { VizWrapper } from './VizWrapper';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { TABLE, TIMESERIES } from '../../utils/constants';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
import { AlertQuery } from 'app/types/unified-alerting-dto';
interface Props {
data: PanelData;
@@ -29,6 +30,8 @@ interface Props {
onDuplicateQuery: (query: AlertQuery) => void;
onRunQueries: () => void;
index: number;
thresholds: ThresholdsConfig;
onChangeThreshold: (thresholds: ThresholdsConfig, index: number) => void;
}
export const QueryWrapper: FC<Props> = ({
@@ -43,6 +46,8 @@ export const QueryWrapper: FC<Props> = ({
onDuplicateQuery,
query,
queries,
thresholds,
onChangeThreshold,
}) => {
const styles = useStyles2(getStyles);
const isExpression = isExpressionQuery(query.model);
@@ -77,7 +82,17 @@ export const QueryWrapper: FC<Props> = ({
onRunQuery={onRunQueries}
queries={queries}
renderHeaderExtras={() => renderTimePicker(query, index)}
visualization={data ? <VizWrapper data={data} changePanel={changePluginId} currentPanel={pluginId} /> : null}
visualization={
data ? (
<VizWrapper
data={data}
changePanel={changePluginId}
currentPanel={pluginId}
thresholds={thresholds}
onThresholdsChange={(thresholds) => onChangeThreshold(thresholds, index)}
/>
) : null
}
hideDisableQuery={true}
/>
</div>

View File

@@ -1,26 +1,55 @@
import React, { FC, useState } from 'react';
import React, { FC, useEffect, useMemo, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { css } from '@emotion/css';
import { GrafanaTheme2, PanelData } from '@grafana/data';
import { FieldConfigSource, GrafanaTheme2, PanelData, ThresholdsConfig } from '@grafana/data';
import { PanelRenderer } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { PanelContext, PanelContextProvider, useStyles2 } from '@grafana/ui';
import { PanelOptions } from 'app/plugins/panel/table/models.gen';
import { useVizHeight } from '../../hooks/useVizHeight';
import { SupportedPanelPlugins, PanelPluginsButtonGroup } from '../PanelPluginsButtonGroup';
import appEvents from 'app/core/app_events';
interface Props {
data: PanelData;
currentPanel: SupportedPanelPlugins;
changePanel: (panel: SupportedPanelPlugins) => void;
thresholds: ThresholdsConfig;
onThresholdsChange: (thresholds: ThresholdsConfig) => void;
}
export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel, onThresholdsChange, thresholds }) => {
const [options, setOptions] = useState<PanelOptions>({
frameIndex: 0,
showHeader: true,
});
const vizHeight = useVizHeight(data, currentPanel, options.frameIndex);
const styles = useStyles2(getStyles(vizHeight));
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>(defaultFieldConfig(thresholds));
useEffect(() => {
setFieldConfig((fieldConfig) => ({
...fieldConfig,
defaults: {
...fieldConfig.defaults,
thresholds: thresholds,
custom: {
...fieldConfig.defaults.custom,
thresholdsStyle: {
mode: 'line',
},
},
},
}));
}, [thresholds, setFieldConfig]);
const context: PanelContext = useMemo(
() => ({
eventBus: appEvents,
canEditThresholds: true,
onThresholdsChange: onThresholdsChange,
}),
[onThresholdsChange]
);
if (!options || !data) {
return null;
@@ -38,6 +67,7 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
}
return (
<div style={{ height: `${vizHeight}px`, width: `${width}px` }}>
<PanelContextProvider value={context}>
<PanelRenderer
height={vizHeight}
width={width}
@@ -46,7 +76,9 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
title="title"
onOptionsChange={setOptions}
options={options}
fieldConfig={fieldConfig}
/>
</PanelContextProvider>
</div>
);
}}
@@ -65,3 +97,20 @@ const getStyles = (visHeight: number) => (theme: GrafanaTheme2) => ({
justify-content: flex-end;
`,
});
function defaultFieldConfig(thresholds: ThresholdsConfig): FieldConfigSource {
if (!thresholds) {
return { defaults: {}, overrides: [] };
}
return {
defaults: {
thresholds: thresholds,
custom: {
thresholdsStyle: {
mode: 'line',
},
},
},
overrides: [],
};
}

View File

@@ -14,6 +14,7 @@ import {
PanelData,
PanelPlugin,
PanelPluginMeta,
ThresholdsConfig,
TimeRange,
toDataFrameDTO,
toUtc,
@@ -84,6 +85,9 @@ export class PanelChrome extends Component<Props, State> {
onAnnotationUpdate: this.onAnnotationUpdate,
onAnnotationDelete: this.onAnnotationDelete,
canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable),
// TODO: remove, added only for testing now
canEditThresholds: true,
onThresholdsChange: this.onThresholdsChange,
},
data: this.getInitialPanelDataState(),
};
@@ -342,6 +346,16 @@ export class PanelChrome extends Component<Props, State> {
this.state.context.eventBus.publish(new AnnotationChangeEvent(anno));
};
onThresholdsChange = (thresholds: ThresholdsConfig) => {
this.onFieldConfigChange({
defaults: {
...this.props.panel.fieldConfig.defaults,
thresholds,
},
overrides: this.props.panel.fieldConfig.overrides,
});
};
get hasPanelSnapshot() {
const { panel } = this.props;
return panel.snapshotData && panel.snapshotData.length;

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useRef, useCallback } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { applyFieldOverrides, FieldConfigSource, getTimeZone, PanelData, PanelPlugin } from '@grafana/data';
import { PanelRendererProps } from '@grafana/runtime';
import { appEvents } from 'app/core/core';
@@ -6,7 +6,7 @@ import { useAsync } from 'react-use';
import { getPanelOptionsWithDefaults, OptionDefaults } from '../dashboard/state/getPanelOptionsWithDefaults';
import { importPanelPlugin } from '../plugins/plugin_loader';
import { useTheme2 } from '@grafana/ui';
const defaultFieldConfig = { defaults: {}, overrides: [] };
export function PanelRenderer<P extends object = any, F extends object = any>(props: PanelRendererProps<P, F>) {
const {
pluginId,
@@ -18,13 +18,18 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
title,
onOptionsChange = () => {},
onChangeTimeRange = () => {},
fieldConfig: externalFieldConfig = defaultFieldConfig,
} = props;
const [fieldConfig, setFieldConfig] = useFieldConfigState(props);
const [localFieldConfig, setFieldConfig] = useState(externalFieldConfig);
const { value: plugin, error, loading } = useAsync(() => importPanelPlugin(pluginId), [pluginId]);
const optionsWithDefaults = useOptionDefaults(plugin, options, fieldConfig);
const optionsWithDefaults = useOptionDefaults(plugin, options, localFieldConfig);
const dataWithOverrides = useFieldOverrides(plugin, optionsWithDefaults, data, timeZone);
useEffect(() => {
setFieldConfig((lfc) => ({ ...lfc, ...externalFieldConfig }));
}, [externalFieldConfig]);
if (error) {
return <div>Failed to load plugin: {error.message}</div>;
}
@@ -51,7 +56,7 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
timeRange={dataWithOverrides.timeRange}
timeZone={timeZone}
options={optionsWithDefaults!.options}
fieldConfig={fieldConfig}
fieldConfig={localFieldConfig}
transparent={false}
width={width}
height={height}
@@ -94,11 +99,13 @@ function useFieldOverrides(
const series = data?.series;
const fieldConfigRegistry = plugin?.fieldConfigRegistry;
const theme = useTheme2();
const structureRev = useRef(0);
return useMemo(() => {
if (!fieldConfigRegistry || !fieldConfig || !data) {
return;
}
structureRev.current = structureRev.current + 1;
return {
...data,
@@ -110,6 +117,7 @@ function useFieldOverrides(
theme,
timeZone,
}),
structureRev: structureRev.current,
};
}, [fieldConfigRegistry, fieldConfig, data, series, timeZone, theme]);
}
@@ -117,35 +125,3 @@ function useFieldOverrides(
function pluginIsLoading(loading: boolean, plugin: PanelPlugin<any, any> | undefined, pluginId: string) {
return loading || plugin?.meta.id !== pluginId;
}
function useFieldConfigState(props: PanelRendererProps): [FieldConfigSource, (config: FieldConfigSource) => void] {
const {
onFieldConfigChange,
fieldConfig = {
defaults: {},
overrides: [],
},
} = props;
// First render will detect if the PanelRenderer will manage the
// field config state internally or externally by the consuming
// component. This will also prevent the way of managing state to
// change during the components life cycle.
const isManagedInternally = useRef(() => !!onFieldConfigChange);
const [internalConfig, setInternalConfig] = useState(fieldConfig);
const setExternalConfig = useCallback(
(config: FieldConfigSource) => {
if (!onFieldConfigChange) {
return;
}
onFieldConfigChange(config);
},
[onFieldConfigChange]
);
if (isManagedInternally) {
return [internalConfig, setInternalConfig];
}
return [fieldConfig, setExternalConfig];
}

View File

@@ -82,6 +82,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,
@@ -211,6 +212,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,
@@ -340,6 +342,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,
@@ -469,6 +472,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,
@@ -598,6 +602,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,
@@ -727,6 +732,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,
@@ -856,6 +862,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,
@@ -985,6 +992,7 @@ Object {
[Function],
],
},
"padding": undefined,
"scales": Object {
"m/s": Object {
"auto": true,

View File

@@ -10,6 +10,7 @@ import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
import { TimeSeriesOptions } from './types';
import { prepareGraphableFields } from './utils';
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
@@ -20,10 +21,11 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
width,
height,
options,
fieldConfig,
onChangeTimeRange,
replaceVariables,
}) => {
const { sync, canAddAnnotations, onSplitOpen } = usePanelContext();
const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, onSplitOpen } = usePanelContext();
const getFieldLinks = (field: Field, rowIndex: number) => {
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
@@ -110,6 +112,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
getFieldLinks={getFieldLinks}
/>
)}
{canEditThresholds && onThresholdsChange && (
<ThresholdControlsPlugin
config={config}
fieldConfig={fieldConfig}
onThresholdsChange={onThresholdsChange}
/>
)}
</>
);
}}

View File

@@ -0,0 +1,109 @@
import React, { useState, useLayoutEffect, useMemo, useRef } from 'react';
import { FieldConfigSource, ThresholdsConfig, getValueFormat } from '@grafana/data';
import { UPlotConfigBuilder, FIXED_UNIT } from '@grafana/ui';
import { ThresholdDragHandle } from './ThresholdDragHandle';
const GUTTER_SIZE = 60;
interface ThresholdControlsPluginProps {
config: UPlotConfigBuilder;
fieldConfig: FieldConfigSource;
onThresholdsChange: (thresholds: ThresholdsConfig) => void;
}
export const ThresholdControlsPlugin: React.FC<ThresholdControlsPluginProps> = ({
config,
fieldConfig,
onThresholdsChange,
}) => {
const plotInstance = useRef<uPlot>();
const [renderToken, setRenderToken] = useState(0);
useLayoutEffect(() => {
config.setPadding([0, GUTTER_SIZE, 0, 0]);
config.addHook('init', (u) => {
plotInstance.current = u;
});
// render token required to re-render handles when resizing uPlot
config.addHook('draw', () => {
setRenderToken((s) => s + 1);
});
}, [config]);
const thresholdHandles = useMemo(() => {
const plot = plotInstance.current;
if (!plot) {
return null;
}
const thresholds = fieldConfig.defaults.thresholds;
if (!thresholds) {
return null;
}
const scale = fieldConfig.defaults.unit ?? FIXED_UNIT;
const decimals = fieldConfig.defaults.decimals;
const handles = [];
for (let i = 0; i < thresholds.steps.length; i++) {
const step = thresholds.steps[i];
const yPos = plot.valToPos(step.value, scale);
if (Number.isNaN(yPos) || !Number.isFinite(yPos)) {
continue;
}
if (yPos < 0 || yPos > plot.bbox.height / window.devicePixelRatio) {
continue;
}
const handle = (
<ThresholdDragHandle
key={`${step.value}-${i}`}
step={step}
y={yPos}
dragBounds={{ top: 0, bottom: plot.bbox.height / window.devicePixelRatio }}
mapPositionToValue={(y) => plot.posToVal(y, scale)}
formatValue={(v) => getValueFormat(scale)(v, decimals).text}
onChange={(value) => {
const nextSteps = [
...thresholds.steps.slice(0, i),
...thresholds.steps.slice(i + 1),
{ ...thresholds.steps[i], value },
].sort((a, b) => a.value - b.value);
onThresholdsChange({
...thresholds,
steps: nextSteps,
});
}}
/>
);
handles.push(handle);
}
return handles;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderToken, fieldConfig, onThresholdsChange]);
if (!plotInstance.current) {
return null;
}
return (
<div
style={{
position: 'absolute',
overflow: 'visible',
left: `${(plotInstance.current.bbox.left + plotInstance.current.bbox.width) / window.devicePixelRatio}px`,
top: `${plotInstance.current.bbox.top / window.devicePixelRatio}px`,
width: `${GUTTER_SIZE}px`,
height: `${plotInstance.current.bbox.height / window.devicePixelRatio}px`,
}}
>
{thresholdHandles}
</div>
);
};
ThresholdControlsPlugin.displayName = 'ThresholdControlsPlugin';

View File

@@ -0,0 +1,89 @@
import React, { useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { Threshold, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, useTheme2 } from '@grafana/ui';
import Draggable, { DraggableBounds } from 'react-draggable';
interface ThresholdDragHandleProps {
step: Threshold;
y: number;
dragBounds: DraggableBounds;
mapPositionToValue: (y: number) => number;
onChange: (value: number) => void;
formatValue: (value: number) => string;
}
export const ThresholdDragHandle: React.FC<ThresholdDragHandleProps> = ({
step,
y,
dragBounds,
mapPositionToValue,
formatValue,
onChange,
}) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const [currentValue, setCurrentValue] = useState(step.value);
const textColor = useMemo(() => {
return theme.colors.getContrastText(theme.visualization.getColorByName(step.color));
}, [step.color, theme]);
return (
<Draggable
axis="y"
grid={[1, 1]}
onStop={(_e, d) => {
onChange(mapPositionToValue(d.lastY));
// as of https://github.com/react-grid-layout/react-draggable/issues/390#issuecomment-623237835
return false;
}}
onDrag={(_e, d) => setCurrentValue(mapPositionToValue(d.lastY))}
position={{ x: 0, y }}
bounds={dragBounds}
>
<div
className={styles.handle}
style={{ color: textColor, background: step.color, borderColor: step.color, borderWidth: 0 }}
>
<span className={styles.handleText}>{formatValue(currentValue)}</span>
</div>
</Draggable>
);
};
ThresholdDragHandle.displayName = 'ThresholdDragHandle';
const getStyles = (theme: GrafanaTheme2) => {
return {
handle: css`
position: absolute;
left: 0;
width: calc(100% - 9px);
height: 18px;
margin-left: 9px;
margin-top: -9px;
cursor: grab;
font-size: ${theme.typography.bodySmall.fontSize};
&:before {
content: '';
position: absolute;
left: -9px;
bottom: 0;
width: 0;
height: 0;
border-right-style: solid;
border-right-width: 9px;
border-right-color: inherit;
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
}
`,
handleText: css`
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`,
};
};