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;
|
||||
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
|
||||
fieldConfig?: FieldConfigSource<F>;
|
||||
onFieldConfigChange?: (config: FieldConfigSource<F>) => void;
|
||||
timeZone?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
@@ -98,6 +98,7 @@ Object {
|
||||
},
|
||||
},
|
||||
"hooks": Object {},
|
||||
"padding": undefined,
|
||||
"scales": Object {
|
||||
"__fixed": Object {
|
||||
"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 { 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.
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import uPlot, { Cursor, Band, Hooks, Select, AlignedData } from 'uplot';
|
||||
import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding } from 'uplot';
|
||||
import { merge } from 'lodash';
|
||||
import {
|
||||
DataFrame,
|
||||
@@ -50,6 +50,7 @@ export class UPlotConfigBuilder {
|
||||
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
||||
// Custom handler for closest datapoint and series lookup
|
||||
private tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined;
|
||||
private padding?: Padding = undefined;
|
||||
|
||||
prepData: PrepData | undefined = undefined;
|
||||
|
||||
@@ -164,6 +165,10 @@ export class UPlotConfigBuilder {
|
||||
return this.sync;
|
||||
}
|
||||
|
||||
setPadding(padding: Padding) {
|
||||
this.padding = padding;
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
const config: PlotConfig = {
|
||||
series: [
|
||||
@@ -203,6 +208,7 @@ export class UPlotConfigBuilder {
|
||||
});
|
||||
|
||||
config.tzDate = this.tzDate;
|
||||
config.padding = this.padding;
|
||||
|
||||
if (this.isStacking) {
|
||||
// Let uPlot handle bands and fills
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { DataQuery, DataSourceInstanceSettings, PanelData, RelativeTimeRange } from '@grafana/data';
|
||||
import {
|
||||
DataQuery,
|
||||
DataSourceInstanceSettings,
|
||||
PanelData,
|
||||
RelativeTimeRange,
|
||||
ThresholdsConfig,
|
||||
ThresholdsMode,
|
||||
} from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { QueryWrapper } from './QueryWrapper';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
|
||||
interface Props {
|
||||
// The query configuration
|
||||
@@ -50,6 +58,43 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
);
|
||||
};
|
||||
|
||||
onChangeThreshold = (thresholds: ThresholdsConfig, index: number) => {
|
||||
const { queries, onQueriesChange } = this.props;
|
||||
|
||||
const referencedRefId = queries[index].refId;
|
||||
|
||||
onQueriesChange(
|
||||
queries.map((query) => {
|
||||
if (!isExpressionQuery(query.model)) {
|
||||
return query;
|
||||
}
|
||||
|
||||
if (query.model.conditions && query.model.conditions[0].query.params[0] === referencedRefId) {
|
||||
return {
|
||||
...query,
|
||||
model: {
|
||||
...query.model,
|
||||
conditions: query.model.conditions.map((condition, conditionIndex) => {
|
||||
// Only update the first condition for a given refId.
|
||||
if (condition.query.params[0] === referencedRefId && conditionIndex === 0) {
|
||||
return {
|
||||
...condition,
|
||||
evaluator: {
|
||||
...condition.evaluator,
|
||||
params: [parseFloat(thresholds.steps[1].value.toPrecision(3))],
|
||||
},
|
||||
};
|
||||
}
|
||||
return condition;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return query;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
onChangeDataSource = (settings: DataSourceInstanceSettings, index: number) => {
|
||||
const { queries, onQueriesChange } = this.props;
|
||||
|
||||
@@ -130,8 +175,53 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
return getDataSourceSrv().getInstanceSettings(query.datasourceUid);
|
||||
};
|
||||
|
||||
getThresholdsForQueries = (queries: AlertQuery[]): Record<string, ThresholdsConfig> => {
|
||||
const record: Record<string, ThresholdsConfig> = {};
|
||||
|
||||
for (const query of queries) {
|
||||
if (!isExpressionQuery(query.model)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(query.model.conditions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
query.model.conditions.forEach((condition, index) => {
|
||||
if (index > 0) {
|
||||
return;
|
||||
}
|
||||
const threshold = condition.evaluator.params[0];
|
||||
const refId = condition.query.params[0];
|
||||
|
||||
if (condition.evaluator.type === 'outside_range' || condition.evaluator.type === 'within_range') {
|
||||
return;
|
||||
}
|
||||
if (!record[refId]) {
|
||||
record[refId] = {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [
|
||||
{
|
||||
value: -Infinity,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
record[refId].steps.push({
|
||||
value: threshold,
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return record;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onDuplicateQuery, onRunQueries, queries } = this.props;
|
||||
const thresholdByRefId = this.getThresholdsForQueries(queries);
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||
@@ -161,6 +251,8 @@ export class QueryRows extends PureComponent<Props, State> {
|
||||
onDuplicateQuery={onDuplicateQuery}
|
||||
onRunQueries={onRunQueries}
|
||||
onChangeTimeRange={this.onChangeTimeRange}
|
||||
thresholds={thresholdByRefId[query.refId]}
|
||||
onChangeThreshold={this.onChangeThreshold}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,18 +4,19 @@ import { cloneDeep } from 'lodash';
|
||||
import {
|
||||
DataQuery,
|
||||
DataSourceInstanceSettings,
|
||||
getDefaultRelativeTimeRange,
|
||||
GrafanaTheme2,
|
||||
PanelData,
|
||||
RelativeTimeRange,
|
||||
getDefaultRelativeTimeRange,
|
||||
ThresholdsConfig,
|
||||
} from '@grafana/data';
|
||||
import { useStyles2, RelativeTimeRangePicker } from '@grafana/ui';
|
||||
import { RelativeTimeRangePicker, useStyles2 } from '@grafana/ui';
|
||||
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
||||
import { VizWrapper } from './VizWrapper';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { TABLE, TIMESERIES } from '../../utils/constants';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
interface Props {
|
||||
data: PanelData;
|
||||
@@ -29,6 +30,8 @@ interface Props {
|
||||
onDuplicateQuery: (query: AlertQuery) => void;
|
||||
onRunQueries: () => void;
|
||||
index: number;
|
||||
thresholds: ThresholdsConfig;
|
||||
onChangeThreshold: (thresholds: ThresholdsConfig, index: number) => void;
|
||||
}
|
||||
|
||||
export const QueryWrapper: FC<Props> = ({
|
||||
@@ -43,6 +46,8 @@ export const QueryWrapper: FC<Props> = ({
|
||||
onDuplicateQuery,
|
||||
query,
|
||||
queries,
|
||||
thresholds,
|
||||
onChangeThreshold,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const isExpression = isExpressionQuery(query.model);
|
||||
@@ -77,7 +82,17 @@ export const QueryWrapper: FC<Props> = ({
|
||||
onRunQuery={onRunQueries}
|
||||
queries={queries}
|
||||
renderHeaderExtras={() => renderTimePicker(query, index)}
|
||||
visualization={data ? <VizWrapper data={data} changePanel={changePluginId} currentPanel={pluginId} /> : null}
|
||||
visualization={
|
||||
data ? (
|
||||
<VizWrapper
|
||||
data={data}
|
||||
changePanel={changePluginId}
|
||||
currentPanel={pluginId}
|
||||
thresholds={thresholds}
|
||||
onThresholdsChange={(thresholds) => onChangeThreshold(thresholds, index)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
hideDisableQuery={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,55 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import React, { FC, useEffect, useMemo, useState } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2, PanelData } from '@grafana/data';
|
||||
import { FieldConfigSource, GrafanaTheme2, PanelData, ThresholdsConfig } from '@grafana/data';
|
||||
import { PanelRenderer } from '@grafana/runtime';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { PanelContext, PanelContextProvider, useStyles2 } from '@grafana/ui';
|
||||
import { PanelOptions } from 'app/plugins/panel/table/models.gen';
|
||||
import { useVizHeight } from '../../hooks/useVizHeight';
|
||||
import { SupportedPanelPlugins, PanelPluginsButtonGroup } from '../PanelPluginsButtonGroup';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
interface Props {
|
||||
data: PanelData;
|
||||
currentPanel: SupportedPanelPlugins;
|
||||
changePanel: (panel: SupportedPanelPlugins) => void;
|
||||
thresholds: ThresholdsConfig;
|
||||
onThresholdsChange: (thresholds: ThresholdsConfig) => void;
|
||||
}
|
||||
|
||||
export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
|
||||
export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel, onThresholdsChange, thresholds }) => {
|
||||
const [options, setOptions] = useState<PanelOptions>({
|
||||
frameIndex: 0,
|
||||
showHeader: true,
|
||||
});
|
||||
const vizHeight = useVizHeight(data, currentPanel, options.frameIndex);
|
||||
const styles = useStyles2(getStyles(vizHeight));
|
||||
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>(defaultFieldConfig(thresholds));
|
||||
|
||||
useEffect(() => {
|
||||
setFieldConfig((fieldConfig) => ({
|
||||
...fieldConfig,
|
||||
defaults: {
|
||||
...fieldConfig.defaults,
|
||||
thresholds: thresholds,
|
||||
custom: {
|
||||
...fieldConfig.defaults.custom,
|
||||
thresholdsStyle: {
|
||||
mode: 'line',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}, [thresholds, setFieldConfig]);
|
||||
|
||||
const context: PanelContext = useMemo(
|
||||
() => ({
|
||||
eventBus: appEvents,
|
||||
canEditThresholds: true,
|
||||
onThresholdsChange: onThresholdsChange,
|
||||
}),
|
||||
[onThresholdsChange]
|
||||
);
|
||||
|
||||
if (!options || !data) {
|
||||
return null;
|
||||
@@ -38,6 +67,7 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
|
||||
}
|
||||
return (
|
||||
<div style={{ height: `${vizHeight}px`, width: `${width}px` }}>
|
||||
<PanelContextProvider value={context}>
|
||||
<PanelRenderer
|
||||
height={vizHeight}
|
||||
width={width}
|
||||
@@ -46,7 +76,9 @@ export const VizWrapper: FC<Props> = ({ data, currentPanel, changePanel }) => {
|
||||
title="title"
|
||||
onOptionsChange={setOptions}
|
||||
options={options}
|
||||
fieldConfig={fieldConfig}
|
||||
/>
|
||||
</PanelContextProvider>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -65,3 +97,20 @@ const getStyles = (visHeight: number) => (theme: GrafanaTheme2) => ({
|
||||
justify-content: flex-end;
|
||||
`,
|
||||
});
|
||||
|
||||
function defaultFieldConfig(thresholds: ThresholdsConfig): FieldConfigSource {
|
||||
if (!thresholds) {
|
||||
return { defaults: {}, overrides: [] };
|
||||
}
|
||||
return {
|
||||
defaults: {
|
||||
thresholds: thresholds,
|
||||
custom: {
|
||||
thresholdsStyle: {
|
||||
mode: 'line',
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
PanelData,
|
||||
PanelPlugin,
|
||||
PanelPluginMeta,
|
||||
ThresholdsConfig,
|
||||
TimeRange,
|
||||
toDataFrameDTO,
|
||||
toUtc,
|
||||
@@ -84,6 +85,9 @@ export class PanelChrome extends Component<Props, State> {
|
||||
onAnnotationUpdate: this.onAnnotationUpdate,
|
||||
onAnnotationDelete: this.onAnnotationDelete,
|
||||
canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable),
|
||||
// TODO: remove, added only for testing now
|
||||
canEditThresholds: true,
|
||||
onThresholdsChange: this.onThresholdsChange,
|
||||
},
|
||||
data: this.getInitialPanelDataState(),
|
||||
};
|
||||
@@ -342,6 +346,16 @@ export class PanelChrome extends Component<Props, State> {
|
||||
this.state.context.eventBus.publish(new AnnotationChangeEvent(anno));
|
||||
};
|
||||
|
||||
onThresholdsChange = (thresholds: ThresholdsConfig) => {
|
||||
this.onFieldConfigChange({
|
||||
defaults: {
|
||||
...this.props.panel.fieldConfig.defaults,
|
||||
thresholds,
|
||||
},
|
||||
overrides: this.props.panel.fieldConfig.overrides,
|
||||
});
|
||||
};
|
||||
|
||||
get hasPanelSnapshot() {
|
||||
const { panel } = this.props;
|
||||
return panel.snapshotData && panel.snapshotData.length;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { applyFieldOverrides, FieldConfigSource, getTimeZone, PanelData, PanelPlugin } from '@grafana/data';
|
||||
import { PanelRendererProps } from '@grafana/runtime';
|
||||
import { appEvents } from 'app/core/core';
|
||||
@@ -6,7 +6,7 @@ import { useAsync } from 'react-use';
|
||||
import { getPanelOptionsWithDefaults, OptionDefaults } from '../dashboard/state/getPanelOptionsWithDefaults';
|
||||
import { importPanelPlugin } from '../plugins/plugin_loader';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
const defaultFieldConfig = { defaults: {}, overrides: [] };
|
||||
export function PanelRenderer<P extends object = any, F extends object = any>(props: PanelRendererProps<P, F>) {
|
||||
const {
|
||||
pluginId,
|
||||
@@ -18,13 +18,18 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
|
||||
title,
|
||||
onOptionsChange = () => {},
|
||||
onChangeTimeRange = () => {},
|
||||
fieldConfig: externalFieldConfig = defaultFieldConfig,
|
||||
} = props;
|
||||
|
||||
const [fieldConfig, setFieldConfig] = useFieldConfigState(props);
|
||||
const [localFieldConfig, setFieldConfig] = useState(externalFieldConfig);
|
||||
const { value: plugin, error, loading } = useAsync(() => importPanelPlugin(pluginId), [pluginId]);
|
||||
const optionsWithDefaults = useOptionDefaults(plugin, options, fieldConfig);
|
||||
const optionsWithDefaults = useOptionDefaults(plugin, options, localFieldConfig);
|
||||
const dataWithOverrides = useFieldOverrides(plugin, optionsWithDefaults, data, timeZone);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldConfig((lfc) => ({ ...lfc, ...externalFieldConfig }));
|
||||
}, [externalFieldConfig]);
|
||||
|
||||
if (error) {
|
||||
return <div>Failed to load plugin: {error.message}</div>;
|
||||
}
|
||||
@@ -51,7 +56,7 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
|
||||
timeRange={dataWithOverrides.timeRange}
|
||||
timeZone={timeZone}
|
||||
options={optionsWithDefaults!.options}
|
||||
fieldConfig={fieldConfig}
|
||||
fieldConfig={localFieldConfig}
|
||||
transparent={false}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -94,11 +99,13 @@ function useFieldOverrides(
|
||||
const series = data?.series;
|
||||
const fieldConfigRegistry = plugin?.fieldConfigRegistry;
|
||||
const theme = useTheme2();
|
||||
const structureRev = useRef(0);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!fieldConfigRegistry || !fieldConfig || !data) {
|
||||
return;
|
||||
}
|
||||
structureRev.current = structureRev.current + 1;
|
||||
|
||||
return {
|
||||
...data,
|
||||
@@ -110,6 +117,7 @@ function useFieldOverrides(
|
||||
theme,
|
||||
timeZone,
|
||||
}),
|
||||
structureRev: structureRev.current,
|
||||
};
|
||||
}, [fieldConfigRegistry, fieldConfig, data, series, timeZone, theme]);
|
||||
}
|
||||
@@ -117,35 +125,3 @@ function useFieldOverrides(
|
||||
function pluginIsLoading(loading: boolean, plugin: PanelPlugin<any, any> | undefined, pluginId: string) {
|
||||
return loading || plugin?.meta.id !== pluginId;
|
||||
}
|
||||
|
||||
function useFieldConfigState(props: PanelRendererProps): [FieldConfigSource, (config: FieldConfigSource) => void] {
|
||||
const {
|
||||
onFieldConfigChange,
|
||||
fieldConfig = {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
} = props;
|
||||
|
||||
// First render will detect if the PanelRenderer will manage the
|
||||
// field config state internally or externally by the consuming
|
||||
// component. This will also prevent the way of managing state to
|
||||
// change during the components life cycle.
|
||||
const isManagedInternally = useRef(() => !!onFieldConfigChange);
|
||||
const [internalConfig, setInternalConfig] = useState(fieldConfig);
|
||||
|
||||
const setExternalConfig = useCallback(
|
||||
(config: FieldConfigSource) => {
|
||||
if (!onFieldConfigChange) {
|
||||
return;
|
||||
}
|
||||
onFieldConfigChange(config);
|
||||
},
|
||||
[onFieldConfigChange]
|
||||
);
|
||||
|
||||
if (isManagedInternally) {
|
||||
return [internalConfig, setInternalConfig];
|
||||
}
|
||||
return [fieldConfig, setExternalConfig];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
|
||||
import { TimeSeriesOptions } from './types';
|
||||
import { prepareGraphableFields } from './utils';
|
||||
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
|
||||
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
||||
|
||||
interface TimeSeriesPanelProps extends PanelProps<TimeSeriesOptions> {}
|
||||
|
||||
@@ -20,10 +21,11 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
||||
width,
|
||||
height,
|
||||
options,
|
||||
fieldConfig,
|
||||
onChangeTimeRange,
|
||||
replaceVariables,
|
||||
}) => {
|
||||
const { sync, canAddAnnotations, onSplitOpen } = usePanelContext();
|
||||
const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, onSplitOpen } = usePanelContext();
|
||||
|
||||
const getFieldLinks = (field: Field, rowIndex: number) => {
|
||||
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
|
||||
@@ -110,6 +112,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
||||
getFieldLinks={getFieldLinks}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEditThresholds && onThresholdsChange && (
|
||||
<ThresholdControlsPlugin
|
||||
config={config}
|
||||
fieldConfig={fieldConfig}
|
||||
onThresholdsChange={onThresholdsChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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