merge master

This commit is contained in:
ryan 2019-03-13 09:03:32 -07:00
commit b4a3aecbbc
132 changed files with 3857 additions and 610 deletions

View File

@ -5,12 +5,15 @@
### Minor
* **Cloudwatch**: Add AWS RDS MaximumUsedTransactionIDs metric [#15077](https://github.com/grafana/grafana/pull/15077), thx [@activeshadow](https://github.com/activeshadow)
* **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683)
* **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683)
### Bug Fixes
* **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506)
* **Datasource**: Handles nil jsondata field gracefully [#14239](https://github.com/grafana/grafana/issues/14239)
* **Gauge**: Interpolate scoped variables in repeated gauges [#15739](https://github.com/grafana/grafana/issues/15739)
* **Datasource**: Empty user/password was not updated when updating datasources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619)
* **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019)
# 6.0.1 (2019-03-06)

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

@ -17,6 +17,7 @@
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.1.0",
"@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
"@types/angular": "^1.6.6",
"@types/chalk": "^2.2.0",
"@types/classnames": "^2.2.6",
"@types/commander": "^2.12.2",

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

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 { omit } from 'lodash';
import { PopperController } from '../Tooltip/PopperController';
import { Popper } from '../Tooltip/Popper';
import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover';
@ -6,14 +7,29 @@ import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
import { withTheme } from '../../themes/ThemeContext';
import { ColorPickerTrigger } from './ColorPickerTrigger';
/**
* If you need custom trigger for the color picker you can do that with a render prop pattern and supply a function
* as a child. You will get show/hide function which you can map to desired interaction (like onClick or onMouseLeave)
* and a ref which needs to be passed to an HTMLElement for correct positioning. If you want to use class or functional
* component as a custom trigger you will need to forward the reference to first HTMLElement child.
*/
type ColorPickerTriggerRenderer = (props: {
// This should be a React.RefObject<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>(
popover: React.ComponentType<T>,
displayName = 'ColorPicker'
) => {
return class ColorPicker extends Component<T, any> {
return class ColorPicker extends Component<T & { children?: ColorPickerTriggerRenderer }, any> {
static displayName = displayName;
pickerTriggerRef = createRef<HTMLDivElement>();
pickerTriggerRef = createRef<any>();
onColorChange = (color: string) => {
const { onColorChange, onChange } = this.props;
@ -23,11 +39,11 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
};
render() {
const { theme, children } = this.props;
const popoverElement = React.createElement(popover, {
...this.props,
...omit(this.props, 'children'),
onChange: this.onColorChange,
});
const { theme, children } = this.props;
return (
<PopperController content={popoverElement} hideAfter={300}>
@ -45,27 +61,21 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
)}
{children ? (
React.cloneElement(children as JSX.Element, {
// Children have a bit weird type due to intersection used in the definition so we need to cast here,
// but the definition is correct and should not allow to pass a children that does not conform to
// ColorPickerTriggerRenderer type.
(children as ColorPickerTriggerRenderer)({
ref: this.pickerTriggerRef,
onClick: showPopper,
onMouseLeave: hidePopper,
showColorPicker: showPopper,
hideColorPicker: hidePopper,
})
) : (
<div
<ColorPickerTrigger
ref={this.pickerTriggerRef}
onClick={showPopper}
onMouseLeave={hidePopper}
className="sp-replacer sp-light"
>
<div className="sp-preview">
<div
className="sp-preview-inner"
style={{
backgroundColor: getColorFromHexRgbOrName(this.props.color || '#000000', theme.type),
}}
/>
</div>
</div>
color={getColorFromHexRgbOrName(this.props.color || '#000000', theme.type)}
/>
)}
</>
);

View File

@ -17,8 +17,8 @@ export interface ColorPickerProps extends Themeable {
*/
onColorChange?: ColorPickerChangeHandler;
enableNamedColors?: boolean;
children?: JSX.Element;
}
export interface Props<T> extends ColorPickerProps, PopperContentProps {
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;
}
.sp-replacer {
background: inherit;
border: none;
color: inherit;
padding: 0;
border-radius: 10px;
cursor: pointer;
}
.sp-replacer:hover,
.sp-replacer.sp-active {
border-color: inherit;
color: inherit;
}
.sp-container {
border-radius: 0;
background-color: $dropdownBackground;
border: none;
padding: 0;
}
.sp-palette-container,
.sp-picker-container {
border: none;
}
.sp-dd {
display: none;
}
.sp-preview {
position: relative;
width: 15px;
height: 15px;
border: none;
margin: 0;
float: left;
z-index: 0;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
.sp-preview-inner,
.sp-alpha-inner,
.sp-thumb-inner {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.gf-color-picker__body {
padding-bottom: $arrowSize;
padding-left: 6px;

View File

@ -1,5 +1,5 @@
.form-field {
margin-bottom: $gf-form-margin;
margin-bottom: $space-xxs;
display: flex;
flex-direction: row;
align-items: center;

View File

@ -1,12 +1,12 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { Themeable, GrafanaThemeType } from '../../types/theme';
import { ValueMapping, Threshold, BasicGaugeColor } from '../../types/panel';
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
type TimeSeriesValue = string | number | null;
import { ValueMapping, Threshold, GrafanaThemeType } from '../../types';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
import { Themeable } from '../../index';
type GaugeValue = string | number | null;
export interface Props extends Themeable {
decimals?: number | null;
@ -30,7 +30,7 @@ const FONT_SCALE = 1;
export class Gauge extends PureComponent<Props> {
canvasElement: any;
static defaultProps = {
static defaultProps: Partial<Props> = {
maxValue: 100,
valueMappings: [],
minValue: 0,
@ -41,7 +41,6 @@ export class Gauge extends PureComponent<Props> {
thresholds: [],
unit: 'none',
stat: 'avg',
theme: GrafanaThemeType.Dark,
};
componentDidMount() {
@ -52,7 +51,7 @@ export class Gauge extends PureComponent<Props> {
this.draw();
}
formatValue(value: TimeSeriesValue) {
formatValue(value: GaugeValue) {
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
if (isNaN(value as number)) {
@ -73,26 +72,16 @@ export class Gauge extends PureComponent<Props> {
return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
}
getFontColor(value: TimeSeriesValue) {
getFontColor(value: GaugeValue): string {
const { thresholds, theme } = this.props;
if (thresholds.length === 1) {
return getColorFromHexRgbOrName(thresholds[0].color, theme.type);
const activeThreshold = getThresholdForValue(thresholds, value);
if (activeThreshold !== null) {
return getColorFromHexRgbOrName(activeThreshold.color, theme.type);
}
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
if (atThreshold) {
return getColorFromHexRgbOrName(atThreshold.color, theme.type);
}
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
if (belowThreshold.length > 0) {
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
return getColorFromHexRgbOrName(nearestThreshold.color, theme.type);
}
return BasicGaugeColor.Red;
return '';
}
getFormattedThresholds() {
@ -134,7 +123,7 @@ export class Gauge extends PureComponent<Props> {
Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1);
const thresholdLabelFontSize = fontSize / 2.5;
const options = {
const options: any = {
series: {
gauges: {
gauge: {
@ -184,19 +173,15 @@ export class Gauge extends PureComponent<Props> {
const { height, width } = this.props;
return (
<div className="singlestat-panel">
<div
style={{
height: `${height * 0.9}px`,
width: `${Math.min(width, height * 1.3)}px`,
top: '10px',
margin: 'auto',
}}
ref={element => (this.canvasElement = element)}
/>
</div>
<div
style={{
height: `${Math.min(height, width * 1.3)}px`,
width: `${Math.min(width, height * 1.3)}px`,
top: '10px',
margin: 'auto',
}}
ref={element => (this.canvasElement = element)}
/>
);
}
}
export default Gauge;

View File

@ -3,7 +3,7 @@ $select-input-bg-disabled: $input-bg-disabled;
@mixin select-control() {
width: 100%;
margin-right: $gf-form-margin;
margin-right: $space-xs;
@include border-radius($input-border-radius-sm);
background-color: $input-bg;
}

View File

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

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

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 'DeleteButton/DeleteButton';
@import 'ThresholdsEditor/ThresholdsEditor';
@import 'Table/Table';
@import 'Table/TableInputCSV';
@import 'Tooltip/Tooltip';
@import 'Select/Select';
@ -10,3 +11,4 @@
@import 'ValueMappingsEditor/ValueMappingsEditor';
@import 'EmptySearchResult/EmptySearchResult';
@import 'FormField/FormField';
@import 'BarGauge/BarGauge';

View File

@ -19,11 +19,15 @@ export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
export { Graph } from './Graph/Graph';
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
export { Gauge } from './Gauge/Gauge';
export { Switch } from './Switch/Switch';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export { UnitPicker } from './UnitPicker/UnitPicker';
// Visualizations
export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { BarGauge } from './BarGauge/BarGauge';
export { VizRepeater } from './VizRepeater/VizRepeater';

View File

@ -17,7 +17,13 @@ $enable-hover-media-query: false !default;
// Control the default styling of most Bootstrap elements by modifying these
// variables. Mostly focused on spacing.
$spacer: ${theme.spacing.m} !default;
$space-xxs: ${theme.spacing.xxs} !default;
$space-xs: ${theme.spacing.xs} !default;
$space-sm: ${theme.spacing.sm} !default;
$space-md: ${theme.spacing.md} !default;
$space-lg: ${theme.spacing.lg} !default;
$space-xl: ${theme.spacing.xl} !default;
$spacer: ${theme.spacing.d} !default;
$spacer-x: $spacer !default;
$spacer-y: $spacer !default;
$spacers: (
@ -46,7 +52,6 @@ $spacers: (
),
),
) !default;
$border-width: ${theme.border.width.s} !default;
// Grid breakpoints
//
@ -55,9 +60,9 @@ $border-width: ${theme.border.width.s} !default;
$grid-breakpoints: (
xs: ${theme.breakpoints.xs},
sm: ${theme.breakpoints.s},
md: ${theme.breakpoints.m},
lg: ${theme.breakpoints.l},
sm: ${theme.breakpoints.sm},
md: ${theme.breakpoints.md},
lg: ${theme.breakpoints.lg},
xl: ${theme.breakpoints.xl},
) !default;
@ -77,27 +82,26 @@ $container-max-widths: (
// Set the number of columns and specify the width of the gutters.
$grid-columns: 12 !default;
$grid-gutter-width: 30px !default;
$enable-flex: true;
$grid-gutter-width: ${theme.spacing.gutter} !default;
// Typography
// -------------------------
$font-family-sans-serif: ${theme.typography.fontFamily.sansSerif};
$font-family-monospace: ${theme.typography.fontFamily.monospace};
$font-family-base: $font-family-sans-serif !default;
$font-size-root: ${theme.typography.size.root} !default;
$font-size-base: ${theme.typography.size.base} !default;
$font-size-lg: ${theme.typography.size.l} !default;
$font-size-md: ${theme.typography.size.m} !default;
$font-size-sm: ${theme.typography.size.s} !default;
$font-size-lg: ${theme.typography.size.lg} !default;
$font-size-md: ${theme.typography.size.md} !default;
$font-size-sm: ${theme.typography.size.sm} !default;
$font-size-xs: ${theme.typography.size.xs} !default;
$line-height-base: ${theme.typography.lineHeight.l} !default;
$font-weight-semi-bold: ${theme.typography.weight.semibold};
$line-height-base: ${theme.typography.lineHeight.lg} !default;
$font-weight-regular: ${theme.typography.weight.regular} !default;
$font-weight-semi-bold: ${theme.typography.weight.semibold} !default;
$font-size-h1: ${theme.typography.heading.h1} !default;
$font-size-h2: ${theme.typography.heading.h2} !default;
@ -106,24 +110,18 @@ $font-size-h4: ${theme.typography.heading.h4} !default;
$font-size-h5: ${theme.typography.heading.h5} !default;
$font-size-h6: ${theme.typography.heading.h6} !default;
$headings-margin-bottom: ($spacer / 2) !default;
$headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$headings-font-weight: ${theme.typography.weight.normal} !default;
$headings-line-height: ${theme.typography.lineHeight.s} !default;
$hr-border-width: $border-width !default;
$dt-font-weight: bold !default;
$headings-line-height: ${theme.typography.lineHeight.sm} !default;
// Components
//
// Define common padding and border radius sizes and more.
$line-height-lg: (4 / 3) !default;
$line-height-sm: 1.5 !default;
$border-width: ${theme.border.width.sm} !default;
$border-radius: 3px !default;
$border-radius-lg: 5px !default;
$border-radius-sm: 2px !default;
$border-radius: ${theme.border.radius.md} !default;
$border-radius-lg: ${theme.border.radius.lg}!default;
$border-radius-sm: ${theme.border.radius.sm} !default;
// Page
@ -146,23 +144,17 @@ $input-padding-x: 10px !default;
$input-padding-y: 8px !default;
$input-line-height: 18px !default;
$input-btn-border-width: 1px;
$input-border-radius: 0 $border-radius $border-radius 0 !default;
$input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 0 !default;
$label-border-radius: $border-radius 0 0 $border-radius !default;
$label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default;
$input-padding-y-sm: 4px !default;
$input-padding-x-lg: 20px !default;
$input-padding-y-lg: 10px !default;
$input-height: 35px !default;
$gf-form-margin: 3px;
$gf-form-input-height: 35px;
$cursor-disabled: not-allowed !default;
// Form validation icons
@ -199,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
$btn-padding-x-xl: 21px !default;
$btn-padding-y-xl: 11px !default;
$btn-border-radius: 2px;
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
@ -207,8 +198,7 @@ $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
$side-menu-width: 60px;
// dashboard
$panel-margin: 10px;
$dashboard-padding: $panel-margin * 2;
$dashboard-padding: 10px * 2;
$panel-horizontal-padding: 10;
$panel-vertical-padding: 5;
$panel-padding: 0px $panel-horizontal-padding + 0px $panel-vertical-padding + 0px $panel-horizontal-padding + 0px;
@ -243,9 +233,4 @@ $external-services: (
icon: '',
),
) !default;
:export {
panelhorizontalpadding: $panel-horizontal-padding;
panelverticalpadding: $panel-vertical-padding;
}
`;

View File

@ -11,9 +11,9 @@ const theme: GrafanaThemeCommons = {
root: '14px',
base: '13px',
xs: '10px',
s: '12px',
m: '14px',
l: '18px',
sm: '12px',
md: '14px',
lg: '18px',
},
heading: {
h1: '28px',
@ -25,40 +25,47 @@ const theme: GrafanaThemeCommons = {
},
weight: {
light: 300,
normal: 400,
regular: 400,
semibold: 500,
},
lineHeight: {
xs: 1,
s: 1.1,
m: 4 / 3,
l: 1.5,
sm: 1.1,
md: 4 / 3,
lg: 1.5,
},
},
breakpoints: {
xs: '0',
s: '544px',
m: '768px',
l: '992px',
sm: '544px',
md: '768px',
lg: '992px',
xl: '1200px',
},
spacing: {
xs: '0',
s: '3px',
m: '14px',
l: '21px',
d: '14px',
xxs: '2px',
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
gutter: '30px',
},
border: {
radius: {
xs: '2px',
s: '3px',
m: '5px',
sm: '2px',
md: '3px',
lg: '5px',
},
width: {
s: '1px',
sm: '1px',
},
},
panelPadding: {
horizontal: 10,
vertical: 5,
},
};
export default theme;

View File

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

View File

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

View File

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

View File

@ -21,10 +21,13 @@ export interface PanelEditorProps<T = any> {
onOptionsChange: (options: T) => void;
}
export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
export class ReactPanelPlugin<TOptions = any> {
panel: ComponentClass<PanelProps<TOptions>>;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
defaults?: TOptions;
preserveOptions?: PreservePanelOptionsHandler<TOptions>;
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
this.panel = panel;
@ -37,6 +40,10 @@ export class ReactPanelPlugin<TOptions = any> {
setDefaults(defaults: TOptions) {
this.defaults = defaults;
}
setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
this.preserveOptions = handler;
}
}
export interface PanelSize {
@ -53,17 +60,6 @@ export interface PanelMenuItem {
subMenu?: PanelMenuItem[];
}
export interface Threshold {
index: number;
value: number;
color: string;
}
export enum BasicGaugeColor {
Green = '#299c46',
Red = '#d44a3a',
}
export enum MappingType {
ValueToText = 1,
RangeToText = 2,
@ -86,3 +82,9 @@ export interface RangeMap extends BaseMap {
from: string;
to: string;
}
export enum VizOrientation {
Auto = 'auto',
Vertical = 'vertical',
Horizontal = 'horizontal',
}

View File

@ -8,9 +8,9 @@ export interface GrafanaThemeCommons {
// TODO: not sure if should be a part of theme
breakpoints: {
xs: string;
s: string;
m: string;
l: string;
sm: string;
md: string;
lg: string;
xl: string;
};
typography: {
@ -22,20 +22,20 @@ export interface GrafanaThemeCommons {
root: string;
base: string;
xs: string;
s: string;
m: string;
l: string;
sm: string;
md: string;
lg: string;
};
weight: {
light: number;
normal: number;
regular: number;
semibold: number;
};
lineHeight: {
xs: number; //1
s: number; //1.1
m: number; // 4/3
l: number; // 1.5
sm: number; //1.1
md: number; // 4/3
lg: number; // 1.5
};
// TODO: Refactor to use size instead of custom defs
heading: {
@ -48,22 +48,29 @@ export interface GrafanaThemeCommons {
};
};
spacing: {
d: string;
xxs: string;
xs: string;
s: string;
m: string;
l: string;
sm: string;
md: string;
lg: string;
xl: string;
gutter: string;
};
border: {
radius: {
xs: string;
s: string;
m: string;
sm: string;
md: string;
lg: string;
};
width: {
s: string;
sm: string;
};
};
panelPadding: {
horizontal: number;
vertical: number;
};
}
export interface GrafanaTheme extends GrafanaThemeCommons {

View File

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

View File

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

View File

@ -1,6 +1,9 @@
import { TableData, Column, TimeSeries } from '../types/index';
// Libraries
import Papa, { ParseError, ParseMeta } from 'papaparse';
import isNumber from 'lodash/isNumber';
// Types
import { TableData, Column, TimeSeries } from '../types';
// Subset of all parse options
export interface TableParseOptions {
@ -163,3 +166,24 @@ export const toTableData = (results?: any[]): TableData[] => {
throw new Error('Unsupported data format');
});
};
export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
if (isNumber(sortIndex)) {
const copy = {
...data,
rows: [...data.rows].sort((a, b) => {
a = a[sortIndex];
b = b[sortIndex];
// Sort null or undefined separately from comparable values
return +(a == null) - +(b == null) || +(a > b) || -(a < b);
}),
};
if (reverse) {
copy.rows.reverse();
}
return copy;
}
return data;
}

View File

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

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
case "gauge":
sort = 3
case "table":
case "bargauge":
sort = 4
case "text":
case "table":
sort = 5
case "heatmap":
case "text":
sort = 6
case "alertlist":
case "heatmap":
sort = 7
case "dashlist":
case "alertlist":
sort = 8
case "dashlist":
sort = 9
}
return sort
}

View File

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

View File

@ -10,13 +10,13 @@ coreModule.directive('jsonTree', [
startExpanded: '@',
rootName: '@',
},
link: (scope, elem) => {
link: (scope: any, elem) => {
const jsonExp = new JsonExplorer(scope.object, 3, {
animateOpen: true,
});
const html = jsonExp.render(true);
elem.html(html);
elem.replaceAll(html);
},
};
},

View File

@ -14,7 +14,7 @@ coreModule.directive('giveFocus', () => {
}
setTimeout(() => {
element.focus();
const domEl = element[0];
const domEl: any = element[0];
if (domEl.setSelectionRange) {
const pos = element.val().length * 2;
domEl.setSelectionRange(pos, pos);

View File

@ -1,15 +0,0 @@
import kbn from '../utils/kbn';
describe('stringToJsRegex', () => {
it('should parse the valid regex value', () => {
const output = kbn.stringToJsRegex('/validRegexp/');
expect(output).toBeInstanceOf(RegExp);
});
it('should throw error on invalid regex value', () => {
const input = '/etc/hostname';
expect(() => {
kbn.stringToJsRegex(input);
}).toThrow();
});
});

View File

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

View File

@ -20,6 +20,7 @@ import {
ResultType,
QueryIntervals,
QueryOptions,
ResultGetter,
} from 'app/types/explore';
import { LogsDedupStrategy } from 'app/core/logs_model';
@ -301,11 +302,24 @@ export function getIntervals(range: RawTimeRange, lowLimit: string, resolution:
return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
}
export function makeTimeSeriesList(dataList) {
return dataList.map((seriesData, index) => {
export const makeTimeSeriesList: ResultGetter = (dataList, transaction, allTransactions) => {
// Prevent multiple Graph transactions to have the same colors
let colorIndexOffset = 0;
for (const other of allTransactions) {
// Only need to consider transactions that came before the current one
if (other === transaction) {
break;
}
// Count timeseries of previous query results
if (other.resultType === 'Graph' && other.done) {
colorIndexOffset += other.result.length;
}
}
return dataList.map((seriesData, index: number) => {
const datapoints = seriesData.datapoints || [];
const alias = seriesData.target;
const colorIndex = index % colors.length;
const colorIndex = (colorIndexOffset + index) % colors.length;
const color = colors[colorIndex];
const series = new TimeSeries({
@ -317,7 +331,7 @@ export function makeTimeSeriesList(dataList) {
return series;
});
}
};
/**
* Update the query history. Side-effect: store history in local storage

View File

@ -20,7 +20,7 @@
.gicon {
font-size: 30px;
margin-right: $spacer;
margin-right: $space-md;
}
&:hover {
@ -32,16 +32,16 @@
.add-panel-widget__title {
font-size: $font-size-md;
font-weight: $font-weight-semi-bold;
margin-right: $spacer * 2;
margin-right: $space-xl;
}
.add-panel-widget__link {
margin: 0 8px;
margin: 0 $space-sm;
width: 154px;
}
.add-panel-widget__icon {
margin-bottom: 8px;
margin-bottom: $space-sm;
.gicon {
color: white;
@ -62,7 +62,7 @@
.add-panel-widget__create {
display: inherit;
margin-bottom: 24px;
margin-bottom: $space-lg;
// this is to have the big button appear centered
margin-top: 55px;
}
@ -72,7 +72,7 @@
}
.add-panel-widget__action {
margin: 0 4px;
margin: 0 $space-xs;
}
.add-panel-widget__btn-container {

View File

@ -16,9 +16,6 @@
<button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs">
Save As...
</button>
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete">
Delete
</button>
</div>
</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>
</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 class="dashboard-settings__content" ng-if="ctrl.viewId === 'annotations'" ng-include="'public/app/features/annotations/partials/editor.html'">

View File

@ -6,11 +6,6 @@ import { cleanUpDashboard } from '../state/actions';
import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux';
import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
jest.mock('sass/_variables.generated.scss', () => ({
panelhorizontalpadding: 10,
panelVerticalPadding: 10,
}));
jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
interface ScenarioContext {

View File

@ -92,11 +92,12 @@ export class DashboardPage extends PureComponent<Props, State> {
componentWillUnmount() {
if (this.props.dashboard) {
this.props.cleanUpDashboard();
this.setPanelFullscreenClass(false);
}
}
componentDidUpdate(prevProps: Props) {
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props;
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId, urlUid } = this.props;
if (!dashboard) {
return;
@ -107,6 +108,12 @@ export class DashboardPage extends PureComponent<Props, State> {
document.title = dashboard.title + ' - Grafana';
}
// Due to the angular -> react url bridge we can ge an update here with new uid before the container unmounts
// Can remove this condition after we switch to react router
if (prevProps.urlUid !== urlUid) {
return;
}
// handle animation states when opening dashboard settings
if (!prevProps.editview && editview) {
this.setState({ isSettingsOpening: true });
@ -163,14 +170,20 @@ export class DashboardPage extends PureComponent<Props, State> {
fullscreenPanel: null,
scrollTop: this.state.rememberScrollTop,
},
() => {
dashboard.render();
}
this.triggerPanelsRendering.bind(this)
);
this.setPanelFullscreenClass(false);
}
triggerPanelsRendering() {
try {
this.props.dashboard.render();
} catch (err) {
this.props.notifyApp(createErrorNotification(`Panel rendering error`, err));
}
}
handleFullscreenPanelNotFound(urlPanelId: string) {
// Panel not found
this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));

View File

@ -76,21 +76,26 @@ export class DashboardPanel extends PureComponent<Props, State> {
// unmount angular panel
this.cleanUpAngularPanel();
if (panel.type !== pluginId) {
this.props.panel.changeType(pluginId, fromAngularPanel);
}
if (plugin.exports) {
this.setState({ plugin, angularPanel: null });
} else {
if (!plugin.exports) {
try {
plugin.exports = await importPluginModule(plugin.module);
} catch (e) {
plugin = getPanelPluginNotFound(pluginId);
}
this.setState({ plugin, angularPanel: null });
}
if (panel.type !== pluginId) {
if (fromAngularPanel) {
// for angular panels only we need to remove all events and let angular panels do some cleanup
panel.destroy();
this.props.panel.changeType(pluginId);
} else {
panel.changeType(pluginId, plugin.exports.reactPanel.preserveOptions);
}
}
this.setState({ plugin, angularPanel: null });
}
}

View File

@ -114,7 +114,7 @@ export class DataPanel extends Component<Props, State> {
this.setState({ loading: LoadingState.Loading });
try {
const ds = await this.dataSourceSrv.get(datasource);
const ds = await this.dataSourceSrv.get(datasource, scopedVars);
// TODO interpolate variables
const minInterval = this.props.minInterval || ds.interval;

View File

@ -1,10 +1,5 @@
import { PanelChrome } from './PanelChrome';
jest.mock('sass/_variables.generated.scss', () => ({
panelhorizontalpadding: 10,
panelVerticalPadding: 10,
}));
describe('PanelChrome', () => {
let chrome: PanelChrome;

View File

@ -14,6 +14,7 @@ import ErrorBoundary from '../../../core/components/ErrorBoundary/ErrorBoundary'
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
import { profiler } from 'app/core/profiler';
import config from 'app/core/config';
// Types
import { DashboardModel, PanelModel } from '../state';
@ -21,7 +22,6 @@ import { PanelPlugin } from 'app/types';
import { DataQueryResponse, TimeRange, LoadingState, TableData, DataQueryError, toTableData } from '@grafana/ui';
import { ScopedVars } from '@grafana/ui';
import variables from 'sass/_variables.generated.scss';
import templateSrv from 'app/features/templating/template_srv';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
@ -160,8 +160,8 @@ export class PanelChrome extends PureComponent<Props, State> {
data={data}
timeRange={timeRange}
options={panel.getOptions(plugin.exports.reactPanel.defaults)}
width={width - 2 * variables.panelhorizontalpadding}
height={height - PANEL_HEADER_HEIGHT - variables.panelverticalpadding}
width={width - 2 * config.theme.panelPadding.horizontal}
height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
renderCounter={renderCounter}
replaceVariables={this.replaceVariables}
/>

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ coreModule.directive('datasourceHttpSettings', () => {
},
templateUrl: 'public/app/features/datasources/partials/http_settings.html',
link: {
pre: ($scope, elem, attrs) => {
pre: ($scope: any, elem, attrs) => {
// do not show access option if direct access is disabled
$scope.showAccessOption = $scope.noDirectAccess !== 'true';
$scope.showAccessHelp = false;

View File

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

View File

@ -81,7 +81,7 @@ class MetricsPanelCtrl extends PanelCtrl {
// load datasource service
this.datasourceSrv
.get(this.panel.datasource)
.get(this.panel.datasource, this.panel.scopedVars)
.then(this.updateTimeRange.bind(this))
.then(this.issueQueries.bind(this))
.then(this.handleQueryResult.bind(this))

View File

@ -33,7 +33,7 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
template: panelTemplate,
transclude: true,
scope: { ctrl: '=' },
link: (scope, elem) => {
link: (scope: any, elem) => {
const panelContainer = elem.find('.panel-container');
const panelContent = elem.find('.panel-content');
const cornerInfoElem = elem.find('.panel-info-corner');
@ -67,7 +67,7 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
// set initial transparency
if (ctrl.panel.transparent) {
transparentLastState = true;
panelContainer.addClass('panel-transparent', true);
panelContainer.addClass('panel-transparent');
}
// update scrollbar after mounting

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

View File

@ -7,7 +7,7 @@ import config from 'app/core/config';
import { importPluginModule } from './plugin_loader';
// Types
import { DataSourceApi, DataSourceSelectItem } from '@grafana/ui/src/types';
import { DataSourceApi, DataSourceSelectItem, ScopedVars } from '@grafana/ui/src/types';
export class DatasourceSrv {
datasources: { [name: string]: DataSourceApi };
@ -21,12 +21,18 @@ export class DatasourceSrv {
this.datasources = {};
}
get(name?: string): Promise<DataSourceApi> {
get(name?: string, scopedVars?: ScopedVars): Promise<DataSourceApi> {
if (!name) {
return this.get(config.defaultDatasource);
}
name = this.templateSrv.replace(name);
// Interpolation here is to support template variable in data source selection
name = this.templateSrv.replace(name, scopedVars, (value, variable) => {
if (Array.isArray(value)) {
return value[0];
}
return value;
});
if (name === 'default') {
return this.get(config.defaultDatasource);

View File

@ -6,6 +6,8 @@ export class DatasourceVariable implements Variable {
query: string;
options: any;
current: any;
multi: boolean;
includeAll: boolean;
refresh: any;
skipUrlSync: boolean;
@ -18,6 +20,8 @@ export class DatasourceVariable implements Variable {
regex: '',
options: [],
query: '',
multi: false,
includeAll: false,
refresh: 1,
skipUrlSync: false,
};
@ -69,9 +73,16 @@ export class DatasourceVariable implements Variable {
}
this.options = options;
if (this.includeAll) {
this.addAllOption();
}
return this.variableSrv.validateVariableSelectionState(this);
}
addAllOption() {
this.options.unshift({ text: 'All', value: '$__all' });
}
dependsOn(variable) {
if (this.regex) {
return containsVariable(this.regex, variable.name);
@ -84,6 +95,9 @@ export class DatasourceVariable implements Variable {
}
getValueForUrl() {
if (this.current.text === 'All') {
return 'All';
}
return this.current.value;
}
}
@ -91,5 +105,6 @@ export class DatasourceVariable implements Variable {
variableTypes['datasource'] = {
name: 'Datasource',
ctor: DatasourceVariable,
supportsMulti: true,
description: 'Enabled you to dynamically switch the datasource for multiple panels',
};

View File

@ -1,5 +1,6 @@
import _ from 'lodash';
import TableModel from 'app/core/table_model';
import { TimeSeries } from '@grafana/ui';
export class ResultTransformer {
constructor(private templateSrv) {}
@ -18,10 +19,10 @@ export class ResultTransformer {
];
} else if (prometheusResult && options.format === 'heatmap') {
let seriesList = [];
prometheusResult.sort(sortSeriesByLabel);
for (const metricData of prometheusResult) {
seriesList.push(this.transformMetricData(metricData, options, options.start, options.end));
}
seriesList.sort(sortSeriesByLabel);
seriesList = this.transformToHistogramOverTime(seriesList);
return seriesList;
} else if (prometheusResult) {
@ -197,13 +198,13 @@ export class ResultTransformer {
}
}
function sortSeriesByLabel(s1, s2): number {
function sortSeriesByLabel(s1: TimeSeries, s2: TimeSeries): number {
let le1, le2;
try {
// fail if not integer. might happen with bad queries
le1 = parseHistogramLabel(s1.metric.le);
le2 = parseHistogramLabel(s2.metric.le);
le1 = parseHistogramLabel(s1.target);
le2 = parseHistogramLabel(s2.target);
} catch (err) {
console.log(err);
return 0;

View File

@ -2,28 +2,6 @@ import coreModule from 'app/core/core_module';
import _ from 'lodash';
import { FilterSegments, DefaultFilterValue } from './filter_segments';
export class StackdriverFilter {
/** @ngInject */
constructor() {
return {
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
controller: 'StackdriverFilterCtrl',
controllerAs: 'ctrl',
bindToController: true,
restrict: 'E',
scope: {
labelData: '<',
loading: '<',
groupBys: '<',
filters: '<',
filtersChanged: '&',
groupBysChanged: '&',
hideGroupBys: '<',
},
};
}
}
export class StackdriverFilterCtrl {
defaultRemoveGroupByValue = '-- remove group by --';
resourceTypeValue = 'resource.type';
@ -193,5 +171,24 @@ export class StackdriverFilterCtrl {
}
}
coreModule.directive('stackdriverFilter', StackdriverFilter);
coreModule.controller('StackdriverFilterCtrl', StackdriverFilterCtrl);
/** @ngInject */
function stackdriverFilter() {
return {
templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
controller: StackdriverFilterCtrl,
controllerAs: 'ctrl',
bindToController: true,
restrict: 'E',
scope: {
labelData: '<',
loading: '<',
groupBys: '<',
filters: '<',
filtersChanged: '&',
groupBysChanged: '&',
hideGroupBys: '<',
},
};
}
coreModule.directive('stackdriverFilter', stackdriverFilter);

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 { data, options, width, height } = this.props;
const values = processSingleStatPanelData({
data,
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';
export class GaugeOptionsBox extends PureComponent<PanelEditorProps<GaugeOptions>> {
labelWidth = 8;
onToggleThresholdLabels = () =>
this.props.onOptionsChange({ ...this.props.options, showThresholdLabels: !this.props.options.showThresholdLabels });
@ -28,17 +30,17 @@ export class GaugeOptionsBox extends PureComponent<PanelEditorProps<GaugeOptions
return (
<PanelOptionsGroup title="Gauge">
<FormField label="Min value" labelWidth={8} onChange={this.onMinValueChange} value={minValue} />
<FormField label="Max value" labelWidth={8} onChange={this.onMaxValueChange} value={maxValue} />
<FormField label="Min value" labelWidth={this.labelWidth} onChange={this.onMinValueChange} value={minValue} />
<FormField label="Max value" labelWidth={this.labelWidth} onChange={this.onMaxValueChange} value={maxValue} />
<Switch
label="Show labels"
labelClass="width-8"
labelClass={`width-${this.labelWidth}`}
checked={showThresholdLabels}
onChange={this.onToggleThresholdLabels}
/>
<Switch
label="Show markers"
labelClass="width-8"
labelClass={`width-${this.labelWidth}`}
checked={showThresholdMarkers}
onChange={this.onToggleThresholdMarkers}
/>

View File

@ -1,80 +1,59 @@
// Libraries
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
// Services & Utils
import { processTimeSeries, ThemeContext } from '@grafana/ui';
import { processSingleStatPanelData } from '@grafana/ui';
import { config } from 'app/core/config';
// Components
import { Gauge } from '@grafana/ui';
import { Gauge, VizRepeater } from '@grafana/ui';
// 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 State {
value: TimeSeriesValue;
}
export class GaugePanel extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
value: this.findValue(props),
};
}
componentDidUpdate(prevProps: Props) {
if (this.props.data !== prevProps.data) {
this.setState({ value: this.findValue(this.props) });
}
}
findValue(props: Props): number | null {
const { data, options } = props;
export class GaugePanel extends PureComponent<Props> {
renderGauge(value, width, height) {
const { replaceVariables, options } = this.props;
const { valueOptions } = options;
if (data) {
const vmSeries = processTimeSeries({
data,
nullValueMode: NullValueMode.Null,
});
if (vmSeries[0]) {
return vmSeries[0].stats[valueOptions.stat];
}
}
return null;
}
render() {
const { width, height, replaceVariables, options } = this.props;
const { valueOptions } = options;
const { value } = this.state;
const prefix = replaceVariables(valueOptions.prefix);
const suffix = replaceVariables(valueOptions.suffix);
return (
<ThemeContext.Consumer>
{theme => (
<Gauge
value={value}
width={width}
height={height}
prefix={prefix}
suffix={suffix}
unit={valueOptions.unit}
decimals={valueOptions.decimals}
thresholds={options.thresholds}
valueMappings={options.valueMappings}
showThresholdLabels={options.showThresholdLabels}
showThresholdMarkers={options.showThresholdMarkers}
minValue={options.minValue}
maxValue={options.maxValue}
theme={theme}
/>
)}
</ThemeContext.Consumer>
<Gauge
value={value}
width={width}
height={height}
prefix={prefix}
suffix={suffix}
unit={valueOptions.unit}
decimals={valueOptions.decimals}
thresholds={options.thresholds}
valueMappings={options.valueMappings}
showThresholdLabels={options.showThresholdLabels}
showThresholdMarkers={options.showThresholdMarkers}
minValue={options.minValue}
maxValue={options.maxValue}
theme={config.theme}
/>
);
}
render() {
const { data, options, height, width } = this.props;
const values = processSingleStatPanelData({
data,
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 {
PanelEditorProps,

View File

@ -8,3 +8,15 @@ export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel);
reactPanel.setEditor(GaugePanelEditor);
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',
},
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}
enableNamedColors
>
<span className="graph-legend-icon">
<SeriesIcon color={this.props.color} />
</span>
{({ ref, showColorPicker, hideColorPicker }) => (
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker} className="graph-legend-icon">
<SeriesIcon color={this.props.color} />
</span>
)}
</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">
</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>
<br/>
<h5 class="section-heading">Y-Axes</h5>

View File

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

View File

@ -43,6 +43,10 @@ export function convertValuesToHistogram(values: number[], bucketSize: number, m
}
for (let i = 0; i < values.length; i++) {
// filter out values outside the min and max boundaries
if (values[i] < min || values[i] > max) {
continue;
}
const bound = getBucketBound(values[i], bucketSize);
histogram[bound] = histogram[bound] + 1;
}

View File

@ -516,4 +516,408 @@ describe('grafanaGraph', () => {
expect(ctx.plotData[0].data[0][1]).toBe(2);
});
});
describe('when graph is histogram, and xaxis min is set', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 150;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should not contain values lower than min', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(200);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min is zero', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 0;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[-100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should not contain values lower than zero', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min is null', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = null;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[-100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis min should not affect the histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min is undefined', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = undefined;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[-100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis min should not affect the histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis max is set', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.max = 250;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should not contain values greater than max', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(200);
});
});
describe('when graph is histogram, and xaxis max is zero', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.max = 0;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should not contain values greater than zero', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(-100);
});
});
describe('when graph is histogram, and xaxis max is null', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.max = null;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis max should not affect the histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis max is undefined', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.max = undefined;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis max should not should node affect the histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(-100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min and max are set', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 150;
ctrl.panel.xaxis.max = 250;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should not contain values lower than min and greater than max', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(200);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(200);
});
});
describe('when graph is histogram, and xaxis min and max are zero', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 0;
ctrl.panel.xaxis.max = 0;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[-100, 1], [100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis max should be ignored otherwise the bucketSize is zero', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min and max are null', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = null;
ctrl.panel.xaxis.max = null;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis min and max should not affect the histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min and max are undefined', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = undefined;
ctrl.panel.xaxis.max = undefined;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis min and max should not affect the histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min is greater than xaxis max', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 150;
ctrl.panel.xaxis.max = 100;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis max should be ignored otherwise the bucketSize is negative', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(200);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
// aaa
describe('when graph is histogram, and xaxis min is greater than the maximum value', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 301;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis min should be ignored otherwise the bucketSize is negative', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min is equal to the maximum value', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 300;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis min should be ignored otherwise the bucketSize is zero', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis min is lower than the minimum value', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.min = 99;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('xaxis min should not affect the histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
describe('when graph is histogram, and xaxis max is equal to the minimum value', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.max = 100;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should calculate correct histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(100);
});
});
describe('when graph is histogram, and xaxis max is a lower than the minimum value', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.max = 99;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should calculate empty histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(nonZero.length).toBe(0);
});
});
describe('when graph is histogram, and xaxis max is greater than the maximum value', () => {
beforeEach(() => {
setupCtx(() => {
ctrl.panel.xaxis.mode = 'histogram';
ctrl.panel.xaxis.max = 301;
ctrl.panel.stack = false;
ctrl.hiddenSeries = {};
ctx.data[0] = new TimeSeries({
datapoints: [[100, 1], [100, 2], [200, 3], [300, 4]],
alias: 'series1',
});
});
});
it('should calculate correct histogram', () => {
const nonZero = ctx.plotData[0].data.filter(t => t[1] > 0);
expect(Math.min.apply(Math, nonZero.map(t => t[0]))).toBe(100);
expect(Math.max.apply(Math, nonZero.map(t => t[0]))).toBe(300);
});
});
});

View File

@ -32,6 +32,7 @@ export class AxesEditorCtrl {
Auto: 'auto',
Upper: 'upper',
Lower: 'lower',
Middle: 'middle',
};
}

View File

@ -11,6 +11,8 @@ const LEGEND_HEIGHT_PX = 6;
const LEGEND_WIDTH_PX = 100;
const LEGEND_TICK_SIZE = 0;
const LEGEND_VALUE_MARGIN = 0;
const LEGEND_PADDING_LEFT = 10;
const LEGEND_SEGMENT_WIDTH = 10;
/**
* Color legend for heatmap editor.
@ -19,7 +21,7 @@ coreModule.directive('colorLegend', () => {
return {
restrict: 'E',
template: '<div class="heatmap-color-legend"><svg width="16.5rem" height="24px"></svg></div>',
link: (scope, elem, attrs) => {
link: (scope: any, elem, attrs) => {
const ctrl = scope.ctrl;
const panel = scope.ctrl.panel;
@ -55,7 +57,7 @@ coreModule.directive('heatmapLegend', () => {
return {
restrict: 'E',
template: `<div class="heatmap-color-legend"><svg width="${LEGEND_WIDTH_PX}px" height="${LEGEND_HEIGHT_PX}px"></svg></div>`,
link: (scope, elem, attrs) => {
link: (scope: any, elem, attrs) => {
const ctrl = scope.ctrl;
const panel = scope.ctrl.panel;
@ -67,10 +69,11 @@ coreModule.directive('heatmapLegend', () => {
function render() {
clearLegend(elem);
if (!_.isEmpty(ctrl.data) && !_.isEmpty(ctrl.data.cards)) {
const rangeFrom = 0;
const rangeTo = ctrl.data.cardStats.max;
const maxValue = panel.color.max || rangeTo;
const minValue = panel.color.min || 0;
const cardStats = ctrl.data.cardStats;
const rangeFrom = _.isNil(panel.color.min) ? Math.min(cardStats.min, 0) : panel.color.min;
const rangeTo = _.isNil(panel.color.max) ? cardStats.max : panel.color.max;
const maxValue = cardStats.max;
const minValue = cardStats.min;
if (panel.color.mode === 'spectrum') {
const colorScheme = _.find(ctrl.colorSchemes, {
@ -95,27 +98,27 @@ function drawColorLegend(elem, colorScheme, rangeFrom, rangeTo, maxValue, minVal
const legendWidth = Math.floor(legendElem.outerWidth()) - 30;
const legendHeight = legendElem.attr('height');
let rangeStep = 1;
if (rangeTo - rangeFrom > legendWidth) {
rangeStep = Math.floor((rangeTo - rangeFrom) / legendWidth);
}
const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH;
const widthFactor = legendWidth / (rangeTo - rangeFrom);
const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep);
const colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue);
legend
.append('g')
.attr('class', 'legend-color-bar')
.attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)')
.selectAll('.heatmap-color-legend-rect')
.data(valuesRange)
.enter()
.append('rect')
.attr('x', d => d * widthFactor)
.attr('x', d => Math.round((d - rangeFrom) * widthFactor))
.attr('y', 0)
.attr('width', rangeStep * widthFactor + 1) // Overlap rectangles to prevent gaps
.attr('width', Math.round(rangeStep * widthFactor + 1)) // Overlap rectangles to prevent gaps
.attr('height', legendHeight)
.attr('stroke-width', 0)
.attr('fill', d => colorScale(d));
drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth);
drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange);
}
function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue) {
@ -126,31 +129,31 @@ function drawOpacityLegend(elem, options, rangeFrom, rangeTo, maxValue, minValue
const legendWidth = Math.floor(legendElem.outerWidth()) - 30;
const legendHeight = legendElem.attr('height');
let rangeStep = 1;
if (rangeTo - rangeFrom > legendWidth) {
rangeStep = Math.floor((rangeTo - rangeFrom) / legendWidth);
}
const rangeStep = ((rangeTo - rangeFrom) / legendWidth) * LEGEND_SEGMENT_WIDTH;
const widthFactor = legendWidth / (rangeTo - rangeFrom);
const valuesRange = d3.range(rangeFrom, rangeTo, rangeStep);
const opacityScale = getOpacityScale(options, maxValue, minValue);
legend
.append('g')
.attr('class', 'legend-color-bar')
.attr('transform', 'translate(' + LEGEND_PADDING_LEFT + ',0)')
.selectAll('.heatmap-opacity-legend-rect')
.data(valuesRange)
.enter()
.append('rect')
.attr('x', d => d * widthFactor)
.attr('x', d => Math.round((d - rangeFrom) * widthFactor))
.attr('y', 0)
.attr('width', rangeStep * widthFactor)
.attr('width', Math.round(rangeStep * widthFactor))
.attr('height', legendHeight)
.attr('stroke-width', 0)
.attr('fill', options.cardColor)
.style('opacity', d => opacityScale(d));
drawLegendValues(elem, opacityScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth);
drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange);
}
function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minValue, legendWidth) {
function drawLegendValues(elem, rangeFrom, rangeTo, maxValue, minValue, legendWidth, valuesRange) {
const legendElem = $(elem).find('svg');
const legend = d3.select(legendElem.get(0));
@ -160,10 +163,10 @@ function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minVal
const legendValueScale = d3
.scaleLinear()
.domain([0, rangeTo])
.domain([rangeFrom, rangeTo])
.range([0, legendWidth]);
const ticks = buildLegendTicks(0, rangeTo, maxValue, minValue);
const ticks = buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue);
const xAxis = d3
.axisBottom(legendValueScale)
.tickValues(ticks)
@ -171,7 +174,7 @@ function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minVal
const colorRect = legendElem.find(':first-child');
const posY = getSvgElemHeight(legendElem) + LEGEND_VALUE_MARGIN;
const posX = getSvgElemX(colorRect);
const posX = getSvgElemX(colorRect) + LEGEND_PADDING_LEFT;
d3.select(legendElem.get(0))
.append('g')
@ -284,11 +287,12 @@ function getSvgElemHeight(elem) {
function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) {
const range = rangeTo - rangeFrom;
const tickStepSize = tickStep(rangeFrom, rangeTo, 3);
const ticksNum = Math.round(range / tickStepSize);
const ticksNum = Math.ceil(range / tickStepSize);
const firstTick = getFirstCloseTick(rangeFrom, tickStepSize);
let ticks = [];
for (let i = 0; i < ticksNum; i++) {
const current = tickStepSize * i;
const current = firstTick + tickStepSize * i;
// Add user-defined min and max if it had been set
if (isValueCloseTo(minValue, current, tickStepSize)) {
ticks.push(minValue);
@ -302,7 +306,7 @@ function buildLegendTicks(rangeFrom, rangeTo, maxValue, minValue) {
} else if (maxValue < current) {
ticks.push(maxValue);
}
ticks.push(tickStepSize * i);
ticks.push(current);
}
if (!isValueCloseTo(maxValue, rangeTo, tickStepSize)) {
ticks.push(maxValue);
@ -316,3 +320,10 @@ function isValueCloseTo(val, valueTo, step) {
const diff = Math.abs(val - valueTo);
return diff < step * 0.3;
}
function getFirstCloseTick(minValue, step) {
if (minValue < 0) {
return Math.floor(minValue / step) * step;
}
return 0;
}

View File

@ -34,6 +34,7 @@ const panelDefaults = {
},
dataFormat: 'timeseries',
yBucketBound: 'auto',
reverseYBuckets: false,
xAxis: {
show: true,
},
@ -55,6 +56,7 @@ const panelDefaults = {
showHistogram: false,
},
highlightCards: true,
hideZeroBuckets: false,
};
const colorModes = ['opacity', 'spectrum'];
@ -97,7 +99,7 @@ const colorSchemes = [
{ name: 'YlOrRd', value: 'interpolateYlOrRd', invert: 'dark' },
];
const dsSupportHistogramSort = ['prometheus', 'elasticsearch'];
const dsSupportHistogramSort = ['elasticsearch'];
export class HeatmapCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html';
@ -108,7 +110,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
selectionActivated: boolean;
unitFormats: any;
data: any;
series: any;
series: any[];
timeSrv: any;
dataWarning: any;
decimals: number;
@ -146,7 +148,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
}
onRender() {
if (!this.range) {
if (!this.range || !this.series) {
return;
}
@ -204,7 +206,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
yBucketSize = 1;
}
const { cards, cardStats } = convertToCards(bucketsData);
const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets);
this.data = {
buckets: bucketsData,
@ -225,13 +227,20 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
this.series.sort(sortSeriesByLabel);
}
if (this.panel.reverseYBuckets) {
this.series.reverse();
}
// Convert histogram to heatmap. Each histogram bucket represented by the series which name is
// a top (or bottom, depends of datasource) bucket bound. Further, these values will be used as X axis labels.
// a top (or bottom, depends of datasource) bucket bound. Further, these values will be used as Y axis labels.
bucketsData = histogramToHeatmap(this.series);
tsBuckets = _.map(this.series, 'label');
const yBucketBound = this.panel.yBucketBound;
if ((panelDatasource === 'prometheus' && yBucketBound !== 'lower') || yBucketBound === 'upper') {
if (
(panelDatasource === 'prometheus' && yBucketBound !== 'lower' && yBucketBound !== 'middle') ||
yBucketBound === 'upper'
) {
// Prometheus labels are upper inclusive bounds, so add empty bottom bucket label.
tsBuckets = [''].concat(tsBuckets);
} else {
@ -246,7 +255,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
// Always let yBucketSize=1 in 'tsbuckets' mode
yBucketSize = 1;
const { cards, cardStats } = convertToCards(bucketsData);
const { cards, cardStats } = convertToCards(bucketsData, this.panel.hideZeroBuckets);
this.data = {
buckets: bucketsData,

View File

@ -93,25 +93,43 @@ function parseHistogramLabel(label: string): number {
return value;
}
interface HeatmapCard {
x: number;
y: number;
yBounds: {
top: number | null;
bottom: number | null;
};
values: number[];
count: number;
}
interface HeatmapCardStats {
min: number;
max: number;
}
/**
* Convert buckets into linear array of "cards" - objects, represented heatmap elements.
* @param {Object} buckets
* @return {Array} Array of "card" objects
* @return {Object} Array of "card" objects and stats
*/
function convertToCards(buckets) {
function convertToCards(buckets: any, hideZero = false): { cards: HeatmapCard[]; cardStats: HeatmapCardStats } {
let min = 0,
max = 0;
const cards = [];
const cards: HeatmapCard[] = [];
_.forEach(buckets, xBucket => {
_.forEach(xBucket.buckets, yBucket => {
const card = {
const card: HeatmapCard = {
x: xBucket.x,
y: yBucket.y,
yBounds: yBucket.bounds,
values: yBucket.values,
count: yBucket.count,
};
cards.push(card);
if (!hideZero || card.count !== 0) {
cards.push(card);
}
if (cards.length === 1) {
min = yBucket.count;

View File

@ -114,7 +114,9 @@ export class HeatmapTooltip {
};
boundBottom = tickFormatter(yBucketIndex);
boundTop = yBucketIndex < data.tsBuckets.length - 1 ? tickFormatter(yBucketIndex + 1) : '';
if (this.panel.yBucketBound !== 'middle') {
boundTop = yBucketIndex < data.tsBuckets.length - 1 ? tickFormatter(yBucketIndex + 1) : '';
}
} else {
// Display 0 if bucket is a special 'zero' bucket
const bottom = yData.y ? yData.bounds.bottom : 0;
@ -122,8 +124,9 @@ export class HeatmapTooltip {
boundTop = bucketBoundFormatter(yData.bounds.top);
}
valuesNumber = countValueFormatter(yData.count);
const boundStr = boundTop && boundBottom ? `${boundBottom} - ${boundTop}` : boundBottom || boundTop;
tooltipHtml += `<div>
bucket: <b>${boundBottom} - ${boundTop}</b> <br>
bucket: <b>${boundStr}</b> <br>
count: <b>${valuesNumber}</b> <br>
</div>`;
} else {

View File

@ -40,6 +40,11 @@
</select>
</div>
</div>
<gf-form-switch ng-if="ctrl.panel.dataFormat == 'tsbuckets'"
class="gf-form" label-class="width-8"
label="Reverse order"
checked="ctrl.panel.reverseYBuckets" on-change="ctrl.refresh()">
</gf-form-switch>
</div>
<div class="section gf-form-group" ng-if="ctrl.panel.dataFormat == 'timeseries'">

View File

@ -63,6 +63,10 @@
<div class="section gf-form-group">
<h5 class="section-heading">Buckets</h5>
<gf-form-switch class="gf-form" label-class="width-8"
label="Hide zero"
checked="ctrl.panel.hideZeroBuckets" on-change="ctrl.render()">
</gf-form-switch>
<div class="gf-form">
<label class="gf-form-label width-8">Space</label>
<input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="''" ng-model="ctrl.panel.cards.cardPadding" ng-change="ctrl.refresh()" ng-model-onblur>

View File

@ -379,6 +379,12 @@ export class HeatmapRenderer {
const posX = this.getYAxisWidth(this.heatmap) + Y_AXIS_TICK_PADDING;
this.heatmap.select('.axis-y').attr('transform', 'translate(' + posX + ',' + posY + ')');
if (this.panel.yBucketBound === 'middle' && tickValues && tickValues.length) {
// Shift Y axis labels to the middle of bucket
const tickShift = 0 - this.chartHeight / (tickValues.length - 1) / 2;
this.heatmap.selectAll('.axis-y text').attr('transform', 'translate(' + 0 + ',' + tickShift + ')');
}
// Remove vertical line in the right of axis labels (called domain in d3)
this.heatmap
.select('.axis-y')
@ -518,14 +524,16 @@ export class HeatmapRenderer {
}
const cardsData = this.data.cards;
const maxValueAuto = this.data.cardStats.max;
const maxValue = this.panel.color.max || maxValueAuto;
const minValue = this.panel.color.min || 0;
const cardStats = this.data.cardStats;
const maxValueAuto = cardStats.max;
const minValueAuto = Math.min(cardStats.min, 0);
const maxValue = _.isNil(this.panel.color.max) ? maxValueAuto : this.panel.color.max;
const minValue = _.isNil(this.panel.color.min) ? minValueAuto : this.panel.color.min;
const colorScheme = _.find(this.ctrl.colorSchemes, {
value: this.panel.color.colorScheme,
});
this.colorScale = getColorScale(colorScheme, contextSrv.user.lightTheme, maxValue, minValue);
this.opacityScale = getOpacityScale(this.panel.color, maxValue);
this.opacityScale = getOpacityScale(this.panel.color, maxValue, minValue);
this.setCardSize();
let cards = this.heatmap.selectAll('.heatmap-card').data(cardsData);
@ -615,8 +623,8 @@ export class HeatmapRenderer {
w = this.cardWidth;
}
// Card width should be MIN_CARD_SIZE at least
w = Math.max(w, MIN_CARD_SIZE);
// Card width should be MIN_CARD_SIZE at least, but cut cards shouldn't be displayed
w = w > 0 ? Math.max(w, MIN_CARD_SIZE) : 0;
return w;
}

View File

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

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 { data, options } = this.props;
if (data.length < 1) {
return <div>No Table Data...</div>;
}
return (
<ThemeContext.Consumer>
{theme => <Table {...this.props} {...options} theme={theme} data={data[0]} />}
</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,
TimeRange,
DataQuery,
DataQueryResponseData,
DataSourceSelectItem,
DataSourceApi,
QueryHint,
ExploreStartPageProps,
} from '@grafana/ui';
import { Emitter } from 'app/core/core';
import { Emitter, TimeSeries } from 'app/core/core';
import { LogsModel, LogsDedupStrategy, LogLevel } from 'app/core/logs_model';
import TableModel from 'app/core/table_model';
@ -322,6 +323,12 @@ export interface QueryTransaction {
export type RangeScanner = () => RawTimeRange;
export type ResultGetter = (
result: DataQueryResponseData,
transaction: QueryTransaction,
allTransactions: QueryTransaction[]
) => TimeSeries;
export interface TextMatch {
text: string;
start: number;

View File

@ -20,6 +20,12 @@ $enable-hover-media-query: false !default;
// Control the default styling of most Bootstrap elements by modifying these
// variables. Mostly focused on spacing.
$space-xxs: 2px !default;
$space-xs: 4px !default;
$space-sm: 8px !default;
$space-md: 16px !default;
$space-lg: 24px !default;
$space-xl: 32px !default;
$spacer: 14px !default;
$spacer-x: $spacer !default;
$spacer-y: $spacer !default;
@ -49,7 +55,6 @@ $spacers: (
),
),
) !default;
$border-width: 1px !default;
// Grid breakpoints
//
@ -82,14 +87,11 @@ $container-max-widths: (
$grid-columns: 12 !default;
$grid-gutter-width: 30px !default;
$enable-flex: true;
// Typography
// -------------------------
$font-family-sans-serif: 'Roboto', Helvetica, Arial, sans-serif;
$font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace;
$font-family-base: $font-family-sans-serif !default;
$font-size-root: 14px !default;
$font-size-base: 13px !default;
@ -100,7 +102,9 @@ $font-size-sm: 12px !default;
$font-size-xs: 10px !default;
$line-height-base: 1.5 !default;
$font-weight-semi-bold: 500;
$font-weight-regular: 400 !default;
$font-weight-semi-bold: 500 !default;
$font-size-h1: 28px !default;
$font-size-h2: 24px !default;
@ -109,20 +113,14 @@ $font-size-h4: 18px !default;
$font-size-h5: 16px !default;
$font-size-h6: 14px !default;
$headings-margin-bottom: ($spacer / 2) !default;
$headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$headings-font-weight: 400 !default;
$headings-line-height: 1.1 !default;
$hr-border-width: $border-width !default;
$dt-font-weight: bold !default;
// Components
//
// Define common padding and border radius sizes and more.
$line-height-lg: (4 / 3) !default;
$line-height-sm: 1.5 !default;
$border-width: 1px !default;
$border-radius: 3px !default;
$border-radius-lg: 5px !default;
@ -149,23 +147,17 @@ $input-padding-x: 10px !default;
$input-padding-y: 8px !default;
$input-line-height: 18px !default;
$input-btn-border-width: 1px;
$input-border-radius: 0 $border-radius $border-radius 0 !default;
$input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 0 !default;
$label-border-radius: $border-radius 0 0 $border-radius !default;
$label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default;
$input-padding-y-sm: 4px !default;
$input-padding-x-lg: 20px !default;
$input-padding-y-lg: 10px !default;
$input-height: 35px !default;
$gf-form-margin: 3px;
$gf-form-input-height: 35px;
$cursor-disabled: not-allowed !default;
// Form validation icons
@ -202,16 +194,13 @@ $btn-padding-y-lg: 11px !default;
$btn-padding-x-xl: 21px !default;
$btn-padding-y-xl: 11px !default;
$btn-border-radius: 2px;
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
// sidemenu
$side-menu-width: 60px;
// dashboard
$panel-margin: 10px;
$dashboard-padding: $panel-margin * 2;
$dashboard-padding: 10px * 2;
$panel-horizontal-padding: 10;
$panel-vertical-padding: 5;
$panel-padding: 0px $panel-horizontal-padding + 0px $panel-vertical-padding + 0px $panel-horizontal-padding + 0px;
@ -246,8 +235,3 @@ $external-services: (
icon: '',
),
) !default;
:export {
panelhorizontalpadding: $panel-horizontal-padding;
panelverticalpadding: $panel-vertical-padding;
}

Some files were not shown because too many files have changed in this diff Show More