mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -17,7 +17,6 @@ export interface PanelRendererProps<P extends object = any, F extends object = a
|
|||||||
onOptionsChange?: (options: P) => void;
|
onOptionsChange?: (options: P) => void;
|
||||||
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
|
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
|
||||||
fieldConfig?: FieldConfigSource<F>;
|
fieldConfig?: FieldConfigSource<F>;
|
||||||
onFieldConfigChange?: (config: FieldConfigSource<F>) => void;
|
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Object {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"__fixed": Object {
|
"__fixed": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
|
|||||||
@@ -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 React from 'react';
|
||||||
import { SeriesVisibilityChangeMode } from '.';
|
import { SeriesVisibilityChangeMode } from '.';
|
||||||
|
|
||||||
@@ -22,6 +29,20 @@ export interface PanelContext {
|
|||||||
onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void;
|
onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void;
|
||||||
onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void;
|
onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void;
|
||||||
onAnnotationDelete?: (id: string) => 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.
|
* 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.
|
* For example TimeSeries panel.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
"series": Array [
|
"series": Array [
|
||||||
@@ -86,6 +87,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-x": Object {
|
"scale-x": Object {
|
||||||
"auto": false,
|
"auto": false,
|
||||||
@@ -164,6 +166,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-y": Object {
|
"scale-y": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -215,6 +218,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-y": Object {
|
"scale-y": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -267,6 +271,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-y": Object {
|
"scale-y": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -382,6 +387,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
"series": Array [
|
"series": Array [
|
||||||
@@ -499,6 +505,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
"series": Array [
|
"series": Array [
|
||||||
@@ -612,6 +619,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
"series": Array [
|
"series": Array [
|
||||||
|
|||||||
@@ -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 { merge } from 'lodash';
|
||||||
import {
|
import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@@ -50,6 +50,7 @@ export class UPlotConfigBuilder {
|
|||||||
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
||||||
// Custom handler for closest datapoint and series lookup
|
// Custom handler for closest datapoint and series lookup
|
||||||
private tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
|
private tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
|
||||||
|
private padding?: Padding = undefined;
|
||||||
|
|
||||||
prepData: PrepData | undefined = undefined;
|
prepData: PrepData | undefined = undefined;
|
||||||
|
|
||||||
@@ -164,6 +165,10 @@ export class UPlotConfigBuilder {
|
|||||||
return this.sync;
|
return this.sync;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPadding(padding: Padding) {
|
||||||
|
this.padding = padding;
|
||||||
|
}
|
||||||
|
|
||||||
getConfig() {
|
getConfig() {
|
||||||
const config: PlotConfig = {
|
const config: PlotConfig = {
|
||||||
series: [
|
series: [
|
||||||
@@ -203,6 +208,7 @@ export class UPlotConfigBuilder {
|
|||||||
});
|
});
|
||||||
|
|
||||||
config.tzDate = this.tzDate;
|
config.tzDate = this.tzDate;
|
||||||
|
config.padding = this.padding;
|
||||||
|
|
||||||
if (this.isStacking) {
|
if (this.isStacking) {
|
||||||
// Let uPlot handle bands and fills
|
// Let uPlot handle bands and fills
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
|||||||
|
|
||||||
export type PlotConfig = Pick<
|
export type PlotConfig = Pick<
|
||||||
Options,
|
Options,
|
||||||
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate'
|
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface PlotPluginProps {
|
export interface PlotPluginProps {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
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 { getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { QueryWrapper } from './QueryWrapper';
|
import { QueryWrapper } from './QueryWrapper';
|
||||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// The query configuration
|
// 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) => {
|
onChangeDataSource = (settings: DataSourceInstanceSettings, index: number) => {
|
||||||
const { queries, onQueriesChange } = this.props;
|
const { queries, onQueriesChange } = this.props;
|
||||||
|
|
||||||
@@ -130,8 +175,53 @@ export class QueryRows extends PureComponent<Props, State> {
|
|||||||
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
|
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() {
|
render() {
|
||||||
const { onDuplicateQuery, onRunQueries, queries } = this.props;
|
const { onDuplicateQuery, onRunQueries, queries } = this.props;
|
||||||
|
const thresholdByRefId = this.getThresholdsForQueries(queries);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||||
@@ -161,6 +251,8 @@ export class QueryRows extends PureComponent<Props, State> {
|
|||||||
onDuplicateQuery={onDuplicateQuery}
|
onDuplicateQuery={onDuplicateQuery}
|
||||||
onRunQueries={onRunQueries}
|
onRunQueries={onRunQueries}
|
||||||
onChangeTimeRange={this.onChangeTimeRange}
|
onChangeTimeRange={this.onChangeTimeRange}
|
||||||
|
thresholds={thresholdByRefId[query.refId]}
|
||||||
|
onChangeThreshold={this.onChangeThreshold}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import { cloneDeep } from 'lodash';
|
|||||||
import {
|
import {
|
||||||
DataQuery,
|
DataQuery,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
|
getDefaultRelativeTimeRange,
|
||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
PanelData,
|
PanelData,
|
||||||
RelativeTimeRange,
|
RelativeTimeRange,
|
||||||
getDefaultRelativeTimeRange,
|
ThresholdsConfig,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { useStyles2, RelativeTimeRangePicker } from '@grafana/ui';
|
import { RelativeTimeRangePicker, useStyles2 } from '@grafana/ui';
|
||||||
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
||||||
import { VizWrapper } from './VizWrapper';
|
import { VizWrapper } from './VizWrapper';
|
||||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
import { TABLE, TIMESERIES } from '../../utils/constants';
|
import { TABLE, TIMESERIES } from '../../utils/constants';
|
||||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
|
||||||
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
|
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
|
||||||
|
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PanelData;
|
data: PanelData;
|
||||||
@@ -29,6 +30,8 @@ interface Props {
|
|||||||
onDuplicateQuery: (query: AlertQuery) => void;
|
onDuplicateQuery: (query: AlertQuery) => void;
|
||||||
onRunQueries: () => void;
|
onRunQueries: () => void;
|
||||||
index: number;
|
index: number;
|
||||||
|
thresholds: ThresholdsConfig;
|
||||||
|
onChangeThreshold: (thresholds: ThresholdsConfig, index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QueryWrapper: FC<Props> = ({
|
export const QueryWrapper: FC<Props> = ({
|
||||||
@@ -43,6 +46,8 @@ export const QueryWrapper: FC<Props> = ({
|
|||||||
onDuplicateQuery,
|
onDuplicateQuery,
|
||||||
query,
|
query,
|
||||||
queries,
|
queries,
|
||||||
|
thresholds,
|
||||||
|
onChangeThreshold,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const isExpression = isExpressionQuery(query.model);
|
const isExpression = isExpressionQuery(query.model);
|
||||||
@@ -77,7 +82,17 @@ export const QueryWrapper: FC<Props> = ({
|
|||||||
onRunQuery={onRunQueries}
|
onRunQuery={onRunQueries}
|
||||||
queries={queries}
|
queries={queries}
|
||||||
renderHeaderExtras={() => renderTimePicker(query, index)}
|
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}
|
hideDisableQuery={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import { css } from '@emotion/css';
|
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 { 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 { PanelOptions } from 'app/plugins/panel/table/models.gen';
|
||||||
import { useVizHeight } from '../../hooks/useVizHeight';
|
import { useVizHeight } from '../../hooks/useVizHeight';
|
||||||
import { SupportedPanelPlugins, PanelPluginsButtonGroup } from '../PanelPluginsButtonGroup';
|
import { SupportedPanelPlugins, PanelPluginsButtonGroup } from '../PanelPluginsButtonGroup';
|
||||||
|
import appEvents from 'app/core/app_events';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PanelData;
|
data: PanelData;
|
||||||
currentPanel: SupportedPanelPlugins;
|
currentPanel: SupportedPanelPlugins;
|
||||||
changePanel: (panel: SupportedPanelPlugins) => void;
|
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>({
|
const [options, setOptions] = useState<PanelOptions>({
|
||||||
frameIndex: 0,
|
frameIndex: 0,
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
});
|
});
|
||||||
const vizHeight = useVizHeight(data, currentPanel, options.frameIndex);
|
const vizHeight = useVizHeight(data, currentPanel, options.frameIndex);
|
||||||
const styles = useStyles2(getStyles(vizHeight));
|
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) {
|
if (!options || !data) {
|
||||||
return null;
|
return null;
|
||||||
@@ -38,15 +67,18 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{ height: `${vizHeight}px`, width: `${width}px` }}>
|
<div style={{ height: `${vizHeight}px`, width: `${width}px` }}>
|
||||||
<PanelRenderer
|
<PanelContextProvider value={context}>
|
||||||
height={vizHeight}
|
<PanelRenderer
|
||||||
width={width}
|
height={vizHeight}
|
||||||
data={data}
|
width={width}
|
||||||
pluginId={currentPanel}
|
data={data}
|
||||||
title="title"
|
pluginId={currentPanel}
|
||||||
onOptionsChange={setOptions}
|
title="title"
|
||||||
options={options}
|
onOptionsChange={setOptions}
|
||||||
/>
|
options={options}
|
||||||
|
fieldConfig={fieldConfig}
|
||||||
|
/>
|
||||||
|
</PanelContextProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -65,3 +97,20 @@ const getStyles = (visHeight: number) => (theme: GrafanaTheme2) => ({
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function defaultFieldConfig(thresholds: ThresholdsConfig): FieldConfigSource {
|
||||||
|
if (!thresholds) {
|
||||||
|
return { defaults: {}, overrides: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
defaults: {
|
||||||
|
thresholds: thresholds,
|
||||||
|
custom: {
|
||||||
|
thresholdsStyle: {
|
||||||
|
mode: 'line',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
PanelData,
|
PanelData,
|
||||||
PanelPlugin,
|
PanelPlugin,
|
||||||
PanelPluginMeta,
|
PanelPluginMeta,
|
||||||
|
ThresholdsConfig,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
toDataFrameDTO,
|
toDataFrameDTO,
|
||||||
toUtc,
|
toUtc,
|
||||||
@@ -84,6 +85,9 @@ export class PanelChrome extends Component<Props, State> {
|
|||||||
onAnnotationUpdate: this.onAnnotationUpdate,
|
onAnnotationUpdate: this.onAnnotationUpdate,
|
||||||
onAnnotationDelete: this.onAnnotationDelete,
|
onAnnotationDelete: this.onAnnotationDelete,
|
||||||
canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable),
|
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(),
|
data: this.getInitialPanelDataState(),
|
||||||
};
|
};
|
||||||
@@ -342,6 +346,16 @@ export class PanelChrome extends Component<Props, State> {
|
|||||||
this.state.context.eventBus.publish(new AnnotationChangeEvent(anno));
|
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() {
|
get hasPanelSnapshot() {
|
||||||
const { panel } = this.props;
|
const { panel } = this.props;
|
||||||
return panel.snapshotData && panel.snapshotData.length;
|
return panel.snapshotData && panel.snapshotData.length;
|
||||||
|
|||||||
@@ -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 { applyFieldOverrides, FieldConfigSource, getTimeZone, PanelData, PanelPlugin } from '@grafana/data';
|
||||||
import { PanelRendererProps } from '@grafana/runtime';
|
import { PanelRendererProps } from '@grafana/runtime';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
@@ -6,7 +6,7 @@ import { useAsync } from 'react-use';
|
|||||||
import { getPanelOptionsWithDefaults, OptionDefaults } from '../dashboard/state/getPanelOptionsWithDefaults';
|
import { getPanelOptionsWithDefaults, OptionDefaults } from '../dashboard/state/getPanelOptionsWithDefaults';
|
||||||
import { importPanelPlugin } from '../plugins/plugin_loader';
|
import { importPanelPlugin } from '../plugins/plugin_loader';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
|
const defaultFieldConfig = { defaults: {}, overrides: [] };
|
||||||
export function PanelRenderer<P extends object = any, F extends object = any>(props: PanelRendererProps<P, F>) {
|
export function PanelRenderer<P extends object = any, F extends object = any>(props: PanelRendererProps<P, F>) {
|
||||||
const {
|
const {
|
||||||
pluginId,
|
pluginId,
|
||||||
@@ -18,13 +18,18 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
|
|||||||
title,
|
title,
|
||||||
onOptionsChange = () => {},
|
onOptionsChange = () => {},
|
||||||
onChangeTimeRange = () => {},
|
onChangeTimeRange = () => {},
|
||||||
|
fieldConfig: externalFieldConfig = defaultFieldConfig,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [fieldConfig, setFieldConfig] = useFieldConfigState(props);
|
const [localFieldConfig, setFieldConfig] = useState(externalFieldConfig);
|
||||||
const { value: plugin, error, loading } = useAsync(() => importPanelPlugin(pluginId), [pluginId]);
|
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);
|
const dataWithOverrides = useFieldOverrides(plugin, optionsWithDefaults, data, timeZone);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFieldConfig((lfc) => ({ ...lfc, ...externalFieldConfig }));
|
||||||
|
}, [externalFieldConfig]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>Failed to load plugin: {error.message}</div>;
|
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}
|
timeRange={dataWithOverrides.timeRange}
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
options={optionsWithDefaults!.options}
|
options={optionsWithDefaults!.options}
|
||||||
fieldConfig={fieldConfig}
|
fieldConfig={localFieldConfig}
|
||||||
transparent={false}
|
transparent={false}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
@@ -94,11 +99,13 @@ function useFieldOverrides(
|
|||||||
const series = data?.series;
|
const series = data?.series;
|
||||||
const fieldConfigRegistry = plugin?.fieldConfigRegistry;
|
const fieldConfigRegistry = plugin?.fieldConfigRegistry;
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
|
const structureRev = useRef(0);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!fieldConfigRegistry || !fieldConfig || !data) {
|
if (!fieldConfigRegistry || !fieldConfig || !data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
structureRev.current = structureRev.current + 1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
@@ -110,6 +117,7 @@ function useFieldOverrides(
|
|||||||
theme,
|
theme,
|
||||||
timeZone,
|
timeZone,
|
||||||
}),
|
}),
|
||||||
|
structureRev: structureRev.current,
|
||||||
};
|
};
|
||||||
}, [fieldConfigRegistry, fieldConfig, data, series, timeZone, theme]);
|
}, [fieldConfigRegistry, fieldConfig, data, series, timeZone, theme]);
|
||||||
}
|
}
|
||||||
@@ -117,35 +125,3 @@ function useFieldOverrides(
|
|||||||
function pluginIsLoading(loading: boolean, plugin: PanelPlugin<any, any> | undefined, pluginId: string) {
|
function pluginIsLoading(loading: boolean, plugin: PanelPlugin<any, any> | undefined, pluginId: string) {
|
||||||
return loading || plugin?.meta.id !== pluginId;
|
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];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -211,6 +212,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -340,6 +342,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -469,6 +472,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -598,6 +602,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -727,6 +732,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -856,6 +862,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
@@ -985,6 +992,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
"auto": true,
|
"auto": true,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
|||||||
import { TimeSeriesOptions } from './types';
|
import { TimeSeriesOptions } from './types';
|
||||||
import { prepareGraphableFields } from './utils';
|
import { prepareGraphableFields } from './utils';
|
||||||
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
|
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
|
||||||
|
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
||||||
|
|
||||||
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
|
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
|
||||||
|
|
||||||
@@ -20,10 +21,11 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
options,
|
options,
|
||||||
|
fieldConfig,
|
||||||
onChangeTimeRange,
|
onChangeTimeRange,
|
||||||
replaceVariables,
|
replaceVariables,
|
||||||
}) => {
|
}) => {
|
||||||
const { sync, canAddAnnotations, onSplitOpen } = usePanelContext();
|
const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, onSplitOpen } = usePanelContext();
|
||||||
|
|
||||||
const getFieldLinks = (field: Field, rowIndex: number) => {
|
const getFieldLinks = (field: Field, rowIndex: number) => {
|
||||||
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
|
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
|
||||||
@@ -110,6 +112,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
|||||||
getFieldLinks={getFieldLinks}
|
getFieldLinks={getFieldLinks}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canEditThresholds && onThresholdsChange && (
|
||||||
|
<ThresholdControlsPlugin
|
||||||
|
config={config}
|
||||||
|
fieldConfig={fieldConfig}
|
||||||
|
onThresholdsChange={onThresholdsChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user