diff --git a/packages/grafana-runtime/src/components/PanelRenderer.tsx b/packages/grafana-runtime/src/components/PanelRenderer.tsx index 23c140408a4..6053c9d2070 100644 --- a/packages/grafana-runtime/src/components/PanelRenderer.tsx +++ b/packages/grafana-runtime/src/components/PanelRenderer.tsx @@ -17,7 +17,6 @@ export interface PanelRendererProps

void; onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void; fieldConfig?: FieldConfigSource; - onFieldConfigChange?: (config: FieldConfigSource) => void; timeZone?: string; width: number; height: number; diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap index 2bc46c1b613..1f9a6b89982 100644 --- a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -98,6 +98,7 @@ Object { }, }, "hooks": Object {}, + "padding": undefined, "scales": Object { "__fixed": Object { "auto": true, diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index bfe31ef4ef4..b444a9e2955 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -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. diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index ea3e80c40fc..57fc787fce7 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -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 [ diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index e31791e4282..58fd1e8e2a8 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -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 = {}; // 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 diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index ef045315caf..36390dcbab4 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -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 { diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx index 70dc339a4cd..e142b24ebf2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx @@ -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 { ); }; + 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 { return getDataSourceSrv().getInstanceSettings(query.datasourceUid); }; + getThresholdsForQueries = (queries: AlertQuery[]): Record => { + const record: Record = {}; + + 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 ( @@ -161,6 +251,8 @@ export class QueryRows extends PureComponent { onDuplicateQuery={onDuplicateQuery} onRunQueries={onRunQueries} onChangeTimeRange={this.onChangeTimeRange} + thresholds={thresholdByRefId[query.refId]} + onChangeThreshold={this.onChangeThreshold} /> ); })} diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index eebef6cee0b..1b1da1170a0 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -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 = ({ @@ -43,6 +46,8 @@ export const QueryWrapper: FC = ({ onDuplicateQuery, query, queries, + thresholds, + onChangeThreshold, }) => { const styles = useStyles2(getStyles); const isExpression = isExpressionQuery(query.model); @@ -77,7 +82,17 @@ export const QueryWrapper: FC = ({ onRunQuery={onRunQueries} queries={queries} renderHeaderExtras={() => renderTimePicker(query, index)} - visualization={data ? : null} + visualization={ + data ? ( + onChangeThreshold(thresholds, index)} + /> + ) : null + } hideDisableQuery={true} /> diff --git a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx index eb077568766..c4a59c9a194 100644 --- a/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/VizWrapper.tsx @@ -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 = ({ data, currentPanel, changePanel }) => { +export const VizWrapper: FC = ({ data, currentPanel, changePanel, onThresholdsChange, thresholds }) => { const [options, setOptions] = useState({ frameIndex: 0, showHeader: true, }); const vizHeight = useVizHeight(data, currentPanel, options.frameIndex); const styles = useStyles2(getStyles(vizHeight)); + const [fieldConfig, setFieldConfig] = useState(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,15 +67,18 @@ export const VizWrapper: FC = ({ data, currentPanel, changePanel }) => { } return (

- + + +
); }} @@ -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: [], + }; +} diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 8af04bd0454..ef9527a1167 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -14,6 +14,7 @@ import { PanelData, PanelPlugin, PanelPluginMeta, + ThresholdsConfig, TimeRange, toDataFrameDTO, toUtc, @@ -84,6 +85,9 @@ export class PanelChrome extends Component { 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 { 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; diff --git a/public/app/features/panel/PanelRenderer.tsx b/public/app/features/panel/PanelRenderer.tsx index 14288099cbe..e3573a419ab 100644 --- a/public/app/features/panel/PanelRenderer.tsx +++ b/public/app/features/panel/PanelRenderer.tsx @@ -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

(props: PanelRendererProps) { const { pluginId, @@ -18,13 +18,18 @@ export function PanelRenderer

(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

Failed to load plugin: {error.message}
; } @@ -51,7 +56,7 @@ export function PanelRenderer

(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 | 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]; -} diff --git a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap index 0bf6eda5f89..ee0ea325e06 100644 --- a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap +++ b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap @@ -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, diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index ff3a91e582d..fc5cbd3c3e9 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -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 {} @@ -20,10 +21,11 @@ export const TimeSeriesPanel: React.FC = ({ 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 = ({ getFieldLinks={getFieldLinks} /> )} + + {canEditThresholds && onThresholdsChange && ( + + )} ); }} diff --git a/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx new file mode 100644 index 00000000000..51ab192d152 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx @@ -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 = ({ + config, + fieldConfig, + onThresholdsChange, +}) => { + const plotInstance = useRef(); + 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 = ( + 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 ( +

+ {thresholdHandles} +
+ ); +}; + +ThresholdControlsPlugin.displayName = 'ThresholdControlsPlugin'; diff --git a/public/app/plugins/panel/timeseries/plugins/ThresholdDragHandle.tsx b/public/app/plugins/panel/timeseries/plugins/ThresholdDragHandle.tsx new file mode 100644 index 00000000000..7bc93429fc7 --- /dev/null +++ b/public/app/plugins/panel/timeseries/plugins/ThresholdDragHandle.tsx @@ -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 = ({ + 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 ( + { + 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} + > +
+ {formatValue(currentValue)} +
+
+ ); +}; + +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; + `, + }; +};