diff --git a/CHANGELOG.md b/CHANGELOG.md index 343e3ccfbb1..5483529f75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,15 @@ ### Minor * **Cloudwatch**: Add AWS RDS MaximumUsedTransactionIDs metric [#15077](https://github.com/grafana/grafana/pull/15077), thx [@activeshadow](https://github.com/activeshadow) +* **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683) +* **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683) ### Bug Fixes * **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506) * **Datasource**: Handles nil jsondata field gracefully [#14239](https://github.com/grafana/grafana/issues/14239) * **Gauge**: Interpolate scoped variables in repeated gauges [#15739](https://github.com/grafana/grafana/issues/15739) * **Datasource**: Empty user/password was not updated when updating datasources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619) +* **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019) # 6.0.1 (2019-03-06) 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/package.json b/package.json index d2760bbad02..c951e5241c1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.1.0", "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1", + "@types/angular": "^1.6.6", "@types/chalk": "^2.2.0", "@types/classnames": "^2.2.6", "@types/commander": "^2.12.2", 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/FormField/_FormField.scss b/packages/grafana-ui/src/components/FormField/_FormField.scss index 36955e2fca6..0c69e67f82a 100644 --- a/packages/grafana-ui/src/components/FormField/_FormField.scss +++ b/packages/grafana-ui/src/components/FormField/_FormField.scss @@ -1,5 +1,5 @@ .form-field { - margin-bottom: $gf-form-margin; + margin-bottom: $space-xxs; display: flex; flex-direction: row; align-items: center; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index d04daae3dab..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; @@ -30,7 +30,7 @@ const FONT_SCALE = 1; export class Gauge extends PureComponent { canvasElement: any; - static defaultProps = { + static defaultProps: Partial = { maxValue: 100, valueMappings: [], minValue: 0, @@ -41,7 +41,6 @@ export class Gauge extends PureComponent { thresholds: [], unit: 'none', stat: 'avg', - theme: GrafanaThemeType.Dark, }; componentDidMount() { @@ -52,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)) { @@ -73,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() { @@ -134,7 +123,7 @@ export class Gauge extends PureComponent { Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1); const thresholdLabelFontSize = fontSize / 2.5; - const options = { + const options: any = { series: { gauges: { gauge: { @@ -184,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/Select/_Select.scss b/packages/grafana-ui/src/components/Select/_Select.scss index bc18ed9d369..f0270b1422e 100644 --- a/packages/grafana-ui/src/components/Select/_Select.scss +++ b/packages/grafana-ui/src/components/Select/_Select.scss @@ -3,7 +3,7 @@ $select-input-bg-disabled: $input-bg-disabled; @mixin select-control() { width: 100%; - margin-right: $gf-form-margin; + margin-right: $space-xs; @include border-radius($input-border-radius-sm); background-color: $input-bg; } 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..8ab53a1ac77 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -0,0 +1,97 @@ +// 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); + }), + }; +} + +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 0ce4fac8182..97ade6da7a5 100644 --- a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts @@ -17,7 +17,13 @@ $enable-hover-media-query: false !default; // Control the default styling of most Bootstrap elements by modifying these // variables. Mostly focused on spacing. -$spacer: ${theme.spacing.m} !default; +$space-xxs: ${theme.spacing.xxs} !default; +$space-xs: ${theme.spacing.xs} !default; +$space-sm: ${theme.spacing.sm} !default; +$space-md: ${theme.spacing.md} !default; +$space-lg: ${theme.spacing.lg} !default; +$space-xl: ${theme.spacing.xl} !default; +$spacer: ${theme.spacing.d} !default; $spacer-x: $spacer !default; $spacer-y: $spacer !default; $spacers: ( @@ -46,7 +52,6 @@ $spacers: ( ), ), ) !default; -$border-width: ${theme.border.width.s} !default; // Grid breakpoints // @@ -55,9 +60,9 @@ $border-width: ${theme.border.width.s} !default; $grid-breakpoints: ( xs: ${theme.breakpoints.xs}, - sm: ${theme.breakpoints.s}, - md: ${theme.breakpoints.m}, - lg: ${theme.breakpoints.l}, + sm: ${theme.breakpoints.sm}, + md: ${theme.breakpoints.md}, + lg: ${theme.breakpoints.lg}, xl: ${theme.breakpoints.xl}, ) !default; @@ -77,27 +82,26 @@ $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; -$font-size-lg: ${theme.typography.size.l} !default; -$font-size-md: ${theme.typography.size.m} !default; -$font-size-sm: ${theme.typography.size.s} !default; +$font-size-lg: ${theme.typography.size.lg} !default; +$font-size-md: ${theme.typography.size.md} !default; +$font-size-sm: ${theme.typography.size.sm} !default; $font-size-xs: ${theme.typography.size.xs} !default; -$line-height-base: ${theme.typography.lineHeight.l} !default; -$font-weight-semi-bold: ${theme.typography.weight.semibold}; +$line-height-base: ${theme.typography.lineHeight.lg} !default; + +$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; @@ -106,24 +110,18 @@ $font-size-h4: ${theme.typography.heading.h4} !default; $font-size-h5: ${theme.typography.heading.h5} !default; $font-size-h6: ${theme.typography.heading.h6} !default; -$headings-margin-bottom: ($spacer / 2) !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.s} !default; - -$hr-border-width: $border-width !default; -$dt-font-weight: bold !default; +$headings-line-height: ${theme.typography.lineHeight.sm} !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 @@ -146,23 +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-margin: 3px; -$gf-form-input-height: 35px; - $cursor-disabled: not-allowed !default; // Form validation icons @@ -199,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; @@ -207,8 +198,7 @@ $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; $side-menu-width: 60px; // dashboard -$panel-margin: 10px; -$dashboard-padding: $panel-margin * 2; +$dashboard-padding: 10px * 2; $panel-horizontal-padding: 10; $panel-vertical-padding: 5; $panel-padding: 0px $panel-horizontal-padding + 0px $panel-vertical-padding + 0px $panel-horizontal-padding + 0px; @@ -243,9 +233,4 @@ $external-services: ( icon: '', ), ) !default; - -:export { - panelhorizontalpadding: $panel-horizontal-padding; - panelverticalpadding: $panel-vertical-padding; -} `; diff --git a/packages/grafana-ui/src/themes/default.ts b/packages/grafana-ui/src/themes/default.ts index 60ebcfa7aa0..2dc50a7a5b1 100644 --- a/packages/grafana-ui/src/themes/default.ts +++ b/packages/grafana-ui/src/themes/default.ts @@ -11,9 +11,9 @@ const theme: GrafanaThemeCommons = { root: '14px', base: '13px', xs: '10px', - s: '12px', - m: '14px', - l: '18px', + sm: '12px', + md: '14px', + lg: '18px', }, heading: { h1: '28px', @@ -25,40 +25,47 @@ const theme: GrafanaThemeCommons = { }, weight: { light: 300, - normal: 400, + regular: 400, semibold: 500, }, lineHeight: { xs: 1, - s: 1.1, - m: 4 / 3, - l: 1.5, + sm: 1.1, + md: 4 / 3, + lg: 1.5, }, }, breakpoints: { xs: '0', - s: '544px', - m: '768px', - l: '992px', + sm: '544px', + md: '768px', + lg: '992px', xl: '1200px', }, spacing: { - xs: '0', - s: '3px', - m: '14px', - l: '21px', + d: '14px', + xxs: '2px', + xs: '4px', + sm: '8px', + md: '16px', + lg: '24px', + xl: '32px', gutter: '30px', }, border: { radius: { - xs: '2px', - s: '3px', - m: '5px', + sm: '2px', + md: '3px', + lg: '5px', }, width: { - s: '1px', + sm: '1px', }, }, + panelPadding: { + horizontal: 10, + vertical: 5, + }, }; export default theme; diff --git a/packages/grafana-ui/src/types/data.ts b/packages/grafana-ui/src/types/data.ts index 21b863049fc..d8d67809ed8 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; // name @@ -64,3 +61,12 @@ export interface TableData { columns: Column[]; rows: 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 4d9dc820b96..bb588ca078d 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -21,10 +21,13 @@ export interface PanelEditorProps { onOptionsChange: (options: T) => void; } +export type PreservePanelOptionsHandler = (pluginId: string, prevOptions: any) => Partial; + export class ReactPanelPlugin { panel: ComponentClass>; editor?: ComponentClass>; defaults?: TOptions; + preserveOptions?: PreservePanelOptionsHandler; constructor(panel: ComponentClass>) { this.panel = panel; @@ -37,6 +40,10 @@ export class ReactPanelPlugin { setDefaults(defaults: TOptions) { this.defaults = defaults; } + + setPreserveOptionsHandler(handler: PreservePanelOptionsHandler) { + this.preserveOptions = handler; + } } export interface PanelSize { @@ -53,17 +60,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, @@ -86,3 +82,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 1226b841836..4da0ba8218c 100644 --- a/packages/grafana-ui/src/types/theme.ts +++ b/packages/grafana-ui/src/types/theme.ts @@ -8,9 +8,9 @@ export interface GrafanaThemeCommons { // TODO: not sure if should be a part of theme breakpoints: { xs: string; - s: string; - m: string; - l: string; + sm: string; + md: string; + lg: string; xl: string; }; typography: { @@ -22,20 +22,20 @@ export interface GrafanaThemeCommons { root: string; base: string; xs: string; - s: string; - m: string; - l: string; + sm: string; + md: string; + lg: string; }; weight: { light: number; - normal: number; + regular: number; semibold: number; }; lineHeight: { xs: number; //1 - s: number; //1.1 - m: number; // 4/3 - l: number; // 1.5 + sm: number; //1.1 + md: number; // 4/3 + lg: number; // 1.5 }; // TODO: Refactor to use size instead of custom defs heading: { @@ -48,22 +48,29 @@ export interface GrafanaThemeCommons { }; }; spacing: { + d: string; + xxs: string; xs: string; - s: string; - m: string; - l: string; + sm: string; + md: string; + lg: string; + xl: string; gutter: string; }; border: { radius: { - xs: string; - s: string; - m: string; + sm: string; + md: string; + lg: string; }; width: { - s: string; + sm: string; }; }; + panelPadding: { + horizontal: number; + vertical: number; + }; } export interface GrafanaTheme extends GrafanaThemeCommons { 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 aab4a30b47d..3cc03ee10dd 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -1,8 +1,10 @@ export * from './processTimeSeries'; export * from './processTableData'; +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 65bcaa54755..d8c6a047a59 100644 --- a/packages/grafana-ui/src/utils/processTableData.ts +++ b/packages/grafana-ui/src/utils/processTableData.ts @@ -1,6 +1,9 @@ -import { TableData, Column, TimeSeries } from '../types/index'; - +// Libraries import Papa, { ParseError, ParseMeta } from 'papaparse'; +import isNumber from 'lodash/isNumber'; + +// Types +import { TableData, Column, TimeSeries } from '../types'; // Subset of all parse options export interface TableParseOptions { @@ -163,3 +166,24 @@ export const toTableData = (results?: any[]): TableData[] => { throw new Error('Unsupported data format'); }); }; + +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..c37c5550e22 --- /dev/null +++ b/packages/grafana-ui/src/utils/singlestat.ts @@ -0,0 +1,31 @@ +import { TableData, NullValueMode, SingleStatValueInfo } from '../types'; +import { processTimeSeries } from './processTimeSeries'; + +export interface SingleStatProcessingOptions { + data: TableData[]; + 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 { data, stat } = options; + + const timeSeries = processTimeSeries({ + data, + xColumn: 0, + yColumn: 0, + nullValueMode: NullValueMode.Null, + }); + + return timeSeries.map((series, index) => { + const value = stat !== 'name' ? series.stats[stat] : series.label; + + return { + value: value, + }; + }); + + 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/components/jsontree/jsontree.ts b/public/app/core/components/jsontree/jsontree.ts index 4bcb2f632c2..a101007f129 100644 --- a/public/app/core/components/jsontree/jsontree.ts +++ b/public/app/core/components/jsontree/jsontree.ts @@ -10,13 +10,13 @@ coreModule.directive('jsonTree', [ startExpanded: '@', rootName: '@', }, - link: (scope, elem) => { + link: (scope: any, elem) => { const jsonExp = new JsonExplorer(scope.object, 3, { animateOpen: true, }); const html = jsonExp.render(true); - elem.html(html); + elem.replaceAll(html); }, }; }, diff --git a/public/app/core/directives/give_focus.ts b/public/app/core/directives/give_focus.ts index 4ef574ec68e..37549ad7229 100644 --- a/public/app/core/directives/give_focus.ts +++ b/public/app/core/directives/give_focus.ts @@ -14,7 +14,7 @@ coreModule.directive('giveFocus', () => { } setTimeout(() => { element.focus(); - const domEl = element[0]; + const domEl: any = element[0]; if (domEl.setSelectionRange) { const pos = element.val().length * 2; domEl.setSelectionRange(pos, pos); diff --git a/public/app/core/specs/kbn.test.ts b/public/app/core/specs/kbn.test.ts deleted file mode 100644 index c97e2e1101a..00000000000 --- a/public/app/core/specs/kbn.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import kbn from '../utils/kbn'; - -describe('stringToJsRegex', () => { - it('should parse the valid regex value', () => { - const output = kbn.stringToJsRegex('/validRegexp/'); - expect(output).toBeInstanceOf(RegExp); - }); - - it('should throw error on invalid regex value', () => { - const input = '/etc/hostname'; - expect(() => { - kbn.stringToJsRegex(input); - }).toThrow(); - }); -}); 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/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss index 8daf935d918..941e36c5e33 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -20,7 +20,7 @@ .gicon { font-size: 30px; - margin-right: $spacer; + margin-right: $space-md; } &:hover { @@ -32,16 +32,16 @@ .add-panel-widget__title { font-size: $font-size-md; font-weight: $font-weight-semi-bold; - margin-right: $spacer * 2; + margin-right: $space-xl; } .add-panel-widget__link { - margin: 0 8px; + margin: 0 $space-sm; width: 154px; } .add-panel-widget__icon { - margin-bottom: 8px; + margin-bottom: $space-sm; .gicon { color: white; @@ -62,7 +62,7 @@ .add-panel-widget__create { display: inherit; - margin-bottom: 24px; + margin-bottom: $space-lg; // this is to have the big button appear centered margin-top: 55px; } @@ -72,7 +72,7 @@ } .add-panel-widget__action { - margin: 0 4px; + margin: 0 $space-xs; } .add-panel-widget__btn-container { 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.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 1095effcfa2..e3b9e1ca77a 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -6,11 +6,6 @@ import { cleanUpDashboard } from '../state/actions'; import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux'; import { DashboardRouteInfo, DashboardInitPhase } from 'app/types'; -jest.mock('sass/_variables.generated.scss', () => ({ - panelhorizontalpadding: 10, - panelVerticalPadding: 10, -})); - jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); interface ScenarioContext { 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 9aeddd5a0d9..3955f45d926 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -76,21 +76,26 @@ export class DashboardPanel extends PureComponent { // unmount angular panel this.cleanUpAngularPanel(); - if (panel.type !== pluginId) { - this.props.panel.changeType(pluginId, fromAngularPanel); - } - - if (plugin.exports) { - this.setState({ plugin, angularPanel: null }); - } else { + if (!plugin.exports) { try { plugin.exports = await importPluginModule(plugin.module); } catch (e) { plugin = getPanelPluginNotFound(pluginId); } - - this.setState({ plugin, angularPanel: null }); } + + if (panel.type !== pluginId) { + if (fromAngularPanel) { + // for angular panels only we need to remove all events and let angular panels do some cleanup + panel.destroy(); + + this.props.panel.changeType(pluginId); + } else { + panel.changeType(pluginId, plugin.exports.reactPanel.preserveOptions); + } + } + + this.setState({ plugin, angularPanel: null }); } } diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx index 872e2823553..6941832c273 100644 --- a/public/app/features/dashboard/dashgrid/DataPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx @@ -114,7 +114,7 @@ export class DataPanel extends Component { this.setState({ loading: LoadingState.Loading }); try { - const ds = await this.dataSourceSrv.get(datasource); + const ds = await this.dataSourceSrv.get(datasource, scopedVars); // TODO interpolate variables const minInterval = this.props.minInterval || ds.interval; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx index 7136a14a907..52bbd494f9a 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx @@ -1,10 +1,5 @@ import { PanelChrome } from './PanelChrome'; -jest.mock('sass/_variables.generated.scss', () => ({ - panelhorizontalpadding: 10, - panelVerticalPadding: 10, -})); - describe('PanelChrome', () => { let chrome: PanelChrome; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index af58202dfa9..9a2dec5acd8 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -14,6 +14,7 @@ import ErrorBoundary from '../../../core/components/ErrorBoundary/ErrorBoundary' import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; import { PANEL_HEADER_HEIGHT } from 'app/core/constants'; import { profiler } from 'app/core/profiler'; +import config from 'app/core/config'; // Types import { DashboardModel, PanelModel } from '../state'; @@ -21,7 +22,6 @@ import { PanelPlugin } from 'app/types'; import { DataQueryResponse, TimeRange, LoadingState, TableData, DataQueryError, toTableData } from '@grafana/ui'; import { ScopedVars } from '@grafana/ui'; -import variables from 'sass/_variables.generated.scss'; import templateSrv from 'app/features/templating/template_srv'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; @@ -160,8 +160,8 @@ export class PanelChrome extends PureComponent { data={data} timeRange={timeRange} options={panel.getOptions(plugin.exports.reactPanel.defaults)} - width={width - 2 * variables.panelhorizontalpadding} - height={height - PANEL_HEADER_HEIGHT - variables.panelverticalpadding} + width={width - 2 * config.theme.panelPadding.horizontal} + height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical} renderCounter={renderCounter} replaceVariables={this.replaceVariables} /> 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/datasources/settings/HttpSettingsCtrl.ts b/public/app/features/datasources/settings/HttpSettingsCtrl.ts index 47022c283f8..5cdebe7b9ab 100644 --- a/public/app/features/datasources/settings/HttpSettingsCtrl.ts +++ b/public/app/features/datasources/settings/HttpSettingsCtrl.ts @@ -9,7 +9,7 @@ coreModule.directive('datasourceHttpSettings', () => { }, templateUrl: 'public/app/features/datasources/partials/http_settings.html', link: { - pre: ($scope, elem, attrs) => { + pre: ($scope: any, elem, attrs) => { // do not show access option if direct access is disabled $scope.showAccessOption = $scope.noDirectAccess !== 'true'; $scope.showAccessHelp = false; 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/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index 028585ae21e..3e217369b15 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -81,7 +81,7 @@ class MetricsPanelCtrl extends PanelCtrl { // load datasource service this.datasourceSrv - .get(this.panel.datasource) + .get(this.panel.datasource, this.panel.scopedVars) .then(this.updateTimeRange.bind(this)) .then(this.issueQueries.bind(this)) .then(this.handleQueryResult.bind(this)) diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index 31da05a2d4f..8dff4de5872 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -33,7 +33,7 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { template: panelTemplate, transclude: true, scope: { ctrl: '=' }, - link: (scope, elem) => { + link: (scope: any, elem) => { const panelContainer = elem.find('.panel-container'); const panelContent = elem.find('.panel-content'); const cornerInfoElem = elem.find('.panel-info-corner'); @@ -67,7 +67,7 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { // set initial transparency if (ctrl.panel.transparent) { transparentLastState = true; - panelContainer.addClass('panel-transparent', true); + panelContainer.addClass('panel-transparent'); } // update scrollbar after mounting 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/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index f7dc0da32c4..fde17ba8f48 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -7,7 +7,7 @@ import config from 'app/core/config'; import { importPluginModule } from './plugin_loader'; // Types -import { DataSourceApi, DataSourceSelectItem } from '@grafana/ui/src/types'; +import { DataSourceApi, DataSourceSelectItem, ScopedVars } from '@grafana/ui/src/types'; export class DatasourceSrv { datasources: { [name: string]: DataSourceApi }; @@ -21,12 +21,18 @@ export class DatasourceSrv { this.datasources = {}; } - get(name?: string): Promise { + get(name?: string, scopedVars?: ScopedVars): Promise { if (!name) { return this.get(config.defaultDatasource); } - name = this.templateSrv.replace(name); + // Interpolation here is to support template variable in data source selection + name = this.templateSrv.replace(name, scopedVars, (value, variable) => { + if (Array.isArray(value)) { + return value[0]; + } + return value; + }); if (name === 'default') { return this.get(config.defaultDatasource); diff --git a/public/app/features/templating/datasource_variable.ts b/public/app/features/templating/datasource_variable.ts index 2b326cb1c5c..30eb0e89589 100644 --- a/public/app/features/templating/datasource_variable.ts +++ b/public/app/features/templating/datasource_variable.ts @@ -6,6 +6,8 @@ export class DatasourceVariable implements Variable { query: string; options: any; current: any; + multi: boolean; + includeAll: boolean; refresh: any; skipUrlSync: boolean; @@ -18,6 +20,8 @@ export class DatasourceVariable implements Variable { regex: '', options: [], query: '', + multi: false, + includeAll: false, refresh: 1, skipUrlSync: false, }; @@ -69,9 +73,16 @@ export class DatasourceVariable implements Variable { } this.options = options; + if (this.includeAll) { + this.addAllOption(); + } return this.variableSrv.validateVariableSelectionState(this); } + addAllOption() { + this.options.unshift({ text: 'All', value: '$__all' }); + } + dependsOn(variable) { if (this.regex) { return containsVariable(this.regex, variable.name); @@ -84,6 +95,9 @@ export class DatasourceVariable implements Variable { } getValueForUrl() { + if (this.current.text === 'All') { + return 'All'; + } return this.current.value; } } @@ -91,5 +105,6 @@ export class DatasourceVariable implements Variable { variableTypes['datasource'] = { name: 'Datasource', ctor: DatasourceVariable, + supportsMulti: true, description: 'Enabled you to dynamically switch the datasource for multiple panels', }; diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index c3fbd7ee1d7..b0fab2564eb 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import TableModel from 'app/core/table_model'; +import { TimeSeries } from '@grafana/ui'; export class ResultTransformer { constructor(private templateSrv) {} @@ -18,10 +19,10 @@ export class ResultTransformer { ]; } else if (prometheusResult && options.format === 'heatmap') { let seriesList = []; - prometheusResult.sort(sortSeriesByLabel); for (const metricData of prometheusResult) { seriesList.push(this.transformMetricData(metricData, options, options.start, options.end)); } + seriesList.sort(sortSeriesByLabel); seriesList = this.transformToHistogramOverTime(seriesList); return seriesList; } else if (prometheusResult) { @@ -197,13 +198,13 @@ export class ResultTransformer { } } -function sortSeriesByLabel(s1, s2): number { +function sortSeriesByLabel(s1: TimeSeries, s2: TimeSeries): number { let le1, le2; try { // fail if not integer. might happen with bad queries - le1 = parseHistogramLabel(s1.metric.le); - le2 = parseHistogramLabel(s2.metric.le); + le1 = parseHistogramLabel(s1.target); + le2 = parseHistogramLabel(s2.target); } catch (err) { console.log(err); return 0; diff --git a/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts index 889720ad286..0cbd1bac564 100644 --- a/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts @@ -2,28 +2,6 @@ import coreModule from 'app/core/core_module'; import _ from 'lodash'; import { FilterSegments, DefaultFilterValue } from './filter_segments'; -export class StackdriverFilter { - /** @ngInject */ - constructor() { - return { - templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html', - controller: 'StackdriverFilterCtrl', - controllerAs: 'ctrl', - bindToController: true, - restrict: 'E', - scope: { - labelData: '<', - loading: '<', - groupBys: '<', - filters: '<', - filtersChanged: '&', - groupBysChanged: '&', - hideGroupBys: '<', - }, - }; - } -} - export class StackdriverFilterCtrl { defaultRemoveGroupByValue = '-- remove group by --'; resourceTypeValue = 'resource.type'; @@ -193,5 +171,24 @@ export class StackdriverFilterCtrl { } } -coreModule.directive('stackdriverFilter', StackdriverFilter); -coreModule.controller('StackdriverFilterCtrl', StackdriverFilterCtrl); +/** @ngInject */ +function stackdriverFilter() { + return { + templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html', + controller: StackdriverFilterCtrl, + controllerAs: 'ctrl', + bindToController: true, + restrict: 'E', + scope: { + labelData: '<', + loading: '<', + groupBys: '<', + filters: '<', + filtersChanged: '&', + groupBysChanged: '&', + hideGroupBys: '<', + }, + }; +} + +coreModule.directive('stackdriverFilter', stackdriverFilter); diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx new file mode 100644 index 00000000000..a00783a7ddd --- /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 { data, options, width, height } = this.props; + + const values = processSingleStatPanelData({ + data, + 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/axes_editor.ts b/public/app/plugins/panel/heatmap/axes_editor.ts index 81df957e2ea..cec3df9f8c5 100644 --- a/public/app/plugins/panel/heatmap/axes_editor.ts +++ b/public/app/plugins/panel/heatmap/axes_editor.ts @@ -32,6 +32,7 @@ export class AxesEditorCtrl { Auto: 'auto', Upper: 'upper', Lower: 'lower', + Middle: 'middle', }; } diff --git a/public/app/plugins/panel/heatmap/color_legend.ts b/public/app/plugins/panel/heatmap/color_legend.ts index c36fad45cba..a1c80629c7e 100644 --- a/public/app/plugins/panel/heatmap/color_legend.ts +++ b/public/app/plugins/panel/heatmap/color_legend.ts @@ -11,6 +11,8 @@ const LEGEND_HEIGHT_PX = 6; const LEGEND_WIDTH_PX = 100; const LEGEND_TICK_SIZE = 0; const LEGEND_VALUE_MARGIN = 0; +const LEGEND_PADDING_LEFT = 10; +const LEGEND_SEGMENT_WIDTH = 10; /** * Color legend for heatmap editor. @@ -19,7 +21,7 @@ coreModule.directive('colorLegend', () => { return { restrict: 'E', template: '
', - link: (scope, elem, attrs) => { + link: (scope: any, elem, attrs) => { const ctrl = scope.ctrl; const panel = scope.ctrl.panel; @@ -55,7 +57,7 @@ coreModule.directive('heatmapLegend', () => { return { restrict: 'E', template: `
`, - link: (scope, elem, attrs) => { + link: (scope: any, elem, attrs) => { const ctrl = scope.ctrl; const panel = scope.ctrl.panel; @@ -67,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, { @@ -95,27 +98,27 @@ function drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minVal const legendWidth = Math.floor(legendElem.outerWidth()) - 30; const legendHeight = legendElem.attr('height'); - let rangeStep = 1; - if (rangeTo - rangeFrom > legendWidth) { - rangeStep = Math.floor((rangeTo - rangeFrom) / legendWidth); - } + const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH; const widthFactor = legendWidth / (rangeTo - rangeFrom); const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep); const colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue); legend + .append('g') + .attr('class', 'legend-color-bar') + .attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)') .selectAll('.heatmap-color-legend-rect') .data(valuesRange) .enter() .append('rect') - .attr('x', d => d * widthFactor) + .attr('x', d => Math.round((d - rangeFrom) * widthFactor)) .attr('y', 0) - .attr('width', rangeStep * widthFactor + 1) // Overlap rectangles to prevent gaps + .attr('width', Math.round(rangeStep * widthFactor + 1)) // Overlap rectangles to prevent gaps .attr('height', legendHeight) .attr('stroke-width', 0) .attr('fill', d => colorScale(d)); - drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth); + drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange); } function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue) { @@ -126,31 +129,31 @@ function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue const legendWidth = Math.floor(legendElem.outerWidth()) - 30; const legendHeight = legendElem.attr('height'); - let rangeStep = 1; - if (rangeTo - rangeFrom > legendWidth) { - rangeStep = Math.floor((rangeTo - rangeFrom) / legendWidth); - } + const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH; const widthFactor = legendWidth / (rangeTo - rangeFrom); const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep); const opacityScale = getOpacityScale(options, maxValue, minValue); legend + .append('g') + .attr('class', 'legend-color-bar') + .attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)') .selectAll('.heatmap-opacity-legend-rect') .data(valuesRange) .enter() .append('rect') - .attr('x', d => d * widthFactor) + .attr('x', d => Math.round((d - rangeFrom) * widthFactor)) .attr('y', 0) - .attr('width', rangeStep * widthFactor) + .attr('width', Math.round(rangeStep * widthFactor)) .attr('height', legendHeight) .attr('stroke-width', 0) .attr('fill', options.cardColor) .style('opacity', d => opacityScale(d)); - drawLegendValues(elem, opacityScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth); + drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange); } -function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth) { +function drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange) { const legendElem = $(elem).find('svg'); const legend = d3.select(legendElem.get(0)); @@ -160,10 +163,10 @@ function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minVal 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) @@ -171,7 +174,7 @@ function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minVal const colorRect = legendElem.find(':first-child'); const posY = getSvgElemHeight(legendElem) + LEGEND_VALUE_MARGIN; - const posX = getSvgElemX(colorRect); + const posX = getSvgElemX(colorRect) + LEGEND_PADDING_LEFT; d3.select(legendElem.get(0)) .append('g') @@ -284,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); @@ -302,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); @@ -316,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/heatmap_ctrl.ts b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts index 71e059a5750..99741a0c6d7 100644 --- a/public/app/plugins/panel/heatmap/heatmap_ctrl.ts +++ b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts @@ -34,6 +34,7 @@ const panelDefaults = { }, dataFormat: 'timeseries', yBucketBound: 'auto', + reverseYBuckets: false, xAxis: { show: true, }, @@ -55,6 +56,7 @@ const panelDefaults = { showHistogram: false, }, highlightCards: true, + hideZeroBuckets: false, }; const colorModes = ['opacity', 'spectrum']; @@ -97,7 +99,7 @@ const colorSchemes = [ { name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' }, ]; -const dsSupportHistogramSort = ['prometheus', 'elasticsearch']; +const dsSupportHistogramSort = ['elasticsearch']; export class HeatmapCtrl extends MetricsPanelCtrl { static templateUrl = 'module.html'; @@ -108,7 +110,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl { selectionActivated: boolean; unitFormats: any; data: any; - series: any; + series: any[]; timeSrv: any; dataWarning: any; decimals: number; @@ -146,7 +148,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl { } onRender() { - if (!this.range) { + if (!this.range || !this.series) { return; } @@ -204,7 +206,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl { yBucketSize = 1; } - const { cards, cardStats } = convertToCards(bucketsData); + const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets); this.data = { buckets: bucketsData, @@ -225,13 +227,20 @@ export class HeatmapCtrl extends MetricsPanelCtrl { this.series.sort(sortSeriesByLabel); } + if (this.panel.reverseYBuckets) { + this.series.reverse(); + } + // Convert histogram to heatmap. Each histogram bucket represented by the series which name is - // a top (or bottom, depends of datasource) bucket bound. Further, these values will be used as X axis labels. + // a top (or bottom, depends of datasource) bucket bound. Further, these values will be used as Y axis labels. bucketsData = histogramToHeatmap(this.series); tsBuckets = _.map(this.series, 'label'); const yBucketBound = this.panel.yBucketBound; - if ((panelDatasource === 'prometheus' && yBucketBound !== 'lower') || yBucketBound === 'upper') { + if ( + (panelDatasource === 'prometheus' && yBucketBound !== 'lower' && yBucketBound !== 'middle') || + yBucketBound === 'upper' + ) { // Prometheus labels are upper inclusive bounds, so add empty bottom bucket label. tsBuckets = [''].concat(tsBuckets); } else { @@ -246,7 +255,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl { // Always let yBucketSize=1 in 'tsbuckets' mode yBucketSize = 1; - const { cards, cardStats } = convertToCards(bucketsData); + const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets); this.data = { buckets: bucketsData, diff --git a/public/app/plugins/panel/heatmap/heatmap_data_converter.ts b/public/app/plugins/panel/heatmap/heatmap_data_converter.ts index 99b61be40dc..07960f68ae3 100644 --- a/public/app/plugins/panel/heatmap/heatmap_data_converter.ts +++ b/public/app/plugins/panel/heatmap/heatmap_data_converter.ts @@ -93,25 +93,43 @@ function parseHistogramLabel(label: string): number { return value; } +interface HeatmapCard { + x: number; + y: number; + yBounds: { + top: number | null; + bottom: number | null; + }; + values: number[]; + count: number; +} + +interface HeatmapCardStats { + min: number; + max: number; +} + /** * Convert buckets into linear array of "cards" - objects, represented heatmap elements. * @param {Object} buckets - * @return {Array} Array of "card" objects + * @return {Object} Array of "card" objects and stats */ -function convertToCards(buckets) { +function convertToCards(buckets: any, hideZero = false): { cards: HeatmapCard[]; cardStats: HeatmapCardStats } { let min = 0, max = 0; - const cards = []; + const cards: HeatmapCard[] = []; _.forEach(buckets, xBucket => { _.forEach(xBucket.buckets, yBucket => { - const card = { + const card: HeatmapCard = { x: xBucket.x, y: yBucket.y, yBounds: yBucket.bounds, values: yBucket.values, count: yBucket.count, }; - cards.push(card); + if (!hideZero || card.count !== 0) { + cards.push(card); + } if (cards.length === 1) { min = yBucket.count; diff --git a/public/app/plugins/panel/heatmap/heatmap_tooltip.ts b/public/app/plugins/panel/heatmap/heatmap_tooltip.ts index c447825eef8..90cbecd6657 100644 --- a/public/app/plugins/panel/heatmap/heatmap_tooltip.ts +++ b/public/app/plugins/panel/heatmap/heatmap_tooltip.ts @@ -114,7 +114,9 @@ export class HeatmapTooltip { }; boundBottom = tickFormatter(yBucketIndex); - boundTop = yBucketIndex < data.tsBuckets.length - 1 ? tickFormatter(yBucketIndex + 1) : ''; + if (this.panel.yBucketBound !== 'middle') { + boundTop = yBucketIndex < data.tsBuckets.length - 1 ? tickFormatter(yBucketIndex + 1) : ''; + } } else { // Display 0 if bucket is a special 'zero' bucket const bottom = yData.y ? yData.bounds.bottom : 0; @@ -122,8 +124,9 @@ export class HeatmapTooltip { boundTop = bucketBoundFormatter(yData.bounds.top); } valuesNumber = countValueFormatter(yData.count); + const boundStr = boundTop && boundBottom ? `${boundBottom} - ${boundTop}` : boundBottom || boundTop; tooltipHtml += `
- bucket: ${boundBottom} - ${boundTop}
+ bucket: ${boundStr}
count: ${valuesNumber}
`; } else { diff --git a/public/app/plugins/panel/heatmap/partials/axes_editor.html b/public/app/plugins/panel/heatmap/partials/axes_editor.html index 0b12ac52e24..0327ee87251 100644 --- a/public/app/plugins/panel/heatmap/partials/axes_editor.html +++ b/public/app/plugins/panel/heatmap/partials/axes_editor.html @@ -40,6 +40,11 @@
+ +
diff --git a/public/app/plugins/panel/heatmap/partials/display_editor.html b/public/app/plugins/panel/heatmap/partials/display_editor.html index 929cf1fe7d4..7b38061e2ff 100644 --- a/public/app/plugins/panel/heatmap/partials/display_editor.html +++ b/public/app/plugins/panel/heatmap/partials/display_editor.html @@ -63,6 +63,10 @@
Buckets
+ +
diff --git a/public/app/plugins/panel/heatmap/rendering.ts b/public/app/plugins/panel/heatmap/rendering.ts index 42b418b06cb..702704b1ada 100644 --- a/public/app/plugins/panel/heatmap/rendering.ts +++ b/public/app/plugins/panel/heatmap/rendering.ts @@ -379,6 +379,12 @@ export class HeatmapRenderer { const posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING; this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')'); + if (this.panel.yBucketBound === 'middle' && tickValues && tickValues.length) { + // Shift Y axis labels to the middle of bucket + const tickShift = 0 - this.chartHeight / (tickValues.length - 1) / 2; + this.heatmap.selectAll('.axis-y text').attr('transform', 'translate(' + 0 + ',' + tickShift + ')'); + } + // Remove vertical line in the right of axis labels (called domain in d3) this.heatmap .select('.axis-y') @@ -518,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); @@ -615,8 +623,8 @@ export class HeatmapRenderer { w = this.cardWidth; } - // Card width should be MIN_CARD_SIZE at least - w = Math.max(w, MIN_CARD_SIZE); + // Card width should be MIN_CARD_SIZE at least, but cut cards shouldn't be displayed + w = w > 0 ? Math.max(w, MIN_CARD_SIZE) : 0; return w; } 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..70b652a8d2c --- /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 { data, options } = this.props; + + if (data.length < 1) { + 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 9a29f9beba9..21fc602b8ba 100644 --- a/public/sass/_variables.generated.scss +++ b/public/sass/_variables.generated.scss @@ -20,6 +20,12 @@ $enable-hover-media-query: false !default; // Control the default styling of most Bootstrap elements by modifying these // variables. Mostly focused on spacing. +$space-xxs: 2px !default; +$space-xs: 4px !default; +$space-sm: 8px !default; +$space-md: 16px !default; +$space-lg: 24px !default; +$space-xl: 32px !default; $spacer: 14px !default; $spacer-x: $spacer !default; $spacer-y: $spacer !default; @@ -49,7 +55,6 @@ $spacers: ( ), ), ) !default; -$border-width: 1px !default; // Grid breakpoints // @@ -82,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; @@ -100,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; @@ -109,20 +113,14 @@ $font-size-h4: 18px !default; $font-size-h5: 16px !default; $font-size-h6: 14px !default; -$headings-margin-bottom: ($spacer / 2) !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; @@ -149,23 +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-margin: 3px; -$gf-form-input-height: 35px; - $cursor-disabled: not-allowed !default; // Form validation icons @@ -202,16 +194,13 @@ $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 $side-menu-width: 60px; // dashboard -$panel-margin: 10px; -$dashboard-padding: $panel-margin * 2; +$dashboard-padding: 10px * 2; $panel-horizontal-padding: 10; $panel-vertical-padding: 5; $panel-padding: 0px $panel-horizontal-padding + 0px $panel-vertical-padding + 0px $panel-horizontal-padding + 0px; @@ -246,8 +235,3 @@ $external-services: ( icon: '', ), ) !default; - -:export { - panelhorizontalpadding: $panel-horizontal-padding; - panelverticalpadding: $panel-vertical-padding; -} diff --git a/public/sass/_variables.generated.scss.d.ts b/public/sass/_variables.generated.scss.d.ts deleted file mode 100644 index 992dbb7bddf..00000000000 --- a/public/sass/_variables.generated.scss.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface GrafanaVariables { - panelhorizontalpadding: number; - panelverticalpadding: number; -} - -declare const variables: GrafanaVariables; - -export default variables; 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 ab4fbc33a55..9efae56d5e4 100644 --- a/public/sass/base/_type.scss +++ b/public/sass/base/_type.scss @@ -109,9 +109,9 @@ h6, .h4, .h5, .h6 { - margin-bottom: $headings-margin-bottom; + 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/_add_data_source.scss b/public/sass/components/_add_data_source.scss index 508f7f80d8e..4046be2a723 100644 --- a/public/sass/components/_add_data_source.scss +++ b/public/sass/components/_add_data_source.scss @@ -1,5 +1,5 @@ .add-data-source-header { - margin-bottom: $spacer * 2; + margin-bottom: $space-xl; padding-top: $spacer; text-align: center; } @@ -7,7 +7,7 @@ .add-data-source-search { display: flex; justify-content: center; - margin-bottom: $panel-margin * 2; + margin-bottom: $space-lg; } .add-data-source-grid { @@ -41,6 +41,6 @@ } .add-data-source-grid-item-logo { - margin: 0 15px; + margin: 0 $space-md; width: 55px; } diff --git a/public/sass/components/_alerts.scss b/public/sass/components/_alerts.scss index 1c4f1b7fcb7..da3ebf7fa7b 100644 --- a/public/sass/components/_alerts.scss +++ b/public/sass/components/_alerts.scss @@ -7,7 +7,7 @@ .alert { padding: 15px 20px; - margin-bottom: $panel-margin / 2; + margin-bottom: $space-xs; text-shadow: 0 2px 0 rgba(255, 255, 255, 0.5); background: $alert-error-bg; position: relative; 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/_cards.scss b/public/sass/components/_cards.scss index 80a4cd7c32a..58d4b1ab5d2 100644 --- a/public/sass/components/_cards.scss +++ b/public/sass/components/_cards.scss @@ -1,7 +1,7 @@ .layout-selector { @include clearfix(); - margin-left: $spacer; + margin-left: $space-md; text-align: right; button { @@ -9,7 +9,7 @@ color: $text-color-weak; box-shadow: $card-shadow; border: none; - padding: 0.5rem; + padding: $space-sm; line-height: 1; font-size: 130%; float: right; @@ -35,7 +35,7 @@ } .card-section { - margin-bottom: $spacer * 2; + margin-bottom: $space-xl; } .card-list { @@ -50,7 +50,7 @@ height: 100%; background: $card-background; box-shadow: $card-shadow; - padding: 1rem; + padding: $space-md; border-radius: 4px; &:hover { @@ -58,7 +58,7 @@ } .label-tag { - margin-left: 6px; + margin-left: $space-sm; font-size: 11px; padding: 2px 6px; } @@ -80,15 +80,8 @@ overflow: hidden; } -.card-item-cog { - font-size: 130%; - position: relative; - top: 1rem; - color: $text-muted; -} - .card-item-header { - margin-bottom: $spacer; + margin-bottom: $space-md; } .card-item-type { @@ -110,7 +103,7 @@ } .card-item-label { - margin-left: 8px; + margin-left: $space-sm; } .card-item-sub-name { @@ -123,7 +116,7 @@ .card-item-sub-name--header { color: $text-color-weak; text-transform: uppercase; - margin-bottom: $spacer; + margin-bottom: $space-md; font-size: $font-size-sm; font-weight: bold; } @@ -136,7 +129,7 @@ .card-item-notice { font-size: $font-size-sm; display: inline-block; - margin-left: $spacer; + margin-left: $space-md; } .card-item-header-action { @@ -145,7 +138,7 @@ .card-item-wrapper { width: 100%; - padding: 0 1rem 1rem 0rem; + padding: 0 $space-md $space-md 0; } .card-item-wrapper--clickable { @@ -153,7 +146,7 @@ } .card-item-figure { - margin: 0 $spacer $spacer 0; + margin: 0 $space-md $space-md 0; height: 6rem; img { @@ -195,7 +188,7 @@ .card-item-wrapper { padding: 0; width: 100%; - margin-bottom: 3px; + margin-bottom: $space-xs; } .card-item-wrapper--clickable { @@ -212,9 +205,9 @@ } .card-item-figure { - margin: 0 $spacer 0 0; + margin: 0 $space-md 0 0; img { - width: 3.5rem; + width: 48px; } } 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/_dashboard_settings.scss b/public/sass/components/_dashboard_settings.scss index de16f0c60b5..173ab1a3935 100644 --- a/public/sass/components/_dashboard_settings.scss +++ b/public/sass/components/_dashboard_settings.scss @@ -41,7 +41,7 @@ font-size: $font-size-h3; padding-right: 60px; white-space: nowrap; - margin-bottom: $spacer; + margin-bottom: $space-md; i { font-size: 25px; @@ -53,7 +53,7 @@ .dashboard-settings__header { font-size: $font-size-h3; - margin-bottom: $spacer * 2; + margin-bottom: $space-xl; } .dashboard-settings__subheader { @@ -89,13 +89,13 @@ flex-direction: column; height: 100%; flex-grow: 1; - margin: $spacer * 3 $spacer * 2 0 0; + margin: 40px $space-xl 0 0; button { - margin-bottom: 10px; + margin-bottom: $space-sm; } } .dashboard-settings__json-save-button { - margin-top: $spacer; + margin-top: $space-md; } diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index c341c686143..f62c2fcb7a0 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -1,8 +1,7 @@ -$gf-form-margin: 3px; $input-border: 1px solid $input-border-color; .gf-form { - margin-bottom: $gf-form-margin; + margin-bottom: $space-xxs; display: flex; flex-direction: row; align-items: flex-start; @@ -33,7 +32,7 @@ $input-border: 1px solid $input-border-color; .gf-form--has-input-icon { position: relative; - margin-right: $gf-form-margin; + margin-right: $space-xs; .gf-form-input-icon { position: absolute; @@ -82,7 +81,7 @@ $input-border: 1px solid $input-border-color; align-content: flex-start; .gf-form + .gf-form { - margin-left: $gf-form-margin; + margin-left: $space-xs; } &--nowrap { @@ -106,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; @@ -128,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 { @@ -147,15 +146,15 @@ $input-border: 1px solid $input-border-color; } .gf-form-label + .gf-form-label { - margin-right: $gf-form-margin; + margin-right: $space-xs; } .gf-form-pre { display: block; flex-grow: 1; margin: 0; - margin-right: $gf-form-margin; - border: $input-btn-border-width solid transparent; + margin-right: $space-xs; + border: $border-width solid transparent; border-left: none; @include border-radius($label-border-radius-sm); } @@ -167,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; @@ -336,7 +335,7 @@ $input-border: 1px solid $input-border-color; .gf-form-btn { padding: $input-padding-y $input-padding-x; - margin-right: $gf-form-margin; + margin-right: $space-xs; line-height: $input-line-height; font-size: $font-size-sm; @@ -354,7 +353,7 @@ $input-border: 1px solid $input-border-color; } .gf-form-dropdown-typeahead { - margin-right: $gf-form-margin; + //margin-right: $space-xs; ? position: relative; &::after { @@ -391,7 +390,7 @@ $input-border: 1px solid $input-border-color; } &--header { - margin-bottom: $gf-form-margin; + margin-bottom: $space-xxs; } &--no-padding { diff --git a/public/sass/components/_infobox.scss b/public/sass/components/_infobox.scss index 6f7a7bf86dc..16e07c68b20 100644 --- a/public/sass/components/_infobox.scss +++ b/public/sass/components/_infobox.scss @@ -5,7 +5,7 @@ margin-bottom: 2rem; border-top: 3px solid $info-box-border-color; margin-bottom: $spacer; - margin-right: $gf-form-margin; + margin-right: $space-xs; box-shadow: $card-shadow; flex-grow: 1; diff --git a/public/sass/components/_page_loader.scss b/public/sass/components/_page_loader.scss index 053a061600f..0a3eac40a0c 100644 --- a/public/sass/components/_page_loader.scss +++ b/public/sass/components/_page_loader.scss @@ -7,7 +7,7 @@ &__spinner { font-size: 32px; - margin-bottom: $panel-margin; + margin-bottom: $space-sm; } &__text { diff --git a/public/sass/components/_panel_editor.scss b/public/sass/components/_panel_editor.scss index 9bd5672e4a8..8acf98be1b2 100644 --- a/public/sass/components/_panel_editor.scss +++ b/public/sass/components/_panel_editor.scss @@ -21,7 +21,7 @@ } .panel-editor-container__editor { - margin-top: $panel-margin * 2; + margin-top: $space-lg; display: flex; flex-direction: row; flex: 1 1 0; @@ -80,7 +80,7 @@ } .submenu-controls { - padding: 0 $dashboard-padding $panel-margin $dashboard-padding; + padding: 0 $dashboard-padding $space-sm $dashboard-padding; } .panel-editor-container__panel { 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/_panel_heatmap.scss b/public/sass/components/_panel_heatmap.scss index 279b9392caa..dad1dc3235b 100644 --- a/public/sass/components/_panel_heatmap.scss +++ b/public/sass/components/_panel_heatmap.scss @@ -66,7 +66,6 @@ $font-size-heatmap-tick: 11px; height: 18px; float: left; white-space: nowrap; - padding-left: 10px; } .heatmap-legend-values { diff --git a/public/sass/components/_panel_logs.scss b/public/sass/components/_panel_logs.scss index 5d35f8fddc0..160a9f78e66 100644 --- a/public/sass/components/_panel_logs.scss +++ b/public/sass/components/_panel_logs.scss @@ -6,7 +6,7 @@ $column-horizontal-spacing: 10px; padding: $panel-padding; padding-top: 10px; border-radius: $border-radius; - margin: 2 * $panel-margin 0 $panel-margin; + margin: $space-md 0 $space-sm; border: $panel-border; flex-direction: column; } diff --git a/public/sass/components/_sidemenu.scss b/public/sass/components/_sidemenu.scss index c16e037c2ad..8ecc7f95205 100644 --- a/public/sass/components/_sidemenu.scss +++ b/public/sass/components/_sidemenu.scss @@ -165,7 +165,7 @@ font-size: $font-size-sm; color: $text-color-weak; border-bottom: 1px solid $dropdownDividerBottom; - margin-bottom: 0.25rem; + margin-bottom: $space-xs; white-space: nowrap; } @@ -190,7 +190,7 @@ li.sidemenu-org-switcher { display: flex; align-items: center; > i.fa.fa-random { - margin-right: 4px; + margin-right: $space-xs; top: 1px; } } @@ -283,8 +283,8 @@ li.sidemenu-org-switcher { position: unset; width: 100%; float: none; - margin-top: 0.5rem; - margin-bottom: 0.5rem; + margin-top: $space-sm; + margin-bottom: $space-sm; > li > a { padding-left: 15px; 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 6c450f7e6dd..2f2a9294799 100644 --- a/public/sass/components/_submenu.scss +++ b/public/sass/components/_submenu.scss @@ -4,7 +4,7 @@ flex-wrap: wrap; align-content: flex-start; align-items: flex-start; - padding: 0 0 $panel-margin 0; + padding: 0 0 $space-sm 0; } .annotation-disabled, @@ -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/pages/_explore.scss b/public/sass/pages/_explore.scss index 0358adb9787..60c1a600014 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -65,6 +65,11 @@ font-size: 18px; min-height: 55px; line-height: 55px; + justify-content: space-between; + margin-left: $space-xl; +} + +.explore-toolbar-header { justify-content: space-between; align-items: center; } @@ -163,7 +168,7 @@ } .explore-panel { - margin-top: $panel-margin; + margin-top: $space-sm; } .explore-panel__body { @@ -182,24 +187,20 @@ .explore-panel__header-label { font-weight: 500; - margin-right: $panel-margin; + margin-right: $space-sm; font-size: $font-size-h6; box-shadow: $text-shadow-faint; } .explore-panel__header-buttons { - margin-right: $panel-margin; + margin-right: $space-sm; font-size: $font-size-lg; line-height: $font-size-h6; } -.result-options { - margin: 2 * $panel-margin 0; -} - .time-series-disclaimer { width: 300px; - margin: $panel-margin auto; + margin: $space-sm auto; padding: 10px 0; border-radius: $border-radius; text-align: center; @@ -207,7 +208,7 @@ .disclaimer-icon { color: $yellow; - margin-right: $panel-margin/2; + margin-right: $space-xs; } .show-all-time-series { @@ -234,7 +235,7 @@ position: relative; overflow: hidden; background: none; - margin: $panel-margin / 2; + margin: $space-xs; } .explore-panel__loader--active:after { @@ -377,7 +378,7 @@ .ReactTable .-pagination { border-top: none; box-shadow: none; - margin-top: $panel-margin; + margin-top: $space-sm; } .ReactTable .-pagination .-btn { color: $blue; @@ -418,7 +419,7 @@ // TODO Experimental .cheat-sheet-item { - margin: 2 * $panel-margin 0; + margin: $space-lg 0; width: 50%; } @@ -427,6 +428,6 @@ } .cheat-sheet-item__expression { - margin: $panel-margin/2 0; + margin: $space-xs 0; cursor: pointer; } 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/public/sass/utils/_widths.scss b/public/sass/utils/_widths.scss index b1213e6ea60..0770ba19fc3 100644 --- a/public/sass/utils/_widths.scss +++ b/public/sass/utils/_widths.scss @@ -8,20 +8,20 @@ // widths @for $i from 1 through 30 { .width-#{$i} { - width: ($spacer * $i) - $gf-form-margin !important; + width: ($spacer * $i) - $space-xs !important; } } @for $i from 1 through 30 { .max-width-#{$i} { - max-width: ($spacer * $i) - $gf-form-margin !important; + max-width: ($spacer * $i) - $space-xs !important; flex-grow: 1; } } @for $i from 1 through 30 { .min-width-#{$i} { - min-width: ($spacer * $i) - $gf-form-margin !important; + min-width: ($spacer * $i) - $space-xs !important; } } diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index b8307186540..58403ac7ed7 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -123,6 +123,7 @@ export function ServiceTestContext(this: any) { }; this.createService = name => { + // @ts-ignore return angularMocks.inject(($q, $rootScope, $httpBackend, $injector, $location, $timeout) => { self.$q = $q; self.$rootScope = $rootScope; @@ -145,7 +146,7 @@ export function DashboardViewStateStub(this: any) { export function TimeSrvStub(this: any) { this.init = () => {}; this.time = { from: 'now-1h', to: 'now' }; - this.timeRange = function(parse) { + this.timeRange = function(parse: boolean) { if (parse === false) { return this.time; } @@ -155,11 +156,7 @@ export function TimeSrvStub(this: any) { }; }; - this.replace = target => { - return target; - }; - - this.setTime = function(time) { + this.setTime = function(time: any) { this.time = time; }; } @@ -174,11 +171,11 @@ export function TemplateSrvStub(this: any) { this.variables = []; this.templateSettings = { interpolate: /\[\[([\s\S]+?)\]\]/g }; this.data = {}; - this.replace = function(text) { + this.replace = function(text: string) { return _.template(text, this.templateSettings)(this.data); }; this.init = () => {}; - this.getAdhocFilters = () => { + this.getAdhocFilters = (): any => { return []; }; this.fillVariableValuesForUrl = () => {}; @@ -187,10 +184,10 @@ export function TemplateSrvStub(this: any) { return false; }; this.variableInitialized = () => {}; - this.highlightVariablesAsHtml = str => { + this.highlightVariablesAsHtml = (str: string) => { return str; }; - this.setGrafanaVariable = function(name, value) { + this.setGrafanaVariable = function(name: string, value: string) { this.data[name] = value; }; } diff --git a/public/vendor/ansicolor/ansicolor.ts b/public/vendor/ansicolor/ansicolor.ts index 98524eb6827..0e3cb0d5d0d 100644 --- a/public/vendor/ansicolor/ansicolor.ts +++ b/public/vendor/ansicolor/ansicolor.ts @@ -48,7 +48,7 @@ const colorCodes = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan' /* ------------------------------------------------------------------------ */ -const clean = obj => { +const clean = (obj: any) => { for (const k in obj) { if (!obj[k]) { delete obj[k]; @@ -60,11 +60,11 @@ const clean = obj => { /* ------------------------------------------------------------------------ */ class Color { - background: string; + background: boolean; name: string; brightness: number; - constructor(background?, name?, brightness?) { + constructor(background?: boolean, name?: string, brightness?: number) { this.background = background; this.name = name; this.brightness = brightness; @@ -82,18 +82,21 @@ class Color { }); } - defaultBrightness(value) { + defaultBrightness(value: number) { return new Color(this.background, this.name, this.brightness || value); } - css(inverted) { + css(inverted: boolean) { const color = inverted ? this.inverse : this; + // @ts-ignore const rgbName = (color.brightness === Code.bright && asBright[color.name]) || color.name; - const prop = color.background ? 'background:' : 'color:', - rgb = Colors.rgb[rgbName], - alpha = this.brightness === Code.dim ? 0.5 : 1; + const prop = color.background ? 'background:' : 'color:'; + + // @ts-ignore + const rgb = Colors.rgb[rgbName]; + const alpha = this.brightness === Code.dim ? 0.5 : 1; return rgb ? prop + 'rgba(' + [...rgb, alpha].join(',') + ');' @@ -117,17 +120,19 @@ class Code { value: number; - constructor(n?) { + constructor(n?: string | number) { if (n !== undefined) { this.value = Number(n); } } get type() { + // @ts-ignore return types[Math.floor(this.value / 10)]; } get subtype() { + // @ts-ignore return subtypes[this.type][this.value % 10]; } @@ -135,7 +140,7 @@ class Code { return this.value ? '\u001b[' + this.value + 'm' : ''; } - static str(x) { + static str(x: string | number) { return new Code(x).str; } @@ -146,16 +151,17 @@ class Code { /* ------------------------------------------------------------------------ */ -const replaceAll = (str, a, b) => str.split(a).join(b); +const replaceAll = (str: string, a: string, b: string) => str.split(a).join(b); /* ANSI brightness codes do not overlap, e.g. "{bright}{dim}foo" will be rendered bright (not dim). So we fix it by adding brightness canceling before each brightness code, so the former example gets converted to "{noBrightness}{bright}{noBrightness}{dim}foo" – this way it gets rendered as expected. */ -const denormalizeBrightness = s => s.replace(/(\u001b\[(1|2)m)/g, '\u001b[22m$1'); -const normalizeBrightness = s => s.replace(/\u001b\[22m(\u001b\[(1|2)m)/g, '$1'); +const denormalizeBrightness = (s: string) => s.replace(/(\u001b\[(1|2)m)/g, '\u001b[22m$1'); +const normalizeBrightness = (s: string) => s.replace(/\u001b\[22m(\u001b\[(1|2)m)/g, '$1'); +// @ts-ignore const wrap = (x, openCode, closeCode) => { const open = Code.str(openCode), close = Code.str(closeCode); @@ -168,7 +174,7 @@ const wrap = (x, openCode, closeCode) => { /* ------------------------------------------------------------------------ */ -const camel = (a, b) => a + b.charAt(0).toUpperCase() + b.slice(1); +const camel = (a: string, b: string) => a + b.charAt(0).toUpperCase() + b.slice(1); const stringWrappingMethods = (() => [ @@ -216,10 +222,12 @@ const stringWrappingMethods = (() => /* ------------------------------------------------------------------------ */ +// @ts-ignore const assignStringWrappingAPI = (target, wrapBefore = target) => stringWrappingMethods.reduce( (memo, [k, open, close]) => O.defineProperty(memo, k, { + // @ts-ignore get: () => assignStringWrappingAPI(str => wrapBefore(wrap(str, open, close))), }), @@ -232,7 +240,7 @@ const TEXT = 0, BRACKET = 1, CODE = 2; -function rawParse(s) { +function rawParse(s: string) { let state = TEXT, buffer = '', text = '', @@ -333,7 +341,7 @@ export default class Colors { /** * @param {string} s a string containing ANSI escape codes. */ - constructor(s?) { + constructor(s?: string) { this.spans = s ? rawParse(s) : []; } @@ -342,7 +350,10 @@ export default class Colors { } get parsed() { - let color, bgColor, brightness, styles; + let styles: Set; + let brightness: number; + let color: Color; + let bgColor: Color; function reset() { (color = new Color()), @@ -431,6 +442,7 @@ export default class Colors { if (!(k in String.prototype)) { O.defineProperty(String.prototype, k, { get: function() { + // @ts-ignore return Colors[k](this); }, }); @@ -444,7 +456,7 @@ export default class Colors { * @desc parses a string containing ANSI escape codes * @return {Colors} parsed representation. */ - static parse(s) { + static parse(s: string) { return new Colors(s).parsed; } @@ -453,7 +465,7 @@ export default class Colors { * @param {string} s a string containing ANSI escape codes. * @return {string} clean string. */ - static strip(s) { + static strip(s: string) { return s.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><]/g, ''); // hope V8 caches the regexp } @@ -468,4 +480,4 @@ export default class Colors { /* ------------------------------------------------------------------------ */ -assignStringWrappingAPI(Colors, str => str); +assignStringWrappingAPI(Colors, (str: string) => str); 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-test-frontend.sh b/scripts/circle-test-frontend.sh index cd42dd0b7e9..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,5 +11,16 @@ function exit_if_fail { fi } +start=$(date +%s) + exit_if_fail npm run prettier:check -exit_if_fail npm run test +# 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 + diff --git a/tsconfig.json b/tsconfig.json index 1af0de2b3b9..f223c027af6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "noImplicitThis": true, "noImplicitUseStrict": false, "noImplicitAny": false, + "downlevelIteration": true, "noUnusedLocals": true, "baseUrl": "public", "pretty": true, diff --git a/yarn.lock b/yarn.lock index 0a3513595a8..c75b8e5a57b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1514,6 +1514,11 @@ react-input-autosize "^2.2.1" react-transition-group "^2.2.1" +"@types/angular@^1.6.6": + version "1.6.54" + resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.6.54.tgz#f9d5a03e4da7b021a6dabe5d63e899ed4567a5bd" + integrity sha512-xA1FuozWXeRQ7FClUbvk8ePL+dydBeDoCWRPFTHU5+8uvVtIIfLGiHA8CMkwsbddFCYnTDVbLxG85a/HBx7LtA== + "@types/chalk@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-2.2.0.tgz#b7f6e446f4511029ee8e3f43075fb5b73fbaa0ba"