diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx index 8fa0b2846a5..9d592ef310b 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -11,16 +11,14 @@ jest.mock('jquery', () => ({ const setup = (propOverrides?: object) => { const props: Props = { maxValue: 100, - valueMappings: [], minValue: 0, - prefix: '', - suffix: '', thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }], - unit: 'none', height: 300, width: 300, - value: 25, - decimals: 0, + value: { + text: '25', + numeric: 25, + }, theme: getTheme(), orientation: VizOrientation.Horizontal, }; diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 97cc4792785..aadd4dbcf3b 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -3,26 +3,21 @@ import React, { PureComponent, CSSProperties } from 'react'; import tinycolor from 'tinycolor2'; // Utils -import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils'; +import { getColorFromHexRgbOrName, getThresholdForValue, DisplayValue } from '../../utils'; // Types -import { Themeable, TimeSeriesValue, Threshold, ValueMapping, VizOrientation } from '../../types'; +import { Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types'; const BAR_SIZE_RATIO = 0.8; export interface Props extends Themeable { height: number; - unit: string; width: number; thresholds: Threshold[]; - valueMappings: ValueMapping[]; - value: TimeSeriesValue; + value: DisplayValue; maxValue: number; minValue: number; orientation: VizOrientation; - prefix?: string; - suffix?: string; - decimals?: number; } /* @@ -32,24 +27,18 @@ export class BarGauge extends PureComponent { static defaultProps: Partial = { maxValue: 100, minValue: 0, - value: 100, - unit: 'none', + value: { + text: '100', + numeric: 100, + }, orientation: VizOrientation.Horizontal, thresholds: [], - valueMappings: [], }; - getNumericValue(): number { - if (Number.isFinite(this.props.value as number)) { - return this.props.value as number; - } - return 0; - } - getValueColors(): BarColors { const { thresholds, theme, value } = this.props; - const activeThreshold = getThresholdForValue(thresholds, value); + const activeThreshold = getThresholdForValue(thresholds, value.numeric); if (activeThreshold !== null) { const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type); @@ -78,7 +67,7 @@ export class BarGauge extends PureComponent { const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type); // if we are past real value the cell is not "on" - if (value === null || (positionValue !== null && positionValue > value)) { + if (value === null || (positionValue !== null && positionValue > value.numeric)) { return tinycolor(color) .setAlpha(0.15) .toRgbString(); @@ -217,18 +206,14 @@ export class BarGauge extends PureComponent { } render() { - const { maxValue, minValue, orientation, unit, decimals } = this.props; + const { maxValue, minValue, orientation, value } = this.props; - const numericValue = this.getNumericValue(); - const valuePercent = Math.min(numericValue / (maxValue - minValue), 1); - - const formatFunc = getValueFormat(unit); - const valueFormatted = formatFunc(numericValue, decimals); + const valuePercent = Math.min(value.numeric / (maxValue - minValue), 1); const vertical = orientation === 'vertical'; return vertical - ? this.renderVerticalBar(valueFormatted, valuePercent) - : this.renderHorizontalLCD(valueFormatted, valuePercent); + ? this.renderVerticalBar(value.text, valuePercent) + : this.renderHorizontalLCD(value.text, valuePercent); } } 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 3d1567d4937..f812cc971bc 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 { ValueMapping, Threshold, GrafanaThemeType } from '../../types'; -import { getMappedValue } from '../../utils/valueMappings'; -import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils'; +import { Threshold, GrafanaThemeType } from '../../types'; +import { getColorFromHexRgbOrName } from '../../utils'; import { Themeable } from '../../index'; - -type GaugeValue = string | number | null; +import { DisplayValue } from '../../utils/displayValue'; export interface Props extends Themeable { - decimals?: number | null; 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: Partial = { maxValue: 100, - valueMappings: [], minValue: 0, - prefix: '', showThresholdMarkers: true, showThresholdLabels: false, - suffix: '', thresholds: [], - unit: 'none', - stat: 'avg', }; componentDidMount() { @@ -51,39 +38,6 @@ export class Gauge extends PureComponent { this.draw(); } - formatValue(value: GaugeValue) { - 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: GaugeValue): string { - const { thresholds, theme } = this.props; - - const activeThreshold = getThresholdForValue(thresholds, value); - - if (activeThreshold !== null) { - return getColorFromHexRgbOrName(activeThreshold.color, theme.type); - } - - return ''; - } - getFormattedThresholds() { const { maxValue, minValue, thresholds, theme } = this.props; @@ -112,15 +66,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) * (value.text !== null ? this.getFontScale(value.text.length) : 1); const thresholdLabelFontSize = fontSize / 2.5; const options: any = { @@ -149,9 +101,9 @@ export class Gauge extends PureComponent { width: thresholdMarkersWidth, }, value: { - color: this.getFontColor(value), + color: value.color, formatter: () => { - return formattedValue; + return value.text; }, font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' }, }, @@ -160,7 +112,7 @@ export class Gauge extends PureComponent { }, }; - const plotSeries = { data: [[0, value]] }; + const plotSeries = { data: [[0, value.numeric]] }; try { $.plot(this.canvasElement, [plotSeries], options); diff --git a/packages/grafana-ui/src/utils/displayValue.test.ts b/packages/grafana-ui/src/utils/displayValue.test.ts new file mode 100644 index 00000000000..bf0e7a89bba --- /dev/null +++ b/packages/grafana-ui/src/utils/displayValue.test.ts @@ -0,0 +1,157 @@ +import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, DisplayValue } from './displayValue'; +import { MappingType, ValueMapping } from '../types/panel'; + +function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) { + processors.forEach(processor => { + const value = processor(input); + expect(value.text).toEqual(match.text); + if (match.hasOwnProperty('numeric')) { + expect(value.numeric).toEqual(match.numeric); + } + }); +} + +describe('Process simple display values', () => { + // Don't test float values here since the decimal formatting changes + const processors = [ + // Without options, this shortcuts to a much easier implementation + getDisplayProcessor(), + + // Add a simple option that is not used (uses a different base class) + getDisplayProcessor({ color: '#FFF' }), + + // Add a simple option that is not used (uses a different base class) + getDisplayProcessor({ unit: 'locale' }), + ]; + + it('support null', () => { + assertSame(null, processors, { text: '', numeric: NaN }); + }); + + it('support undefined', () => { + assertSame(undefined, processors, { text: '', numeric: NaN }); + }); + + it('support NaN', () => { + assertSame(NaN, processors, { text: 'NaN', numeric: NaN }); + }); + + it('Integer', () => { + assertSame(3, processors, { text: '3', numeric: 3 }); + }); + + it('Text to number', () => { + assertSame('3', processors, { text: '3', numeric: 3 }); + }); + + it('Simple String', () => { + assertSame('hello', processors, { text: 'hello', numeric: NaN }); + }); + + it('empty array', () => { + assertSame([], processors, { text: '', numeric: NaN }); + }); + + it('array of text', () => { + assertSame(['a', 'b', 'c'], processors, { text: 'a,b,c', numeric: NaN }); + }); + + it('array of numbers', () => { + assertSame([1, 2, 3], processors, { text: '1,2,3', numeric: NaN }); + }); + + it('empty object', () => { + assertSame({}, processors, { text: '[object Object]', numeric: NaN }); + }); + + it('boolean true', () => { + assertSame(true, processors, { text: 'true', numeric: 1 }); + }); + + it('boolean false', () => { + assertSame(false, processors, { text: 'false', numeric: 0 }); + }); +}); + +describe('Processor with more configs', () => { + it('support prefix & suffix', () => { + const processor = getDisplayProcessor({ + prefix: 'AA_', + suffix: '_ZZ', + }); + + expect(processor('XXX').text).toEqual('AA_XXX_ZZ'); + }); +}); + +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 = getDisplayProcessor({ 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 = getDisplayProcessor({ 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 = getDisplayProcessor({ 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 = getDisplayProcessor({ mappings: valueMappings, decimals: 1 }); + + expect(instance(value).text).toEqual('1-20'); + }); +}); diff --git a/packages/grafana-ui/src/utils/displayValue.ts b/packages/grafana-ui/src/utils/displayValue.ts new file mode 100644 index 00000000000..75191f34434 --- /dev/null +++ b/packages/grafana-ui/src/utils/displayValue.ts @@ -0,0 +1,145 @@ +import { ValueMapping, Threshold } from '../types'; +import _ from 'lodash'; +import { getValueFormat, DecimalCount } from './valueFormats/valueFormats'; +import { getMappedValue } from './valueMappings'; +import { GrafanaTheme, GrafanaThemeType } from '../types'; +import { getColorFromHexRgbOrName } from './namedColorsPalette'; +import moment from 'moment'; + +export interface DisplayValue { + text: string; // Show in the UI + numeric: number; // Use isNaN to check if it is a real number + color?: string; // color based on configs or Threshold +} + +export interface DisplayValueOptions { + unit?: string; + decimals?: DecimalCount; + scaledDecimals?: DecimalCount; + dateFormat?: string; // If set try to convert numbers to date + + color?: string; + mappings?: ValueMapping[]; + thresholds?: Threshold[]; + prefix?: string; + suffix?: string; + + // Alternative to empty string + noValue?: string; + + // Context + isUtc?: boolean; + theme?: GrafanaTheme; // Will pick 'dark' if not defined +} + +export type DisplayProcessor = (value: any) => DisplayValue; + +export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProcessor { + 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); + let numeric = toNumber(value); + + let shouldFormat = true; + if (mappings && mappings.length > 0) { + const mappedValue = getMappedValue(mappings, value); + if (mappedValue) { + text = mappedValue.text; + const v = toNumber(text); + if (!isNaN(v)) { + numeric = v; + } + shouldFormat = false; + } + } + + if (options.dateFormat) { + const date = toMoment(value, numeric, options.dateFormat); + if (date.isValid()) { + text = date.format(options.dateFormat); + shouldFormat = false; + } + } + + if (!isNaN(numeric)) { + if (shouldFormat && !_.isBoolean(value)) { + 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 toMoment(value: any, numeric: number, format: string): moment.Moment { + if (!isNaN(numeric)) { + const v = moment(numeric); + if (v.isValid()) { + return v; + } + } + const v = moment(value, format); + if (v.isValid) { + return v; + } + return moment(value); // moment will try to parse the format +} + +/** Will return any value as a number or NaN */ +function toNumber(value: any): number { + if (typeof value === 'number') { + return value; + } + if (value === null || value === undefined || Array.isArray(value)) { + return NaN; // lodash calls them 0 + } + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + return _.toNumber(value); +} + +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/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index a08b9ce1a89..db9290b79c2 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -5,5 +5,6 @@ export * from './colors'; export * from './namedColorsPalette'; export * from './thresholds'; export * from './string'; +export * from './displayValue'; export * from './deprecationWarning'; export { getMappedValue } from './valueMappings'; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 9a156652a65..ab9d9aba08a 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -25,6 +25,7 @@ import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; import * as tablePanel from 'app/plugins/panel/table/module'; import * as table2Panel from 'app/plugins/panel/table2/module'; import * as singlestatPanel from 'app/plugins/panel/singlestat/module'; +import * as singlestatPanel2 from 'app/plugins/panel/singlestat2/module'; import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; import * as gaugePanel from 'app/plugins/panel/gauge/module'; import * as barGaugePanel from 'app/plugins/panel/bargauge/module'; @@ -57,6 +58,7 @@ const builtInPlugins = { 'app/plugins/panel/table/module': tablePanel, 'app/plugins/panel/table2/module': table2Panel, 'app/plugins/panel/singlestat/module': singlestatPanel, + 'app/plugins/panel/singlestat2/module': singlestatPanel2, 'app/plugins/panel/gettingstarted/module': gettingStartedPanel, 'app/plugins/panel/gauge/module': gaugePanel, 'app/plugins/panel/bargauge/module': barGaugePanel, diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index 9f60c3440eb..e738475afc8 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -2,55 +2,46 @@ import React, { PureComponent } from 'react'; // Services & Utils -import { processSingleStatPanelData } from '@grafana/ui'; +import { DisplayValue, PanelProps, BarGauge } from '@grafana/ui'; import { config } from 'app/core/config'; -// Components -import { BarGauge, VizRepeater } from '@grafana/ui'; - // Types import { BarGaugeOptions } from './types'; -import { PanelProps, SingleStatValueInfo } from '@grafana/ui/src/types'; +import { getSingleStatValues } from '../singlestat2/SingleStatPanel'; +import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater'; -interface Props extends PanelProps {} - -export class BarGaugePanel extends PureComponent { - renderBarGauge(value: SingleStatValueInfo, width, height) { - const { replaceVariables, options } = this.props; - const { valueOptions } = options; - - const prefix = replaceVariables(valueOptions.prefix); - const suffix = replaceVariables(valueOptions.suffix); +export class BarGaugePanel extends PureComponent> { + renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => { + const { options } = this.props; return ( ); - } + }; + + getProcessedValues = (): DisplayValue[] => { + return getSingleStatValues(this.props); + }; render() { - const { panelData, options, width, height } = this.props; - - const values = processSingleStatPanelData({ - panelData: panelData, - stat: options.valueOptions.stat, - }); - + const { height, width, options, panelData } = this.props; + const { orientation } = options; return ( - - {({ vizHeight, vizWidth, value }) => this.renderBarGauge(value, vizWidth, vizHeight)} - + ); } } diff --git a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx index 4232155228b..cccd4e88b8e 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx @@ -2,13 +2,13 @@ import React, { PureComponent } from 'react'; // Components -import { SingleStatValueEditor } from 'app/plugins/panel/gauge/SingleStatValueEditor'; import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGroup, FormField } from '@grafana/ui'; // Types import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui'; import { BarGaugeOptions, orientationOptions } from './types'; -import { SingleStatValueOptions } from '../gauge/types'; +import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor'; +import { SingleStatValueOptions } from '../singlestat2/types'; export class BarGaugePanelEditor extends PureComponent> { onThresholdsChanged = (thresholds: Threshold[]) => diff --git a/public/app/plugins/panel/bargauge/module.tsx b/public/app/plugins/panel/bargauge/module.tsx index e7f2e2d7738..3c46adeb4f9 100644 --- a/public/app/plugins/panel/bargauge/module.tsx +++ b/public/app/plugins/panel/bargauge/module.tsx @@ -3,18 +3,10 @@ import { ReactPanelPlugin } from '@grafana/ui'; import { BarGaugePanel } from './BarGaugePanel'; import { BarGaugePanelEditor } from './BarGaugePanelEditor'; import { BarGaugeOptions, defaults } from './types'; +import { singleStatBaseOptionsCheck } from '../singlestat2/module'; export const reactPanel = new ReactPanelPlugin(BarGaugePanel); reactPanel.setEditor(BarGaugePanelEditor); reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook((options: BarGaugeOptions, prevPluginId?: string, prevOptions?: any) => { - if (prevOptions && prevOptions.valueOptions) { - options.valueOptions = prevOptions.valueOptions; - options.thresholds = prevOptions.thresholds; - options.maxValue = prevOptions.maxValue; - options.minValue = prevOptions.minValue; - } - - return options; -}); +reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck); diff --git a/public/app/plugins/panel/bargauge/types.ts b/public/app/plugins/panel/bargauge/types.ts index 962d18d0e3d..ea6f1887501 100644 --- a/public/app/plugins/panel/bargauge/types.ts +++ b/public/app/plugins/panel/bargauge/types.ts @@ -1,20 +1,17 @@ -import { Threshold, SelectOptionItem, ValueMapping, VizOrientation } from '@grafana/ui'; -import { SingleStatValueOptions } from '../gauge/types'; +import { VizOrientation, SelectOptionItem } from '@grafana/ui'; -export interface BarGaugeOptions { - minValue: number; - maxValue: number; - orientation: VizOrientation; - valueOptions: SingleStatValueOptions; - valueMappings: ValueMapping[]; - thresholds: Threshold[]; -} +import { SingleStatBaseOptions } from '../singlestat2/types'; export const orientationOptions: SelectOptionItem[] = [ { value: VizOrientation.Horizontal, label: 'Horizontal' }, { value: VizOrientation.Vertical, label: 'Vertical' }, ]; +export interface BarGaugeOptions extends SingleStatBaseOptions { + minValue: number; + maxValue: number; +} + export const defaults: BarGaugeOptions = { minValue: 0, maxValue: 100, diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index f309b38e67d..b83dc9ad440 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -2,37 +2,27 @@ import React, { PureComponent } from 'react'; // Services & Utils -import { processSingleStatPanelData } from '@grafana/ui'; import { config } from 'app/core/config'; // Components -import { Gauge, VizRepeater } from '@grafana/ui'; +import { Gauge } from '@grafana/ui'; // Types import { GaugeOptions } from './types'; -import { PanelProps, VizOrientation, SingleStatValueInfo } from '@grafana/ui/src/types'; +import { DisplayValue, PanelProps } from '@grafana/ui'; +import { getSingleStatValues } from '../singlestat2/SingleStatPanel'; +import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater'; -interface Props extends PanelProps {} - -export class GaugePanel extends PureComponent { - renderGauge(value: SingleStatValueInfo, width, height) { - const { replaceVariables, options } = this.props; - const { valueOptions } = options; - - const prefix = replaceVariables(valueOptions.prefix); - const suffix = replaceVariables(valueOptions.suffix); +export class GaugePanel extends PureComponent> { + renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => { + const { options } = this.props; return ( { theme={config.theme} /> ); - } + }; + + getProcessedValues = (): DisplayValue[] => { + return getSingleStatValues(this.props); + }; render() { - const { panelData, options, height, width } = this.props; - - const values = processSingleStatPanelData({ - panelData: panelData, - stat: options.valueOptions.stat, - }); - + const { height, width, options, panelData } = this.props; + const { orientation } = options; return ( - - {({ vizHeight, vizWidth, value }) => this.renderGauge(value, vizWidth, vizHeight)} - + ); } } diff --git a/public/app/plugins/panel/gauge/GaugePanelEditor.tsx b/public/app/plugins/panel/gauge/GaugePanelEditor.tsx index f33ded6a28d..e3b21302331 100644 --- a/public/app/plugins/panel/gauge/GaugePanelEditor.tsx +++ b/public/app/plugins/panel/gauge/GaugePanelEditor.tsx @@ -9,9 +9,10 @@ import { ValueMapping, } from '@grafana/ui'; -import { SingleStatValueEditor } from 'app/plugins/panel/gauge/SingleStatValueEditor'; import { GaugeOptionsBox } from './GaugeOptionsBox'; -import { GaugeOptions, SingleStatValueOptions } from './types'; +import { GaugeOptions } from './types'; +import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor'; +import { SingleStatValueOptions } from '../singlestat2/types'; export class GaugePanelEditor extends PureComponent> { onThresholdsChanged = (thresholds: Threshold[]) => diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index b8e90a27bff..340af06a080 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -3,18 +3,10 @@ import { ReactPanelPlugin } from '@grafana/ui'; import { GaugePanelEditor } from './GaugePanelEditor'; import { GaugePanel } from './GaugePanel'; import { GaugeOptions, defaults } from './types'; +import { singleStatBaseOptionsCheck } from '../singlestat2/module'; export const reactPanel = new ReactPanelPlugin(GaugePanel); reactPanel.setEditor(GaugePanelEditor); reactPanel.setDefaults(defaults); -reactPanel.setPanelTypeChangedHook((options: GaugeOptions, prevPluginId?: string, prevOptions?: any) => { - if (prevOptions && prevOptions.valueOptions) { - options.valueOptions = prevOptions.valueOptions; - options.thresholds = prevOptions.thresholds; - options.maxValue = prevOptions.maxValue; - options.minValue = prevOptions.minValue; - } - - return options; -}); +reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck); diff --git a/public/app/plugins/panel/gauge/types.ts b/public/app/plugins/panel/gauge/types.ts index 6922538c210..bab29d5d2ad 100644 --- a/public/app/plugins/panel/gauge/types.ts +++ b/public/app/plugins/panel/gauge/types.ts @@ -1,21 +1,11 @@ -import { Threshold, ValueMapping } from '@grafana/ui'; +import { SingleStatBaseOptions } from '../singlestat2/types'; +import { VizOrientation } from '@grafana/ui'; -export interface GaugeOptions { - valueMappings: ValueMapping[]; +export interface GaugeOptions extends SingleStatBaseOptions { maxValue: number; minValue: number; showThresholdLabels: boolean; showThresholdMarkers: boolean; - thresholds: Threshold[]; - valueOptions: SingleStatValueOptions; -} - -export interface SingleStatValueOptions { - unit: string; - suffix: string; - stat: string; - prefix: string; - decimals?: number | null; } export const defaults: GaugeOptions = { @@ -32,4 +22,5 @@ export const defaults: GaugeOptions = { }, 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/ProcessedValuesRepeater.tsx b/public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx new file mode 100644 index 00000000000..d42c033eac2 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/ProcessedValuesRepeater.tsx @@ -0,0 +1,48 @@ +import React, { PureComponent } from 'react'; +import { VizOrientation } from '@grafana/ui'; +import { VizRepeater } from '@grafana/ui'; + +export interface Props { + width: number; + height: number; + orientation: VizOrientation; + source: any; // If this changes, the values will be processed + processFlag?: boolean; // change to force processing + + getProcessedValues: () => T[]; + renderValue: (value: T, width: number, height: number) => JSX.Element; +} + +interface State { + values: T[]; +} + +/** + * This is essentially a cache of processed values. This checks for changes + * to the source and then saves the processed values in the State + */ +export class ProcessedValuesRepeater extends PureComponent, State> { + constructor(props: Props) { + super(props); + this.state = { + values: props.getProcessedValues(), + }; + } + + componentDidUpdate(prevProps: Props) { + const { processFlag, source } = this.props; + if (processFlag !== prevProps.processFlag || source !== prevProps.source) { + this.setState({ values: this.props.getProcessedValues() }); + } + } + + render() { + const { orientation, height, width, renderValue } = this.props; + const { values } = this.state; + return ( + + {({ vizHeight, vizWidth, value }) => renderValue(value, vizWidth, vizHeight)} + + ); + } +} 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/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..1c731e0a0c7 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/SingleStatPanel.tsx @@ -0,0 +1,66 @@ +// Libraries +import React, { PureComponent, CSSProperties } from 'react'; + +// Types +import { SingleStatOptions, SingleStatBaseOptions } from './types'; + +import { processSingleStatPanelData, DisplayValue, PanelProps } from '@grafana/ui'; +import { config } from 'app/core/config'; +import { getDisplayProcessor } from '@grafana/ui'; +import { ProcessedValuesRepeater } from './ProcessedValuesRepeater'; + +export const getSingleStatValues = (props: PanelProps): DisplayValue[] => { + const { panelData, replaceVariables, options } = props; + const { valueOptions, valueMappings } = options; + const processor = getDisplayProcessor({ + 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: valueOptions.stat, + }).map(stat => processor(stat.value)); +}; + +export class SingleStatPanel extends PureComponent> { + renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => { + const style: CSSProperties = {}; + style.margin = '0 auto'; + style.fontSize = '250%'; + style.textAlign = 'center'; + if (value.color) { + style.color = value.color; + } + + return ( +
+
{value.text}
+
+ ); + }; + + getProcessedValues = (): DisplayValue[] => { + return getSingleStatValues(this.props); + }; + + render() { + const { height, width, options, panelData } = this.props; + const { orientation } = options; + return ( + + ); + } +} diff --git a/public/app/plugins/panel/gauge/SingleStatValueEditor.tsx b/public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx similarity index 100% rename from public/app/plugins/panel/gauge/SingleStatValueEditor.tsx rename to public/app/plugins/panel/singlestat2/SingleStatValueEditor.tsx diff --git a/public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg b/public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg new file mode 100644 index 00000000000..746687d360f --- /dev/null +++ b/public/app/plugins/panel/singlestat2/img/icn-singlestat-panel.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/singlestat2/module.tsx b/public/app/plugins/panel/singlestat2/module.tsx new file mode 100644 index 00000000000..283b32802e1 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/module.tsx @@ -0,0 +1,29 @@ +import { ReactPanelPlugin } from '@grafana/ui'; +import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types'; +import { SingleStatPanel } from './SingleStatPanel'; +import cloneDeep from 'lodash/cloneDeep'; +import { SingleStatEditor } from './SingleStatEditor'; + +export const reactPanel = new ReactPanelPlugin(SingleStatPanel); + +const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings']; + +export const singleStatBaseOptionsCheck = ( + options: Partial, + prevPluginId?: string, + prevOptions?: any +) => { + if (prevOptions) { + optionsToKeep.forEach(v => { + if (prevOptions.hasOwnProperty(v)) { + options[v] = cloneDeep(prevOptions.display); + } + }); + } + + return options; +}; + +reactPanel.setEditor(SingleStatEditor); +reactPanel.setDefaults(defaults); +reactPanel.setPanelTypeChangedHook(singleStatBaseOptionsCheck); diff --git a/public/app/plugins/panel/singlestat2/plugin.json b/public/app/plugins/panel/singlestat2/plugin.json new file mode 100644 index 00000000000..6828399ec2b --- /dev/null +++ b/public/app/plugins/panel/singlestat2/plugin.json @@ -0,0 +1,20 @@ +{ + "type": "panel", + "name": "Singlestat (react)", + "id": "singlestat2", + "state": "alpha", + + "dataFormats": ["time_series", "table"], + + "info": { + "description": "Singlestat Panel for Grafana", + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-singlestat-panel.svg", + "large": "img/icn-singlestat-panel.svg" + } + } +} diff --git a/public/app/plugins/panel/singlestat2/types.ts b/public/app/plugins/panel/singlestat2/types.ts new file mode 100644 index 00000000000..1f31783e814 --- /dev/null +++ b/public/app/plugins/panel/singlestat2/types.ts @@ -0,0 +1,33 @@ +import { VizOrientation, ValueMapping, Threshold } from '@grafana/ui'; + +export interface SingleStatBaseOptions { + valueMappings: ValueMapping[]; + thresholds: Threshold[]; + valueOptions: SingleStatValueOptions; + orientation: VizOrientation; +} + +export interface SingleStatValueOptions { + unit: string; + suffix: string; + stat: string; + prefix: string; + decimals?: number | null; +} + +export interface SingleStatOptions extends SingleStatBaseOptions { + // TODO, fill in with options from angular +} + +export const defaults: SingleStatOptions = { + valueOptions: { + prefix: '', + suffix: '', + decimals: null, + stat: 'avg', + unit: 'none', + }, + valueMappings: [], + thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }], + orientation: VizOrientation.Auto, +};