diff --git a/devenv/dev-dashboards/panel_tests_multiseries_gauge.json b/devenv/dev-dashboards/panel_tests_multiseries_gauge.json new file mode 100644 index 00000000000..91313b7d439 --- /dev/null +++ b/devenv/dev-dashboards/panel_tests_multiseries_gauge.json @@ -0,0 +1,296 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 6, + "links": [], + "options-gauge": { + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "options": { + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [ + { + "color": "#1F78C1", + "index": 5, + "value": 96.875 + }, + { + "color": "#E24D42", + "index": 4, + "value": 93.75 + }, + { + "color": "#EF843C", + "index": 3, + "value": 87.5 + }, + { + "color": "#6ED0E0", + "index": 2, + "value": 75 + }, + { + "color": "#EAB839", + "index": 1, + "value": 50 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [ + { + "from": "50", + "id": 1, + "operator": "", + "text": "Hello :) ", + "to": "90", + "type": 2, + "value": "" + } + ] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + }, + { + "refId": "D", + "scenarioId": "random_walk" + }, + { + "refId": "E", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Horizontal with range variable", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 2, + "links": [], + "options-gauge": { + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "options": { + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [ + { + "color": "#EAB839", + "index": 1, + "value": 50 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + }, + { + "refId": "D", + "scenarioId": "random_walk" + }, + { + "refId": "E", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Repeat horizontal", + "type": "gauge" + }, + { + "datasource": "gdev-testdata", + "gridPos": { + "h": 14, + "w": 5, + "x": 0, + "y": 16 + }, + "id": 4, + "links": [], + "options-gauge": { + "decimals": 0, + "maxValue": "200", + "minValue": 0, + "options": { + "decimals": 0, + "maxValue": 100, + "minValue": 0, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "avg", + "suffix": "", + "thresholds": [], + "unit": "none", + "valueMappings": [] + }, + "prefix": "", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "stat": "max", + "suffix": "", + "thresholds": [ + { + "color": "#6ED0E0", + "index": 2, + "value": 75 + }, + { + "color": "#EAB839", + "index": 1, + "value": 50 + }, + { + "color": "#7EB26D", + "index": 0, + "value": null + } + ], + "unit": "none", + "valueMappings": [] + }, + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + }, + { + "refId": "D", + "scenarioId": "random_walk" + }, + { + "refId": "E", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Vertical", + "type": "gauge" + } + ], + "schemaVersion": 17, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "Multi series gauges", + "uid": "szkuR1umk", + "version": 7 +} diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx new file mode 100644 index 00000000000..c7a53af5ccf --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx @@ -0,0 +1,54 @@ +import { storiesOf } from '@storybook/react'; +import { number, text } from '@storybook/addon-knobs'; +import { BarGauge } from './BarGauge'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; + +const getKnobs = () => { + return { + value: number('value', 70), + minValue: number('minValue', 0), + maxValue: number('maxValue', 100), + threshold1Value: number('threshold1Value', 40), + threshold1Color: text('threshold1Color', 'orange'), + threshold2Value: number('threshold2Value', 60), + threshold2Color: text('threshold2Color', 'red'), + unit: text('unit', 'ms'), + decimals: number('decimals', 1), + }; +}; + +const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module); + +BarGaugeStories.addDecorator(withCenteredStory); + +BarGaugeStories.add('Vertical, with basic thresholds', () => { + const { + value, + minValue, + maxValue, + threshold1Color, + threshold2Color, + threshold1Value, + threshold2Value, + unit, + decimals, + } = getKnobs(); + + return renderComponentWithTheme(BarGauge, { + width: 200, + height: 400, + value: value, + minValue: minValue, + maxValue: maxValue, + unit: unit, + prefix: '', + postfix: '', + decimals: decimals, + thresholds: [ + { index: 0, value: -Infinity, color: 'green' }, + { index: 1, value: threshold1Value, color: threshold1Color }, + { index: 1, value: threshold2Value, color: threshold2Color }, + ], + }); +}); diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx new file mode 100644 index 00000000000..8fa0b2846a5 --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { BarGauge, Props } from './BarGauge'; +import { VizOrientation } from '../../types'; +import { getTheme } from '../../themes'; + +jest.mock('jquery', () => ({ + plot: jest.fn(), +})); + +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, + theme: getTheme(), + orientation: VizOrientation.Horizontal, + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as BarGauge; + + return { + instance, + wrapper, + }; +}; + +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.getValueColors().value).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: 10, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ], + }); + + expect(instance.getValueColors().value).toEqual('#EAB839'); + }); +}); + +describe('Render BarGauge with basic options', () => { + it('should render', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx new file mode 100644 index 00000000000..97cc4792785 --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -0,0 +1,239 @@ +// Library +import React, { PureComponent, CSSProperties } from 'react'; +import tinycolor from 'tinycolor2'; + +// Utils +import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils'; + +// Types +import { Themeable, TimeSeriesValue, Threshold, ValueMapping, 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; + maxValue: number; + minValue: number; + orientation: VizOrientation; + prefix?: string; + suffix?: string; + decimals?: number; +} + +/* + * This visualization is still in POC state, needed more tests & better structure + */ +export class BarGauge extends PureComponent { + static defaultProps: Partial = { + maxValue: 100, + minValue: 0, + value: 100, + unit: 'none', + 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); + + if (activeThreshold !== null) { + const color = getColorFromHexRgbOrName(activeThreshold.color, theme.type); + + return { + value: color, + border: color, + bar: tinycolor(color) + .setAlpha(0.3) + .toRgbString(), + }; + } + + return { + value: getColorFromHexRgbOrName('gray', theme.type), + bar: getColorFromHexRgbOrName('gray', theme.type), + border: getColorFromHexRgbOrName('gray', theme.type), + }; + } + + getCellColor(positionValue: TimeSeriesValue): string { + const { thresholds, theme, value } = this.props; + const activeThreshold = getThresholdForValue(thresholds, positionValue); + + if (activeThreshold !== null) { + 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)) { + return tinycolor(color) + .setAlpha(0.15) + .toRgbString(); + } else { + return tinycolor(color) + .setAlpha(0.7) + .toRgbString(); + } + } + + return 'gray'; + } + + getValueStyles(value: string, color: string, width: number): CSSProperties { + const guess = width / (value.length * 1.1); + const fontSize = Math.min(Math.max(guess, 14), 40); + + return { + color: color, + fontSize: fontSize + 'px', + }; + } + + renderVerticalBar(valueFormatted: string, valuePercent: number) { + const { height, width } = this.props; + + const maxHeight = height * BAR_SIZE_RATIO; + const barHeight = Math.max(valuePercent * maxHeight, 0); + const colors = this.getValueColors(); + const valueStyles = this.getValueStyles(valueFormatted, colors.value, width); + + const containerStyles: CSSProperties = { + width: `${width}px`, + height: `${height}px`, + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + }; + + const barStyles: CSSProperties = { + height: `${barHeight}px`, + width: `${width}px`, + backgroundColor: colors.bar, + borderTop: `1px solid ${colors.border}`, + }; + + return ( +
+
+ {valueFormatted} +
+
+
+ ); + } + + renderHorizontalBar(valueFormatted: string, valuePercent: number) { + const { height, width } = this.props; + + const maxWidth = width * BAR_SIZE_RATIO; + const barWidth = Math.max(valuePercent * maxWidth, 0); + const colors = this.getValueColors(); + const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO)); + + valueStyles.marginLeft = '8px'; + + const containerStyles: CSSProperties = { + width: `${width}px`, + height: `${height}px`, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }; + + const barStyles = { + height: `${height}px`, + width: `${barWidth}px`, + backgroundColor: colors.bar, + borderRight: `1px solid ${colors.border}`, + }; + + return ( +
+
+
+ {valueFormatted} +
+
+ ); + } + + renderHorizontalLCD(valueFormatted: string, valuePercent: number) { + const { height, width, maxValue, minValue } = this.props; + + const valueRange = maxValue - minValue; + const maxWidth = width * BAR_SIZE_RATIO; + const cellSpacing = 4; + const cellCount = 30; + const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount; + const colors = this.getValueColors(); + const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO)); + valueStyles.marginLeft = '8px'; + + const containerStyles: CSSProperties = { + width: `${width}px`, + height: `${height}px`, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }; + + const cells: JSX.Element[] = []; + + for (let i = 0; i < cellCount; i++) { + const currentValue = (valueRange / cellCount) * i; + const cellColor = this.getCellColor(currentValue); + const cellStyles: CSSProperties = { + width: `${cellWidth}px`, + backgroundColor: cellColor, + marginRight: '4px', + height: `${height}px`, + borderRadius: '2px', + }; + + cells.push(
); + } + + return ( +
+ {cells} +
+ {valueFormatted} +
+
+ ); + } + + render() { + const { maxValue, minValue, orientation, unit, decimals } = this.props; + + const numericValue = this.getNumericValue(); + const valuePercent = Math.min(numericValue / (maxValue - minValue), 1); + + const formatFunc = getValueFormat(unit); + const valueFormatted = formatFunc(numericValue, decimals); + const vertical = orientation === 'vertical'; + + return vertical + ? this.renderVerticalBar(valueFormatted, valuePercent) + : this.renderHorizontalLCD(valueFormatted, valuePercent); + } +} + +interface BarColors { + value: string; + bar: string; + border: string; +} diff --git a/packages/grafana-ui/src/components/BarGauge/_BarGauge.scss b/packages/grafana-ui/src/components/BarGauge/_BarGauge.scss new file mode 100644 index 00000000000..9e43439f0a2 --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/_BarGauge.scss @@ -0,0 +1,9 @@ +.bar-gauge { + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.bar-gauge__value { + text-align: center; +} diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap new file mode 100644 index 00000000000..65c647bd90c --- /dev/null +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -0,0 +1,358 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render BarGauge with basic options should render 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 25 +
+
+`; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index aa6d2a40258..3d1567d4937 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -1,12 +1,12 @@ 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 { ValueMapping, Threshold, GrafanaThemeType } from '../../types'; +import { getMappedValue } from '../../utils/valueMappings'; +import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils'; +import { Themeable } from '../../index'; + +type GaugeValue = string | number | null; export interface Props extends Themeable { decimals?: number | null; @@ -51,7 +51,7 @@ export class Gauge extends PureComponent { this.draw(); } - formatValue(value: TimeSeriesValue) { + formatValue(value: GaugeValue) { const { decimals, valueMappings, prefix, suffix, unit } = this.props; if (isNaN(value as number)) { @@ -72,26 +72,16 @@ export class Gauge extends PureComponent { return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`; } - getFontColor(value: TimeSeriesValue) { + getFontColor(value: GaugeValue): string { const { thresholds, theme } = this.props; - if (thresholds.length === 1) { - return getColorFromHexRgbOrName(thresholds[0].color, theme.type); + const activeThreshold = getThresholdForValue(thresholds, value); + + if (activeThreshold !== null) { + return getColorFromHexRgbOrName(activeThreshold.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; + return ''; } getFormattedThresholds() { @@ -183,19 +173,15 @@ export class Gauge extends PureComponent { const { height, width } = this.props; return ( -
-
(this.canvasElement = element)} - /> -
+
(this.canvasElement = element)} + /> ); } } - -export default Gauge; diff --git a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx new file mode 100644 index 00000000000..767be6764ca --- /dev/null +++ b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx @@ -0,0 +1,77 @@ +import React, { PureComponent } from 'react'; +import { SingleStatValueInfo, VizOrientation } from '../../types'; + +interface RenderProps { + vizWidth: number; + vizHeight: number; + valueInfo: SingleStatValueInfo; +} + +interface Props { + children: (renderProps: RenderProps) => JSX.Element | JSX.Element[]; + height: number; + width: number; + values: SingleStatValueInfo[]; + orientation: VizOrientation; +} + +const SPACE_BETWEEN = 10; + +export class VizRepeater extends PureComponent { + getOrientation(): VizOrientation { + const { orientation, width, height } = this.props; + + if (orientation === VizOrientation.Auto) { + if (width > height) { + return VizOrientation.Vertical; + } else { + return VizOrientation.Horizontal; + } + } + + return orientation; + } + + render() { + const { children, height, values, width } = this.props; + const orientation = this.getOrientation(); + + const itemStyles: React.CSSProperties = { + display: 'flex', + }; + + const repeaterStyle: React.CSSProperties = { + display: 'flex', + }; + + let vizHeight = height; + let vizWidth = width; + + if (orientation === VizOrientation.Horizontal) { + repeaterStyle.flexDirection = 'column'; + itemStyles.margin = `${SPACE_BETWEEN / 2}px 0`; + vizWidth = width; + vizHeight = height / values.length - SPACE_BETWEEN; + } else { + repeaterStyle.flexDirection = 'row'; + itemStyles.margin = `0 ${SPACE_BETWEEN / 2}px`; + vizHeight = height; + vizWidth = width / values.length - SPACE_BETWEEN; + } + + itemStyles.width = `${vizWidth}px`; + itemStyles.height = `${vizHeight}px`; + + return ( +
+ {values.map((valueInfo, index) => { + return ( +
+ {children({ vizHeight, vizWidth, valueInfo })} +
+ ); + })} +
+ ); + } +} diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss index ffd11febc29..91e5c88e33e 100644 --- a/packages/grafana-ui/src/components/index.scss +++ b/packages/grafana-ui/src/components/index.scss @@ -11,3 +11,4 @@ @import 'ValueMappingsEditor/ValueMappingsEditor'; @import 'EmptySearchResult/EmptySearchResult'; @import 'FormField/FormField'; +@import 'BarGauge/BarGauge'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index b62fdff6498..b8c8d66cead 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -19,11 +19,15 @@ export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder'; export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker'; export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover'; export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor'; -export { Graph } from './Graph/Graph'; export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup'; export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid'; export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor'; -export { Gauge } from './Gauge/Gauge'; export { Switch } from './Switch/Switch'; export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult'; export { UnitPicker } from './UnitPicker/UnitPicker'; + +// Visualizations +export { Gauge } from './Gauge/Gauge'; +export { Graph } from './Graph/Graph'; +export { BarGauge } from './BarGauge/BarGauge'; +export { VizRepeater } from './VizRepeater/VizRepeater'; diff --git a/packages/grafana-ui/src/types/data.ts b/packages/grafana-ui/src/types/data.ts index e7e0bdc2b4e..b36e23ae6c0 100644 --- a/packages/grafana-ui/src/types/data.ts +++ b/packages/grafana-ui/src/types/data.ts @@ -48,10 +48,7 @@ export enum NullValueMode { } /** View model projection of many time series */ -export interface TimeSeriesVMs { - [index: number]: TimeSeriesVM; - length: number; -} +export type TimeSeriesVMs = TimeSeriesVM[]; export interface Column { text: string; @@ -69,3 +66,12 @@ export interface TableData { type: string; columnMap: any; } + +export type SingleStatValue = number | string | null; + +/* + * So we can add meta info like tags & series name + */ +export interface SingleStatValueInfo { + value: SingleStatValue; +} diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index 263b1de4287..b09d88bab4d 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -4,3 +4,4 @@ export * from './panel'; export * from './plugin'; export * from './datasource'; export * from './theme'; +export * from './threshold'; diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index 260ff78df76..889e4ab310e 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -26,10 +26,13 @@ export interface PanelEditorProps { onOptionsChange: (options: T) => void; } +export type PreservePanelOptionsHandler = (pluginId: string, prevOptions: any) => Partial; + export class ReactPanelPlugin { panel: ComponentClass>; editor?: ComponentClass>; defaults?: TOptions; + preserveOptions?: PreservePanelOptionsHandler; constructor(panel: ComponentClass>) { this.panel = panel; @@ -42,6 +45,10 @@ export class ReactPanelPlugin { setDefaults(defaults: TOptions) { this.defaults = defaults; } + + setPreserveOptionsHandler(handler: PreservePanelOptionsHandler) { + this.preserveOptions = handler; + } } export interface PanelSize { @@ -58,17 +65,6 @@ export interface PanelMenuItem { subMenu?: PanelMenuItem[]; } -export interface Threshold { - index: number; - value: number; - color: string; -} - -export enum BasicGaugeColor { - Green = '#299c46', - Red = '#d44a3a', -} - export enum MappingType { ValueToText = 1, RangeToText = 2, @@ -91,3 +87,9 @@ export interface RangeMap extends BaseMap { from: string; to: string; } + +export enum VizOrientation { + Auto = 'auto', + Vertical = 'vertical', + Horizontal = 'horizontal', +} diff --git a/packages/grafana-ui/src/types/threshold.ts b/packages/grafana-ui/src/types/threshold.ts new file mode 100644 index 00000000000..741ecd940bf --- /dev/null +++ b/packages/grafana-ui/src/types/threshold.ts @@ -0,0 +1,5 @@ +export interface Threshold { + index: number; + value: number; + color: string; +} diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index a2acc828752..a08b9ce1a89 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -1,7 +1,9 @@ export * from './processTimeSeries'; +export * from './singlestat'; export * from './valueFormats/valueFormats'; export * from './colors'; export * from './namedColorsPalette'; +export * from './thresholds'; export * from './string'; export * from './deprecationWarning'; export { getMappedValue } from './valueMappings'; diff --git a/packages/grafana-ui/src/utils/singlestat.ts b/packages/grafana-ui/src/utils/singlestat.ts new file mode 100644 index 00000000000..5f5fbb8f247 --- /dev/null +++ b/packages/grafana-ui/src/utils/singlestat.ts @@ -0,0 +1,33 @@ +import { PanelData, NullValueMode, SingleStatValueInfo } from '../types'; +import { processTimeSeries } from './processTimeSeries'; + +export interface SingleStatProcessingOptions { + panelData: PanelData; + stat: string; +} + +// +// This is a temporary thing, waiting for a better data model and maybe unification between time series & table data +// +export function processSingleStatPanelData(options: SingleStatProcessingOptions): SingleStatValueInfo[] { + const { panelData, stat } = options; + + if (panelData.timeSeries) { + const timeSeries = processTimeSeries({ + timeSeries: panelData.timeSeries, + nullValueMode: NullValueMode.Null, + }); + + return timeSeries.map((series, index) => { + const value = stat !== 'name' ? series.stats[stat] : series.label; + + return { + value: value, + }; + }); + } else if (panelData.tableData) { + throw { message: 'Panel data not supported' }; + } + + return []; +} diff --git a/packages/grafana-ui/src/utils/thresholds.ts b/packages/grafana-ui/src/utils/thresholds.ts new file mode 100644 index 00000000000..2fdca67194a --- /dev/null +++ b/packages/grafana-ui/src/utils/thresholds.ts @@ -0,0 +1,23 @@ +import { Threshold } from '../types'; + +export function getThresholdForValue( + thresholds: Threshold[], + value: number | null | string | undefined +): Threshold | null { + if (thresholds.length === 1) { + return thresholds[0]; + } + + const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0]; + if (atThreshold) { + return atThreshold; + } + + const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value); + if (belowThreshold.length > 0) { + const nearestThreshold = belowThreshold.sort((t1: Threshold, t2: Threshold) => t2.value - t1.value)[0]; + return nearestThreshold; + } + + return null; +} diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index ff84f290a4f..67a511b8b4d 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -192,16 +192,18 @@ func getPanelSort(id string) int { sort = 2 case "gauge": sort = 3 - case "table": + case "bargauge": sort = 4 - case "text": + case "table": sort = 5 - case "heatmap": + case "text": sort = 6 - case "alertlist": + case "heatmap": sort = 7 - case "dashlist": + case "alertlist": sort = 8 + case "dashlist": + sort = 9 } return sort } diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx index cb3ad88b191..2162abae29b 100644 --- a/public/app/core/utils/ConfigProvider.tsx +++ b/public/app/core/utils/ConfigProvider.tsx @@ -15,6 +15,7 @@ export const provideConfig = (component: React.ComponentType) => { export const getCurrentThemeName = () => config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark; + export const getCurrentTheme = () => getTheme(getCurrentThemeName()); export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 9aeddd5a0d9..3955f45d926 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -76,21 +76,26 @@ export class DashboardPanel extends PureComponent { // unmount angular panel this.cleanUpAngularPanel(); - if (panel.type !== pluginId) { - this.props.panel.changeType(pluginId, fromAngularPanel); - } - - if (plugin.exports) { - this.setState({ plugin, angularPanel: null }); - } else { + if (!plugin.exports) { try { plugin.exports = await importPluginModule(plugin.module); } catch (e) { plugin = getPanelPluginNotFound(pluginId); } - - this.setState({ plugin, angularPanel: null }); } + + if (panel.type !== pluginId) { + if (fromAngularPanel) { + // for angular panels only we need to remove all events and let angular panels do some cleanup + panel.destroy(); + + this.props.panel.changeType(pluginId); + } else { + panel.changeType(pluginId, plugin.exports.reactPanel.preserveOptions); + } + } + + this.setState({ plugin, angularPanel: null }); } } diff --git a/public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx b/public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx index 4067f361f06..fa16be55e75 100644 --- a/public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx +++ b/public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx @@ -2,9 +2,12 @@ import _ from 'lodash'; import React, { PureComponent } from 'react'; +// Components +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; + // Types +import { PanelPlugin, AppNotificationSeverity } from 'app/types'; import { PanelProps, ReactPanelPlugin } from '@grafana/ui'; -import { PanelPlugin } from 'app/types'; interface Props { pluginId: string; @@ -19,15 +22,13 @@ class PanelPluginNotFound extends PureComponent { const style = { display: 'flex', alignItems: 'center', - textAlign: 'center' as 'center', + justifyContent: 'center', height: '100%', }; return (
-
- Panel plugin with id {this.props.pluginId} could not be found -
+
); } diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index 079946b1521..686a8ba6d28 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -1,5 +1,4 @@ -import _ from 'lodash'; -import { PanelModel } from '../state/PanelModel'; +import { PanelModel } from './PanelModel'; describe('PanelModel', () => { describe('when creating new panel model', () => { @@ -66,7 +65,7 @@ describe('PanelModel', () => { describe('when changing panel type', () => { beforeEach(() => { - model.changeType('graph', true); + model.changeType('graph'); model.alert = { id: 2 }; }); @@ -75,12 +74,12 @@ describe('PanelModel', () => { }); it('should restore table properties when changing back', () => { - model.changeType('table', true); + model.changeType('table'); expect(model.showColumns).toBe(true); }); it('should remove alert rule when changing type that does not support it', () => { - model.changeType('table', true); + model.changeType('table'); expect(model.alert).toBe(undefined); }); }); diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 0c3ab44d8e8..88065fdf208 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -229,10 +229,6 @@ export class PanelModel { }, {}); } - private saveCurrentPanelOptions() { - this.cachedPluginOptions[this.type] = this.getOptionsToRemember(); - } - private restorePanelOptions(pluginId: string) { const prevOptions = this.cachedPluginOptions[pluginId] || {}; @@ -241,14 +237,11 @@ export class PanelModel { }); } - changeType(pluginId: string, fromAngularPanel: boolean) { - this.saveCurrentPanelOptions(); - this.type = pluginId; + changeType(pluginId: string, preserveOptions?: any) { + const oldOptions: any = this.getOptionsToRemember(); + const oldPluginId = this.type; - // for angular panels only we need to remove all events and let angular panels do some cleanup - if (fromAngularPanel) { - this.destroy(); - } + this.type = pluginId; // remove panel type specific options for (const key of _.keys(this)) { @@ -259,7 +252,13 @@ export class PanelModel { delete this[key]; } + this.cachedPluginOptions[oldPluginId] = oldOptions; this.restorePanelOptions(pluginId); + + if (preserveOptions && oldOptions) { + this.options = this.options || {}; + Object.assign(this.options, preserveOptions(oldPluginId, oldOptions.options)); + } } addQuery(query?: Partial) { diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index dba6478baab..9a156652a65 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -27,6 +27,7 @@ import * as table2Panel from 'app/plugins/panel/table2/module'; import * as singlestatPanel from 'app/plugins/panel/singlestat/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'; const builtInPlugins = { 'app/plugins/datasource/graphite/module': graphitePlugin, @@ -58,6 +59,7 @@ const builtInPlugins = { 'app/plugins/panel/singlestat/module': singlestatPanel, 'app/plugins/panel/gettingstarted/module': gettingStartedPanel, 'app/plugins/panel/gauge/module': gaugePanel, + 'app/plugins/panel/bargauge/module': barGaugePanel, }; export default builtInPlugins; diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx new file mode 100644 index 00000000000..cef28414e73 --- /dev/null +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -0,0 +1,56 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Services & Utils +import { processSingleStatPanelData } from '@grafana/ui'; +import { config } from 'app/core/config'; + +// Components +import { BarGauge, VizRepeater } from '@grafana/ui'; + +// Types +import { BarGaugeOptions } from './types'; +import { PanelProps } from '@grafana/ui/src/types'; + +interface Props extends PanelProps {} + +export class BarGaugePanel extends PureComponent { + renderBarGauge(value, width, height) { + const { replaceVariables, options } = this.props; + const { valueOptions } = options; + + const prefix = replaceVariables(valueOptions.prefix); + const suffix = replaceVariables(valueOptions.suffix); + + return ( + + ); + } + + render() { + const { panelData, options, width, height } = this.props; + + const values = processSingleStatPanelData({ + panelData: panelData, + stat: options.valueOptions.stat, + }); + + return ( + + {({ vizHeight, vizWidth, valueInfo }) => this.renderBarGauge(valueInfo.value, vizWidth, vizHeight)} + + ); + } +} diff --git a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx new file mode 100644 index 00000000000..4232155228b --- /dev/null +++ b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx @@ -0,0 +1,64 @@ +// Libraries +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'; + +export class BarGaugePanelEditor 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, + }); + + onMinValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, minValue: target.value }); + onMaxValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, maxValue: target.value }); + onOrientationChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, orientation: value }); + + render() { + const { options } = this.props; + + return ( + <> + + + + + +
+ Orientation +