merge master

This commit is contained in:
ryan 2019-03-13 08:36:51 -07:00
commit b933c57c62
91 changed files with 3539 additions and 387 deletions

View File

@ -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
}

View File

@ -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 },
],
});
});

View File

@ -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(<BarGauge {...props} />);
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();
});
});

View File

@ -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<Props> {
static defaultProps: Partial<Props> = {
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 (
<div style={containerStyles}>
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
</div>
<div style={barStyles} />
</div>
);
}
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 (
<div style={containerStyles}>
<div style={barStyles} />
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
</div>
</div>
);
}
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(<div style={cellStyles} />);
}
return (
<div style={containerStyles}>
{cells}
<div className="bar-gauge__value" style={valueStyles}>
{valueFormatted}
</div>
</div>
);
}
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;
}

View File

@ -0,0 +1,9 @@
.bar-gauge {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.bar-gauge__value {
text-align: center;
}

View File

@ -0,0 +1,358 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render BarGauge with basic options should render 1`] = `
<div
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row",
"height": "300px",
"width": "300px",
}
}
>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.7)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
style={
Object {
"backgroundColor": "rgba(126, 178, 109, 0.15)",
"borderRadius": "2px",
"height": "300px",
"marginRight": "4px",
"width": "4px",
}
}
/>
<div
className="bar-gauge__value"
style={
Object {
"color": "#7EB26D",
"fontSize": "27.272727272727263px",
"marginLeft": "8px",
}
}
>
25
</div>
</div>
`;

View File

@ -50,7 +50,16 @@ ColorPickerStories.add('Series color picker', () => {
color={selectedColor} color={selectedColor}
onChange={color => updateSelectedColor(color)} onChange={color => updateSelectedColor(color)}
> >
<div style={{ color: selectedColor, cursor: 'pointer' }}>Open color picker</div> {({ ref, showColorPicker, hideColorPicker }) => (
<div
ref={ref}
onMouseLeave={hideColorPicker}
onClick={showColorPicker}
style={{ color: selectedColor, cursor: 'pointer' }}
>
Open color picker
</div>
)}
</SeriesColorPicker> </SeriesColorPicker>
); );
}} }}

View File

@ -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(<ColorPicker color="#EAB839" onChange={() => {}} />).root.findByType(ColorPickerTrigger)
).toBeTruthy();
});
it('renders custom trigger when supplied', () => {
const div = renderer
.create(
<ColorPicker color="#EAB839" onChange={() => {}}>
{() => <div>Custom trigger</div>}
</ColorPicker>
)
.root.findByType('div');
expect(div.children[0]).toBe('Custom trigger');
});
});

View File

@ -1,4 +1,5 @@
import React, { Component, createRef } from 'react'; import React, { Component, createRef } from 'react';
import { omit } from 'lodash';
import { PopperController } from '../Tooltip/PopperController'; import { PopperController } from '../Tooltip/PopperController';
import { Popper } from '../Tooltip/Popper'; import { Popper } from '../Tooltip/Popper';
import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover'; import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover';
@ -6,14 +7,29 @@ import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover'; import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
import { withTheme } from '../../themes/ThemeContext'; 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<HTMLElement> but due to how object refs are defined you cannot downcast from that
// to a specific type like React.RefObject<HTMLDivElement> even though it would be fine in runtime.
ref: React.RefObject<any>;
showColorPicker: () => void;
hideColorPicker: () => void;
}) => React.ReactNode;
export const colorPickerFactory = <T extends ColorPickerProps>( export const colorPickerFactory = <T extends ColorPickerProps>(
popover: React.ComponentType<T>, popover: React.ComponentType<T>,
displayName = 'ColorPicker' displayName = 'ColorPicker'
) => { ) => {
return class ColorPicker extends Component<T, any> { return class ColorPicker extends Component<T & { children?: ColorPickerTriggerRenderer }, any> {
static displayName = displayName; static displayName = displayName;
pickerTriggerRef = createRef<HTMLDivElement>(); pickerTriggerRef = createRef<any>();
onColorChange = (color: string) => { onColorChange = (color: string) => {
const { onColorChange, onChange } = this.props; const { onColorChange, onChange } = this.props;
@ -23,11 +39,11 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
}; };
render() { render() {
const { theme, children } = this.props;
const popoverElement = React.createElement(popover, { const popoverElement = React.createElement(popover, {
...this.props, ...omit(this.props, 'children'),
onChange: this.onColorChange, onChange: this.onColorChange,
}); });
const { theme, children } = this.props;
return ( return (
<PopperController content={popoverElement} hideAfter={300}> <PopperController content={popoverElement} hideAfter={300}>
@ -45,27 +61,21 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
)} )}
{children ? ( {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, ref: this.pickerTriggerRef,
onClick: showPopper, showColorPicker: showPopper,
onMouseLeave: hidePopper, hideColorPicker: hidePopper,
}) })
) : ( ) : (
<div <ColorPickerTrigger
ref={this.pickerTriggerRef} ref={this.pickerTriggerRef}
onClick={showPopper} onClick={showPopper}
onMouseLeave={hidePopper} onMouseLeave={hidePopper}
className="sp-replacer sp-light" color={getColorFromHexRgbOrName(this.props.color || '#000000', theme.type)}
> />
<div className="sp-preview">
<div
className="sp-preview-inner"
style={{
backgroundColor: getColorFromHexRgbOrName(this.props.color || '#000000', theme.type),
}}
/>
</div>
</div>
)} )}
</> </>
); );

View File

@ -17,8 +17,8 @@ export interface ColorPickerProps extends Themeable {
*/ */
onColorChange?: ColorPickerChangeHandler; onColorChange?: ColorPickerChangeHandler;
enableNamedColors?: boolean; enableNamedColors?: boolean;
children?: JSX.Element;
} }
export interface Props<T> extends ColorPickerProps, PopperContentProps { export interface Props<T> extends ColorPickerProps, PopperContentProps {
customPickers?: T; customPickers?: T;
} }

View File

@ -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<HTMLDivElement>
) {
return (
<div
ref={ref}
onClick={props.onClick}
onMouseLeave={props.onMouseLeave}
style={{
overflow: 'hidden',
background: 'inherit',
border: 'none',
color: 'inherit',
padding: 0,
borderRadius: 10,
cursor: 'pointer',
}}
>
<div
style={{
position: 'relative',
width: 15,
height: 15,
border: 'none',
margin: 0,
float: 'left',
zIndex: 0,
backgroundImage:
// tslint:disable-next-line:max-line-length
'url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)',
}}
>
<div
style={{
backgroundColor: props.color,
display: 'block',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
/>
</div>
</div>
);
});

View File

@ -161,59 +161,6 @@ $arrowSize: 15px;
flex-grow: 1; 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 { .gf-color-picker__body {
padding-bottom: $arrowSize; padding-bottom: $arrowSize;
padding-left: 6px; padding-left: 6px;

View File

@ -1,12 +1,12 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import $ from 'jquery'; 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 { export interface Props extends Themeable {
decimals?: number | null; decimals?: number | null;
@ -51,7 +51,7 @@ export class Gauge extends PureComponent<Props> {
this.draw(); this.draw();
} }
formatValue(value: TimeSeriesValue) { formatValue(value: GaugeValue) {
const { decimals, valueMappings, prefix, suffix, unit } = this.props; const { decimals, valueMappings, prefix, suffix, unit } = this.props;
if (isNaN(value as number)) { if (isNaN(value as number)) {
@ -72,26 +72,16 @@ export class Gauge extends PureComponent<Props> {
return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`; return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
} }
getFontColor(value: TimeSeriesValue) { getFontColor(value: GaugeValue): string {
const { thresholds, theme } = this.props; const { thresholds, theme } = this.props;
if (thresholds.length === 1) { const activeThreshold = getThresholdForValue(thresholds, value);
return getColorFromHexRgbOrName(thresholds[0].color, theme.type);
if (activeThreshold !== null) {
return getColorFromHexRgbOrName(activeThreshold.color, theme.type);
} }
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0]; return '';
if (atThreshold) {
return getColorFromHexRgbOrName(atThreshold.color, theme.type);
}
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
if (belowThreshold.length > 0) {
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
return getColorFromHexRgbOrName(nearestThreshold.color, theme.type);
}
return BasicGaugeColor.Red;
} }
getFormattedThresholds() { getFormattedThresholds() {
@ -183,19 +173,15 @@ export class Gauge extends PureComponent<Props> {
const { height, width } = this.props; const { height, width } = this.props;
return ( return (
<div className="singlestat-panel"> <div
<div style={{
style={{ height: `${Math.min(height, width * 1.3)}px`,
height: `${height * 0.9}px`, width: `${Math.min(width, height * 1.3)}px`,
width: `${Math.min(width, height * 1.3)}px`, top: '10px',
top: '10px', margin: 'auto',
margin: 'auto', }}
}} ref={element => (this.canvasElement = element)}
ref={element => (this.canvasElement = element)} />
/>
</div>
); );
} }
} }
export default Gauge;

View File

@ -0,0 +1,99 @@
// import React from 'react';
import { storiesOf } from '@storybook/react';
import { Table } from './Table';
import { getTheme } from '../../themes';
import { migratedTestTable, migratedTestStyles, simpleTable } from './examples';
import { ScopedVars, TableData, GrafanaThemeType } from '../../types/index';
import { withFullSizeStory } from '../../utils/storybook/withFullSizeStory';
import { number, boolean } from '@storybook/addon-knobs';
const replaceVariables = (value: string, scopedVars?: ScopedVars) => {
if (scopedVars) {
// For testing variables replacement in link
for (const key in scopedVars) {
const val = scopedVars[key];
value = value.replace('$' + key, val.value);
}
}
return value;
};
export function columnIndexToLeter(column: number) {
const A = 'A'.charCodeAt(0);
const c1 = Math.floor(column / 26);
const c2 = column % 26;
if (c1 > 0) {
return String.fromCharCode(A + c1 - 1) + String.fromCharCode(A + c2);
}
return String.fromCharCode(A + c2);
}
export function makeDummyTable(columnCount: number, rowCount: number): TableData {
return {
columns: Array.from(new Array(columnCount), (x, i) => {
return {
text: columnIndexToLeter(i),
};
}),
rows: Array.from(new Array(rowCount), (x, rowId) => {
const suffix = (rowId + 1).toString();
return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix);
}),
type: 'table',
columnMap: {},
};
}
storiesOf('Alpha/Table', module)
.add('Basic Table', () => {
// NOTE: This example does not seem to survice rotate &
// Changing fixed headers... but the next one does?
// perhaps `simpleTable` is static and reused?
const showHeader = boolean('Show Header', true);
const fixedHeader = boolean('Fixed Header', true);
const fixedColumns = number('Fixed Columns', 0, { min: 0, max: 50, step: 1, range: false });
const rotate = boolean('Rotate', false);
return withFullSizeStory(Table, {
styles: [],
data: simpleTable,
replaceVariables,
showHeader,
fixedHeader,
fixedColumns,
rotate,
theme: getTheme(GrafanaThemeType.Light),
});
})
.add('Variable Size', () => {
const columnCount = number('Column Count', 15, { min: 2, max: 50, step: 1, range: false });
const rowCount = number('Row Count', 20, { min: 0, max: 100, step: 1, range: false });
const showHeader = boolean('Show Header', true);
const fixedHeader = boolean('Fixed Header', true);
const fixedColumns = number('Fixed Columns', 1, { min: 0, max: 50, step: 1, range: false });
const rotate = boolean('Rotate', false);
return withFullSizeStory(Table, {
styles: [],
data: makeDummyTable(columnCount, rowCount),
replaceVariables,
showHeader,
fixedHeader,
fixedColumns,
rotate,
theme: getTheme(GrafanaThemeType.Light),
});
})
.add('Test Config (migrated)', () => {
return withFullSizeStory(Table, {
styles: migratedTestStyles,
data: migratedTestTable,
replaceVariables,
showHeader: true,
rotate: true,
theme: getTheme(GrafanaThemeType.Light),
});
});

View File

@ -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<Props, State> {
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 (
<div className="gf-table-header" style={style} onClick={() => this.onCellClick(rowIndex, columnIndex)}>
{col.text}
{sorting && <SortIndicator sortDirection={sortDirection} />}
</div>
);
};
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 (
<CellMeasurer cache={this.measurer} columnIndex={columnIndex} key={key} parent={parent} rowIndex={rowIndex}>
{builder({
value,
row: rowData,
column: data.columns[column],
table: this,
props,
})}
</CellMeasurer>
);
};
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 (
<MultiGrid
{
...this.state /** Force MultiGrid to update when data changes */
}
{
...this.props /** Force MultiGrid to update when data changes */
}
scrollToRow={scrollToRow}
columnCount={columnCount}
scrollToColumn={scrollToColumn}
rowCount={rowCount}
overscanColumnCount={8}
overscanRowCount={8}
columnWidth={this.measurer.columnWidth}
deferredMeasurementCache={this.measurer}
cellRenderer={this.cellRenderer}
rowHeight={this.measurer.rowHeight}
width={width}
height={height}
fixedColumnCount={fixedColumnCount}
fixedRowCount={fixedRowCount}
classNameTopLeftGrid="gf-table-fixed-column"
classNameBottomLeftGrid="gf-table-fixed-column"
/>
);
}
}
export default Table;

View File

@ -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 (
<div style={style} className={'gf-table-cell ' + className}>
{value}
</div>
);
};
// ***************************************************************************
// 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 = (
<a
href={cellLink}
target={cellTarget}
data-link-tooltip
data-original-title={cellLinkTooltip}
data-placement="right"
>
{value}
</a>
);
}
// ??? I don't think this will still work!
if (this.column.filterable) {
cellClasses.push('table-panel-cell-filterable');
value = (
<>
{value}
<span>
<a
className="table-panel-filter-link"
data-link-tooltip
data-original-title="Filter out value"
data-placement="bottom"
data-row={props.rowIndex}
data-column={props.columnIndex}
data-operator="!="
>
<i className="fa fa-search-minus" />
</a>
<a
className="table-panel-filter-link"
data-link-tooltip
data-original-title="Filter for value"
data-placement="bottom"
data-row={props.rowIndex}
data-column={props.columnIndex}
data-operator="="
>
<i className="fa fa-search-plus" />
</a>
</span>
</>
);
}
let className;
if (cellClasses.length) {
className = cellClasses.join(' ');
}
return simpleCellBuilder({ value, props, className });
};
}

View File

@ -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;
}

View File

@ -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&param_1=$__cell_1&param_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]],
};

View File

@ -68,7 +68,7 @@
} }
.thresholds-row-input-inner-value > input { .thresholds-row-input-inner-value > input {
height: $gf-form-input-height; height: $input-height;
padding: $input-padding-y $input-padding-x; padding: $input-padding-y $input-padding-x;
width: 150px; width: 150px;
border-top: 1px solid $input-label-border-color; border-top: 1px solid $input-label-border-color;
@ -86,7 +86,6 @@
.thresholds-row-input-inner-color-colorpicker { .thresholds-row-input-inner-color-colorpicker {
border-radius: 10px; border-radius: 10px;
overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
@ -96,7 +95,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: $gf-form-input-height; height: $input-height;
padding: $input-padding-y $input-padding-x; padding: $input-padding-y $input-padding-x;
width: 42px; width: 42px;
background-color: $input-label-bg; background-color: $input-label-bg;

View File

@ -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<Props> {
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 (
<div style={repeaterStyle}>
{values.map((valueInfo, index) => {
return (
<div key={index} style={itemStyles}>
{children({ vizHeight, vizWidth, valueInfo })}
</div>
);
})}
</div>
);
}
}

View File

@ -1,6 +1,7 @@
@import 'CustomScrollbar/CustomScrollbar'; @import 'CustomScrollbar/CustomScrollbar';
@import 'DeleteButton/DeleteButton'; @import 'DeleteButton/DeleteButton';
@import 'ThresholdsEditor/ThresholdsEditor'; @import 'ThresholdsEditor/ThresholdsEditor';
@import 'Table/Table';
@import 'Table/TableInputCSV'; @import 'Table/TableInputCSV';
@import 'Tooltip/Tooltip'; @import 'Tooltip/Tooltip';
@import 'Select/Select'; @import 'Select/Select';
@ -10,3 +11,4 @@
@import 'ValueMappingsEditor/ValueMappingsEditor'; @import 'ValueMappingsEditor/ValueMappingsEditor';
@import 'EmptySearchResult/EmptySearchResult'; @import 'EmptySearchResult/EmptySearchResult';
@import 'FormField/FormField'; @import 'FormField/FormField';
@import 'BarGauge/BarGauge';

View File

@ -19,11 +19,15 @@ export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker'; export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover'; export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor'; export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
export { Graph } from './Graph/Graph';
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup'; export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid'; export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor'; export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
export { Gauge } from './Gauge/Gauge';
export { Switch } from './Switch/Switch'; export { Switch } from './Switch/Switch';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult'; export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export { UnitPicker } from './UnitPicker/UnitPicker'; 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';

View File

@ -52,7 +52,6 @@ $spacers: (
), ),
), ),
) !default; ) !default;
$border-width: ${theme.border.width.sm} !default;
// Grid breakpoints // Grid breakpoints
// //
@ -83,16 +82,13 @@ $container-max-widths: (
// Set the number of columns and specify the width of the gutters. // Set the number of columns and specify the width of the gutters.
$grid-columns: 12 !default; $grid-columns: 12 !default;
$grid-gutter-width: 30px !default; $grid-gutter-width: ${theme.spacing.gutter} !default;
$enable-flex: true;
// Typography // Typography
// ------------------------- // -------------------------
$font-family-sans-serif: ${theme.typography.fontFamily.sansSerif}; $font-family-sans-serif: ${theme.typography.fontFamily.sansSerif};
$font-family-monospace: ${theme.typography.fontFamily.monospace}; $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-root: ${theme.typography.size.root} !default;
$font-size-base: ${theme.typography.size.base} !default; $font-size-base: ${theme.typography.size.base} !default;
@ -103,7 +99,9 @@ $font-size-sm: ${theme.typography.size.sm} !default;
$font-size-xs: ${theme.typography.size.xs} !default; $font-size-xs: ${theme.typography.size.xs} !default;
$line-height-base: ${theme.typography.lineHeight.lg} !default; $line-height-base: ${theme.typography.lineHeight.lg} !default;
$font-weight-semi-bold: ${theme.typography.weight.semibold};
$font-weight-regular: ${theme.typography.weight.regular} !default;
$font-weight-semi-bold: ${theme.typography.weight.semibold} !default;
$font-size-h1: ${theme.typography.heading.h1} !default; $font-size-h1: ${theme.typography.heading.h1} !default;
$font-size-h2: ${theme.typography.heading.h2} !default; $font-size-h2: ${theme.typography.heading.h2} !default;
@ -113,22 +111,17 @@ $font-size-h5: ${theme.typography.heading.h5} !default;
$font-size-h6: ${theme.typography.heading.h6} !default; $font-size-h6: ${theme.typography.heading.h6} !default;
$headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; $headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$headings-font-weight: ${theme.typography.weight.normal} !default;
$headings-line-height: ${theme.typography.lineHeight.sm} !default; $headings-line-height: ${theme.typography.lineHeight.sm} !default;
$hr-border-width: $border-width !default;
$dt-font-weight: bold !default;
// Components // Components
// //
// Define common padding and border radius sizes and more. // Define common padding and border radius sizes and more.
$line-height-lg: (4 / 3) !default; $border-width: ${theme.border.width.sm} !default;
$line-height-sm: 1.5 !default;
$border-radius: 3px !default; $border-radius: ${theme.border.radius.md} !default;
$border-radius-lg: 5px !default; $border-radius-lg: ${theme.border.radius.lg}!default;
$border-radius-sm: 2px !default; $border-radius-sm: ${theme.border.radius.sm} !default;
// Page // Page
@ -151,22 +144,17 @@ $input-padding-x: 10px !default;
$input-padding-y: 8px !default; $input-padding-y: 8px !default;
$input-line-height: 18px !default; $input-line-height: 18px !default;
$input-btn-border-width: 1px;
$input-border-radius: 0 $border-radius $border-radius 0 !default; $input-border-radius: 0 $border-radius $border-radius 0 !default;
$input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 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: $border-radius 0 0 $border-radius !default;
$label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !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-x-lg: 20px !default;
$input-padding-y-lg: 10px !default; $input-padding-y-lg: 10px !default;
$input-height: 35px !default; $input-height: 35px !default;
$gf-form-input-height: 35px;
$cursor-disabled: not-allowed !default; $cursor-disabled: not-allowed !default;
// Form validation icons // Form validation icons
@ -203,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
$btn-padding-x-xl: 21px !default; $btn-padding-x-xl: 21px !default;
$btn-padding-y-xl: 11px !default; $btn-padding-y-xl: 11px !default;
$btn-border-radius: 2px;
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;

View File

@ -25,7 +25,7 @@ const theme: GrafanaThemeCommons = {
}, },
weight: { weight: {
light: 300, light: 300,
normal: 400, regular: 400,
semibold: 500, semibold: 500,
}, },
lineHeight: { lineHeight: {
@ -54,9 +54,9 @@ const theme: GrafanaThemeCommons = {
}, },
border: { border: {
radius: { radius: {
xs: '2px', sm: '2px',
sm: '3px', md: '3px',
md: '5px', lg: '5px',
}, },
width: { width: {
sm: '1px', sm: '1px',

View File

@ -48,10 +48,7 @@ export enum NullValueMode {
} }
/** View model projection of many time series */ /** View model projection of many time series */
export interface TimeSeriesVMs { export type TimeSeriesVMs = TimeSeriesVM[];
[index: number]: TimeSeriesVM;
length: number;
}
export interface Column { export interface Column {
text: string; text: string;
@ -69,3 +66,12 @@ export interface TableData {
type: string; type: string;
columnMap: any; columnMap: any;
} }
export type SingleStatValue = number | string | null;
/*
* So we can add meta info like tags & series name
*/
export interface SingleStatValueInfo {
value: SingleStatValue;
}

View File

@ -3,9 +3,11 @@ import { PluginMeta } from './plugin';
import { TableData, TimeSeries } from './data'; import { TableData, TimeSeries } from './data';
export interface DataQueryResponse { export interface DataQueryResponse {
data: TimeSeries[] | [TableData] | any; data: DataQueryResponseData;
} }
export type DataQueryResponseData = TimeSeries[] | [TableData] | any;
export interface DataQuery { export interface DataQuery {
/** /**
* A - Z * A - Z

View File

@ -4,3 +4,4 @@ export * from './panel';
export * from './plugin'; export * from './plugin';
export * from './datasource'; export * from './datasource';
export * from './theme'; export * from './theme';
export * from './threshold';

View File

@ -39,11 +39,14 @@ export interface PanelEditorProps<T = any> {
*/ */
export type PanelOptionsValidator<T = any> = (panelModel: any) => T; export type PanelOptionsValidator<T = any> = (panelModel: any) => T;
export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
export class ReactPanelPlugin<TOptions = any> { export class ReactPanelPlugin<TOptions = any> {
panel: ComponentClass<PanelProps<TOptions>>; panel: ComponentClass<PanelProps<TOptions>>;
editor?: ComponentClass<PanelEditorProps<TOptions>>; editor?: ComponentClass<PanelEditorProps<TOptions>>;
optionsValidator?: PanelOptionsValidator<TOptions>; optionsValidator?: PanelOptionsValidator<TOptions>;
defaults?: TOptions; defaults?: TOptions;
preserveOptions?: PreservePanelOptionsHandler<TOptions>;
constructor(panel: ComponentClass<PanelProps<TOptions>>) { constructor(panel: ComponentClass<PanelProps<TOptions>>) {
this.panel = panel; this.panel = panel;
@ -60,6 +63,10 @@ export class ReactPanelPlugin<TOptions = any> {
setDefaults(defaults: TOptions) { setDefaults(defaults: TOptions) {
this.defaults = defaults; this.defaults = defaults;
} }
setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
this.preserveOptions = handler;
}
} }
export interface PanelSize { export interface PanelSize {
@ -76,17 +83,6 @@ export interface PanelMenuItem {
subMenu?: PanelMenuItem[]; subMenu?: PanelMenuItem[];
} }
export interface Threshold {
index: number;
value: number;
color: string;
}
export enum BasicGaugeColor {
Green = '#299c46',
Red = '#d44a3a',
}
export enum MappingType { export enum MappingType {
ValueToText = 1, ValueToText = 1,
RangeToText = 2, RangeToText = 2,
@ -109,3 +105,9 @@ export interface RangeMap extends BaseMap {
from: string; from: string;
to: string; to: string;
} }
export enum VizOrientation {
Auto = 'auto',
Vertical = 'vertical',
Horizontal = 'horizontal',
}

View File

@ -28,7 +28,7 @@ export interface GrafanaThemeCommons {
}; };
weight: { weight: {
light: number; light: number;
normal: number; regular: number;
semibold: number; semibold: number;
}; };
lineHeight: { lineHeight: {
@ -59,9 +59,9 @@ export interface GrafanaThemeCommons {
}; };
border: { border: {
radius: { radius: {
xs: string;
sm: string; sm: string;
md: string; md: string;
lg: string;
}; };
width: { width: {
sm: string; sm: string;

View File

@ -0,0 +1,5 @@
export interface Threshold {
index: number;
value: number;
color: string;
}

View File

@ -1,7 +1,9 @@
export * from './processTimeSeries'; export * from './processTimeSeries';
export * from './singlestat';
export * from './valueFormats/valueFormats'; export * from './valueFormats/valueFormats';
export * from './colors'; export * from './colors';
export * from './namedColorsPalette'; export * from './namedColorsPalette';
export * from './thresholds';
export * from './string'; export * from './string';
export * from './deprecationWarning'; export * from './deprecationWarning';
export { getMappedValue } from './valueMappings'; export { getMappedValue } from './valueMappings';

View File

@ -1,7 +1,10 @@
import { TableData, Column } from '../types/index'; // Libraries
import isNumber from 'lodash/isNumber';
import Papa, { ParseError, ParseMeta } from 'papaparse'; import Papa, { ParseError, ParseMeta } from 'papaparse';
// Types
import { TableData, Column } from '../types';
// Subset of all parse options // Subset of all parse options
export interface TableParseOptions { export interface TableParseOptions {
headerIsFirstLine?: boolean; // Not a papa-parse option headerIsFirstLine?: boolean; // Not a papa-parse option
@ -131,3 +134,24 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
columnMap: {}, columnMap: {},
}); });
} }
export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
if (isNumber(sortIndex)) {
const copy = {
...data,
rows: [...data.rows].sort((a, b) => {
a = a[sortIndex];
b = b[sortIndex];
// Sort null or undefined separately from comparable values
return +(a == null) - +(b == null) || +(a > b) || -(a < b);
}),
};
if (reverse) {
copy.rows.reverse();
}
return copy;
}
return data;
}

View File

@ -0,0 +1,33 @@
import { PanelData, NullValueMode, SingleStatValueInfo } from '../types';
import { processTimeSeries } from './processTimeSeries';
export interface SingleStatProcessingOptions {
panelData: PanelData;
stat: string;
}
//
// This is a temporary thing, waiting for a better data model and maybe unification between time series & table data
//
export function processSingleStatPanelData(options: SingleStatProcessingOptions): SingleStatValueInfo[] {
const { panelData, stat } = options;
if (panelData.timeSeries) {
const timeSeries = processTimeSeries({
timeSeries: panelData.timeSeries,
nullValueMode: NullValueMode.Null,
});
return timeSeries.map((series, index) => {
const value = stat !== 'name' ? series.stats[stat] : series.label;
return {
value: value,
};
});
} else if (panelData.tableData) {
throw { message: 'Panel data not supported' };
}
return [];
}

View File

@ -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<any>, props: any) => (
<div
style={{
height: '100vh',
width: '100%',
}}
>
<AutoSizer>
{({ width, height }) => (
<>
{React.createElement(component, {
...props,
width,
height,
})}
</>
)}
</AutoSizer>
</div>
);

View File

@ -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;
}

View File

@ -192,16 +192,18 @@ func getPanelSort(id string) int {
sort = 2 sort = 2
case "gauge": case "gauge":
sort = 3 sort = 3
case "table": case "bargauge":
sort = 4 sort = 4
case "text": case "table":
sort = 5 sort = 5
case "heatmap": case "text":
sort = 6 sort = 6
case "alertlist": case "heatmap":
sort = 7 sort = 7
case "dashlist": case "alertlist":
sort = 8 sort = 8
case "dashlist":
sort = 9
} }
return sort return sort
} }

View File

@ -49,7 +49,7 @@ func (e *GraphiteExecutor) Query(ctx context.Context, dsInfo *models.DataSource,
} }
for _, query := range tsdbQuery.Queries { 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 { if fullTarget, err := query.Model.Get("targetFull").String(); err == nil {
target = fixIntervalFormat(fullTarget) target = fixIntervalFormat(fullTarget)
} else { } else {

View File

@ -15,6 +15,7 @@ export const provideConfig = (component: React.ComponentType<any>) => {
export const getCurrentThemeName = () => export const getCurrentThemeName = () =>
config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark; config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark;
export const getCurrentTheme = () => getTheme(getCurrentThemeName()); export const getCurrentTheme = () => getTheme(getCurrentThemeName());
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {

View File

@ -20,6 +20,7 @@ import {
ResultType, ResultType,
QueryIntervals, QueryIntervals,
QueryOptions, QueryOptions,
ResultGetter,
} from 'app/types/explore'; } from 'app/types/explore';
import { LogsDedupStrategy } from 'app/core/logs_model'; 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); return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
} }
export function makeTimeSeriesList(dataList) { export const makeTimeSeriesList: ResultGetter = (dataList, transaction, allTransactions) => {
return dataList.map((seriesData, index) => { // 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 datapoints = seriesData.datapoints || [];
const alias = seriesData.target; const alias = seriesData.target;
const colorIndex = index % colors.length; const colorIndex = (colorIndexOffset + index) % colors.length;
const color = colors[colorIndex]; const color = colors[colorIndex];
const series = new TimeSeries({ const series = new TimeSeries({
@ -317,7 +331,7 @@ export function makeTimeSeriesList(dataList) {
return series; return series;
}); });
} };
/** /**
* Update the query history. Side-effect: store history in local storage * Update the query history. Side-effect: store history in local storage

View File

@ -16,9 +16,6 @@
<button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs"> <button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs">
Save As... Save As...
</button> </button>
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete">
Delete
</button>
</div> </div>
</aside> </aside>
@ -70,6 +67,11 @@
<select ng-model="ctrl.dashboard.graphTooltip" class='gf-form-input' ng-options="f.value as f.text for f in [{value: 0, text: 'Default'}, {value: 1, text: 'Shared crosshair'},{value: 2, text: 'Shared Tooltip'}]"></select> <select ng-model="ctrl.dashboard.graphTooltip" class='gf-form-input' ng-options="f.value as f.text for f in [{value: 0, text: 'Default'}, {value: 1, text: 'Shared crosshair'},{value: 2, text: 'Shared Tooltip'}]"></select>
</div> </div>
</div> </div>
<div class="gf-form-button-row">
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete">
Delete Dashboard
</button>
</div>
</div> </div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'annotations'" ng-include="'public/app/features/annotations/partials/editor.html'"> <div class="dashboard-settings__content" ng-if="ctrl.viewId === 'annotations'" ng-include="'public/app/features/annotations/partials/editor.html'">

View File

@ -92,11 +92,12 @@ export class DashboardPage extends PureComponent<Props, State> {
componentWillUnmount() { componentWillUnmount() {
if (this.props.dashboard) { if (this.props.dashboard) {
this.props.cleanUpDashboard(); this.props.cleanUpDashboard();
this.setPanelFullscreenClass(false);
} }
} }
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props; const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId, urlUid } = this.props;
if (!dashboard) { if (!dashboard) {
return; return;
@ -107,6 +108,12 @@ export class DashboardPage extends PureComponent<Props, State> {
document.title = dashboard.title + ' - Grafana'; 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 // handle animation states when opening dashboard settings
if (!prevProps.editview && editview) { if (!prevProps.editview && editview) {
this.setState({ isSettingsOpening: true }); this.setState({ isSettingsOpening: true });
@ -163,14 +170,20 @@ export class DashboardPage extends PureComponent<Props, State> {
fullscreenPanel: null, fullscreenPanel: null,
scrollTop: this.state.rememberScrollTop, scrollTop: this.state.rememberScrollTop,
}, },
() => { this.triggerPanelsRendering.bind(this)
dashboard.render();
}
); );
this.setPanelFullscreenClass(false); this.setPanelFullscreenClass(false);
} }
triggerPanelsRendering() {
try {
this.props.dashboard.render();
} catch (err) {
this.props.notifyApp(createErrorNotification(`Panel rendering error`, err));
}
}
handleFullscreenPanelNotFound(urlPanelId: string) { handleFullscreenPanelNotFound(urlPanelId: string) {
// Panel not found // Panel not found
this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`)); this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));

View File

@ -76,35 +76,33 @@ export class DashboardPanel extends PureComponent<Props, State> {
// unmount angular panel // unmount angular panel
this.cleanUpAngularPanel(); this.cleanUpAngularPanel();
if (panel.type !== pluginId) { if (!plugin.exports) {
this.props.panel.changeType(pluginId, fromAngularPanel);
}
if (plugin.exports) {
this.validateOptions(plugin, panel);
this.setState({ plugin, angularPanel: null });
} else {
try { try {
plugin.exports = await importPluginModule(plugin.module); plugin.exports = await importPluginModule(plugin.module);
this.validateOptions(plugin, panel);
} catch (e) { } catch (e) {
plugin = getPanelPluginNotFound(pluginId); plugin = getPanelPluginNotFound(pluginId);
} }
this.setState({ plugin, angularPanel: null });
} }
if (panel.type !== pluginId) {
if (fromAngularPanel) {
// for angular panels only we need to remove all events and let angular panels do some cleanup
panel.destroy();
this.props.panel.changeType(pluginId);
} else {
const { reactPanel } = plugin.exports;
panel.changeType(pluginId, reactPanel.preserveOptions);
if (reactPanel && reactPanel.optionsValidator) {
panel.options = reactPanel.optionsValidator(panel);
}
}
}
this.setState({ plugin, angularPanel: null });
} }
} }
// This is called before the plugin is added to the three,
// it allows plugins to update options before loading
validateOptions = (plugin: PanelPlugin, panel: PanelModel) => {
const { reactPanel } = plugin.exports;
if (reactPanel && reactPanel.optionsValidator) {
panel.options = reactPanel.optionsValidator(panel);
}
};
componentDidMount() { componentDidMount() {
this.loadPlugin(this.props.panel.type); this.loadPlugin(this.props.panel.type);
} }

View File

@ -2,9 +2,12 @@
import _ from 'lodash'; import _ from 'lodash';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
// Components
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
// Types // Types
import { PanelPlugin, AppNotificationSeverity } from 'app/types';
import { PanelProps, ReactPanelPlugin } from '@grafana/ui'; import { PanelProps, ReactPanelPlugin } from '@grafana/ui';
import { PanelPlugin } from 'app/types';
interface Props { interface Props {
pluginId: string; pluginId: string;
@ -19,15 +22,13 @@ class PanelPluginNotFound extends PureComponent<Props> {
const style = { const style = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
textAlign: 'center' as 'center', justifyContent: 'center',
height: '100%', height: '100%',
}; };
return ( return (
<div style={style}> <div style={style}>
<div className="alert alert-error" style={{ margin: '0 auto' }}> <AlertBox severity={AppNotificationSeverity.Error} title={`Panel plugin not found: ${this.props.pluginId}`} />
Panel plugin with id {this.props.pluginId} could not be found
</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,4 @@
import _ from 'lodash'; import { PanelModel } from './PanelModel';
import { PanelModel } from '../state/PanelModel';
describe('PanelModel', () => { describe('PanelModel', () => {
describe('when creating new panel model', () => { describe('when creating new panel model', () => {
@ -66,7 +65,7 @@ describe('PanelModel', () => {
describe('when changing panel type', () => { describe('when changing panel type', () => {
beforeEach(() => { beforeEach(() => {
model.changeType('graph', true); model.changeType('graph');
model.alert = { id: 2 }; model.alert = { id: 2 };
}); });
@ -75,12 +74,12 @@ describe('PanelModel', () => {
}); });
it('should restore table properties when changing back', () => { it('should restore table properties when changing back', () => {
model.changeType('table', true); model.changeType('table');
expect(model.showColumns).toBe(true); expect(model.showColumns).toBe(true);
}); });
it('should remove alert rule when changing type that does not support it', () => { 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); expect(model.alert).toBe(undefined);
}); });
}); });

View File

@ -229,10 +229,6 @@ export class PanelModel {
}, {}); }, {});
} }
private saveCurrentPanelOptions() {
this.cachedPluginOptions[this.type] = this.getOptionsToRemember();
}
private restorePanelOptions(pluginId: string) { private restorePanelOptions(pluginId: string) {
const prevOptions = this.cachedPluginOptions[pluginId] || {}; const prevOptions = this.cachedPluginOptions[pluginId] || {};
@ -241,14 +237,11 @@ export class PanelModel {
}); });
} }
changeType(pluginId: string, fromAngularPanel: boolean) { changeType(pluginId: string, preserveOptions?: any) {
this.saveCurrentPanelOptions(); const oldOptions: any = this.getOptionsToRemember();
this.type = pluginId; const oldPluginId = this.type;
// for angular panels only we need to remove all events and let angular panels do some cleanup this.type = pluginId;
if (fromAngularPanel) {
this.destroy();
}
// remove panel type specific options // remove panel type specific options
for (const key of _.keys(this)) { for (const key of _.keys(this)) {
@ -259,7 +252,13 @@ export class PanelModel {
delete this[key]; delete this[key];
} }
this.cachedPluginOptions[oldPluginId] = oldOptions;
this.restorePanelOptions(pluginId); this.restorePanelOptions(pluginId);
if (preserveOptions && oldOptions) {
this.options = this.options || {};
Object.assign(this.options, preserveOptions(oldPluginId, oldOptions.options));
}
} }
addQuery(query?: Partial<DataQuery>) { addQuery(query?: Partial<DataQuery>) {

View File

@ -597,7 +597,8 @@ function runQueriesForType(
const res = await datasourceInstance.query(transaction.options); const res = await datasourceInstance.query(transaction.options);
eventBridge.emit('data-received', res.data || []); eventBridge.emit('data-received', res.data || []);
const latency = Date.now() - now; 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)); dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId));
} catch (response) { } catch (response) {
eventBridge.emit('data-error', response); eventBridge.emit('data-error', response);

View File

@ -23,9 +23,11 @@ import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module';
import * as alertListPanel from 'app/plugins/panel/alertlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module';
import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; import * as heatmapPanel from 'app/plugins/panel/heatmap/module';
import * as tablePanel from 'app/plugins/panel/table/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 singlestatPanel from 'app/plugins/panel/singlestat/module';
import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
import * as gaugePanel from 'app/plugins/panel/gauge/module'; import * as gaugePanel from 'app/plugins/panel/gauge/module';
import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
const builtInPlugins = { const builtInPlugins = {
'app/plugins/datasource/graphite/module': graphitePlugin, 'app/plugins/datasource/graphite/module': graphitePlugin,
@ -53,9 +55,11 @@ const builtInPlugins = {
'app/plugins/panel/alertlist/module': alertListPanel, 'app/plugins/panel/alertlist/module': alertListPanel,
'app/plugins/panel/heatmap/module': heatmapPanel, 'app/plugins/panel/heatmap/module': heatmapPanel,
'app/plugins/panel/table/module': tablePanel, 'app/plugins/panel/table/module': tablePanel,
'app/plugins/panel/table2/module': table2Panel,
'app/plugins/panel/singlestat/module': singlestatPanel, 'app/plugins/panel/singlestat/module': singlestatPanel,
'app/plugins/panel/gettingstarted/module': gettingStartedPanel, 'app/plugins/panel/gettingstarted/module': gettingStartedPanel,
'app/plugins/panel/gauge/module': gaugePanel, 'app/plugins/panel/gauge/module': gaugePanel,
'app/plugins/panel/bargauge/module': barGaugePanel,
}; };
export default builtInPlugins; export default builtInPlugins;

View File

@ -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<BarGaugeOptions> {}
export class BarGaugePanel extends PureComponent<Props> {
renderBarGauge(value, width, height) {
const { replaceVariables, options } = this.props;
const { valueOptions } = options;
const prefix = replaceVariables(valueOptions.prefix);
const suffix = replaceVariables(valueOptions.suffix);
return (
<BarGauge
value={value}
width={width}
height={height}
prefix={prefix}
suffix={suffix}
orientation={options.orientation}
unit={valueOptions.unit}
decimals={valueOptions.decimals}
thresholds={options.thresholds}
valueMappings={options.valueMappings}
theme={config.theme}
/>
);
}
render() {
const { panelData, options, width, height } = this.props;
const values = processSingleStatPanelData({
panelData: panelData,
stat: options.valueOptions.stat,
});
return (
<VizRepeater height={height} width={width} values={values} orientation={options.orientation}>
{({ vizHeight, vizWidth, valueInfo }) => this.renderBarGauge(valueInfo.value, vizWidth, vizHeight)}
</VizRepeater>
);
}
}

View File

@ -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<PanelEditorProps<BarGaugeOptions>> {
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 (
<>
<PanelOptionsGrid>
<SingleStatValueEditor onChange={this.onValueOptionsChanged} options={options.valueOptions} />
<PanelOptionsGroup title="Gauge">
<FormField label="Min value" labelWidth={8} onChange={this.onMinValueChange} value={options.minValue} />
<FormField label="Max value" labelWidth={8} onChange={this.onMaxValueChange} value={options.maxValue} />
<div className="form-field">
<FormLabel width={8}>Orientation</FormLabel>
<Select
width={12}
options={orientationOptions}
defaultValue={orientationOptions[0]}
onChange={this.onOrientationChange}
value={orientationOptions.find(item => item.value === options.orientation)}
/>
</div>
</PanelOptionsGroup>
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
</>
);
}
}

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="218.21px" height="187.4px" viewBox="0 0 218.21 187.4" style="enable-background:new 0 0 218.21 187.4;"
xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{fill:url(#SVGID_3_);}
.st3{fill:url(#SVGID_4_);}
.st4{fill:url(#SVGID_5_);}
.st5{fill:url(#SVGID_6_);}
.st6{fill:url(#SVGID_7_);}
.st7{fill:url(#SVGID_8_);}
.st8{fill:url(#SVGID_9_);}
</style>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="68.7753" y1="8.4663" x2="68.7753" y2="59.7143">
<stop offset="0" style="stop-color:#FAA91F"/>
<stop offset="1" style="stop-color:#F37324"/>
</linearGradient>
<path class="st0" d="M7.61,50.71V12.52c0-2.66,1.95-4.82,4.36-4.82h113.62c2.41,0,4.36,2.16,4.36,4.82v38.19
c0,2.66-1.95,4.82-4.36,4.82H11.97C9.56,55.53,7.61,53.37,7.61,50.71z"/>
<g>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="152.8987" y1="19.9183" x2="152.3315" y2="39.7688">
<stop offset="0" style="stop-color:#FAA91F"/>
<stop offset="1" style="stop-color:#F37324"/>
</linearGradient>
<path class="st1" d="M153.89,28.04c0.95,0,1.78,0.18,2.47,0.55c0.7,0.36,1.28,0.86,1.75,1.49c0.47,0.63,0.81,1.37,1.05,2.23
c0.23,0.86,0.35,1.79,0.35,2.78c0,1.24-0.17,2.33-0.52,3.29c-0.34,0.96-0.83,1.77-1.46,2.43c-0.63,0.66-1.37,1.16-2.22,1.5
c-0.85,0.34-1.78,0.52-2.79,0.52c-1.03,0-1.98-0.17-2.85-0.5c-0.87-0.33-1.62-0.83-2.25-1.49c-0.63-0.66-1.12-1.46-1.47-2.41
c-0.35-0.95-0.53-2.03-0.53-3.25c0-1.32,0.17-2.5,0.52-3.55c0.34-1.05,0.78-2.07,1.31-3.04l4.1-7.71h5.56l-4.16,7.35l0.03,0.03
c0.1-0.06,0.27-0.11,0.5-0.15C153.5,28.06,153.71,28.04,153.89,28.04z M154.83,35.02c0-1.05-0.21-1.91-0.62-2.57
c-0.41-0.66-0.99-0.99-1.72-0.99c-0.69,0-1.26,0.33-1.72,1c-0.46,0.67-0.68,1.52-0.68,2.55c0,1.03,0.22,1.88,0.67,2.55
c0.44,0.67,1.01,1,1.7,1c0.71,0,1.28-0.33,1.72-1C154.62,36.9,154.83,36.05,154.83,35.02z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="167.1879" y1="20.3265" x2="166.6207" y2="40.1771">
<stop offset="0" style="stop-color:#FAA91F"/>
<stop offset="1" style="stop-color:#F37324"/>
</linearGradient>
<path class="st2" d="M173.57,26.79c0,0.71-0.09,1.37-0.27,1.97c-0.18,0.61-0.43,1.18-0.73,1.73c-0.3,0.55-0.65,1.07-1.03,1.58
c-0.38,0.51-0.78,0.99-1.18,1.46l-4.01,4.65h6.95v4.19h-12.72v-4.31l6.53-7.5c0.53-0.61,0.94-1.23,1.24-1.87
c0.3-0.64,0.46-1.25,0.46-1.84c0-0.63-0.15-1.16-0.44-1.61c-0.29-0.45-0.75-0.67-1.38-0.67c-0.59,0-1.07,0.24-1.46,0.71
c-0.38,0.48-0.62,1.17-0.7,2.08l-4.56-0.43c0.24-2.19,0.99-3.82,2.23-4.9c1.24-1.08,2.79-1.62,4.63-1.62
c0.99,0,1.88,0.15,2.67,0.46c0.79,0.3,1.46,0.73,2.02,1.28c0.56,0.55,0.99,1.21,1.29,2C173.41,24.94,173.57,25.82,173.57,26.79z"/>
</g>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="90.8472" y1="69.5294" x2="90.8472" y2="118.2775">
<stop offset="0" style="stop-color:#E81E25"/>
<stop offset="1" style="stop-color:#A91D39"/>
</linearGradient>
<path class="st3" d="M7.61,115.16V71.24c0-1.08,0.87-1.95,1.95-1.95h162.57c1.08,0,1.95,0.87,1.95,1.95v43.92
c0,1.08-0.87,1.95-1.95,1.95H9.56C8.48,117.11,7.61,116.23,7.61,115.16z"/>
<g>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="195.8516" y1="81.1631" x2="196.9859" y2="100.4465">
<stop offset="0" style="stop-color:#E81E25"/>
<stop offset="1" style="stop-color:#A91D39"/>
</linearGradient>
<path class="st4" d="M196.03,103.72h-5.28l6.35-17.22h-7.38v-4.28h12.66v3.64L196.03,103.72z"/>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="208.6078" y1="80.4128" x2="209.7422" y2="99.6962">
<stop offset="0" style="stop-color:#E81E25"/>
<stop offset="1" style="stop-color:#A91D39"/>
</linearGradient>
<path class="st5" d="M210.98,89.39c0.95,0,1.78,0.18,2.47,0.55c0.7,0.36,1.28,0.86,1.75,1.49c0.47,0.63,0.81,1.37,1.05,2.23
c0.23,0.86,0.35,1.79,0.35,2.78c0,1.24-0.17,2.33-0.52,3.29c-0.34,0.96-0.83,1.77-1.46,2.43c-0.63,0.66-1.37,1.16-2.22,1.5
c-0.85,0.34-1.78,0.52-2.79,0.52c-1.03,0-1.99-0.17-2.85-0.5c-0.87-0.33-1.62-0.83-2.25-1.49c-0.63-0.66-1.12-1.46-1.47-2.41
c-0.35-0.95-0.53-2.03-0.53-3.25c0-1.32,0.17-2.5,0.52-3.55c0.34-1.05,0.78-2.07,1.31-3.04l4.1-7.71h5.56l-4.16,7.35l0.03,0.03
c0.1-0.06,0.27-0.11,0.5-0.15C210.59,89.41,210.8,89.39,210.98,89.39z M211.92,96.37c0-1.05-0.21-1.91-0.62-2.57
c-0.41-0.66-0.99-0.99-1.72-0.99c-0.69,0-1.26,0.33-1.72,1c-0.46,0.67-0.68,1.52-0.68,2.55s0.22,1.88,0.67,2.55
c0.44,0.67,1.01,1,1.7,1c0.71,0,1.28-0.33,1.72-1C211.7,98.25,211.92,97.4,211.92,96.37z"/>
</g>
<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="49.8893" y1="131.8424" x2="49.8893" y2="178.0907">
<stop offset="0" style="stop-color:#04A64D"/>
<stop offset="1" style="stop-color:#007E39"/>
</linearGradient>
<path class="st6" d="M7.61,176.74v-43.92c0-1.08,0.87-1.95,1.95-1.95h80.66c1.08,0,1.95,0.87,1.95,1.95v43.92
c0,1.08-0.87,1.95-1.95,1.95H9.56C8.48,178.69,7.61,177.81,7.61,176.74z"/>
<g>
<linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="112.038" y1="145.9514" x2="118.2768" y2="172.6079">
<stop offset="0" style="stop-color:#04A64D"/>
<stop offset="1" style="stop-color:#007E39"/>
</linearGradient>
<path class="st7" d="M119.69,161.21v4.31h-4.31v-4.31h-7.87v-4.13l6.29-13.06h5.89v13.18h2.19v4.01H119.69z M115.47,148.77h-0.06
l-3.68,8.44h3.74V148.77z"/>
<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="126.6068" y1="142.5417" x2="132.8455" y2="169.1982">
<stop offset="0" style="stop-color:#04A64D"/>
<stop offset="1" style="stop-color:#007E39"/>
</linearGradient>
<path class="st8" d="M136.52,159.24c0,1.09-0.18,2.06-0.53,2.88c-0.35,0.83-0.84,1.53-1.44,2.11c-0.61,0.58-1.32,1.01-2.14,1.31
c-0.82,0.29-1.71,0.44-2.66,0.44c-1.76,0-3.27-0.45-4.52-1.35c-1.26-0.9-2.09-2.27-2.49-4.11l4.28-1.09
c0.16,0.71,0.45,1.29,0.85,1.73c0.4,0.45,0.96,0.67,1.67,0.67c0.38,0,0.72-0.08,1-0.24c0.28-0.16,0.52-0.37,0.71-0.64
s0.33-0.56,0.43-0.9c0.09-0.33,0.14-0.67,0.14-1.02c0-0.93-0.27-1.62-0.8-2.07c-0.54-0.45-1.25-0.67-2.14-0.67h-1.09v-3.58h1.21
c0.81,0,1.43-0.24,1.87-0.71c0.43-0.48,0.65-1.14,0.65-1.99c0-0.55-0.16-1.06-0.47-1.55c-0.31-0.49-0.82-0.73-1.5-0.73
c-0.59,0-1.04,0.19-1.37,0.58c-0.32,0.39-0.57,0.87-0.73,1.46l-4.16-1.03c0.24-0.93,0.59-1.72,1.05-2.37s0.97-1.18,1.55-1.59
c0.58-0.42,1.21-0.72,1.9-0.91c0.69-0.19,1.39-0.29,2.1-0.29c0.79,0,1.55,0.12,2.29,0.36c0.74,0.24,1.4,0.61,1.99,1.09
c0.59,0.49,1.06,1.09,1.41,1.82c0.35,0.73,0.53,1.58,0.53,2.55c0,1.26-0.27,2.31-0.8,3.17c-0.54,0.86-1.25,1.43-2.14,1.72v0.09
c1.03,0.28,1.85,0.87,2.46,1.76C136.22,157.04,136.52,158.07,136.52,159.24z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,22 @@
import { ReactPanelPlugin } from '@grafana/ui';
import { BarGaugePanel } from './BarGaugePanel';
import { BarGaugePanelEditor } from './BarGaugePanelEditor';
import { BarGaugeOptions, defaults } from './types';
export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel);
reactPanel.setEditor(BarGaugePanelEditor);
reactPanel.setDefaults(defaults);
reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => {
const options: Partial<BarGaugeOptions> = {};
if (prevOptions.valueOptions) {
options.valueOptions = prevOptions.valueOptions;
options.thresholds = prevOptions.thresholds;
options.maxValue = prevOptions.maxValue;
options.minValue = prevOptions.minValue;
}
return options;
});

View File

@ -0,0 +1,19 @@
{
"type": "panel",
"name": "Bar Gauge",
"id": "bargauge",
"state": "alpha",
"dataFormats": ["time_series"],
"info": {
"author": {
"name": "Grafana Project",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icon_bar_gauge.svg",
"large": "img/icon_bar_gauge.svg"
}
}
}

View File

@ -0,0 +1,31 @@
import { Threshold, SelectOptionItem, ValueMapping, VizOrientation } from '@grafana/ui';
import { SingleStatValueOptions } from '../gauge/types';
export interface BarGaugeOptions {
minValue: number;
maxValue: number;
orientation: VizOrientation;
valueOptions: SingleStatValueOptions;
valueMappings: ValueMapping[];
thresholds: Threshold[];
}
export const orientationOptions: SelectOptionItem[] = [
{ value: VizOrientation.Horizontal, label: 'Horizontal' },
{ value: VizOrientation.Vertical, label: 'Vertical' },
];
export const defaults: BarGaugeOptions = {
minValue: 0,
maxValue: 100,
orientation: VizOrientation.Horizontal,
valueOptions: {
unit: 'none',
stat: 'avg',
prefix: '',
suffix: '',
decimals: null,
},
thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
valueMappings: [],
};

View File

@ -9,6 +9,8 @@ import { FormField, PanelEditorProps } from '@grafana/ui';
import { GaugeOptions } from './types'; import { GaugeOptions } from './types';
export class GaugeOptionsBox extends PureComponent<PanelEditorProps<GaugeOptions>> { export class GaugeOptionsBox extends PureComponent<PanelEditorProps<GaugeOptions>> {
labelWidth = 8;
onToggleThresholdLabels = () => onToggleThresholdLabels = () =>
this.props.onOptionsChange({ ...this.props.options, showThresholdLabels: !this.props.options.showThresholdLabels }); this.props.onOptionsChange({ ...this.props.options, showThresholdLabels: !this.props.options.showThresholdLabels });
@ -28,17 +30,17 @@ export class GaugeOptionsBox extends PureComponent<PanelEditorProps<GaugeOptions
return ( return (
<PanelOptionsGroup title="Gauge"> <PanelOptionsGroup title="Gauge">
<FormField label="Min value" labelWidth={8} onChange={this.onMinValueChange} value={minValue} /> <FormField label="Min value" labelWidth={this.labelWidth} onChange={this.onMinValueChange} value={minValue} />
<FormField label="Max value" labelWidth={8} onChange={this.onMaxValueChange} value={maxValue} /> <FormField label="Max value" labelWidth={this.labelWidth} onChange={this.onMaxValueChange} value={maxValue} />
<Switch <Switch
label="Show labels" label="Show labels"
labelClass="width-8" labelClass={`width-${this.labelWidth}`}
checked={showThresholdLabels} checked={showThresholdLabels}
onChange={this.onToggleThresholdLabels} onChange={this.onToggleThresholdLabels}
/> />
<Switch <Switch
label="Show markers" label="Show markers"
labelClass="width-8" labelClass={`width-${this.labelWidth}`}
checked={showThresholdMarkers} checked={showThresholdMarkers}
onChange={this.onToggleThresholdMarkers} onChange={this.onToggleThresholdMarkers}
/> />

View File

@ -1,82 +1,59 @@
// Libraries // Libraries
import React, { Component } from 'react'; import React, { PureComponent } from 'react';
// Services & Utils // Services & Utils
import { processTimeSeries, ThemeContext } from '@grafana/ui'; import { processSingleStatPanelData } from '@grafana/ui';
import { config } from 'app/core/config';
// Components // Components
import { Gauge } from '@grafana/ui'; import { Gauge, VizRepeater } from '@grafana/ui';
// Types // Types
import { GaugeOptions } from './types'; import { GaugeOptions } from './types';
import { PanelProps, NullValueMode, TimeSeriesValue } from '@grafana/ui/src/types'; import { PanelProps, VizOrientation } from '@grafana/ui/src/types';
interface Props extends PanelProps<GaugeOptions> {} interface Props extends PanelProps<GaugeOptions> {}
interface State {
value: TimeSeriesValue;
}
export class GaugePanel extends Component<Props, State> { export class GaugePanel extends PureComponent<Props> {
constructor(props: Props) { renderGauge(value, width, height) {
super(props); const { replaceVariables, options } = this.props;
this.state = {
value: this.findValue(props),
};
}
componentDidUpdate(prevProps: Props) {
if (this.props.panelData !== prevProps.panelData) {
this.setState({ value: this.findValue(this.props) });
}
}
findValue(props: Props): number | null {
const { panelData, options } = props;
const { valueOptions } = options; const { valueOptions } = options;
if (panelData.timeSeries) {
const vmSeries = processTimeSeries({
timeSeries: panelData.timeSeries,
nullValueMode: NullValueMode.Null,
});
if (vmSeries[0]) {
return vmSeries[0].stats[valueOptions.stat];
}
} else if (panelData.tableData) {
return panelData.tableData.rows[0].find(prop => prop > 0);
}
return null;
}
render() {
const { width, height, replaceVariables, options } = this.props;
const { valueOptions } = options;
const { value } = this.state;
const prefix = replaceVariables(valueOptions.prefix); const prefix = replaceVariables(valueOptions.prefix);
const suffix = replaceVariables(valueOptions.suffix); const suffix = replaceVariables(valueOptions.suffix);
return ( return (
<ThemeContext.Consumer> <Gauge
{theme => ( value={value}
<Gauge width={width}
value={value} height={height}
width={width} prefix={prefix}
height={height} suffix={suffix}
prefix={prefix} unit={valueOptions.unit}
suffix={suffix} decimals={valueOptions.decimals}
unit={valueOptions.unit} thresholds={options.thresholds}
decimals={valueOptions.decimals} valueMappings={options.valueMappings}
thresholds={options.thresholds} showThresholdLabels={options.showThresholdLabels}
valueMappings={options.valueMappings} showThresholdMarkers={options.showThresholdMarkers}
showThresholdLabels={options.showThresholdLabels} minValue={options.minValue}
showThresholdMarkers={options.showThresholdMarkers} maxValue={options.maxValue}
minValue={options.minValue} theme={config.theme}
maxValue={options.maxValue} />
theme={theme} );
/> }
)}
</ThemeContext.Consumer> render() {
const { panelData, options, height, width } = this.props;
const values = processSingleStatPanelData({
panelData: panelData,
stat: options.valueOptions.stat,
});
return (
<VizRepeater height={height} width={width} values={values} orientation={VizOrientation.Auto}>
{({ vizHeight, vizWidth, valueInfo }) => this.renderGauge(valueInfo.value, vizWidth, vizHeight)}
</VizRepeater>
); );
} }
} }

View File

@ -1,3 +1,4 @@
// Libraries
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { import {
PanelEditorProps, PanelEditorProps,

View File

@ -8,3 +8,15 @@ export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel);
reactPanel.setEditor(GaugePanelEditor); reactPanel.setEditor(GaugePanelEditor);
reactPanel.setDefaults(defaults); reactPanel.setDefaults(defaults);
reactPanel.setPreserveOptionsHandler((pluginId: string, prevOptions: any) => {
const options: Partial<GaugeOptions> = {};
if (prevOptions.valueOptions) {
options.valueOptions = prevOptions.valueOptions;
options.thresholds = prevOptions.thresholds;
options.maxValue = prevOptions.maxValue;
options.minValue = prevOptions.minValue;
}
return options;
});

View File

@ -31,5 +31,5 @@ export const defaults: GaugeOptions = {
unit: 'none', unit: 'none',
}, },
valueMappings: [], valueMappings: [],
thresholds: [], thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
}; };

View File

@ -174,9 +174,11 @@ class LegendSeriesIcon extends PureComponent<LegendSeriesIconProps, LegendSeries
onToggleAxis={this.props.onToggleAxis} onToggleAxis={this.props.onToggleAxis}
enableNamedColors enableNamedColors
> >
<span className="graph-legend-icon"> {({ ref, showColorPicker, hideColorPicker }) => (
<SeriesIcon color={this.props.color} /> <span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker} className="graph-legend-icon">
</span> <SeriesIcon color={this.props.color} />
</span>
)}
</SeriesColorPicker> </SeriesColorPicker>
); );
} }

View File

@ -67,6 +67,17 @@
<input type="number" class="gf-form-input max-width-8" ng-model="ctrl.panel.xaxis.buckets" placeholder="auto" ng-change="ctrl.render()" ng-model-onblur bs-tooltip="'Number of buckets'" data-placement="right"> <input type="number" class="gf-form-input max-width-8" ng-model="ctrl.panel.xaxis.buckets" placeholder="auto" ng-change="ctrl.render()" ng-model-onblur bs-tooltip="'Number of buckets'" data-placement="right">
</div> </div>
<div class="gf-form-inline" ng-if="ctrl.panel.xaxis.mode === 'histogram'">
<div class="gf-form">
<label class="gf-form-label width-6">X-Min</label>
<input type="number" class="gf-form-input width-5" placeholder="auto" empty-to-null ng-model="ctrl.panel.xaxis.min" ng-change="ctrl.render()" ng-model-onblur>
</div>
<div class="gf-form">
<label class="gf-form-label width-6">X-Max</label>
<input type="number" class="gf-form-input width-5" placeholder="auto" empty-to-null ng-model="ctrl.panel.xaxis.max" ng-change="ctrl.render()" ng-model-onblur>
</div>
</div>
<div> <div>
<br/> <br/>
<h5 class="section-heading">Y-Axes</h5> <h5 class="section-heading">Y-Axes</h5>

View File

@ -337,9 +337,17 @@ class GraphElement {
let bucketSize: number; let bucketSize: number;
if (this.data.length) { if (this.data.length) {
const histMin = _.min(_.map(this.data, s => s.stats.min)); let histMin = _.min(_.map(this.data, s => s.stats.min));
const histMax = _.max(_.map(this.data, s => s.stats.max)); let histMax = _.max(_.map(this.data, s => s.stats.max));
const ticks = panel.xaxis.buckets || this.panelWidth / 50; 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); bucketSize = tickStep(histMin, histMax, ticks);
options.series.bars.barWidth = bucketSize * 0.8; options.series.bars.barWidth = bucketSize * 0.8;
this.data = convertToHistogramData(this.data, bucketSize, this.ctrl.hiddenSeries, histMin, histMax); this.data = convertToHistogramData(this.data, bucketSize, this.ctrl.hiddenSeries, histMin, histMax);

View File

@ -43,6 +43,10 @@ export function convertValuesToHistogram(values: number[], bucketSize: number, m
} }
for (let i = 0; i < values.length; i++) { 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); const bound = getBucketBound(values[i], bucketSize);
histogram[bound] = histogram[bound] + 1; histogram[bound] = histogram[bound] + 1;
} }

View File

@ -516,4 +516,408 @@ describe('grafanaGraph', () => {
expect(ctx.plotData[0].data[0][1]).toBe(2); 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);
});
});
}); });

View File

@ -69,10 +69,11 @@ coreModule.directive('heatmapLegend', () => {
function render() { function render() {
clearLegend(elem); clearLegend(elem);
if (!_.isEmpty(ctrl.data) && !_.isEmpty(ctrl.data.cards)) { if (!_.isEmpty(ctrl.data) && !_.isEmpty(ctrl.data.cards)) {
const rangeFrom = 0; const cardStats = ctrl.data.cardStats;
const rangeTo = ctrl.data.cardStats.max; const rangeFrom = _.isNil(panel.color.min) ? Math.min(cardStats.min, 0) : panel.color.min;
const maxValue = panel.color.max || rangeTo; const rangeTo = _.isNil(panel.color.max) ? cardStats.max : panel.color.max;
const minValue = panel.color.min || 0; const maxValue = cardStats.max;
const minValue = cardStats.min;
if (panel.color.mode === 'spectrum') { if (panel.color.mode === 'spectrum') {
const colorScheme = _.find(ctrl.colorSchemes, { const colorScheme = _.find(ctrl.colorSchemes, {
@ -110,7 +111,7 @@ function drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minVal
.data(valuesRange) .data(valuesRange)
.enter() .enter()
.append('rect') .append('rect')
.attr('x', d => Math.round(d * widthFactor)) .attr('x', d => Math.round((d - rangeFrom) * widthFactor))
.attr('y', 0) .attr('y', 0)
.attr('width', Math.round(rangeStep * widthFactor + 1)) // Overlap rectangles to prevent gaps .attr('width', Math.round(rangeStep * widthFactor + 1)) // Overlap rectangles to prevent gaps
.attr('height', legendHeight) .attr('height', legendHeight)
@ -141,7 +142,7 @@ function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue
.data(valuesRange) .data(valuesRange)
.enter() .enter()
.append('rect') .append('rect')
.attr('x', d => Math.round(d * widthFactor)) .attr('x', d => Math.round((d - rangeFrom) * widthFactor))
.attr('y', 0) .attr('y', 0)
.attr('width', Math.round(rangeStep * widthFactor)) .attr('width', Math.round(rangeStep * widthFactor))
.attr('height', legendHeight) .attr('height', legendHeight)
@ -162,10 +163,10 @@ function drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWi
const legendValueScale = d3 const legendValueScale = d3
.scaleLinear() .scaleLinear()
.domain([0, rangeTo]) .domain([rangeFrom, rangeTo])
.range([0, legendWidth]); .range([0, legendWidth]);
const ticks = buildLegendTicks(0, rangeTo, maxValue, minValue); const ticks = buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue);
const xAxis = d3 const xAxis = d3
.axisBottom(legendValueScale) .axisBottom(legendValueScale)
.tickValues(ticks) .tickValues(ticks)
@ -286,11 +287,12 @@ function getSvgElemHeight(elem) {
function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) { function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) {
const range = rangeTo - rangeFrom; const range = rangeTo - rangeFrom;
const tickStepSize = tickStep(rangeFrom, rangeTo, 3); 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 = []; let ticks = [];
for (let i = 0; i < ticksNum; i++) { 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 // Add user-defined min and max if it had been set
if (isValueCloseTo(minValue, current, tickStepSize)) { if (isValueCloseTo(minValue, current, tickStepSize)) {
ticks.push(minValue); ticks.push(minValue);
@ -304,7 +306,7 @@ function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) {
} else if (maxValue < current) { } else if (maxValue < current) {
ticks.push(maxValue); ticks.push(maxValue);
} }
ticks.push(tickStepSize * i); ticks.push(current);
} }
if (!isValueCloseTo(maxValue, rangeTo, tickStepSize)) { if (!isValueCloseTo(maxValue, rangeTo, tickStepSize)) {
ticks.push(maxValue); ticks.push(maxValue);
@ -318,3 +320,10 @@ function isValueCloseTo(val, valueTo, step) {
const diff = Math.abs(val - valueTo); const diff = Math.abs(val - valueTo);
return diff < step * 0.3; return diff < step * 0.3;
} }
function getFirstCloseTick(minValue, step) {
if (minValue < 0) {
return Math.floor(minValue / step) * step;
}
return 0;
}

View File

@ -524,14 +524,16 @@ export class HeatmapRenderer {
} }
const cardsData = this.data.cards; const cardsData = this.data.cards;
const maxValueAuto = this.data.cardStats.max; const cardStats = this.data.cardStats;
const maxValue = this.panel.color.max || maxValueAuto; const maxValueAuto = cardStats.max;
const minValue = this.panel.color.min || 0; 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, { const colorScheme = _.find(this.ctrl.colorSchemes, {
value: this.panel.color.colorScheme, value: this.panel.color.colorScheme,
}); });
this.colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue); 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(); this.setCardSize();
let cards = this.heatmap.selectAll('.heatmap-card').data(cardsData); let cards = this.heatmap.selectAll('.heatmap-card').data(cardsData);

View File

@ -1,6 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, stringToJsRegex } from '@grafana/ui'; import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType, stringToJsRegex } from '@grafana/ui';
import { ColumnStyle } from '@grafana/ui/src/components/Table/TableCellBuilder';
export class TableRenderer { export class TableRenderer {
formatters: any[]; formatters: any[];
@ -50,7 +51,7 @@ export class TableRenderer {
} }
} }
getColorForValue(value, style) { getColorForValue(value, style: ColumnStyle) {
if (!style.thresholds) { if (!style.thresholds) {
return null; return null;
} }
@ -62,7 +63,7 @@ export class TableRenderer {
return getColorFromHexRgbOrName(_.first(style.colors), this.theme); return getColorFromHexRgbOrName(_.first(style.colors), this.theme);
} }
defaultCellFormatter(v, style) { defaultCellFormatter(v, style: ColumnStyle) {
if (v === null || v === void 0 || v === undefined) { if (v === null || v === void 0 || v === undefined) {
return ''; return '';
} }
@ -189,7 +190,7 @@ export class TableRenderer {
}; };
} }
setColorState(value, style) { setColorState(value, style: ColumnStyle) {
if (!style.colorMode) { if (!style.colorMode) {
return; return;
} }

View File

@ -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/)

View File

@ -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<Options> {}
export class TablePanel extends Component<Props> {
constructor(props: Props) {
super(props);
}
render() {
const { panelData, options } = this.props;
if (!panelData || !panelData.tableData) {
return <div>No Table Data...</div>;
}
return (
<ThemeContext.Consumer>
{theme => <Table {...this.props} {...options} theme={theme} data={panelData.tableData} />}
</ThemeContext.Consumer>
);
}
}

View File

@ -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<PanelEditorProps<Options>> {
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 (
<div>
<div className="section gf-form-group">
<h5 className="section-heading">Header</h5>
<Switch label="Show" labelClass="width-6" checked={showHeader} onChange={this.onToggleShowHeader} />
<Switch label="Fixed" labelClass="width-6" checked={fixedHeader} onChange={this.onToggleFixedHeader} />
</div>
<div className="section gf-form-group">
<h5 className="section-heading">Display</h5>
<Switch label="Rotate" labelClass="width-8" checked={rotate} onChange={this.onToggleRotate} />
<FormField
label="Fixed Columns"
labelWidth={8}
inputWidth={4}
type="number"
step="1"
min="0"
max="100"
onChange={this.onFixedColumnsChange}
value={fixedColumns}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="15.8113" y1="25" x2="15.8113" y2="-2.5362">
<stop offset="0" style="stop-color:#FFF33B"/>
<stop offset="0.0595" style="stop-color:#FFE029"/>
<stop offset="0.1303" style="stop-color:#FFD218"/>
<stop offset="0.2032" style="stop-color:#FEC90F"/>
<stop offset="0.2809" style="stop-color:#FDC70C"/>
<stop offset="0.6685" style="stop-color:#F3903F"/>
<stop offset="0.8876" style="stop-color:#ED683C"/>
<stop offset="1" style="stop-color:#E93E3A"/>
</linearGradient>
<rect x="0" style="fill:url(#SVGID_1_);" width="31.623" height="15.049"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="50" y1="25" x2="50" y2="-2.5362">
<stop offset="0" style="stop-color:#FFF33B"/>
<stop offset="0.0595" style="stop-color:#FFE029"/>
<stop offset="0.1303" style="stop-color:#FFD218"/>
<stop offset="0.2032" style="stop-color:#FEC90F"/>
<stop offset="0.2809" style="stop-color:#FDC70C"/>
<stop offset="0.6685" style="stop-color:#F3903F"/>
<stop offset="0.8876" style="stop-color:#ED683C"/>
<stop offset="1" style="stop-color:#E93E3A"/>
</linearGradient>
<rect x="34.188" style="fill:url(#SVGID_2_);" width="31.623" height="15.049"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="84.1887" y1="25" x2="84.1887" y2="-2.5362">
<stop offset="0" style="stop-color:#FFF33B"/>
<stop offset="0.0595" style="stop-color:#FFE029"/>
<stop offset="0.1303" style="stop-color:#FFD218"/>
<stop offset="0.2032" style="stop-color:#FEC90F"/>
<stop offset="0.2809" style="stop-color:#FDC70C"/>
<stop offset="0.6685" style="stop-color:#F3903F"/>
<stop offset="0.8876" style="stop-color:#ED683C"/>
<stop offset="1" style="stop-color:#E93E3A"/>
</linearGradient>
<rect x="68.377" style="fill:url(#SVGID_3_);" width="31.623" height="15.049"/>
</g>
<g>
<rect x="0" y="16.99" style="fill:#898989;" width="31.623" height="15.049"/>
<rect x="34.188" y="16.99" style="fill:#898989;" width="31.623" height="15.049"/>
<rect x="68.377" y="16.99" style="fill:#898989;" width="31.623" height="15.049"/>
</g>
<g>
<rect x="0" y="33.981" style="fill:#6D6E71;" width="31.623" height="15.049"/>
<rect x="34.188" y="33.981" style="fill:#6D6E71;" width="31.623" height="15.049"/>
<rect x="68.377" y="33.981" style="fill:#6D6E71;" width="31.623" height="15.049"/>
</g>
<g>
<rect x="0" y="50.971" style="fill:#898989;" width="31.623" height="15.049"/>
<rect x="34.188" y="50.971" style="fill:#898989;" width="31.623" height="15.049"/>
<rect x="68.377" y="50.971" style="fill:#898989;" width="31.623" height="15.049"/>
</g>
<g>
<rect x="0" y="67.961" style="fill:#6D6E71;" width="31.623" height="15.049"/>
<rect x="34.188" y="67.961" style="fill:#6D6E71;" width="31.623" height="15.049"/>
<rect x="68.377" y="67.961" style="fill:#6D6E71;" width="31.623" height="15.049"/>
</g>
<g>
<rect x="0" y="84.951" style="fill:#898989;" width="31.623" height="15.049"/>
<rect x="34.188" y="84.951" style="fill:#898989;" width="31.623" height="15.049"/>
<rect x="68.377" y="84.951" style="fill:#898989;" width="31.623" height="15.049"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -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<Options>(TablePanel);
reactPanel.setEditor(TablePanelEditor);
reactPanel.setDefaults(defaults);

View File

@ -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"
}
}
}

View File

@ -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: [],
},
],
};

View File

@ -4,13 +4,14 @@ import {
RawTimeRange, RawTimeRange,
TimeRange, TimeRange,
DataQuery, DataQuery,
DataQueryResponseData,
DataSourceSelectItem, DataSourceSelectItem,
DataSourceApi, DataSourceApi,
QueryHint, QueryHint,
ExploreStartPageProps, ExploreStartPageProps,
} from '@grafana/ui'; } 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 { LogsModel, LogsDedupStrategy, LogLevel } from 'app/core/logs_model';
import TableModel from 'app/core/table_model'; import TableModel from 'app/core/table_model';
@ -322,6 +323,12 @@ export interface QueryTransaction {
export type RangeScanner = () => RawTimeRange; export type RangeScanner = () => RawTimeRange;
export type ResultGetter = (
result: DataQueryResponseData,
transaction: QueryTransaction,
allTransactions: QueryTransaction[]
) => TimeSeries;
export interface TextMatch { export interface TextMatch {
text: string; text: string;
start: number; start: number;

View File

@ -55,7 +55,6 @@ $spacers: (
), ),
), ),
) !default; ) !default;
$border-width: 1px !default;
// Grid breakpoints // Grid breakpoints
// //
@ -88,14 +87,11 @@ $container-max-widths: (
$grid-columns: 12 !default; $grid-columns: 12 !default;
$grid-gutter-width: 30px !default; $grid-gutter-width: 30px !default;
$enable-flex: true;
// Typography // Typography
// ------------------------- // -------------------------
$font-family-sans-serif: 'Roboto', Helvetica, Arial, sans-serif; $font-family-sans-serif: 'Roboto', Helvetica, Arial, sans-serif;
$font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace; $font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace;
$font-family-base: $font-family-sans-serif !default;
$font-size-root: 14px !default; $font-size-root: 14px !default;
$font-size-base: 13px !default; $font-size-base: 13px !default;
@ -106,7 +102,9 @@ $font-size-sm: 12px !default;
$font-size-xs: 10px !default; $font-size-xs: 10px !default;
$line-height-base: 1.5 !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-h1: 28px !default;
$font-size-h2: 24px !default; $font-size-h2: 24px !default;
@ -116,18 +114,13 @@ $font-size-h5: 16px !default;
$font-size-h6: 14px !default; $font-size-h6: 14px !default;
$headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; $headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$headings-font-weight: 400 !default;
$headings-line-height: 1.1 !default; $headings-line-height: 1.1 !default;
$hr-border-width: $border-width !default;
$dt-font-weight: bold !default;
// Components // Components
// //
// Define common padding and border radius sizes and more. // Define common padding and border radius sizes and more.
$line-height-lg: (4 / 3) !default; $border-width: 1px !default;
$line-height-sm: 1.5 !default;
$border-radius: 3px !default; $border-radius: 3px !default;
$border-radius-lg: 5px !default; $border-radius-lg: 5px !default;
@ -154,22 +147,17 @@ $input-padding-x: 10px !default;
$input-padding-y: 8px !default; $input-padding-y: 8px !default;
$input-line-height: 18px !default; $input-line-height: 18px !default;
$input-btn-border-width: 1px;
$input-border-radius: 0 $border-radius $border-radius 0 !default; $input-border-radius: 0 $border-radius $border-radius 0 !default;
$input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 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: $border-radius 0 0 $border-radius !default;
$label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !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-x-lg: 20px !default;
$input-padding-y-lg: 10px !default; $input-padding-y-lg: 10px !default;
$input-height: 35px !default; $input-height: 35px !default;
$gf-form-input-height: 35px;
$cursor-disabled: not-allowed !default; $cursor-disabled: not-allowed !default;
// Form validation icons // Form validation icons
@ -206,8 +194,6 @@ $btn-padding-y-lg: 11px !default;
$btn-padding-x-xl: 21px !default; $btn-padding-x-xl: 21px !default;
$btn-padding-y-xl: 11px !default; $btn-padding-y-xl: 11px !default;
$btn-border-radius: 2px;
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
// sidemenu // sidemenu

View File

@ -37,7 +37,7 @@ input,
button, button,
select, select,
textarea { 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 // Identify controls by their labels

View File

@ -70,7 +70,7 @@ html {
body { body {
// Make the `body` use the `font-size-root` // Make the `body` use the `font-size-root`
font-family: $font-family-base; font-family: $font-family-sans-serif;
font-size: $font-size-base; font-size: $font-size-base;
line-height: $line-height-base; line-height: $line-height-base;
// Go easy on the eyes and use something other than `#000` for text // Go easy on the eyes and use something other than `#000` for text
@ -145,7 +145,7 @@ ul ol {
} }
dt { dt {
font-weight: $dt-font-weight; font-weight: $font-weight-semi-bold;
} }
dd { dd {

View File

@ -111,7 +111,7 @@ h6,
.h6 { .h6 {
margin-bottom: $space-sm; margin-bottom: $space-sm;
font-family: $headings-font-family; font-family: $headings-font-family;
font-weight: $headings-font-weight; font-weight: $font-weight-regular;
line-height: $headings-line-height; line-height: $headings-line-height;
color: $headings-color; color: $headings-color;
} }
@ -149,7 +149,7 @@ hr {
margin-top: $spacer-y; margin-top: $spacer-y;
margin-bottom: $spacer-y; margin-bottom: $spacer-y;
border: 0; border: 0;
border-top: $hr-border-width solid $hr-border-color; border-top: $border-width solid $hr-border-color;
} }
// //

View File

@ -16,7 +16,7 @@
cursor: pointer; cursor: pointer;
border: none; 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, &:active,
@ -53,7 +53,7 @@
// -------------------------------------------------- // --------------------------------------------------
// XLarge // XLarge
.btn-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; font-weight: normal;
padding-bottom: $btn-padding-y-xl - 3; padding-bottom: $btn-padding-y-xl - 3;
.gicon { .gicon {
@ -64,16 +64,16 @@
// Large // Large
.btn-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; font-weight: normal;
} }
.btn-small { .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 { .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 { .btn-link {

View File

@ -10,7 +10,7 @@
min-height: 3.6rem; // Include space for horizontal scrollbar min-height: 3.6rem; // Include space for horizontal scrollbar
@include border-radius($input-border-radius-sm); @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 { .ace_content {

View File

@ -105,9 +105,9 @@ $input-border: 1px solid $input-border-color;
background-color: $input-label-bg; background-color: $input-label-bg;
display: block; 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-right: none;
border-radius: $label-border-radius; border-radius: $label-border-radius;
@ -127,7 +127,7 @@ $input-border: 1px solid $input-border-color;
} }
&--btn { &--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; border-radius: $border-radius;
&:hover { &:hover {
@ -154,7 +154,7 @@ $input-border: 1px solid $input-border-color;
flex-grow: 1; flex-grow: 1;
margin: 0; margin: 0;
margin-right: $space-xs; margin-right: $space-xs;
border: $input-btn-border-width solid transparent; border: $border-width solid transparent;
border-left: none; border-left: none;
@include border-radius($label-border-radius-sm); @include border-radius($label-border-radius-sm);
} }
@ -166,7 +166,7 @@ $input-border: 1px solid $input-border-color;
.gf-form-input { .gf-form-input {
display: block; display: block;
width: 100%; width: 100%;
height: $gf-form-input-height; height: $input-height;
padding: $input-padding-y $input-padding-x; padding: $input-padding-y $input-padding-x;
font-size: $font-size-md; font-size: $font-size-md;
line-height: $input-line-height; line-height: $input-line-height;

View File

@ -117,7 +117,7 @@ $path-position: $marker-size-half - ($path-height / 2);
} }
.progress-step-cta { .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); @include buttonBackground($btn-primary-bg, $btn-primary-bg-hl);
display: none; display: none;
} }

View File

@ -9,7 +9,7 @@
position: relative; position: relative;
display: inline-block; display: inline-block;
padding: $input-padding-y $input-padding-x; padding: $input-padding-y $input-padding-x;
min-height: $gf-form-input-height; min-height: $input-height;
width: 100%; width: 100%;
cursor: text; cursor: text;
line-height: $line-height-base; line-height: $line-height-base;

View File

@ -42,7 +42,7 @@
border-radius: $input-border-radius; border-radius: $input-border-radius;
display: inline-block; display: inline-block;
color: $text-color; color: $text-color;
height: $gf-form-input-height; height: $input-height;
.label-tag { .label-tag {
margin: 0 5px; margin: 0 5px;

View File

@ -26,7 +26,7 @@ gf-form-switch[disabled] {
display: flex; display: flex;
position: relative; position: relative;
width: 60px; width: 60px;
height: $gf-form-input-height; height: $input-height;
background: $switch-bg; background: $switch-bg;
border: 1px solid $input-border-color; border: 1px solid $input-border-color;
border-left: none; border-left: none;
@ -82,7 +82,7 @@ input:checked + .gf-form-switch__slider::before {
position: relative; position: relative;
display: flex; display: flex;
width: 50px; width: 50px;
height: $gf-form-input-height; height: $input-height;
background: $switch-bg; background: $switch-bg;
border: 1px solid $input-border-color; border: 1px solid $input-border-color;
border-left: none; border-left: none;

View File

@ -27,7 +27,7 @@
line-height: $input-line-height; line-height: $input-line-height;
color: $input-color; color: $input-color;
background-color: $input-bg; background-color: $input-bg;
height: $gf-form-input-height; height: $input-height;
border: $input-border; border: $input-border;
border-radius: $input-border-radius; border-radius: $input-border-radius;
display: flex; display: flex;

View File

@ -96,7 +96,9 @@ button.close {
} }
.center-vh { .center-vh {
height: 100%;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
justify-items: center; justify-items: center;

19
scripts/ci-frontend-metrics.sh Executable file
View File

@ -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 \

18
scripts/ci-metrics-publisher.sh Executable file
View File

@ -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]"

View File

@ -1,30 +0,0 @@
#!/bin/bash
echo "Collecting code stats (typescript errors & more)"
ERROR_COUNT="$(./node_modules/.bin/tsc --project tsconfig.json --noEmit --noImplicitAny true | grep -oP 'Found \K(\d+)')"
DIRECTIVES="$(grep -r -o directive public/app/**/* | wc -l)"
CONTROLLERS="$(grep -r -oP 'class .*Ctrl' public/app/**/* | wc -l)"
echo "Typescript errors: $ERROR_COUNT"
echo "Directives: $DIRECTIVES"
echo "Controllers: $CONTROLLERS"
curl \
-d "{\"metrics\": {
\"ci.code.noImplicitAny\": $ERROR_COUNT,
\"ci.code.directives\": $DIRECTIVES,
\"ci.code.controllers\": $CONTROLLERS
}
}" \
-H "Content-Type: application/json" \
-u ci:$CIRCLE_STATS_PWD \
-X POST https://stats.grafana.org/metric-receiver
curl https://6371:$GRAFANA_MISC_STATS_API_KEY@graphite-us-central1.grafana.net/metrics \
-H 'Content-type: application/json' \
-d '[
{"name":"grafana.ci-code.noImplicitAny", "interval":60, "value": '$ERROR_COUNT', "mtype": "gauge", "time": '$(date +%s)'},
{"name":"grafana.ci-code.directives", "interval":60, "value": '$DIRECTIVES', "mtype": "gauge", "time": '$(date +%s)'},
{"name":"grafana.ci-code.controllers", "interval":60, "value": '$CONTROLLERS', "mtype": "gauge", "time": '$(date +%s)'}
]'

View File

@ -1,4 +1,5 @@
#!/bin/bash #!/bin/bash
function exit_if_fail { function exit_if_fail {
command=$@ command=$@
echo "Executing '$command'" echo "Executing '$command'"
@ -10,11 +11,16 @@ function exit_if_fail {
fi fi
} }
exit_if_fail npm run prettier:check start=$(date +%s)
exit_if_fail npm run test
# On master also collect some and send some metrics exit_if_fail npm run prettier:check
branch="$(git rev-parse --abbrev-ref HEAD)" # exit_if_fail npm run test
if [ "${branch}" == "master" ]; then
exit_if_fail ./scripts/circle-metrics.sh 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 fi