merge master

This commit is contained in:
ryan
2019-03-13 08:36:51 -07:00
91 changed files with 3539 additions and 387 deletions
@@ -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 },
],
});
});
@@ -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();
});
});
@@ -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;
}
@@ -0,0 +1,9 @@
.bar-gauge {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.bar-gauge__value {
text-align: center;
}
@@ -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>
`;
@@ -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>
);
}}
@@ -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');
});
});
@@ -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)}
/>
)}
</>
);
@@ -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;
}
@@ -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>
);
});
@@ -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;
@@ -1,12 +1,12 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { Themeable, GrafanaThemeType } from '../../types/theme';
import { ValueMapping, Threshold, BasicGaugeColor } from '../../types/panel';
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
type TimeSeriesValue = string | number | null;
import { ValueMapping, Threshold, GrafanaThemeType } from '../../types';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName, getValueFormat, getThresholdForValue } from '../../utils';
import { Themeable } from '../../index';
type GaugeValue = string | number | null;
export interface Props extends Themeable {
decimals?: number | null;
@@ -51,7 +51,7 @@ export class Gauge extends PureComponent<Props> {
this.draw();
}
formatValue(value: TimeSeriesValue) {
formatValue(value: GaugeValue) {
const { decimals, valueMappings, prefix, suffix, unit } = this.props;
if (isNaN(value as number)) {
@@ -72,26 +72,16 @@ export class Gauge extends PureComponent<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() {
@@ -183,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;
@@ -0,0 +1,99 @@
// import React from 'react';
import { storiesOf } from '@storybook/react';
import { Table } from './Table';
import { getTheme } from '../../themes';
import { migratedTestTable, migratedTestStyles, simpleTable } from './examples';
import { ScopedVars, TableData, GrafanaThemeType } from '../../types/index';
import { withFullSizeStory } from '../../utils/storybook/withFullSizeStory';
import { number, boolean } from '@storybook/addon-knobs';
const replaceVariables = (value: string, scopedVars?: ScopedVars) => {
if (scopedVars) {
// For testing variables replacement in link
for (const key in scopedVars) {
const val = scopedVars[key];
value = value.replace('$' + key, val.value);
}
}
return value;
};
export function columnIndexToLeter(column: number) {
const A = 'A'.charCodeAt(0);
const c1 = Math.floor(column / 26);
const c2 = column % 26;
if (c1 > 0) {
return String.fromCharCode(A + c1 - 1) + String.fromCharCode(A + c2);
}
return String.fromCharCode(A + c2);
}
export function makeDummyTable(columnCount: number, rowCount: number): TableData {
return {
columns: Array.from(new Array(columnCount), (x, i) => {
return {
text: columnIndexToLeter(i),
};
}),
rows: Array.from(new Array(rowCount), (x, rowId) => {
const suffix = (rowId + 1).toString();
return Array.from(new Array(columnCount), (x, colId) => columnIndexToLeter(colId) + suffix);
}),
type: 'table',
columnMap: {},
};
}
storiesOf('Alpha/Table', module)
.add('Basic Table', () => {
// NOTE: This example does not seem to survice rotate &
// Changing fixed headers... but the next one does?
// perhaps `simpleTable` is static and reused?
const showHeader = boolean('Show Header', true);
const fixedHeader = boolean('Fixed Header', true);
const fixedColumns = number('Fixed Columns', 0, { min: 0, max: 50, step: 1, range: false });
const rotate = boolean('Rotate', false);
return withFullSizeStory(Table, {
styles: [],
data: simpleTable,
replaceVariables,
showHeader,
fixedHeader,
fixedColumns,
rotate,
theme: getTheme(GrafanaThemeType.Light),
});
})
.add('Variable Size', () => {
const columnCount = number('Column Count', 15, { min: 2, max: 50, step: 1, range: false });
const rowCount = number('Row Count', 20, { min: 0, max: 100, step: 1, range: false });
const showHeader = boolean('Show Header', true);
const fixedHeader = boolean('Fixed Header', true);
const fixedColumns = number('Fixed Columns', 1, { min: 0, max: 50, step: 1, range: false });
const rotate = boolean('Rotate', false);
return withFullSizeStory(Table, {
styles: [],
data: makeDummyTable(columnCount, rowCount),
replaceVariables,
showHeader,
fixedHeader,
fixedColumns,
rotate,
theme: getTheme(GrafanaThemeType.Light),
});
})
.add('Test Config (migrated)', () => {
return withFullSizeStory(Table, {
styles: migratedTestStyles,
data: migratedTestTable,
replaceVariables,
showHeader: true,
rotate: true,
theme: getTheme(GrafanaThemeType.Light),
});
});
@@ -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;
@@ -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 });
};
}
@@ -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;
}
@@ -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]],
};
@@ -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;
@@ -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>
);
}
}
@@ -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';
+6 -2
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';
@@ -52,7 +52,6 @@ $spacers: (
),
),
) !default;
$border-width: ${theme.border.width.sm} !default;
// Grid breakpoints
//
@@ -83,16 +82,13 @@ $container-max-widths: (
// Set the number of columns and specify the width of the gutters.
$grid-columns: 12 !default;
$grid-gutter-width: 30px !default;
$enable-flex: true;
$grid-gutter-width: ${theme.spacing.gutter} !default;
// Typography
// -------------------------
$font-family-sans-serif: ${theme.typography.fontFamily.sansSerif};
$font-family-monospace: ${theme.typography.fontFamily.monospace};
$font-family-base: $font-family-sans-serif !default;
$font-size-root: ${theme.typography.size.root} !default;
$font-size-base: ${theme.typography.size.base} !default;
@@ -103,7 +99,9 @@ $font-size-sm: ${theme.typography.size.sm} !default;
$font-size-xs: ${theme.typography.size.xs} !default;
$line-height-base: ${theme.typography.lineHeight.lg} !default;
$font-weight-semi-bold: ${theme.typography.weight.semibold};
$font-weight-regular: ${theme.typography.weight.regular} !default;
$font-weight-semi-bold: ${theme.typography.weight.semibold} !default;
$font-size-h1: ${theme.typography.heading.h1} !default;
$font-size-h2: ${theme.typography.heading.h2} !default;
@@ -113,22 +111,17 @@ $font-size-h5: ${theme.typography.heading.h5} !default;
$font-size-h6: ${theme.typography.heading.h6} !default;
$headings-font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$headings-font-weight: ${theme.typography.weight.normal} !default;
$headings-line-height: ${theme.typography.lineHeight.sm} !default;
$hr-border-width: $border-width !default;
$dt-font-weight: bold !default;
// Components
//
// Define common padding and border radius sizes and more.
$line-height-lg: (4 / 3) !default;
$line-height-sm: 1.5 !default;
$border-width: ${theme.border.width.sm} !default;
$border-radius: 3px !default;
$border-radius-lg: 5px !default;
$border-radius-sm: 2px !default;
$border-radius: ${theme.border.radius.md} !default;
$border-radius-lg: ${theme.border.radius.lg}!default;
$border-radius-sm: ${theme.border.radius.sm} !default;
// Page
@@ -151,22 +144,17 @@ $input-padding-x: 10px !default;
$input-padding-y: 8px !default;
$input-line-height: 18px !default;
$input-btn-border-width: 1px;
$input-border-radius: 0 $border-radius $border-radius 0 !default;
$input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 0 !default;
$label-border-radius: $border-radius 0 0 $border-radius !default;
$label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default;
$input-padding-y-sm: 4px !default;
$input-padding-x-lg: 20px !default;
$input-padding-y-lg: 10px !default;
$input-height: 35px !default;
$gf-form-input-height: 35px;
$cursor-disabled: not-allowed !default;
// Form validation icons
@@ -203,7 +191,6 @@ $btn-padding-y-lg: 11px !default;
$btn-padding-x-xl: 21px !default;
$btn-padding-y-xl: 11px !default;
$btn-border-radius: 2px;
$btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
+4 -4
View File
@@ -25,7 +25,7 @@ const theme: GrafanaThemeCommons = {
},
weight: {
light: 300,
normal: 400,
regular: 400,
semibold: 500,
},
lineHeight: {
@@ -54,9 +54,9 @@ const theme: GrafanaThemeCommons = {
},
border: {
radius: {
xs: '2px',
sm: '3px',
md: '5px',
sm: '2px',
md: '3px',
lg: '5px',
},
width: {
sm: '1px',
+10 -4
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;
@@ -69,3 +66,12 @@ export interface TableData {
type: string;
columnMap: any;
}
export type SingleStatValue = number | string | null;
/*
* So we can add meta info like tags & series name
*/
export interface SingleStatValueInfo {
value: SingleStatValue;
}
+3 -1
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
+1
View File
@@ -4,3 +4,4 @@ export * from './panel';
export * from './plugin';
export * from './datasource';
export * from './theme';
export * from './threshold';
+13 -11
View File
@@ -39,11 +39,14 @@ export interface PanelEditorProps<T = any> {
*/
export type PanelOptionsValidator<T = any> = (panelModel: any) => T;
export type PreservePanelOptionsHandler<TOptions = any> = (pluginId: string, prevOptions: any) => Partial<TOptions>;
export class ReactPanelPlugin<TOptions = any> {
panel: ComponentClass<PanelProps<TOptions>>;
editor?: ComponentClass<PanelEditorProps<TOptions>>;
optionsValidator?: PanelOptionsValidator<TOptions>;
defaults?: TOptions;
preserveOptions?: PreservePanelOptionsHandler<TOptions>;
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
this.panel = panel;
@@ -60,6 +63,10 @@ export class ReactPanelPlugin<TOptions = any> {
setDefaults(defaults: TOptions) {
this.defaults = defaults;
}
setPreserveOptionsHandler(handler: PreservePanelOptionsHandler<TOptions>) {
this.preserveOptions = handler;
}
}
export interface PanelSize {
@@ -76,17 +83,6 @@ export interface PanelMenuItem {
subMenu?: PanelMenuItem[];
}
export interface Threshold {
index: number;
value: number;
color: string;
}
export enum BasicGaugeColor {
Green = '#299c46',
Red = '#d44a3a',
}
export enum MappingType {
ValueToText = 1,
RangeToText = 2,
@@ -109,3 +105,9 @@ export interface RangeMap extends BaseMap {
from: string;
to: string;
}
export enum VizOrientation {
Auto = 'auto',
Vertical = 'vertical',
Horizontal = 'horizontal',
}
+2 -2
View File
@@ -28,7 +28,7 @@ export interface GrafanaThemeCommons {
};
weight: {
light: number;
normal: number;
regular: number;
semibold: number;
};
lineHeight: {
@@ -59,9 +59,9 @@ export interface GrafanaThemeCommons {
};
border: {
radius: {
xs: string;
sm: string;
md: string;
lg: string;
};
width: {
sm: string;
@@ -0,0 +1,5 @@
export interface Threshold {
index: number;
value: number;
color: string;
}
+2
View File
@@ -1,7 +1,9 @@
export * from './processTimeSeries';
export * from './singlestat';
export * from './valueFormats/valueFormats';
export * from './colors';
export * from './namedColorsPalette';
export * from './thresholds';
export * from './string';
export * from './deprecationWarning';
export { getMappedValue } from './valueMappings';
@@ -1,7 +1,10 @@
import { TableData, Column } from '../types/index';
// Libraries
import isNumber from 'lodash/isNumber';
import Papa, { ParseError, ParseMeta } from 'papaparse';
// Types
import { TableData, Column } from '../types';
// Subset of all parse options
export interface TableParseOptions {
headerIsFirstLine?: boolean; // Not a papa-parse option
@@ -131,3 +134,24 @@ export function parseCSV(text: string, options?: TableParseOptions, details?: Ta
columnMap: {},
});
}
export function sortTableData(data: TableData, sortIndex?: number, reverse = false): TableData {
if (isNumber(sortIndex)) {
const copy = {
...data,
rows: [...data.rows].sort((a, b) => {
a = a[sortIndex];
b = b[sortIndex];
// Sort null or undefined separately from comparable values
return +(a == null) - +(b == null) || +(a > b) || -(a < b);
}),
};
if (reverse) {
copy.rows.reverse();
}
return copy;
}
return data;
}
@@ -0,0 +1,33 @@
import { PanelData, NullValueMode, SingleStatValueInfo } from '../types';
import { processTimeSeries } from './processTimeSeries';
export interface SingleStatProcessingOptions {
panelData: PanelData;
stat: string;
}
//
// This is a temporary thing, waiting for a better data model and maybe unification between time series & table data
//
export function processSingleStatPanelData(options: SingleStatProcessingOptions): SingleStatValueInfo[] {
const { panelData, stat } = options;
if (panelData.timeSeries) {
const timeSeries = processTimeSeries({
timeSeries: panelData.timeSeries,
nullValueMode: NullValueMode.Null,
});
return timeSeries.map((series, index) => {
const value = stat !== 'name' ? series.stats[stat] : series.label;
return {
value: value,
};
});
} else if (panelData.tableData) {
throw { message: 'Panel data not supported' };
}
return [];
}
@@ -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>
);
@@ -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;
}