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/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx index f7fa71391ae..c4f2a848029 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx @@ -50,7 +50,16 @@ ColorPickerStories.add('Series color picker', () => { color={selectedColor} onChange={color => updateSelectedColor(color)} > -
Open color picker
+ {({ ref, showColorPicker, hideColorPicker }) => ( +
+ Open color picker +
+ )} ); }} diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx new file mode 100644 index 00000000000..8bf41f307f3 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { ColorPicker } from './ColorPicker'; +import { ColorPickerTrigger } from './ColorPickerTrigger'; + +describe('ColorPicker', () => { + it('renders ColorPickerTrigger component by default', () => { + expect( + renderer.create( {}} />).root.findByType(ColorPickerTrigger) + ).toBeTruthy(); + }); + + it('renders custom trigger when supplied', () => { + const div = renderer + .create( + {}}> + {() =>
Custom trigger
} +
+ ) + .root.findByType('div'); + expect(div.children[0]).toBe('Custom trigger'); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx index 5a6ddcd01b9..fd5f81c3f33 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -1,4 +1,5 @@ import React, { Component, createRef } from 'react'; +import { omit } from 'lodash'; import { PopperController } from '../Tooltip/PopperController'; import { Popper } from '../Tooltip/Popper'; import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover'; @@ -6,14 +7,29 @@ import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette'; import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; import { withTheme } from '../../themes/ThemeContext'; +import { ColorPickerTrigger } from './ColorPickerTrigger'; + +/** + * If you need custom trigger for the color picker you can do that with a render prop pattern and supply a function + * as a child. You will get show/hide function which you can map to desired interaction (like onClick or onMouseLeave) + * and a ref which needs to be passed to an HTMLElement for correct positioning. If you want to use class or functional + * component as a custom trigger you will need to forward the reference to first HTMLElement child. + */ +type ColorPickerTriggerRenderer = (props: { + // This should be a React.RefObject but due to how object refs are defined you cannot downcast from that + // to a specific type like React.RefObject even though it would be fine in runtime. + ref: React.RefObject; + showColorPicker: () => void; + hideColorPicker: () => void; +}) => React.ReactNode; export const colorPickerFactory = ( popover: React.ComponentType, displayName = 'ColorPicker' ) => { - return class ColorPicker extends Component { + return class ColorPicker extends Component { static displayName = displayName; - pickerTriggerRef = createRef(); + pickerTriggerRef = createRef(); onColorChange = (color: string) => { const { onColorChange, onChange } = this.props; @@ -23,11 +39,11 @@ export const colorPickerFactory = ( }; render() { + const { theme, children } = this.props; const popoverElement = React.createElement(popover, { - ...this.props, + ...omit(this.props, 'children'), onChange: this.onColorChange, }); - const { theme, children } = this.props; return ( @@ -45,27 +61,21 @@ export const colorPickerFactory = ( )} {children ? ( - React.cloneElement(children as JSX.Element, { + // Children have a bit weird type due to intersection used in the definition so we need to cast here, + // but the definition is correct and should not allow to pass a children that does not conform to + // ColorPickerTriggerRenderer type. + (children as ColorPickerTriggerRenderer)({ ref: this.pickerTriggerRef, - onClick: showPopper, - onMouseLeave: hidePopper, + showColorPicker: showPopper, + hideColorPicker: hidePopper, }) ) : ( -
-
-
-
-
+ color={getColorFromHexRgbOrName(this.props.color || '#000000', theme.type)} + /> )} ); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx index 674a283ddb9..934bc56a550 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx @@ -17,8 +17,8 @@ export interface ColorPickerProps extends Themeable { */ onColorChange?: ColorPickerChangeHandler; enableNamedColors?: boolean; - children?: JSX.Element; } + export interface Props extends ColorPickerProps, PopperContentProps { customPickers?: T; } diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerTrigger.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerTrigger.tsx new file mode 100644 index 00000000000..0445fd465e3 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerTrigger.tsx @@ -0,0 +1,56 @@ +import React, { forwardRef } from 'react'; + +interface ColorPickerTriggerProps { + onClick: () => void; + onMouseLeave: () => void; + color: string; +} + +export const ColorPickerTrigger = forwardRef(function ColorPickerTrigger( + props: ColorPickerTriggerProps, + ref: React.Ref +) { + return ( +
+
+
+
+
+ ); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss index b07fe2433c9..626a03538bb 100644 --- a/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss +++ b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss @@ -161,59 +161,6 @@ $arrowSize: 15px; flex-grow: 1; } -.sp-replacer { - background: inherit; - border: none; - color: inherit; - padding: 0; - border-radius: 10px; - cursor: pointer; -} - -.sp-replacer:hover, -.sp-replacer.sp-active { - border-color: inherit; - color: inherit; -} - -.sp-container { - border-radius: 0; - background-color: $dropdownBackground; - border: none; - padding: 0; -} - -.sp-palette-container, -.sp-picker-container { - border: none; -} - -.sp-dd { - display: none; -} - -.sp-preview { - position: relative; - width: 15px; - height: 15px; - border: none; - margin: 0; - float: left; - z-index: 0; - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); -} - -.sp-preview-inner, -.sp-alpha-inner, -.sp-thumb-inner { - display: block; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; -} - .gf-color-picker__body { padding-bottom: $arrowSize; padding-left: 6px; 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/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx new file mode 100644 index 00000000000..03765730343 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -0,0 +1,99 @@ +// import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { Table } from './Table'; +import { getTheme } from '../../themes'; + +import { migratedTestTable, migratedTestStyles, simpleTable } from './examples'; +import { ScopedVars, TableData, GrafanaThemeType } from '../../types/index'; +import { withFullSizeStory } from '../../utils/storybook/withFullSizeStory'; +import { number, boolean } from '@storybook/addon-knobs'; + +const replaceVariables = (value: string, scopedVars?: ScopedVars) => { + if (scopedVars) { + // For testing variables replacement in link + for (const key in scopedVars) { + const val = scopedVars[key]; + value = value.replace('$' + key, val.value); + } + } + return value; +}; + +export function columnIndexToLeter(column: number) { + const A = 'A'.charCodeAt(0); + const c1 = Math.floor(column / 26); + const c2 = column % 26; + if (c1 > 0) { + return String.fromCharCode(A + c1 - 1) + String.fromCharCode(A + c2); + } + return String.fromCharCode(A + c2); +} + +export function makeDummyTable(columnCount: number, rowCount: number): TableData { + return { + columns: Array.from(new Array(columnCount), (x, i) => { + return { + text: columnIndexToLeter(i), + }; + }), + rows: Array.from(new Array(rowCount), (x, rowId) => { + const suffix = (rowId + 1).toString(); + return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix); + }), + type: 'table', + columnMap: {}, + }; +} + +storiesOf('Alpha/Table', module) + .add('Basic Table', () => { + // NOTE: This example does not seem to survice rotate & + // Changing fixed headers... but the next one does? + // perhaps `simpleTable` is static and reused? + + const showHeader = boolean('Show Header', true); + const fixedHeader = boolean('Fixed Header', true); + const fixedColumns = number('Fixed Columns', 0, { min: 0, max: 50, step: 1, range: false }); + const rotate = boolean('Rotate', false); + + return withFullSizeStory(Table, { + styles: [], + data: simpleTable, + replaceVariables, + showHeader, + fixedHeader, + fixedColumns, + rotate, + theme: getTheme(GrafanaThemeType.Light), + }); + }) + .add('Variable Size', () => { + const columnCount = number('Column Count', 15, { min: 2, max: 50, step: 1, range: false }); + const rowCount = number('Row Count', 20, { min: 0, max: 100, step: 1, range: false }); + + const showHeader = boolean('Show Header', true); + const fixedHeader = boolean('Fixed Header', true); + const fixedColumns = number('Fixed Columns', 1, { min: 0, max: 50, step: 1, range: false }); + const rotate = boolean('Rotate', false); + + return withFullSizeStory(Table, { + styles: [], + data: makeDummyTable(columnCount, rowCount), + replaceVariables, + showHeader, + fixedHeader, + fixedColumns, + rotate, + theme: getTheme(GrafanaThemeType.Light), + }); + }) + .add('Test Config (migrated)', () => { + return withFullSizeStory(Table, { + styles: migratedTestStyles, + data: migratedTestTable, + replaceVariables, + showHeader: true, + rotate: true, + theme: getTheme(GrafanaThemeType.Light), + }); + }); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx new file mode 100644 index 00000000000..a551d6d221d --- /dev/null +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -0,0 +1,287 @@ +// Libraries +import _ from 'lodash'; +import React, { Component, ReactElement } from 'react'; +import { + SortDirectionType, + SortIndicator, + MultiGrid, + CellMeasurerCache, + CellMeasurer, + GridCellProps, +} from 'react-virtualized'; +import { Themeable } from '../../types/theme'; + +import { sortTableData } from '../../utils/processTableData'; + +import { TableData, InterpolateFunction } from '@grafana/ui'; +import { + TableCellBuilder, + ColumnStyle, + getCellBuilder, + TableCellBuilderOptions, + simpleCellBuilder, +} from './TableCellBuilder'; +import { stringToJsRegex } from '../../utils/index'; + +export interface Props extends Themeable { + data: TableData; + + showHeader: boolean; + fixedHeader: boolean; + fixedColumns: number; + rotate: boolean; + styles: ColumnStyle[]; + + replaceVariables: InterpolateFunction; + width: number; + height: number; + isUTC?: boolean; +} + +interface State { + sortBy?: number; + sortDirection?: SortDirectionType; + data: TableData; +} + +interface ColumnRenderInfo { + header: string; + builder: TableCellBuilder; +} + +interface DataIndex { + column: number; + row: number; // -1 is the header! +} + +export class Table extends Component { + renderer: ColumnRenderInfo[]; + measurer: CellMeasurerCache; + scrollToTop = false; + + static defaultProps = { + showHeader: true, + fixedHeader: true, + fixedColumns: 0, + rotate: false, + }; + + constructor(props: Props) { + super(props); + + this.state = { + data: props.data, + }; + + this.renderer = this.initColumns(props); + this.measurer = new CellMeasurerCache({ + defaultHeight: 30, + defaultWidth: 150, + }); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { data, styles, showHeader } = this.props; + const { sortBy, sortDirection } = this.state; + const dataChanged = data !== prevProps.data; + const configsChanged = + showHeader !== prevProps.showHeader || + this.props.rotate !== prevProps.rotate || + this.props.fixedColumns !== prevProps.fixedColumns || + this.props.fixedHeader !== prevProps.fixedHeader; + + // Reset the size cache + if (dataChanged || configsChanged) { + this.measurer.clearAll(); + } + + // Update the renderer if options change + // We only *need* do to this if the header values changes, but this does every data update + if (dataChanged || styles !== prevProps.styles) { + this.renderer = this.initColumns(this.props); + } + + // Update the data when data or sort changes + if (dataChanged || sortBy !== prevState.sortBy || sortDirection !== prevState.sortDirection) { + this.scrollToTop = true; + this.setState({ data: sortTableData(data, sortBy, sortDirection === 'DESC') }); + } + } + + /** Given the configuration, setup how each column gets rendered */ + initColumns(props: Props): ColumnRenderInfo[] { + const { styles, data } = props; + + return data.columns.map((col, index) => { + let title = col.text; + let style: ColumnStyle | null = null; // ColumnStyle + + // Find the style based on the text + for (let i = 0; i < styles.length; i++) { + const s = styles[i]; + const regex = stringToJsRegex(s.pattern); + if (title.match(regex)) { + style = s; + if (s.alias) { + title = title.replace(regex, s.alias); + } + break; + } + } + + return { + header: title, + builder: getCellBuilder(col, style, this.props), + }; + }); + } + + //---------------------------------------------------------------------- + //---------------------------------------------------------------------- + + doSort = (columnIndex: number) => { + let sort: any = this.state.sortBy; + let dir = this.state.sortDirection; + if (sort !== columnIndex) { + dir = 'DESC'; + sort = columnIndex; + } else if (dir === 'DESC') { + dir = 'ASC'; + } else { + sort = null; + } + this.setState({ sortBy: sort, sortDirection: dir }); + }; + + /** Converts the grid coordinates to TableData coordinates */ + getCellRef = (rowIndex: number, columnIndex: number): DataIndex => { + const { showHeader, rotate } = this.props; + const rowOffset = showHeader ? -1 : 0; + + if (rotate) { + return { column: rowIndex, row: columnIndex + rowOffset }; + } else { + return { column: columnIndex, row: rowIndex + rowOffset }; + } + }; + + onCellClick = (rowIndex: number, columnIndex: number) => { + const { row, column } = this.getCellRef(rowIndex, columnIndex); + if (row < 0) { + this.doSort(column); + } else { + const values = this.state.data.rows[row]; + const value = values[column]; + console.log('CLICK', value, row); + } + }; + + headerBuilder = (cell: TableCellBuilderOptions): ReactElement<'div'> => { + const { data, sortBy, sortDirection } = this.state; + const { columnIndex, rowIndex, style } = cell.props; + const { column } = this.getCellRef(rowIndex, columnIndex); + + let col = data.columns[column]; + const sorting = sortBy === column; + if (!col) { + col = { + text: '??' + columnIndex + '???', + }; + } + + return ( +
this.onCellClick(rowIndex, columnIndex)}> + {col.text} + {sorting && } +
+ ); + }; + + getTableCellBuilder = (column: number): TableCellBuilder => { + const render = this.renderer[column]; + if (render && render.builder) { + return render.builder; + } + return simpleCellBuilder; // the default + }; + + cellRenderer = (props: GridCellProps): React.ReactNode => { + const { rowIndex, columnIndex, key, parent } = props; + const { row, column } = this.getCellRef(rowIndex, columnIndex); + const { data } = this.state; + + const isHeader = row < 0; + const rowData = isHeader ? data.columns : data.rows[row]; + const value = rowData ? rowData[column] : ''; + const builder = isHeader ? this.headerBuilder : this.getTableCellBuilder(column); + + return ( + + {builder({ + value, + row: rowData, + column: data.columns[column], + table: this, + props, + })} + + ); + }; + + render() { + const { showHeader, fixedHeader, fixedColumns, rotate, width, height } = this.props; + const { data } = this.state; + + let columnCount = data.columns.length; + let rowCount = data.rows.length + (showHeader ? 1 : 0); + + let fixedColumnCount = Math.min(fixedColumns, columnCount); + let fixedRowCount = showHeader && fixedHeader ? 1 : 0; + + if (rotate) { + const temp = columnCount; + columnCount = rowCount; + rowCount = temp; + + fixedRowCount = 0; + fixedColumnCount = Math.min(fixedColumns, rowCount) + (showHeader && fixedHeader ? 1 : 0); + } + + // Called after sort or the data changes + const scroll = this.scrollToTop ? 1 : -1; + const scrollToRow = rotate ? -1 : scroll; + const scrollToColumn = rotate ? scroll : -1; + if (this.scrollToTop) { + this.scrollToTop = false; + } + + return ( + + ); + } +} + +export default Table; diff --git a/packages/grafana-ui/src/components/Table/TableCellBuilder.tsx b/packages/grafana-ui/src/components/Table/TableCellBuilder.tsx new file mode 100644 index 00000000000..b8cb91053d8 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableCellBuilder.tsx @@ -0,0 +1,291 @@ +// Libraries +import _ from 'lodash'; +import React, { ReactElement } from 'react'; +import { GridCellProps } from 'react-virtualized'; +import { Table, Props } from './Table'; +import moment from 'moment'; +import { ValueFormatter } from '../../utils/index'; +import { GrafanaTheme } from '../../types/theme'; +import { getValueFormat, getColorFromHexRgbOrName, Column } from '@grafana/ui'; +import { InterpolateFunction } from '../../types/panel'; + +export interface TableCellBuilderOptions { + value: any; + column?: Column; + row?: any[]; + table?: Table; + className?: string; + props: GridCellProps; +} + +export type TableCellBuilder = (cell: TableCellBuilderOptions) => ReactElement<'div'>; + +/** Simplest cell that just spits out the value */ +export const simpleCellBuilder: TableCellBuilder = (cell: TableCellBuilderOptions) => { + const { props, value, className } = cell; + const { style } = props; + + return ( +
+ {value} +
+ ); +}; + +// *************************************************************************** +// HERE BE DRAGONS!!! +// *************************************************************************** +// +// The following code has been migrated blindy two times from the angular +// table panel. I don't understand all the options nor do I know if they +// are correct! +// +// *************************************************************************** + +// Made to match the existing (untyped) settings in the angular table +export interface ColumnStyle { + pattern: string; + + alias?: string; + colorMode?: 'cell' | 'value'; + colors?: any[]; + decimals?: number; + thresholds?: any[]; + type?: 'date' | 'number' | 'string' | 'hidden'; + unit?: string; + dateFormat?: string; + sanitize?: boolean; // not used in react + mappingType?: any; + valueMaps?: any; + rangeMaps?: any; + + link?: any; + linkUrl?: any; + linkTooltip?: any; + linkTargetBlank?: boolean; + + preserveFormat?: boolean; +} + +// private mapper:ValueMapper, +// private style:ColumnStyle, +// private theme:GrafanaTheme, +// private column:Column, +// private replaceVariables: InterpolateFunction, +// private fmt?:ValueFormatter) { + +export function getCellBuilder(schema: Column, style: ColumnStyle | null, props: Props): TableCellBuilder { + if (!style) { + return simpleCellBuilder; + } + + if (style.type === 'hidden') { + // TODO -- for hidden, we either need to: + // 1. process the Table and remove hidden fields + // 2. do special math to pick the right column skipping hidden fields + throw new Error('hidden not supported!'); + } + + if (style.type === 'date') { + return new CellBuilderWithStyle( + (v: any) => { + if (v === undefined || v === null) { + return '-'; + } + + if (_.isArray(v)) { + v = v[0]; + } + let date = moment(v); + if (false) { + // TODO?????? this.props.isUTC) { + date = date.utc(); + } + return date.format(style.dateFormat); + }, + style, + props.theme, + schema, + props.replaceVariables + ).build; + } + + if (style.type === 'string') { + return new CellBuilderWithStyle( + (v: any) => { + if (_.isArray(v)) { + v = v.join(', '); + } + return v; + }, + style, + props.theme, + schema, + props.replaceVariables + ).build; + // TODO!!!! all the mapping stuff!!!! + } + + if (style.type === 'number') { + const valueFormatter = getValueFormat(style.unit || schema.unit || 'none'); + return new CellBuilderWithStyle( + (v: any) => { + if (v === null || v === void 0) { + return '-'; + } + return v; + }, + style, + props.theme, + schema, + props.replaceVariables, + valueFormatter + ).build; + } + + return simpleCellBuilder; +} + +type ValueMapper = (value: any) => any; + +// Runs the value through a formatter and adds colors to the cell properties +class CellBuilderWithStyle { + constructor( + private mapper: ValueMapper, + private style: ColumnStyle, + private theme: GrafanaTheme, + private column: Column, + private replaceVariables: InterpolateFunction, + private fmt?: ValueFormatter + ) { + // + console.log('COLUMN', column.text, theme); + } + + getColorForValue = (value: any): string | null => { + const { thresholds, colors } = this.style; + if (!thresholds || !colors) { + return null; + } + + for (let i = thresholds.length; i > 0; i--) { + if (value >= thresholds[i - 1]) { + return getColorFromHexRgbOrName(colors[i], this.theme.type); + } + } + return getColorFromHexRgbOrName(_.first(colors), this.theme.type); + }; + + build = (cell: TableCellBuilderOptions) => { + let { props } = cell; + let value = this.mapper(cell.value); + + if (_.isNumber(value)) { + if (this.fmt) { + value = this.fmt(value, this.style.decimals); + } + + // For numeric values set the color + const { colorMode } = this.style; + if (colorMode) { + const color = this.getColorForValue(Number(value)); + if (color) { + if (colorMode === 'cell') { + props = { + ...props, + style: { + ...props.style, + backgroundColor: color, + color: 'white', + }, + }; + } else if (colorMode === 'value') { + props = { + ...props, + style: { + ...props.style, + color: color, + }, + }; + } + } + } + } + + const cellClasses = []; + if (this.style.preserveFormat) { + cellClasses.push('table-panel-cell-pre'); + } + + if (this.style.link) { + // Render cell as link + const { row } = cell; + + const scopedVars: any = {}; + if (row) { + for (let i = 0; i < row.length; i++) { + scopedVars[`__cell_${i}`] = { value: row[i] }; + } + } + scopedVars['__cell'] = { value: value }; + + const cellLink = this.replaceVariables(this.style.linkUrl, scopedVars, encodeURIComponent); + const cellLinkTooltip = this.replaceVariables(this.style.linkTooltip, scopedVars); + const cellTarget = this.style.linkTargetBlank ? '_blank' : ''; + + cellClasses.push('table-panel-cell-link'); + value = ( + + {value} + + ); + } + + // ??? I don't think this will still work! + if (this.column.filterable) { + cellClasses.push('table-panel-cell-filterable'); + value = ( + <> + {value} + + + + + + + + + + ); + } + + let className; + if (cellClasses.length) { + className = cellClasses.join(' '); + } + + return simpleCellBuilder({ value, props, className }); + }; +} diff --git a/packages/grafana-ui/src/components/Table/_Table.scss b/packages/grafana-ui/src/components/Table/_Table.scss new file mode 100644 index 00000000000..d9fb2dafe6c --- /dev/null +++ b/packages/grafana-ui/src/components/Table/_Table.scss @@ -0,0 +1,80 @@ +// .ReactVirtualized__Table { +// } + +// .ReactVirtualized__Table__Grid { +// } + +.ReactVirtualized__Table__headerRow { + font-weight: 700; + display: flex; + flex-direction: row; + align-items: left; +} +.ReactVirtualized__Table__row { + display: flex; + flex-direction: row; + align-items: center; + + border-bottom: 2px solid $body-bg; +} + +.ReactVirtualized__Table__headerTruncatedText { + display: inline-block; + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.ReactVirtualized__Table__headerColumn, +.ReactVirtualized__Table__rowColumn { + margin-right: 10px; + min-width: 0px; +} + +.ReactVirtualized__Table__headerColumn:first-of-type, +.ReactVirtualized__Table__rowColumn:first-of-type { + margin-left: 10px; +} +.ReactVirtualized__Table__sortableHeaderColumn { + cursor: pointer; +} + +.ReactVirtualized__Table__sortableHeaderIconContainer { + align-items: center; +} +.ReactVirtualized__Table__sortableHeaderIcon { + flex: 0 0 24px; + height: 1em; + width: 1em; + fill: currentColor; +} + +.gf-table-header { + padding: 3px 10px; + + background: $list-item-bg; + border-top: 2px solid $body-bg; + border-bottom: 2px solid $body-bg; + + cursor: pointer; + white-space: nowrap; + + color: $blue; +} + +.gf-table-cell { + padding: 3px 10px; + + background: $page-gradient; + + text-overflow: ellipsis; + white-space: nowrap; + + border-right: 2px solid $body-bg; + border-bottom: 2px solid $body-bg; +} + +.gf-table-fixed-column { + border-right: 1px solid #ccc; +} diff --git a/packages/grafana-ui/src/components/Table/examples.ts b/packages/grafana-ui/src/components/Table/examples.ts new file mode 100644 index 00000000000..2d08e5cdf0c --- /dev/null +++ b/packages/grafana-ui/src/components/Table/examples.ts @@ -0,0 +1,167 @@ +import { TableData } from '../../types/data'; +import { ColumnStyle } from './TableCellBuilder'; + +import { getColorDefinitionByName } from '@grafana/ui'; + +const SemiDarkOrange = getColorDefinitionByName('semi-dark-orange'); + +export const migratedTestTable = { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Value' }, + { text: 'Colored' }, + { text: 'Undefined' }, + { text: 'String' }, + { text: 'United', unit: 'bps' }, + { text: 'Sanitized' }, + { text: 'Link' }, + { text: 'Array' }, + { text: 'Mapping' }, + { text: 'RangeMapping' }, + { text: 'MappingColored' }, + { text: 'RangeMappingColored' }, + ], + rows: [[1388556366666, 1230, 40, undefined, '', '', 'my.host.com', 'host1', ['value1', 'value2'], 1, 2, 1, 2]], +} as TableData; + +export const migratedTestStyles: ColumnStyle[] = [ + { + pattern: 'Time', + type: 'date', + alias: 'Timestamp', + }, + { + pattern: '/(Val)ue/', + type: 'number', + unit: 'ms', + decimals: 3, + alias: '$1', + }, + { + pattern: 'Colored', + type: 'number', + unit: 'none', + decimals: 1, + colorMode: 'value', + thresholds: [50, 80], + colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'], + }, + { + pattern: 'String', + type: 'string', + }, + { + pattern: 'String', + type: 'string', + }, + { + pattern: 'United', + type: 'number', + unit: 'ms', + decimals: 2, + }, + { + pattern: 'Sanitized', + type: 'string', + sanitize: true, + }, + { + pattern: 'Link', + type: 'string', + link: true, + linkUrl: '/dashboard?param=$__cell¶m_1=$__cell_1¶m_2=$__cell_2', + linkTooltip: '$__cell $__cell_1 $__cell_6', + linkTargetBlank: true, + }, + { + pattern: 'Array', + type: 'number', + unit: 'ms', + decimals: 3, + }, + { + pattern: 'Mapping', + type: 'string', + mappingType: 1, + valueMaps: [ + { + value: '1', + text: 'on', + }, + { + value: '0', + text: 'off', + }, + { + value: 'HELLO WORLD', + text: 'HELLO GRAFANA', + }, + { + value: 'value1, value2', + text: 'value3, value4', + }, + ], + }, + { + pattern: 'RangeMapping', + type: 'string', + mappingType: 2, + rangeMaps: [ + { + from: '1', + to: '3', + text: 'on', + }, + { + from: '3', + to: '6', + text: 'off', + }, + ], + }, + { + pattern: 'MappingColored', + type: 'string', + mappingType: 1, + valueMaps: [ + { + value: '1', + text: 'on', + }, + { + value: '0', + text: 'off', + }, + ], + colorMode: 'value', + thresholds: [1, 2], + colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'], + }, + { + pattern: 'RangeMappingColored', + type: 'string', + mappingType: 2, + rangeMaps: [ + { + from: '1', + to: '3', + text: 'on', + }, + { + from: '3', + to: '6', + text: 'off', + }, + ], + colorMode: 'value', + thresholds: [2, 5], + colors: ['#00ff00', SemiDarkOrange.name, 'rgb(1,0,0)'], + }, +]; + +export const simpleTable = { + type: 'table', + columns: [{ text: 'First' }, { text: 'Second' }, { text: 'Third' }], + rows: [[701, 205, 305], [702, 206, 301], [703, 207, 304]], +}; diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss index af70fd86f7a..80a937ae0bc 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss +++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss @@ -68,7 +68,7 @@ } .thresholds-row-input-inner-value > input { - height: $gf-form-input-height; + height: $input-height; padding: $input-padding-y $input-padding-x; width: 150px; border-top: 1px solid $input-label-border-color; @@ -86,7 +86,6 @@ .thresholds-row-input-inner-color-colorpicker { border-radius: 10px; - overflow: hidden; display: flex; align-items: center; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); @@ -96,7 +95,7 @@ display: flex; align-items: center; justify-content: center; - height: $gf-form-input-height; + height: $input-height; padding: $input-padding-y $input-padding-x; width: 42px; background-color: $input-label-bg; 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 56e20bd78e7..91e5c88e33e 100644 --- a/packages/grafana-ui/src/components/index.scss +++ b/packages/grafana-ui/src/components/index.scss @@ -1,6 +1,7 @@ @import 'CustomScrollbar/CustomScrollbar'; @import 'DeleteButton/DeleteButton'; @import 'ThresholdsEditor/ThresholdsEditor'; +@import 'Table/Table'; @import 'Table/TableInputCSV'; @import 'Tooltip/Tooltip'; @import 'Select/Select'; @@ -10,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/themes/_variables.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts index bb7d66acbe2..97ade6da7a5 100644 --- a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts @@ -52,7 +52,6 @@ $spacers: ( ), ), ) !default; -$border-width: ${theme.border.width.sm} !default; // Grid breakpoints // @@ -83,16 +82,13 @@ $container-max-widths: ( // Set the number of columns and specify the width of the gutters. $grid-columns: 12 !default; -$grid-gutter-width: 30px !default; - -$enable-flex: true; +$grid-gutter-width: ${theme.spacing.gutter} !default; // Typography // ------------------------- $font-family-sans-serif: ${theme.typography.fontFamily.sansSerif}; $font-family-monospace: ${theme.typography.fontFamily.monospace}; -$font-family-base: $font-family-sans-serif !default; $font-size-root: ${theme.typography.size.root} !default; $font-size-base: ${theme.typography.size.base} !default; @@ -103,7 +99,9 @@ $font-size-sm: ${theme.typography.size.sm} !default; $font-size-xs: ${theme.typography.size.xs} !default; $line-height-base: ${theme.typography.lineHeight.lg} !default; -$font-weight-semi-bold: ${theme.typography.weight.semibold}; + +$font-weight-regular: ${theme.typography.weight.regular} !default; +$font-weight-semi-bold: ${theme.typography.weight.semibold} !default; $font-size-h1: ${theme.typography.heading.h1} !default; $font-size-h2: ${theme.typography.heading.h2} !default; @@ -113,22 +111,17 @@ $font-size-h5: ${theme.typography.heading.h5} !default; $font-size-h6: ${theme.typography.heading.h6} !default; $headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; -$headings-font-weight: ${theme.typography.weight.normal} !default; $headings-line-height: ${theme.typography.lineHeight.sm} !default; -$hr-border-width: $border-width !default; -$dt-font-weight: bold !default; - // Components // // Define common padding and border radius sizes and more. -$line-height-lg: (4 / 3) !default; -$line-height-sm: 1.5 !default; +$border-width: ${theme.border.width.sm} !default; -$border-radius: 3px !default; -$border-radius-lg: 5px !default; -$border-radius-sm: 2px !default; +$border-radius: ${theme.border.radius.md} !default; +$border-radius-lg: ${theme.border.radius.lg}!default; +$border-radius-sm: ${theme.border.radius.sm} !default; // Page @@ -151,22 +144,17 @@ $input-padding-x: 10px !default; $input-padding-y: 8px !default; $input-line-height: 18px !default; -$input-btn-border-width: 1px; $input-border-radius: 0 $border-radius $border-radius 0 !default; $input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 0 !default; $label-border-radius: $border-radius 0 0 $border-radius !default; $label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default; -$input-padding-y-sm: 4px !default; - $input-padding-x-lg: 20px !default; $input-padding-y-lg: 10px !default; $input-height: 35px !default; -$gf-form-input-height: 35px; - $cursor-disabled: not-allowed !default; // Form validation icons @@ -203,7 +191,6 @@ $btn-padding-y-lg: 11px !default; $btn-padding-x-xl: 21px !default; $btn-padding-y-xl: 11px !default; -$btn-border-radius: 2px; $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; diff --git a/packages/grafana-ui/src/themes/default.ts b/packages/grafana-ui/src/themes/default.ts index fb32175820d..2dc50a7a5b1 100644 --- a/packages/grafana-ui/src/themes/default.ts +++ b/packages/grafana-ui/src/themes/default.ts @@ -25,7 +25,7 @@ const theme: GrafanaThemeCommons = { }, weight: { light: 300, - normal: 400, + regular: 400, semibold: 500, }, lineHeight: { @@ -54,9 +54,9 @@ const theme: GrafanaThemeCommons = { }, border: { radius: { - xs: '2px', - sm: '3px', - md: '5px', + sm: '2px', + md: '3px', + lg: '5px', }, width: { sm: '1px', 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/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index 79c5b22488d..d7f628707da 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -3,9 +3,11 @@ import { PluginMeta } from './plugin'; import { TableData, TimeSeries } from './data'; export interface DataQueryResponse { - data: TimeSeries[] | [TableData] | any; + data: DataQueryResponseData; } +export type DataQueryResponseData = TimeSeries[] | [TableData] | any; + export interface DataQuery { /** * A - Z 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 d66fa601a29..0b9e5ab4b90 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -39,11 +39,14 @@ export interface PanelEditorProps { */ export type PanelOptionsValidator = (panelModel: any) => T; +export type PreservePanelOptionsHandler = (pluginId: string, prevOptions: any) => Partial; + export class ReactPanelPlugin { panel: ComponentClass>; editor?: ComponentClass>; optionsValidator?: PanelOptionsValidator; defaults?: TOptions; + preserveOptions?: PreservePanelOptionsHandler; constructor(panel: ComponentClass>) { this.panel = panel; @@ -60,6 +63,10 @@ export class ReactPanelPlugin { setDefaults(defaults: TOptions) { this.defaults = defaults; } + + setPreserveOptionsHandler(handler: PreservePanelOptionsHandler) { + this.preserveOptions = handler; + } } export interface PanelSize { @@ -76,17 +83,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, @@ -109,3 +105,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/theme.ts b/packages/grafana-ui/src/types/theme.ts index 667a0684827..4da0ba8218c 100644 --- a/packages/grafana-ui/src/types/theme.ts +++ b/packages/grafana-ui/src/types/theme.ts @@ -28,7 +28,7 @@ export interface GrafanaThemeCommons { }; weight: { light: number; - normal: number; + regular: number; semibold: number; }; lineHeight: { @@ -59,9 +59,9 @@ export interface GrafanaThemeCommons { }; border: { radius: { - xs: string; sm: string; md: string; + lg: string; }; width: { sm: string; 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/processTableData.ts b/packages/grafana-ui/src/utils/processTableData.ts index b9fd0519565..d65e42827e0 100644 --- a/packages/grafana-ui/src/utils/processTableData.ts +++ b/packages/grafana-ui/src/utils/processTableData.ts @@ -1,7 +1,10 @@ -import { TableData, Column } from '../types/index'; - +// Libraries +import isNumber from 'lodash/isNumber'; import Papa, { ParseError, ParseMeta } from 'papaparse'; +// Types +import { TableData, Column } from '../types'; + // Subset of all parse options export interface TableParseOptions { headerIsFirstLine?: boolean; // Not a papa-parse option @@ -131,3 +134,24 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta columnMap: {}, }); } + +export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData { + if (isNumber(sortIndex)) { + const copy = { + ...data, + rows: [...data.rows].sort((a, b) => { + a = a[sortIndex]; + b = b[sortIndex]; + // Sort null or undefined separately from comparable values + return +(a == null) - +(b == null) || +(a > b) || -(a < b); + }), + }; + + if (reverse) { + copy.rows.reverse(); + } + + return copy; + } + return data; +} 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/storybook/withFullSizeStory.tsx b/packages/grafana-ui/src/utils/storybook/withFullSizeStory.tsx new file mode 100644 index 00000000000..98c3ccb2e7f --- /dev/null +++ b/packages/grafana-ui/src/utils/storybook/withFullSizeStory.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { AutoSizer } from 'react-virtualized'; + +/** This will add full size with & height properties */ +export const withFullSizeStory = (component: React.ComponentType, props: any) => ( +
+ + {({ width, height }) => ( + <> + {React.createElement(component, { + ...props, + width, + height, + })} + + )} + +
+); 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/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index ff0ed8d0620..108e9188329 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -49,7 +49,7 @@ func (e *GraphiteExecutor) Query(ctx context.Context, dsInfo *models.DataSource, } for _, query := range tsdbQuery.Queries { - glog.Info("graphite", "query", query.Model) + glog.Debug("graphite", "query", query.Model) if fullTarget, err := query.Model.Get("targetFull").String(); err == nil { target = fixIntervalFormat(fullTarget) } else { 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/core/utils/explore.ts b/public/app/core/utils/explore.ts index 619391d46d1..31e5a392050 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -20,6 +20,7 @@ import { ResultType, QueryIntervals, QueryOptions, + ResultGetter, } from 'app/types/explore'; import { LogsDedupStrategy } from 'app/core/logs_model'; @@ -301,11 +302,24 @@ export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: return kbn.calculateInterval(absoluteRange, resolution, lowLimit); } -export function makeTimeSeriesList(dataList) { - return dataList.map((seriesData, index) => { +export const makeTimeSeriesList: ResultGetter = (dataList, transaction, allTransactions) => { + // Prevent multiple Graph transactions to have the same colors + let colorIndexOffset = 0; + for (const other of allTransactions) { + // Only need to consider transactions that came before the current one + if (other === transaction) { + break; + } + // Count timeseries of previous query results + if (other.resultType === 'Graph' && other.done) { + colorIndexOffset += other.result.length; + } + } + + return dataList.map((seriesData, index: number) => { const datapoints = seriesData.datapoints || []; const alias = seriesData.target; - const colorIndex = index % colors.length; + const colorIndex = (colorIndexOffset + index) % colors.length; const color = colors[colorIndex]; const series = new TimeSeries({ @@ -317,7 +331,7 @@ export function makeTimeSeriesList(dataList) { return series; }); -} +}; /** * Update the query history. Side-effect: store history in local storage diff --git a/public/app/features/dashboard/components/DashboardSettings/template.html b/public/app/features/dashboard/components/DashboardSettings/template.html index 4db390dc47a..23193fd8348 100644 --- a/public/app/features/dashboard/components/DashboardSettings/template.html +++ b/public/app/features/dashboard/components/DashboardSettings/template.html @@ -16,9 +16,6 @@ -
@@ -70,6 +67,11 @@
+
+ +
diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 1a970d3edc0..878b53e2824 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -92,11 +92,12 @@ export class DashboardPage extends PureComponent { componentWillUnmount() { if (this.props.dashboard) { this.props.cleanUpDashboard(); + this.setPanelFullscreenClass(false); } } componentDidUpdate(prevProps: Props) { - const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props; + const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId, urlUid } = this.props; if (!dashboard) { return; @@ -107,6 +108,12 @@ export class DashboardPage extends PureComponent { document.title = dashboard.title + ' - Grafana'; } + // Due to the angular -> react url bridge we can ge an update here with new uid before the container unmounts + // Can remove this condition after we switch to react router + if (prevProps.urlUid !== urlUid) { + return; + } + // handle animation states when opening dashboard settings if (!prevProps.editview && editview) { this.setState({ isSettingsOpening: true }); @@ -163,14 +170,20 @@ export class DashboardPage extends PureComponent { fullscreenPanel: null, scrollTop: this.state.rememberScrollTop, }, - () => { - dashboard.render(); - } + this.triggerPanelsRendering.bind(this) ); this.setPanelFullscreenClass(false); } + triggerPanelsRendering() { + try { + this.props.dashboard.render(); + } catch (err) { + this.props.notifyApp(createErrorNotification(`Panel rendering error`, err)); + } + } + handleFullscreenPanelNotFound(urlPanelId: string) { // Panel not found this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`)); diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index faefa97a74e..c111349c6fa 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -76,35 +76,33 @@ export class DashboardPanel extends PureComponent { // unmount angular panel this.cleanUpAngularPanel(); - if (panel.type !== pluginId) { - this.props.panel.changeType(pluginId, fromAngularPanel); - } - - if (plugin.exports) { - this.validateOptions(plugin, panel); - this.setState({ plugin, angularPanel: null }); - } else { + if (!plugin.exports) { try { plugin.exports = await importPluginModule(plugin.module); - this.validateOptions(plugin, panel); } 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 { + const { reactPanel } = plugin.exports; + panel.changeType(pluginId, reactPanel.preserveOptions); + if (reactPanel && reactPanel.optionsValidator) { + panel.options = reactPanel.optionsValidator(panel); + } + } + } + + this.setState({ plugin, angularPanel: null }); } } - // This is called before the plugin is added to the three, - // it allows plugins to update options before loading - validateOptions = (plugin: PanelPlugin, panel: PanelModel) => { - const { reactPanel } = plugin.exports; - if (reactPanel && reactPanel.optionsValidator) { - panel.options = reactPanel.optionsValidator(panel); - } - }; - componentDidMount() { this.loadPlugin(this.props.panel.type); } 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/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index b84a0534836..e0b84320fa7 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -597,7 +597,8 @@ function runQueriesForType( const res = await datasourceInstance.query(transaction.options); eventBridge.emit('data-received', res.data || []); const latency = Date.now() - now; - const results = resultGetter ? resultGetter(res.data) : res.data; + const { queryTransactions } = getState().explore[exploreId]; + const results = resultGetter ? resultGetter(res.data, transaction, queryTransactions) : res.data; dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId)); } catch (response) { eventBridge.emit('data-error', response); diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 078443b019a..9a156652a65 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -23,9 +23,11 @@ import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; 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 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, @@ -53,9 +55,11 @@ const builtInPlugins = { 'app/plugins/panel/alertlist/module': alertListPanel, 'app/plugins/panel/heatmap/module': heatmapPanel, 'app/plugins/panel/table/module': tablePanel, + 'app/plugins/panel/table2/module': table2Panel, '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 +
+
+
+ + +
+
+ + +
+
+

Y-Axes
diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 4033e4b3778..e3eed6fc382 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -337,9 +337,17 @@ class GraphElement { let bucketSize: number; if (this.data.length) { - const histMin = _.min(_.map(this.data, s => s.stats.min)); - const histMax = _.max(_.map(this.data, s => s.stats.max)); + let histMin = _.min(_.map(this.data, s => s.stats.min)); + let histMax = _.max(_.map(this.data, s => s.stats.max)); const ticks = panel.xaxis.buckets || this.panelWidth / 50; + if (panel.xaxis.min != null) { + const isInvalidXaxisMin = tickStep(panel.xaxis.min, histMax, ticks) <= 0; + histMin = isInvalidXaxisMin ? histMin : panel.xaxis.min; + } + if (panel.xaxis.max != null) { + const isInvalidXaxisMax = tickStep(histMin, panel.xaxis.max, ticks) <= 0; + histMax = isInvalidXaxisMax ? histMax : panel.xaxis.max; + } bucketSize = tickStep(histMin, histMax, ticks); options.series.bars.barWidth = bucketSize * 0.8; this.data = convertToHistogramData(this.data, bucketSize, this.ctrl.hiddenSeries, histMin, histMax); diff --git a/public/app/plugins/panel/graph/histogram.ts b/public/app/plugins/panel/graph/histogram.ts index b09226d15e7..b5c478198c0 100644 --- a/public/app/plugins/panel/graph/histogram.ts +++ b/public/app/plugins/panel/graph/histogram.ts @@ -43,6 +43,10 @@ export function convertValuesToHistogram(values: number[], bucketSize: number, m } for (let i = 0; i < values.length; i++) { + // filter out values outside the min and max boundaries + if (values[i] < min || values[i] > max) { + continue; + } const bound = getBucketBound(values[i], bucketSize); histogram[bound] = histogram[bound] + 1; } diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index 58a35ea2a5f..ff81a8ae6eb 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -516,4 +516,408 @@ describe('grafanaGraph', () => { expect(ctx.plotData[0].data[0][1]).toBe(2); }); }); + + describe('when graph is histogram, and xaxis min is set', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 150; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should not contain values lower than min', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(200); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min is zero', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 0; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[-100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should not contain values lower than zero', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min is null', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = null; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[-100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis min should not affect the histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min is undefined', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = undefined; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[-100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis min should not affect the histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis max is set', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.max = 250; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should not contain values greater than max', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(200); + }); + }); + + describe('when graph is histogram, and xaxis max is zero', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.max = 0; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should not contain values greater than zero', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(-100); + }); + }); + + describe('when graph is histogram, and xaxis max is null', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.max = null; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis max should not affect the histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis max is undefined', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.max = undefined; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis max should not should node affect the histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min and max are set', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 150; + ctrl.panel.xaxis.max = 250; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should not contain values lower than min and greater than max', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(200); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(200); + }); + }); + + describe('when graph is histogram, and xaxis min and max are zero', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 0; + ctrl.panel.xaxis.max = 0; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis max should be ignored otherwise the bucketSize is zero', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min and max are null', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = null; + ctrl.panel.xaxis.max = null; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis min and max should not affect the histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min and max are undefined', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = undefined; + ctrl.panel.xaxis.max = undefined; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis min and max should not affect the histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min is greater than xaxis max', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 150; + ctrl.panel.xaxis.max = 100; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis max should be ignored otherwise the bucketSize is negative', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(200); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + // aaa + describe('when graph is histogram, and xaxis min is greater than the maximum value', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 301; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis min should be ignored otherwise the bucketSize is negative', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min is equal to the maximum value', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 300; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis min should be ignored otherwise the bucketSize is zero', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis min is lower than the minimum value', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.min = 99; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('xaxis min should not affect the histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); + + describe('when graph is histogram, and xaxis max is equal to the minimum value', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.max = 100; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should calculate correct histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(100); + }); + }); + + describe('when graph is histogram, and xaxis max is a lower than the minimum value', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.max = 99; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should calculate empty histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(nonZero.length).toBe(0); + }); + }); + + describe('when graph is histogram, and xaxis max is greater than the maximum value', () => { + beforeEach(() => { + setupCtx(() => { + ctrl.panel.xaxis.mode = 'histogram'; + ctrl.panel.xaxis.max = 301; + ctrl.panel.stack = false; + ctrl.hiddenSeries = {}; + ctx.data[0] = new TimeSeries({ + datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]], + alias: 'series1', + }); + }); + }); + + it('should calculate correct histogram', () => { + const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0); + expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100); + expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300); + }); + }); }); diff --git a/public/app/plugins/panel/heatmap/color_legend.ts b/public/app/plugins/panel/heatmap/color_legend.ts index a86a498b723..a1c80629c7e 100644 --- a/public/app/plugins/panel/heatmap/color_legend.ts +++ b/public/app/plugins/panel/heatmap/color_legend.ts @@ -69,10 +69,11 @@ coreModule.directive('heatmapLegend', () => { function render() { clearLegend(elem); if (!_.isEmpty(ctrl.data) && !_.isEmpty(ctrl.data.cards)) { - const rangeFrom = 0; - const rangeTo = ctrl.data.cardStats.max; - const maxValue = panel.color.max || rangeTo; - const minValue = panel.color.min || 0; + const cardStats = ctrl.data.cardStats; + const rangeFrom = _.isNil(panel.color.min) ? Math.min(cardStats.min, 0) : panel.color.min; + const rangeTo = _.isNil(panel.color.max) ? cardStats.max : panel.color.max; + const maxValue = cardStats.max; + const minValue = cardStats.min; if (panel.color.mode === 'spectrum') { const colorScheme = _.find(ctrl.colorSchemes, { @@ -110,7 +111,7 @@ function drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minVal .data(valuesRange) .enter() .append('rect') - .attr('x', d => Math.round(d * widthFactor)) + .attr('x', d => Math.round((d - rangeFrom) * widthFactor)) .attr('y', 0) .attr('width', Math.round(rangeStep * widthFactor + 1)) // Overlap rectangles to prevent gaps .attr('height', legendHeight) @@ -141,7 +142,7 @@ function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue .data(valuesRange) .enter() .append('rect') - .attr('x', d => Math.round(d * widthFactor)) + .attr('x', d => Math.round((d - rangeFrom) * widthFactor)) .attr('y', 0) .attr('width', Math.round(rangeStep * widthFactor)) .attr('height', legendHeight) @@ -162,10 +163,10 @@ function drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWi const legendValueScale = d3 .scaleLinear() - .domain([0, rangeTo]) + .domain([rangeFrom, rangeTo]) .range([0, legendWidth]); - const ticks = buildLegendTicks(0, rangeTo, maxValue, minValue); + const ticks = buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue); const xAxis = d3 .axisBottom(legendValueScale) .tickValues(ticks) @@ -286,11 +287,12 @@ function getSvgElemHeight(elem) { function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) { const range = rangeTo - rangeFrom; const tickStepSize = tickStep(rangeFrom, rangeTo, 3); - const ticksNum = Math.round(range / tickStepSize); + const ticksNum = Math.ceil(range / tickStepSize); + const firstTick = getFirstCloseTick(rangeFrom, tickStepSize); let ticks = []; for (let i = 0; i < ticksNum; i++) { - const current = tickStepSize * i; + const current = firstTick + tickStepSize * i; // Add user-defined min and max if it had been set if (isValueCloseTo(minValue, current, tickStepSize)) { ticks.push(minValue); @@ -304,7 +306,7 @@ function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) { } else if (maxValue < current) { ticks.push(maxValue); } - ticks.push(tickStepSize * i); + ticks.push(current); } if (!isValueCloseTo(maxValue, rangeTo, tickStepSize)) { ticks.push(maxValue); @@ -318,3 +320,10 @@ function isValueCloseTo(val, valueTo, step) { const diff = Math.abs(val - valueTo); return diff < step * 0.3; } + +function getFirstCloseTick(minValue, step) { + if (minValue < 0) { + return Math.floor(minValue / step) * step; + } + return 0; +} diff --git a/public/app/plugins/panel/heatmap/rendering.ts b/public/app/plugins/panel/heatmap/rendering.ts index 59d704f0d55..702704b1ada 100644 --- a/public/app/plugins/panel/heatmap/rendering.ts +++ b/public/app/plugins/panel/heatmap/rendering.ts @@ -524,14 +524,16 @@ export class HeatmapRenderer { } const cardsData = this.data.cards; - const maxValueAuto = this.data.cardStats.max; - const maxValue = this.panel.color.max || maxValueAuto; - const minValue = this.panel.color.min || 0; + const cardStats = this.data.cardStats; + const maxValueAuto = cardStats.max; + const minValueAuto = Math.min(cardStats.min, 0); + const maxValue = _.isNil(this.panel.color.max) ? maxValueAuto : this.panel.color.max; + const minValue = _.isNil(this.panel.color.min) ? minValueAuto : this.panel.color.min; const colorScheme = _.find(this.ctrl.colorSchemes, { value: this.panel.color.colorScheme, }); this.colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue); - this.opacityScale = getOpacityScale(this.panel.color, maxValue); + this.opacityScale = getOpacityScale(this.panel.color, maxValue, minValue); this.setCardSize(); let cards = this.heatmap.selectAll('.heatmap-card').data(cardsData); diff --git a/public/app/plugins/panel/table/renderer.ts b/public/app/plugins/panel/table/renderer.ts index 6b6189f7482..bbc0f3f9ea6 100644 --- a/public/app/plugins/panel/table/renderer.ts +++ b/public/app/plugins/panel/table/renderer.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; import moment from 'moment'; import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, stringToJsRegex } from '@grafana/ui'; +import { ColumnStyle } from '@grafana/ui/src/components/Table/TableCellBuilder'; export class TableRenderer { formatters: any[]; @@ -50,7 +51,7 @@ export class TableRenderer { } } - getColorForValue(value, style) { + getColorForValue(value, style: ColumnStyle) { if (!style.thresholds) { return null; } @@ -62,7 +63,7 @@ export class TableRenderer { return getColorFromHexRgbOrName(_.first(style.colors), this.theme); } - defaultCellFormatter(v, style) { + defaultCellFormatter(v, style: ColumnStyle) { if (v === null || v === void 0 || v === undefined) { return ''; } @@ -189,7 +190,7 @@ export class TableRenderer { }; } - setColorState(value, style) { + setColorState(value, style: ColumnStyle) { if (!style.colorMode) { return; } diff --git a/public/app/plugins/panel/table2/README.md b/public/app/plugins/panel/table2/README.md new file mode 100644 index 00000000000..98f2c13f75c --- /dev/null +++ b/public/app/plugins/panel/table2/README.md @@ -0,0 +1,9 @@ +# Table Panel - Native Plugin + +The Table Panel is **included** with Grafana. + +The table panel is very flexible, supporting both multiple modes for time series as well as for table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options. + +Check out the [Table Panel Showcase in the Grafana Playground](http://play.grafana.org/dashboard/db/table-panel-showcase) or read more about it here: + +[http://docs.grafana.org/reference/table_panel/](http://docs.grafana.org/reference/table_panel/) diff --git a/public/app/plugins/panel/table2/TablePanel.tsx b/public/app/plugins/panel/table2/TablePanel.tsx new file mode 100644 index 00000000000..a7cd84f8ecb --- /dev/null +++ b/public/app/plugins/panel/table2/TablePanel.tsx @@ -0,0 +1,29 @@ +// Libraries +import React, { Component } from 'react'; + +// Types +import { PanelProps, ThemeContext } from '@grafana/ui'; +import { Options } from './types'; +import Table from '@grafana/ui/src/components/Table/Table'; + +interface Props extends PanelProps {} + +export class TablePanel extends Component { + constructor(props: Props) { + super(props); + } + + render() { + const { panelData, options } = this.props; + + if (!panelData || !panelData.tableData) { + return
No Table Data...
; + } + + return ( + + {theme => } + + ); + } +} diff --git a/public/app/plugins/panel/table2/TablePanelEditor.tsx b/public/app/plugins/panel/table2/TablePanelEditor.tsx new file mode 100644 index 00000000000..ca64cc5e9d0 --- /dev/null +++ b/public/app/plugins/panel/table2/TablePanelEditor.tsx @@ -0,0 +1,55 @@ +//// Libraries +import _ from 'lodash'; +import React, { PureComponent } from 'react'; + +// Types +import { PanelEditorProps, Switch, FormField } from '@grafana/ui'; +import { Options } from './types'; + +export class TablePanelEditor extends PureComponent> { + onToggleShowHeader = () => { + this.props.onOptionsChange({ ...this.props.options, showHeader: !this.props.options.showHeader }); + }; + + onToggleFixedHeader = () => { + this.props.onOptionsChange({ ...this.props.options, fixedHeader: !this.props.options.fixedHeader }); + }; + + onToggleRotate = () => { + this.props.onOptionsChange({ ...this.props.options, rotate: !this.props.options.rotate }); + }; + + onFixedColumnsChange = ({ target }) => { + this.props.onOptionsChange({ ...this.props.options, fixedColumns: target.value }); + }; + + render() { + const { showHeader, fixedHeader, rotate, fixedColumns } = this.props.options; + + return ( +
+
+
Header
+ + +
+ +
+
Display
+ + +
+
+ ); + } +} diff --git a/public/app/plugins/panel/table2/img/icn-table-panel.svg b/public/app/plugins/panel/table2/img/icn-table-panel.svg new file mode 100644 index 00000000000..83097e259dc --- /dev/null +++ b/public/app/plugins/panel/table2/img/icn-table-panel.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/table2/module.tsx b/public/app/plugins/panel/table2/module.tsx new file mode 100644 index 00000000000..d93e7911074 --- /dev/null +++ b/public/app/plugins/panel/table2/module.tsx @@ -0,0 +1,9 @@ +import { ReactPanelPlugin } from '@grafana/ui'; + +import { TablePanelEditor } from './TablePanelEditor'; +import { TablePanel } from './TablePanel'; +import { Options, defaults } from './types'; + +export const reactPanel = new ReactPanelPlugin(TablePanel); +reactPanel.setEditor(TablePanelEditor); +reactPanel.setDefaults(defaults); diff --git a/public/app/plugins/panel/table2/plugin.json b/public/app/plugins/panel/table2/plugin.json new file mode 100644 index 00000000000..4fa7728bd55 --- /dev/null +++ b/public/app/plugins/panel/table2/plugin.json @@ -0,0 +1,19 @@ +{ + "type": "panel", + "name": "React Table", + "id": "table2", + "state": "alpha", + + "dataFormats": ["table"], + + "info": { + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-table-panel.svg", + "large": "img/icn-table-panel.svg" + } + } +} diff --git a/public/app/plugins/panel/table2/types.ts b/public/app/plugins/panel/table2/types.ts new file mode 100644 index 00000000000..d58c58810ef --- /dev/null +++ b/public/app/plugins/panel/table2/types.ts @@ -0,0 +1,35 @@ +import { ColumnStyle } from '@grafana/ui/src/components/Table/TableCellBuilder'; + +export interface Options { + showHeader: boolean; + fixedHeader: boolean; + fixedColumns: number; + rotate: boolean; + + styles: ColumnStyle[]; +} + +export const defaults: Options = { + showHeader: true, + fixedHeader: true, + fixedColumns: 0, + rotate: false, + styles: [ + { + type: 'date', + pattern: 'Time', + alias: 'Time', + dateFormat: 'YYYY-MM-DD HH:mm:ss', + }, + { + unit: 'short', + type: 'number', + alias: '', + decimals: 2, + colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'], + colorMode: null, + pattern: '/.*/', + thresholds: [], + }, + ], +}; diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 7a6af04b2ee..27894200e51 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -4,13 +4,14 @@ import { RawTimeRange, TimeRange, DataQuery, + DataQueryResponseData, DataSourceSelectItem, DataSourceApi, QueryHint, ExploreStartPageProps, } from '@grafana/ui'; -import { Emitter } from 'app/core/core'; +import { Emitter, TimeSeries } from 'app/core/core'; import { LogsModel, LogsDedupStrategy, LogLevel } from 'app/core/logs_model'; import TableModel from 'app/core/table_model'; @@ -322,6 +323,12 @@ export interface QueryTransaction { export type RangeScanner = () => RawTimeRange; +export type ResultGetter = ( + result: DataQueryResponseData, + transaction: QueryTransaction, + allTransactions: QueryTransaction[] +) => TimeSeries; + export interface TextMatch { text: string; start: number; diff --git a/public/sass/_variables.generated.scss b/public/sass/_variables.generated.scss index ddb94f804d4..21fc602b8ba 100644 --- a/public/sass/_variables.generated.scss +++ b/public/sass/_variables.generated.scss @@ -55,7 +55,6 @@ $spacers: ( ), ), ) !default; -$border-width: 1px !default; // Grid breakpoints // @@ -88,14 +87,11 @@ $container-max-widths: ( $grid-columns: 12 !default; $grid-gutter-width: 30px !default; -$enable-flex: true; - // Typography // ------------------------- $font-family-sans-serif: 'Roboto', Helvetica, Arial, sans-serif; $font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace; -$font-family-base: $font-family-sans-serif !default; $font-size-root: 14px !default; $font-size-base: 13px !default; @@ -106,7 +102,9 @@ $font-size-sm: 12px !default; $font-size-xs: 10px !default; $line-height-base: 1.5 !default; -$font-weight-semi-bold: 500; + +$font-weight-regular: 400 !default; +$font-weight-semi-bold: 500 !default; $font-size-h1: 28px !default; $font-size-h2: 24px !default; @@ -116,18 +114,13 @@ $font-size-h5: 16px !default; $font-size-h6: 14px !default; $headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; -$headings-font-weight: 400 !default; $headings-line-height: 1.1 !default; -$hr-border-width: $border-width !default; -$dt-font-weight: bold !default; - // Components // // Define common padding and border radius sizes and more. -$line-height-lg: (4 / 3) !default; -$line-height-sm: 1.5 !default; +$border-width: 1px !default; $border-radius: 3px !default; $border-radius-lg: 5px !default; @@ -154,22 +147,17 @@ $input-padding-x: 10px !default; $input-padding-y: 8px !default; $input-line-height: 18px !default; -$input-btn-border-width: 1px; $input-border-radius: 0 $border-radius $border-radius 0 !default; $input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 0 !default; $label-border-radius: $border-radius 0 0 $border-radius !default; $label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default; -$input-padding-y-sm: 4px !default; - $input-padding-x-lg: 20px !default; $input-padding-y-lg: 10px !default; $input-height: 35px !default; -$gf-form-input-height: 35px; - $cursor-disabled: not-allowed !default; // Form validation icons @@ -206,8 +194,6 @@ $btn-padding-y-lg: 11px !default; $btn-padding-x-xl: 21px !default; $btn-padding-y-xl: 11px !default; -$btn-border-radius: 2px; - $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; // sidemenu diff --git a/public/sass/base/_forms.scss b/public/sass/base/_forms.scss index 76d936c18ec..f58e75f41b2 100644 --- a/public/sass/base/_forms.scss +++ b/public/sass/base/_forms.scss @@ -37,7 +37,7 @@ input, button, select, textarea { - font-family: $font-family-base; // And only set font-family here for those that need it (note the missing label element) + font-family: $font-family-sans-serif; // And only set font-family here for those that need it (note the missing label element) } // Identify controls by their labels diff --git a/public/sass/base/_reboot.scss b/public/sass/base/_reboot.scss index 65cfafc107d..3b05bff61bf 100644 --- a/public/sass/base/_reboot.scss +++ b/public/sass/base/_reboot.scss @@ -70,7 +70,7 @@ html { body { // Make the `body` use the `font-size-root` - font-family: $font-family-base; + font-family: $font-family-sans-serif; font-size: $font-size-base; line-height: $line-height-base; // Go easy on the eyes and use something other than `#000` for text @@ -145,7 +145,7 @@ ul ol { } dt { - font-weight: $dt-font-weight; + font-weight: $font-weight-semi-bold; } dd { diff --git a/public/sass/base/_type.scss b/public/sass/base/_type.scss index 6cf32687188..9efae56d5e4 100644 --- a/public/sass/base/_type.scss +++ b/public/sass/base/_type.scss @@ -111,7 +111,7 @@ h6, .h6 { margin-bottom: $space-sm; font-family: $headings-font-family; - font-weight: $headings-font-weight; + font-weight: $font-weight-regular; line-height: $headings-line-height; color: $headings-color; } @@ -149,7 +149,7 @@ hr { margin-top: $spacer-y; margin-bottom: $spacer-y; border: 0; - border-top: $hr-border-width solid $hr-border-color; + border-top: $border-width solid $hr-border-color; } // diff --git a/public/sass/components/_buttons.scss b/public/sass/components/_buttons.scss index 0c1ac726690..f85fecec50c 100644 --- a/public/sass/components/_buttons.scss +++ b/public/sass/components/_buttons.scss @@ -16,7 +16,7 @@ cursor: pointer; border: none; - @include button-size($btn-padding-y, $btn-padding-x, $font-size-base, $btn-border-radius); + @include button-size($btn-padding-y, $btn-padding-x, $font-size-base, $border-radius-sm); &, &:active, @@ -53,7 +53,7 @@ // -------------------------------------------------- // XLarge .btn-xlarge { - @include button-size($btn-padding-y-xl, $btn-padding-x-xl, $font-size-lg, $btn-border-radius); + @include button-size($btn-padding-y-xl, $btn-padding-x-xl, $font-size-lg, $border-radius-sm); font-weight: normal; padding-bottom: $btn-padding-y-xl - 3; .gicon { @@ -64,16 +64,16 @@ // Large .btn-large { - @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $font-size-lg, $btn-border-radius); + @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $font-size-lg, $border-radius-sm); font-weight: normal; } .btn-small { - @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $btn-border-radius); + @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $border-radius-sm); } .btn-mini { - @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-xs, $btn-border-radius); + @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-xs, $border-radius-sm); } .btn-link { diff --git a/public/sass/components/_code_editor.scss b/public/sass/components/_code_editor.scss index a9c7ebf2e75..832449e31f1 100644 --- a/public/sass/components/_code_editor.scss +++ b/public/sass/components/_code_editor.scss @@ -10,7 +10,7 @@ min-height: 3.6rem; // Include space for horizontal scrollbar @include border-radius($input-border-radius-sm); - border: $input-btn-border-width solid $input-border-color; + border: $border-width solid $input-border-color; } .ace_content { diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 2eccdd52e8b..f62c2fcb7a0 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -105,9 +105,9 @@ $input-border: 1px solid $input-border-color; background-color: $input-label-bg; display: block; - height: $gf-form-input-height; + height: $input-height; - border: $input-btn-border-width solid $input-label-border-color; + border: $border-width solid $input-label-border-color; border-right: none; border-radius: $label-border-radius; @@ -127,7 +127,7 @@ $input-border: 1px solid $input-border-color; } &--btn { - border-right: $input-btn-border-width solid $input-label-border-color; + border-right: $border-width solid $input-label-border-color; border-radius: $border-radius; &:hover { @@ -154,7 +154,7 @@ $input-border: 1px solid $input-border-color; flex-grow: 1; margin: 0; margin-right: $space-xs; - border: $input-btn-border-width solid transparent; + border: $border-width solid transparent; border-left: none; @include border-radius($label-border-radius-sm); } @@ -166,7 +166,7 @@ $input-border: 1px solid $input-border-color; .gf-form-input { display: block; width: 100%; - height: $gf-form-input-height; + height: $input-height; padding: $input-padding-y $input-padding-x; font-size: $font-size-md; line-height: $input-line-height; diff --git a/public/sass/components/_panel_gettingstarted.scss b/public/sass/components/_panel_gettingstarted.scss index fe2f3704b0b..7e23550dda6 100644 --- a/public/sass/components/_panel_gettingstarted.scss +++ b/public/sass/components/_panel_gettingstarted.scss @@ -117,7 +117,7 @@ $path-position: $marker-size-half - ($path-height / 2); } .progress-step-cta { - @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $btn-border-radius); + @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $font-size-sm, $border-radius-sm); @include buttonBackground($btn-primary-bg, $btn-primary-bg-hl); display: none; } diff --git a/public/sass/components/_slate_editor.scss b/public/sass/components/_slate_editor.scss index 9dfdb685466..db09b8fffd7 100644 --- a/public/sass/components/_slate_editor.scss +++ b/public/sass/components/_slate_editor.scss @@ -9,7 +9,7 @@ position: relative; display: inline-block; padding: $input-padding-y $input-padding-x; - min-height: $gf-form-input-height; + min-height: $input-height; width: 100%; cursor: text; line-height: $line-height-base; diff --git a/public/sass/components/_submenu.scss b/public/sass/components/_submenu.scss index 514aa44525e..2f2a9294799 100644 --- a/public/sass/components/_submenu.scss +++ b/public/sass/components/_submenu.scss @@ -42,7 +42,7 @@ border-radius: $input-border-radius; display: inline-block; color: $text-color; - height: $gf-form-input-height; + height: $input-height; .label-tag { margin: 0 5px; diff --git a/public/sass/components/_switch.scss b/public/sass/components/_switch.scss index f7980215659..09b32463370 100644 --- a/public/sass/components/_switch.scss +++ b/public/sass/components/_switch.scss @@ -26,7 +26,7 @@ gf-form-switch[disabled] { display: flex; position: relative; width: 60px; - height: $gf-form-input-height; + height: $input-height; background: $switch-bg; border: 1px solid $input-border-color; border-left: none; @@ -82,7 +82,7 @@ input:checked + .gf-form-switch__slider::before { position: relative; display: flex; width: 50px; - height: $gf-form-input-height; + height: $input-height; background: $switch-bg; border: 1px solid $input-border-color; border-left: none; diff --git a/public/sass/components/_toolbar.scss b/public/sass/components/_toolbar.scss index 36be8a18739..905a8ef6829 100644 --- a/public/sass/components/_toolbar.scss +++ b/public/sass/components/_toolbar.scss @@ -27,7 +27,7 @@ line-height: $input-line-height; color: $input-color; background-color: $input-bg; - height: $gf-form-input-height; + height: $input-height; border: $input-border; border-radius: $input-border-radius; display: flex; diff --git a/public/sass/utils/_utils.scss b/public/sass/utils/_utils.scss index 16a84784de8..41a0df5f363 100644 --- a/public/sass/utils/_utils.scss +++ b/public/sass/utils/_utils.scss @@ -96,7 +96,9 @@ button.close { } .center-vh { + height: 100%; display: flex; + flex-direction: column; align-items: center; justify-content: center; justify-items: center; diff --git a/scripts/ci-frontend-metrics.sh b/scripts/ci-frontend-metrics.sh new file mode 100755 index 00000000000..49786b752af --- /dev/null +++ b/scripts/ci-frontend-metrics.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +echo -e "Collecting code stats (typescript errors & more)" + +ERROR_COUNT="$(./node_modules/.bin/tsc --project tsconfig.json --noEmit --noImplicitAny true | grep -oP 'Found \K(\d+)')" +DIRECTIVES="$(grep -r -o directive public/app/**/* | wc -l)" +CONTROLLERS="$(grep -r -oP 'class .*Ctrl' public/app/**/* | wc -l)" + +echo -e "Typescript errors: $ERROR_COUNT" +echo -e "Directives: $DIRECTIVES" +echo -e "Controllers: $CONTROLLERS" + +./scripts/ci-metrics-publisher.sh \ + grafana.ci-code.noImplicitAny=$ERROR_COUNT \ + grafana.ci-code.directives=$DIRECTIVES \ + grafana.ci-code.controllers=$CONTROLLERS \ + + + diff --git a/scripts/ci-metrics-publisher.sh b/scripts/ci-metrics-publisher.sh new file mode 100755 index 00000000000..bb7d042caf1 --- /dev/null +++ b/scripts/ci-metrics-publisher.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +echo "Publishing CI Metrics" + +data="" + +for ((i = 1; i <= $#; i++ )); do + remainder="${!i}" + first="${remainder%%=*}"; remainder="${remainder#*=}" + if [ -n "$data" ]; then + data="$data," + fi + data=''$data'{"name": "'${first}'", "value": '${remainder}', "interval": 60, "mtype": "gauge", "time": '$(date +%s)'}' +done + +curl https://6371:$GRAFANA_MISC_STATS_API_KEY@graphite-us-central1.grafana.net/metrics \ + -H 'Content-type: application/json' \ + -d "[$data]" diff --git a/scripts/circle-metrics.sh b/scripts/circle-metrics.sh deleted file mode 100755 index b52347d0755..00000000000 --- a/scripts/circle-metrics.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash - -echo "Collecting code stats (typescript errors & more)" - -ERROR_COUNT="$(./node_modules/.bin/tsc --project tsconfig.json --noEmit --noImplicitAny true | grep -oP 'Found \K(\d+)')" -DIRECTIVES="$(grep -r -o directive public/app/**/* | wc -l)" -CONTROLLERS="$(grep -r -oP 'class .*Ctrl' public/app/**/* | wc -l)" - -echo "Typescript errors: $ERROR_COUNT" -echo "Directives: $DIRECTIVES" -echo "Controllers: $CONTROLLERS" - -curl \ - -d "{\"metrics\": { - \"ci.code.noImplicitAny\": $ERROR_COUNT, - \"ci.code.directives\": $DIRECTIVES, - \"ci.code.controllers\": $CONTROLLERS - } - }" \ - -H "Content-Type: application/json" \ - -u ci:$CIRCLE_STATS_PWD \ - -X POST https://stats.grafana.org/metric-receiver - -curl https://6371:$GRAFANA_MISC_STATS_API_KEY@graphite-us-central1.grafana.net/metrics \ - -H 'Content-type: application/json' \ - -d '[ - {"name":"grafana.ci-code.noImplicitAny", "interval":60, "value": '$ERROR_COUNT', "mtype": "gauge", "time": '$(date +%s)'}, - {"name":"grafana.ci-code.directives", "interval":60, "value": '$DIRECTIVES', "mtype": "gauge", "time": '$(date +%s)'}, - {"name":"grafana.ci-code.controllers", "interval":60, "value": '$CONTROLLERS', "mtype": "gauge", "time": '$(date +%s)'} - ]' diff --git a/scripts/circle-test-frontend.sh b/scripts/circle-test-frontend.sh index 3366bf3d4fb..9d945a03b7f 100755 --- a/scripts/circle-test-frontend.sh +++ b/scripts/circle-test-frontend.sh @@ -1,4 +1,5 @@ #!/bin/bash + function exit_if_fail { command=$@ echo "Executing '$command'" @@ -10,11 +11,16 @@ function exit_if_fail { fi } -exit_if_fail npm run prettier:check -exit_if_fail npm run test +start=$(date +%s) -# On master also collect some and send some metrics -branch="$(git rev-parse --abbrev-ref HEAD)" -if [ "${branch}" == "master" ]; then - exit_if_fail ./scripts/circle-metrics.sh +exit_if_fail npm run prettier:check +# exit_if_fail npm run test + +end=$(date +%s) +seconds=$((end - start)) + +if [ "${CIRCLE_BRANCH}" == "master" ]; then + exit_if_fail ./scripts/ci-frontend-metrics.sh + exit_if_fail ./scripts/ci-metrics-publisher.sh grafana.ci-performance.frontend-tests=$seconds fi +