From 8cd54c94e99e9221c0901d15bdcf74c51764c246 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 11 Mar 2019 14:47:54 -0700 Subject: [PATCH 01/13] make value processing reusable --- .../src/components/Gauge/Gauge.test.tsx | 92 +-------------- .../grafana-ui/src/components/Gauge/Gauge.tsx | 77 ++----------- .../src/utils/valueProcessor.test.ts | 107 ++++++++++++++++++ .../grafana-ui/src/utils/valueProcessor.ts | 97 ++++++++++++++++ .../panel/gauge/DisplayValueEditor.tsx | 64 +++++++++++ public/app/plugins/panel/gauge/GaugePanel.tsx | 42 ++++--- .../plugins/panel/gauge/GaugePanelEditor.tsx | 13 ++- .../panel/gauge/SingleStatValueEditor.tsx | 49 +------- public/app/plugins/panel/gauge/types.ts | 22 ++-- 9 files changed, 342 insertions(+), 221 deletions(-) create mode 100644 packages/grafana-ui/src/utils/valueProcessor.test.ts create mode 100644 packages/grafana-ui/src/utils/valueProcessor.ts create mode 100644 public/app/plugins/panel/gauge/DisplayValueEditor.tsx diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx index 70e29abc221..c6a49eb5b55 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Gauge, Props } from './Gauge'; -import { ValueMapping, MappingType } from '../../types'; import { getTheme } from '../../themes'; jest.mock('jquery', () => ({ @@ -12,19 +11,16 @@ jest.mock('jquery', () => ({ const setup = (propOverrides?: object) => { const props: Props = { maxValue: 100, - valueMappings: [], minValue: 0, - prefix: '', showThresholdMarkers: true, showThresholdLabels: false, - suffix: '', thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }], - unit: 'none', - stat: 'avg', height: 300, width: 300, - value: 25, - decimals: 0, + value: { + text: '25', + numeric: 25, + }, theme: getTheme(), }; @@ -39,38 +35,6 @@ const setup = (propOverrides?: object) => { }; }; -describe('Get font color', () => { - it('should get first threshold color when only one threshold', () => { - const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] }); - - expect(instance.getFontColor(49)).toEqual('#7EB26D'); - }); - - it('should get the threshold color if value is same as a threshold', () => { - const { instance } = setup({ - thresholds: [ - { index: 2, value: 75, color: '#6ED0E0' }, - { index: 1, value: 50, color: '#EAB839' }, - { index: 0, value: -Infinity, color: '#7EB26D' }, - ], - }); - - expect(instance.getFontColor(50)).toEqual('#EAB839'); - }); - - it('should get the nearest threshold color between thresholds', () => { - const { instance } = setup({ - thresholds: [ - { index: 2, value: 75, color: '#6ED0E0' }, - { index: 1, value: 50, color: '#EAB839' }, - { index: 0, value: -Infinity, color: '#7EB26D' }, - ], - }); - - expect(instance.getFontColor(55)).toEqual('#EAB839'); - }); -}); - describe('Get thresholds formatted', () => { it('should return first thresholds color for min and max', () => { const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] }); @@ -98,51 +62,3 @@ describe('Get thresholds formatted', () => { ]); }); }); - -describe('Format value', () => { - it('should return if value isNaN', () => { - const valueMappings: ValueMapping[] = []; - const value = 'N/A'; - const { instance } = setup({ valueMappings }); - - const result = instance.formatValue(value); - - expect(result).toEqual('N/A'); - }); - - it('should return formatted value if there are no value mappings', () => { - const valueMappings: ValueMapping[] = []; - const value = '6'; - const { instance } = setup({ valueMappings, decimals: 1 }); - - const result = instance.formatValue(value); - - expect(result).toEqual('6.0'); - }); - - it('should return formatted value if there are no matching value mappings', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, - { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, - ]; - const value = '10'; - const { instance } = setup({ valueMappings, decimals: 1 }); - - const result = instance.formatValue(value); - - expect(result).toEqual('10.0'); - }); - - it('should return mapped value if there are matching value mappings', () => { - const valueMappings: ValueMapping[] = [ - { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, - { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, - ]; - const value = '11'; - const { instance } = setup({ valueMappings, decimals: 1 }); - - const result = instance.formatValue(value); - - expect(result).toEqual('1-20'); - }); -}); diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index d04daae3dab..460547a4d7e 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -1,28 +1,20 @@ import React, { PureComponent } from 'react'; import $ from 'jquery'; -import { getMappedValue } from '../../utils/valueMappings'; import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette'; import { Themeable, GrafanaThemeType } from '../../types/theme'; -import { ValueMapping, Threshold, BasicGaugeColor } from '../../types/panel'; -import { getValueFormat } from '../../utils/valueFormats/valueFormats'; - -type TimeSeriesValue = string | number | null; +import { Threshold, BasicGaugeColor } from '../../types/panel'; +import { DisplayValue } from '../../utils/valueProcessor'; export interface Props extends Themeable { - decimals?: number | null; + width: number; height: number; - valueMappings: ValueMapping[]; maxValue: number; minValue: number; - prefix: string; thresholds: Threshold[]; showThresholdMarkers: boolean; showThresholdLabels: boolean; - stat: string; - suffix: string; - unit: string; - width: number; - value: number; + + value: DisplayValue; } const FONT_SCALE = 1; @@ -32,15 +24,10 @@ export class Gauge extends PureComponent { static defaultProps = { maxValue: 100, - valueMappings: [], minValue: 0, - prefix: '', showThresholdMarkers: true, showThresholdLabels: false, - suffix: '', thresholds: [], - unit: 'none', - stat: 'avg', theme: GrafanaThemeType.Dark, }; @@ -52,49 +39,6 @@ export class Gauge extends PureComponent { this.draw(); } - formatValue(value: TimeSeriesValue) { - const { decimals, valueMappings, prefix, suffix, unit } = this.props; - - if (isNaN(value as number)) { - return value; - } - - if (valueMappings.length > 0) { - const valueMappedValue = getMappedValue(valueMappings, value); - if (valueMappedValue) { - return `${prefix && prefix + ' '}${valueMappedValue.text}${suffix && ' ' + suffix}`; - } - } - - const formatFunc = getValueFormat(unit); - const formattedValue = formatFunc(value as number, decimals); - const handleNoValueValue = formattedValue || 'no value'; - - return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`; - } - - getFontColor(value: TimeSeriesValue) { - const { thresholds, theme } = this.props; - - if (thresholds.length === 1) { - return getColorFromHexRgbOrName(thresholds[0].color, theme.type); - } - - const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0]; - if (atThreshold) { - return getColorFromHexRgbOrName(atThreshold.color, theme.type); - } - - const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value); - - if (belowThreshold.length > 0) { - const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0]; - return getColorFromHexRgbOrName(nearestThreshold.color, theme.type); - } - - return BasicGaugeColor.Red; - } - getFormattedThresholds() { const { maxValue, minValue, thresholds, theme } = this.props; @@ -123,15 +67,13 @@ export class Gauge extends PureComponent { draw() { const { maxValue, minValue, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props; - const formattedValue = this.formatValue(value) as string; const dimension = Math.min(width, height * 1.3); const backgroundColor = theme.type === GrafanaThemeType.Light ? 'rgb(230,230,230)' : theme.colors.dark3; const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1; const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio; const thresholdMarkersWidth = gaugeWidth / 5; - const fontSize = - Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1); + const fontSize = Math.min(dimension / 5, 100) * this.getFontScale(value.text.length); const thresholdLabelFontSize = fontSize / 2.5; const options = { @@ -160,9 +102,9 @@ export class Gauge extends PureComponent { width: thresholdMarkersWidth, }, value: { - color: this.getFontColor(value), + color: value.color ? value.color : BasicGaugeColor.Red, formatter: () => { - return formattedValue; + return value.text; }, font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' }, }, @@ -171,7 +113,8 @@ export class Gauge extends PureComponent { }, }; - const plotSeries = { data: [[0, value]] }; + const numeric = value.numeric !== null ? value.numeric : 0; + const plotSeries = { data: [[0, numeric]] }; try { $.plot(this.canvasElement, [plotSeries], options); diff --git a/packages/grafana-ui/src/utils/valueProcessor.test.ts b/packages/grafana-ui/src/utils/valueProcessor.test.ts new file mode 100644 index 00000000000..76c18f9e93c --- /dev/null +++ b/packages/grafana-ui/src/utils/valueProcessor.test.ts @@ -0,0 +1,107 @@ +import { getValueProcessor, getColorFromThreshold } from './valueProcessor'; +import { getTheme } from '../themes/index'; +import { GrafanaThemeType } from '../types/theme'; +import { MappingType, ValueMapping } from '../types/panel'; + +describe('Process values', () => { + const basicConversions = [ + { value: null, text: '' }, + { value: undefined, text: '' }, + { value: 1.23, text: '1.23' }, + { value: 1, text: '1' }, + { value: 'hello', text: 'hello' }, + { value: {}, text: '[object Object]' }, + { value: [], text: '' }, + { value: [1, 2, 3], text: '1,2,3' }, + { value: ['a', 'b', 'c'], text: 'a,b,c' }, + ]; + + it('should return return a string for any input value', () => { + const processor = getValueProcessor(); + basicConversions.forEach(item => { + expect(processor(item.value).text).toBe(item.text); + }); + }); + + it('should add a suffix to any value', () => { + const processor = getValueProcessor({ + prefix: 'xxx', + theme: getTheme(GrafanaThemeType.Dark), + }); + basicConversions.forEach(item => { + expect(processor(item.value).text).toBe('xxx' + item.text); + }); + }); +}); + +describe('Get color from threshold', () => { + it('should get first threshold color when only one threshold', () => { + const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }]; + expect(getColorFromThreshold(49, thresholds)).toEqual('#7EB26D'); + }); + + it('should get the threshold color if value is same as a threshold', () => { + const thresholds = [ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]; + expect(getColorFromThreshold(50, thresholds)).toEqual('#EAB839'); + }); + + it('should get the nearest threshold color between thresholds', () => { + const thresholds = [ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]; + expect(getColorFromThreshold(55, thresholds)).toEqual('#EAB839'); + }); +}); + +describe('Format value', () => { + it('should return if value isNaN', () => { + const valueMappings: ValueMapping[] = []; + const value = 'N/A'; + const instance = getValueProcessor({ mappings: valueMappings }); + + const result = instance(value); + + expect(result.text).toEqual('N/A'); + }); + + it('should return formatted value if there are no value mappings', () => { + const valueMappings: ValueMapping[] = []; + const value = '6'; + + const instance = getValueProcessor({ mappings: valueMappings, decimals: 1 }); + + const result = instance(value); + + expect(result.text).toEqual('6.0'); + }); + + it('should return formatted value if there are no matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' }, + ]; + const value = '10'; + const instance = getValueProcessor({ mappings: valueMappings, decimals: 1 }); + + const result = instance(value); + + expect(result.text).toEqual('10.0'); + }); + + it('should return mapped value if there are matching value mappings', () => { + const valueMappings: ValueMapping[] = [ + { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, + { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' }, + ]; + const value = '11'; + const instance = getValueProcessor({ mappings: valueMappings, decimals: 1 }); + + expect(instance(value).text).toEqual('1-20'); + }); +}); diff --git a/packages/grafana-ui/src/utils/valueProcessor.ts b/packages/grafana-ui/src/utils/valueProcessor.ts new file mode 100644 index 00000000000..243904c269c --- /dev/null +++ b/packages/grafana-ui/src/utils/valueProcessor.ts @@ -0,0 +1,97 @@ +import { ValueMapping, Threshold } from '../types/panel'; +import _ from 'lodash'; +import { getValueFormat, DecimalCount } from './valueFormats/valueFormats'; +import { getMappedValue } from './valueMappings'; +import { GrafanaTheme, GrafanaThemeType } from '../types/theme'; +import { getColorFromHexRgbOrName } from './namedColorsPalette'; + +export interface DisplayValue { + text: string; // How the value should be displayed + numeric?: number; // the value as a number + color?: string; // suggested color +} + +export interface DisplayValueOptions { + unit?: string; + decimals?: DecimalCount; + scaledDecimals?: DecimalCount; + isUtc?: boolean; + + color?: string; + mappings?: ValueMapping[]; + thresholds?: Threshold[]; + prefix?: string; + suffix?: string; + + noValue?: string; + theme?: GrafanaTheme; // Will pick 'dark' if not defined +} + +export type ValueProcessor = (value: any) => DisplayValue; + +export function getValueProcessor(options?: DisplayValueOptions): ValueProcessor { + if (options && !_.isEmpty(options)) { + const formatFunc = getValueFormat(options.unit || 'none'); + return (value: any) => { + const { prefix, suffix, mappings, thresholds, theme } = options; + let color = options.color; + + let text = _.toString(value); + const numeric = _.toNumber(value); + + if (mappings && mappings.length > 0) { + const mappedValue = getMappedValue(mappings, value); + if (mappedValue) { + text = mappedValue.text; + // TODO? convert the mapped value back to a number? + } + } + + if (_.isNumber(numeric)) { + text = formatFunc(numeric, options.decimals, options.scaledDecimals, options.isUtc); + if (thresholds && thresholds.length > 0) { + color = getColorFromThreshold(numeric, thresholds, theme); + } + } + + if (!text) { + text = options.noValue ? options.noValue : ''; + } + if (prefix) { + text = prefix + text; + } + if (suffix) { + text = text + suffix; + } + return { text, numeric, color }; + }; + } + return toStringProcessor; +} + +function toStringProcessor(value: any): DisplayValue { + return { text: _.toString(value), numeric: _.toNumber(value) }; +} + +export function getColorFromThreshold(value: number, thresholds: Threshold[], theme?: GrafanaTheme): string { + const themeType = theme ? theme.type : GrafanaThemeType.Dark; + + if (thresholds.length === 1) { + return getColorFromHexRgbOrName(thresholds[0].color, themeType); + } + + const atThreshold = thresholds.filter(threshold => value === threshold.value)[0]; + if (atThreshold) { + return getColorFromHexRgbOrName(atThreshold.color, themeType); + } + + const belowThreshold = thresholds.filter(threshold => value > threshold.value); + + if (belowThreshold.length > 0) { + const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0]; + return getColorFromHexRgbOrName(nearestThreshold.color, themeType); + } + + // Use the first threshold as the default color + return getColorFromHexRgbOrName(thresholds[0].color, themeType); +} diff --git a/public/app/plugins/panel/gauge/DisplayValueEditor.tsx b/public/app/plugins/panel/gauge/DisplayValueEditor.tsx new file mode 100644 index 00000000000..51c956a9529 --- /dev/null +++ b/public/app/plugins/panel/gauge/DisplayValueEditor.tsx @@ -0,0 +1,64 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Components +import { FormField, FormLabel, PanelOptionsGroup, UnitPicker } from '@grafana/ui'; + +// Types +import { DisplayValueOptions } from '@grafana/ui/src/utils/valueProcessor'; + +const labelWidth = 6; + +export interface Props { + options: DisplayValueOptions; + onChange: (options: DisplayValueOptions) => void; +} + +export class DisplayValueEditor extends PureComponent { + onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value }); + + onDecimalChange = event => { + if (!isNaN(event.target.value)) { + this.props.onChange({ + ...this.props.options, + decimals: parseInt(event.target.value, 10), + }); + } else { + this.props.onChange({ + ...this.props.options, + decimals: null, + }); + } + }; + + onPrefixChange = event => this.props.onChange({ ...this.props.options, prefix: event.target.value }); + onSuffixChange = event => this.props.onChange({ ...this.props.options, suffix: event.target.value }); + + render() { + const { unit, decimals, prefix, suffix } = this.props.options; + + let decimalsString = ''; + if (Number.isFinite(decimals)) { + decimalsString = decimals.toString(); + } + + return ( + +
+ Unit + +
+ + + +
+ ); + } +} diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index b75d4a1c7f3..425ccb00356 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -9,30 +9,50 @@ import { Gauge } from '@grafana/ui'; // Types import { GaugeOptions } from './types'; -import { PanelProps, NullValueMode, TimeSeriesValue } from '@grafana/ui/src/types'; +import { PanelProps, NullValueMode, BasicGaugeColor } from '@grafana/ui/src/types'; +import { DisplayValue, getValueProcessor } from '@grafana/ui/src/utils/valueProcessor'; interface Props extends PanelProps {} interface State { - value: TimeSeriesValue; + value: DisplayValue; } export class GaugePanel extends Component { constructor(props: Props) { super(props); + + if (props.options.valueOptions) { + console.warn('TODO!! how do we best migration options?'); + } + this.state = { - value: this.findValue(props), + value: this.findDisplayValue(props), }; } componentDidUpdate(prevProps: Props) { if (this.props.panelData !== prevProps.panelData) { - this.setState({ value: this.findValue(this.props) }); + this.setState({ value: this.findDisplayValue(this.props) }); } } + findDisplayValue(props: Props): DisplayValue { + const { replaceVariables, options } = this.props; + const { displayOptions } = options; + + const prefix = replaceVariables(displayOptions.prefix); + const suffix = replaceVariables(displayOptions.suffix); + return getValueProcessor({ + color: BasicGaugeColor.Red, // The default color + ...displayOptions, + prefix, + suffix, + // ??? theme:getTheme(GrafanaThemeType.Dark), !! how do I get it here??? + })(this.findValue(props)); + } + findValue(props: Props): number | null { const { panelData, options } = props; - const { valueOptions } = options; if (panelData.timeSeries) { const vmSeries = processTimeSeries({ @@ -41,7 +61,7 @@ export class GaugePanel extends Component { }); if (vmSeries[0]) { - return vmSeries[0].stats[valueOptions.stat]; + return vmSeries[0].stats[options.stat]; } } else if (panelData.tableData) { return panelData.tableData.rows[0].find(prop => prop > 0); @@ -50,12 +70,9 @@ export class GaugePanel extends Component { } render() { - const { width, height, replaceVariables, options } = this.props; - const { valueOptions } = options; + const { width, height, options } = this.props; const { value } = this.state; - const prefix = replaceVariables(valueOptions.prefix); - const suffix = replaceVariables(valueOptions.suffix); return ( {theme => ( @@ -63,12 +80,7 @@ export class GaugePanel extends Component { value={value} width={width} height={height} - prefix={prefix} - suffix={suffix} - unit={valueOptions.unit} - decimals={valueOptions.decimals} thresholds={options.thresholds} - valueMappings={options.valueMappings} showThresholdLabels={options.showThresholdLabels} showThresholdMarkers={options.showThresholdMarkers} minValue={options.minValue} diff --git a/public/app/plugins/panel/gauge/GaugePanelEditor.tsx b/public/app/plugins/panel/gauge/GaugePanelEditor.tsx index f226be7328c..55a0377848d 100644 --- a/public/app/plugins/panel/gauge/GaugePanelEditor.tsx +++ b/public/app/plugins/panel/gauge/GaugePanelEditor.tsx @@ -11,6 +11,8 @@ import { import { SingleStatValueEditor } from 'app/plugins/panel/gauge/SingleStatValueEditor'; import { GaugeOptionsBox } from './GaugeOptionsBox'; import { GaugeOptions, SingleStatValueOptions } from './types'; +import { DisplayValueEditor } from './DisplayValueEditor'; +import { DisplayValueOptions } from '@grafana/ui/src/utils/valueProcessor'; export class GaugePanelEditor extends PureComponent> { onThresholdsChanged = (thresholds: Threshold[]) => @@ -31,13 +33,22 @@ export class GaugePanelEditor extends PureComponent + this.props.onOptionsChange({ + ...this.props.options, + displayOptions, + }); + render() { const { onOptionsChange, options } = this.props; return ( <> - + {/* This just sets the 'stats', that should be moved to somethign more general */} + + + diff --git a/public/app/plugins/panel/gauge/SingleStatValueEditor.tsx b/public/app/plugins/panel/gauge/SingleStatValueEditor.tsx index e711df6a2d3..414a606b108 100644 --- a/public/app/plugins/panel/gauge/SingleStatValueEditor.tsx +++ b/public/app/plugins/panel/gauge/SingleStatValueEditor.tsx @@ -2,10 +2,10 @@ import React, { PureComponent } from 'react'; // Components -import { FormField, FormLabel, PanelOptionsGroup, Select, UnitPicker } from '@grafana/ui'; +import { FormLabel, PanelOptionsGroup, Select } from '@grafana/ui'; // Types -import { SingleStatValueOptions } from './types'; +import { GaugeOptions } from './types'; const statOptions = [ { value: 'min', label: 'Min' }, @@ -24,41 +24,18 @@ const statOptions = [ const labelWidth = 6; export interface Props { - options: SingleStatValueOptions; - onChange: (valueOptions: SingleStatValueOptions) => void; + options: GaugeOptions; + onChange: (options: GaugeOptions) => void; } export class SingleStatValueEditor extends PureComponent { - onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value }); onStatChange = stat => this.props.onChange({ ...this.props.options, stat: stat.value }); - onDecimalChange = event => { - if (!isNaN(event.target.value)) { - this.props.onChange({ - ...this.props.options, - decimals: parseInt(event.target.value, 10), - }); - } else { - this.props.onChange({ - ...this.props.options, - decimals: null, - }); - } - }; - - onPrefixChange = event => this.props.onChange({ ...this.props.options, prefix: event.target.value }); - onSuffixChange = event => this.props.onChange({ ...this.props.options, suffix: event.target.value }); - render() { - const { stat, unit, decimals, prefix, suffix } = this.props.options; - - let decimalsString = ''; - if (Number.isFinite(decimals)) { - decimalsString = decimals.toString(); - } + const { stat } = this.props.options; return ( - +
Stat option.value === stat)} - /> -
-
- ); - } -} diff --git a/public/app/plugins/panel/gauge/__snapshots__/module.test.ts.snap b/public/app/plugins/panel/gauge/__snapshots__/module.test.ts.snap deleted file mode 100644 index 502c8d7c853..00000000000 --- a/public/app/plugins/panel/gauge/__snapshots__/module.test.ts.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Gauge Module migrations should migrate from 6.0 settings to 6.1 1`] = ` -Object { - "display": Object { - "decimals": 4, - "mappings": Array [], - "prefix": "a", - "stat": "avg", - "suffix": "z", - "thresholds": Array [ - Object { - "color": "green", - "index": 0, - "value": -Infinity, - }, - Object { - "color": "red", - "index": 1, - "value": 80, - }, - ], - "unit": "ms", - }, - "maxValue": 60, - "minValue": 50, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "stat": "avg", -} -`; diff --git a/public/app/plugins/panel/gauge/module.test.ts b/public/app/plugins/panel/gauge/module.test.ts deleted file mode 100644 index 8dc29d42643..00000000000 --- a/public/app/plugins/panel/gauge/module.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { gaugePanelTypeChangedHook } from './module'; - -describe('Gauge Module', () => { - describe('migrations', () => { - it('should migrate from 6.0 settings to 6.1', () => { - const v60 = { - minValue: 50, - maxValue: 60, - showThresholdMarkers: true, - showThresholdLabels: false, - valueOptions: { - prefix: 'a', - suffix: 'z', - decimals: 4, - stat: 'avg', - unit: 'ms', - }, - valueMappings: [], - thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }], - }; - - const after = gaugePanelTypeChangedHook(v60); - expect((after.stat = 'avg')); - expect(after).toMatchSnapshot(); - }); - }); -}); diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index e33d8f0a878..7d56ac5641b 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -1,58 +1,12 @@ -import { ReactPanelPlugin, DisplayValueOptions } from '@grafana/ui'; -import cloneDeep from 'lodash/cloneDeep'; +import { ReactPanelPlugin } from '@grafana/ui'; import { GaugePanelEditor } from './GaugePanelEditor'; import { GaugePanel } from './GaugePanel'; import { GaugeOptions, defaults } from './types'; +import { singleStatOptionsCheck } from '../singlestat2/module'; export const reactPanel = new ReactPanelPlugin(GaugePanel); -// Bar Gauge uses the same handler - -const optionsToCheck = ['display', 'stat', 'maxValue', 'maxValue']; - -export const gaugePanelTypeChangedHook = (options: Partial, prevPluginId?: string, prevOptions?: any) => { - // TODO! migrate to new settings format - // - // thresholds?: Threshold[]; - // valueMappings?: ValueMapping[]; - // valueOptions?: SingleStatValueOptions; - // - // if (props.options.valueOptions) { - // console.warn('TODO!! how do we best migration options?'); - // } - - // 6.0 -> 6.1, settings were stored on the root, now moved to display - if (!options.display && !prevOptions && options.hasOwnProperty('thresholds')) { - console.log('Migrating old gauge settings format', options); - const migrate = options as any; - const display = (migrate.valueOptions || {}) as DisplayValueOptions; - - display.thresholds = migrate.thresholds; - display.mappings = migrate.valueMappings; - if (migrate.valueMappings) { - options.stat = migrate.valueMappings.stat; - delete migrate.valueMappings.stat; - } - - delete migrate.valueOptions; - delete migrate.thresholds; - delete migrate.valueMappings; - - options.display = display; - } - - if (prevOptions) { - optionsToCheck.forEach(v => { - if (prevOptions.hasOwnProperty(v)) { - options[v] = cloneDeep(prevOptions.display); - } - }); - } - - return options; -}; - reactPanel.setEditor(GaugePanelEditor); reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook(gaugePanelTypeChangedHook); +reactPanel.setPanelTypeChangedHook(singleStatOptionsCheck); diff --git a/public/app/plugins/panel/gauge/types.ts b/public/app/plugins/panel/gauge/types.ts index 14c1a739dff..bab29d5d2ad 100644 --- a/public/app/plugins/panel/gauge/types.ts +++ b/public/app/plugins/panel/gauge/types.ts @@ -1,6 +1,7 @@ -import { SingleStatOptions } from '@grafana/ui'; +import { SingleStatBaseOptions } from '../singlestat2/types'; +import { VizOrientation } from '@grafana/ui'; -export interface GaugeOptions extends SingleStatOptions { +export interface GaugeOptions extends SingleStatBaseOptions { maxValue: number; minValue: number; showThresholdLabels: boolean; @@ -12,14 +13,14 @@ export const defaults: GaugeOptions = { maxValue: 100, showThresholdMarkers: true, showThresholdLabels: false, - - stat: 'avg', - display: { + valueOptions: { prefix: '', suffix: '', decimals: null, + stat: 'avg', unit: 'none', - mappings: [], - thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }], }, + valueMappings: [], + thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }], + orientation: VizOrientation.Auto, }; diff --git a/public/app/plugins/panel/singlestat2/README.md b/public/app/plugins/panel/singlestat2/README.md new file mode 100644 index 00000000000..42d72825c27 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/README.md @@ -0,0 +1,9 @@ +# Singlestat Panel - Native Plugin + +The Singlestat Panel is **included** with Grafana. + +The Singlestat Panel allows you to show the one main summary stat of a SINGLE series. It reduces the series into a single number (by looking at the max, min, average, or sum of values in the series). Singlestat also provides thresholds to color the stat or the Panel background. It can also translate the single number into a text value, and show a sparkline summary of the series. + +Read more about it here: + +[http://docs.grafana.org/reference/singlestat/](http://docs.grafana.org/reference/singlestat/) \ No newline at end of file diff --git a/public/app/plugins/panel/gauge/SingleStatPanel.tsx b/public/app/plugins/panel/singlestat2/SingleStatBase.tsx similarity index 64% rename from public/app/plugins/panel/gauge/SingleStatPanel.tsx rename to public/app/plugins/panel/singlestat2/SingleStatBase.tsx index c891e05988a..fb5e54b68ed 100644 --- a/public/app/plugins/panel/gauge/SingleStatPanel.tsx +++ b/public/app/plugins/panel/singlestat2/SingleStatBase.tsx @@ -1,21 +1,16 @@ -// Libraries import React, { PureComponent } from 'react'; - -// Services & Utils -import { processSingleStatPanelData, SingleStatOptions, DisplayValue, PanelProps, VizOrientation } from '@grafana/ui'; +import { processSingleStatPanelData, DisplayValue, PanelProps } from '@grafana/ui'; import { config } from 'app/core/config'; - -// Components import { VizRepeater, getDisplayProcessor } from '@grafana/ui'; +import { SingleStatBaseOptions } from './types'; -interface State { +export interface State { values: DisplayValue[]; } -export class SingleStatPanel extends PureComponent, State> { +export class SingleStatBase extends PureComponent, State> { constructor(props: PanelProps) { super(props); - this.state = { values: this.findDisplayValues(props), }; @@ -29,18 +24,20 @@ export class SingleStatPanel extends PureComponent< findDisplayValues(props: PanelProps): DisplayValue[] { const { panelData, replaceVariables, options } = this.props; - const { display } = options; - + const { valueOptions, valueMappings } = options; const processor = getDisplayProcessor({ - ...display, - prefix: replaceVariables(display.prefix), - suffix: replaceVariables(display.suffix), + unit: valueOptions.unit, + decimals: valueOptions.decimals, + mappings: valueMappings, + thresholds: options.thresholds, + + prefix: replaceVariables(valueOptions.prefix), + suffix: replaceVariables(valueOptions.suffix), theme: config.theme, }); - return processSingleStatPanelData({ panelData: panelData, - stat: options.stat, + stat: valueOptions.stat, }).map(stat => processor(stat.value)); } @@ -51,17 +48,12 @@ export class SingleStatPanel extends PureComponent< return
{value.text}
; } - // Or we could add this to single stat props? - getOrientation(): VizOrientation { - return VizOrientation.Auto; - } - render() { - const { height, width } = this.props; + const { height, width, options } = this.props; + const { orientation } = options; const { values } = this.state; - return ( - + {({ vizHeight, vizWidth, value }) => this.renderStat(value, vizWidth, vizHeight)} ); diff --git a/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx b/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx new file mode 100644 index 00000000000..61b8588adce --- /dev/null +++ b/public/app/plugins/panel/singlestat2/SingleStatEditor.tsx @@ -0,0 +1,48 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { + PanelEditorProps, + ThresholdsEditor, + Threshold, + PanelOptionsGrid, + ValueMappingsEditor, + ValueMapping, +} from '@grafana/ui'; + +import { SingleStatOptions, SingleStatValueOptions } from './types'; +import { SingleStatValueEditor } from './SingleStatValueEditor'; + +export class SingleStatEditor extends PureComponent> { + onThresholdsChanged = (thresholds: Threshold[]) => + this.props.onOptionsChange({ + ...this.props.options, + thresholds, + }); + + onValueMappingsChanged = (valueMappings: ValueMapping[]) => + this.props.onOptionsChange({ + ...this.props.options, + valueMappings, + }); + + onValueOptionsChanged = (valueOptions: SingleStatValueOptions) => + this.props.onOptionsChange({ + ...this.props.options, + valueOptions, + }); + + render() { + const { options } = this.props; + + return ( + <> + + + + + + + + ); + } +} diff --git a/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx b/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx new file mode 100644 index 00000000000..f9f20487c95 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx @@ -0,0 +1,17 @@ +// Libraries +import React from 'react'; + +// Types +import { SingleStatOptions } from './types'; +import { DisplayValue } from '@grafana/ui/src/utils/displayValue'; +import { SingleStatBase } from './SingleStatBase'; + +export class SingleStatPanel extends SingleStatBase { + renderStat(value: DisplayValue, width: number, height: number) { + return ( +
+ {value.text} +
+ ); + } +} diff --git a/public/app/plugins/panel/gauge/DisplayValueEditor.tsx b/public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx similarity index 55% rename from public/app/plugins/panel/gauge/DisplayValueEditor.tsx rename to public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx index 94660de85af..e711df6a2d3 100644 --- a/public/app/plugins/panel/gauge/DisplayValueEditor.tsx +++ b/public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx @@ -2,20 +2,35 @@ import React, { PureComponent } from 'react'; // Components -import { FormField, FormLabel, PanelOptionsGroup, UnitPicker } from '@grafana/ui'; +import { FormField, FormLabel, PanelOptionsGroup, Select, UnitPicker } from '@grafana/ui'; // Types -import { DisplayValueOptions } from '@grafana/ui'; +import { SingleStatValueOptions } from './types'; + +const statOptions = [ + { value: 'min', label: 'Min' }, + { value: 'max', label: 'Max' }, + { value: 'avg', label: 'Average' }, + { value: 'current', label: 'Current' }, + { value: 'total', label: 'Total' }, + { value: 'name', label: 'Name' }, + { value: 'first', label: 'First' }, + { value: 'delta', label: 'Delta' }, + { value: 'diff', label: 'Difference' }, + { value: 'range', label: 'Range' }, + { value: 'last_time', label: 'Time of last point' }, +]; const labelWidth = 6; export interface Props { - options: DisplayValueOptions; - onChange: (options: DisplayValueOptions) => void; + options: SingleStatValueOptions; + onChange: (valueOptions: SingleStatValueOptions) => void; } -export class DisplayValueEditor extends PureComponent { +export class SingleStatValueEditor extends PureComponent { onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value }); + onStatChange = stat => this.props.onChange({ ...this.props.options, stat: stat.value }); onDecimalChange = event => { if (!isNaN(event.target.value)) { @@ -35,7 +50,7 @@ export class DisplayValueEditor extends PureComponent { onSuffixChange = event => this.props.onChange({ ...this.props.options, suffix: event.target.value }); render() { - const { unit, decimals, prefix, suffix } = this.props.options; + const { stat, unit, decimals, prefix, suffix } = this.props.options; let decimalsString = ''; if (Number.isFinite(decimals)) { @@ -43,7 +58,16 @@ export class DisplayValueEditor extends PureComponent { } return ( - + +
+ Stat +