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;
+ `,
+ };
+};