mirror of https://github.com/grafana/grafana
Feat: Singlestat panel react progress & refactorings (#16039)
* big value component * big value component * editor for font and sparkline * less logging * remove sparkline from storybook * add display value link wrapper * follow tooltip * follow tooltip * merge master * Just minor refactoring * use series after last merge * Refactoring: moving shared singlestat stuff to grafana-ui * Refactor: Moved final getSingleStatDisplayValues funcpull/16278/head
parent
1d955a8762
commit
c8b2102500
@ -0,0 +1,37 @@ |
||||
import { storiesOf } from '@storybook/react'; |
||||
import { number, text } from '@storybook/addon-knobs'; |
||||
import { BigValue } from './BigValue'; |
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; |
||||
|
||||
const getKnobs = () => { |
||||
return { |
||||
value: text('value', 'Hello'), |
||||
valueFontSize: number('valueFontSize', 120), |
||||
prefix: text('prefix', ''), |
||||
}; |
||||
}; |
||||
|
||||
const BigValueStories = storiesOf('UI/BigValue', module); |
||||
|
||||
BigValueStories.addDecorator(withCenteredStory); |
||||
|
||||
BigValueStories.add('Singlestat viz', () => { |
||||
const { value, prefix, valueFontSize } = getKnobs(); |
||||
|
||||
return renderComponentWithTheme(BigValue, { |
||||
width: 300, |
||||
height: 250, |
||||
value: { |
||||
text: value, |
||||
numeric: NaN, |
||||
fontSize: valueFontSize + '%', |
||||
}, |
||||
prefix: prefix |
||||
? { |
||||
text: prefix, |
||||
numeric: NaN, |
||||
} |
||||
: null, |
||||
}); |
||||
}); |
@ -0,0 +1,38 @@ |
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import { BigValue, Props } from './BigValue'; |
||||
import { getTheme } from '../../themes/index'; |
||||
|
||||
jest.mock('jquery', () => ({ |
||||
plot: jest.fn(), |
||||
})); |
||||
|
||||
const setup = (propOverrides?: object) => { |
||||
const props: Props = { |
||||
height: 300, |
||||
width: 300, |
||||
value: { |
||||
text: '25', |
||||
numeric: 25, |
||||
}, |
||||
theme: getTheme(), |
||||
}; |
||||
|
||||
Object.assign(props, propOverrides); |
||||
|
||||
const wrapper = shallow(<BigValue {...props} />); |
||||
const instance = wrapper.instance() as BigValue; |
||||
|
||||
return { |
||||
instance, |
||||
wrapper, |
||||
}; |
||||
}; |
||||
|
||||
describe('Render BarGauge with basic options', () => { |
||||
it('should render', () => { |
||||
const { wrapper } = setup(); |
||||
expect(wrapper).toBeDefined(); |
||||
// expect(wrapper).toMatchSnapshot();
|
||||
}); |
||||
}); |
@ -0,0 +1,134 @@ |
||||
// Library
|
||||
import React, { PureComponent, ReactNode, CSSProperties } from 'react'; |
||||
import $ from 'jquery'; |
||||
|
||||
// Utils
|
||||
import { getColorFromHexRgbOrName } from '../../utils'; |
||||
|
||||
// Types
|
||||
import { Themeable, DisplayValue } from '../../types'; |
||||
|
||||
export interface BigValueSparkline { |
||||
data: any[][]; // [[number,number]]
|
||||
minX: number; |
||||
maxX: number; |
||||
full: boolean; // full height
|
||||
fillColor: string; |
||||
lineColor: string; |
||||
} |
||||
|
||||
export interface Props extends Themeable { |
||||
height: number; |
||||
width: number; |
||||
value: DisplayValue; |
||||
prefix?: DisplayValue; |
||||
suffix?: DisplayValue; |
||||
sparkline?: BigValueSparkline; |
||||
backgroundColor?: string; |
||||
} |
||||
|
||||
/* |
||||
* This visualization is still in POC state, needed more tests & better structure |
||||
*/ |
||||
export class BigValue extends PureComponent<Props> { |
||||
canvasElement: any; |
||||
|
||||
componentDidMount() { |
||||
this.draw(); |
||||
} |
||||
|
||||
componentDidUpdate() { |
||||
this.draw(); |
||||
} |
||||
|
||||
draw() { |
||||
const { sparkline, theme } = this.props; |
||||
|
||||
if (sparkline && this.canvasElement) { |
||||
const { data, minX, maxX, fillColor, lineColor } = sparkline; |
||||
|
||||
const options = { |
||||
legend: { show: false }, |
||||
series: { |
||||
lines: { |
||||
show: true, |
||||
fill: 1, |
||||
zero: false, |
||||
lineWidth: 1, |
||||
fillColor: getColorFromHexRgbOrName(fillColor, theme.type), |
||||
}, |
||||
}, |
||||
yaxes: { show: false }, |
||||
xaxis: { |
||||
show: false, |
||||
min: minX, |
||||
max: maxX, |
||||
}, |
||||
grid: { hoverable: false, show: false }, |
||||
}; |
||||
|
||||
const plotSeries = { |
||||
data, |
||||
color: getColorFromHexRgbOrName(lineColor, theme.type), |
||||
}; |
||||
|
||||
try { |
||||
$.plot(this.canvasElement, [plotSeries], options); |
||||
} catch (err) { |
||||
console.log('sparkline rendering error', err, options); |
||||
} |
||||
} |
||||
} |
||||
|
||||
renderText = (value?: DisplayValue, padding?: string): ReactNode => { |
||||
if (!value || !value.text) { |
||||
return null; |
||||
} |
||||
const css: CSSProperties = {}; |
||||
if (padding) { |
||||
css.padding = padding; |
||||
} |
||||
if (value.color) { |
||||
css.color = value.color; |
||||
} |
||||
if (value.fontSize) { |
||||
css.fontSize = value.fontSize; |
||||
} |
||||
|
||||
return <span style={css}>{value.text}</span>; |
||||
}; |
||||
|
||||
render() { |
||||
const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props; |
||||
|
||||
const plotCss: CSSProperties = {}; |
||||
plotCss.position = 'absolute'; |
||||
|
||||
if (sparkline) { |
||||
if (sparkline.full) { |
||||
plotCss.bottom = '5px'; |
||||
plotCss.left = '-5px'; |
||||
plotCss.width = width - 10 + 'px'; |
||||
const dynamicHeightMargin = height <= 100 ? 5 : Math.round(height / 100) * 15 + 5; |
||||
plotCss.height = height - dynamicHeightMargin + 'px'; |
||||
} else { |
||||
plotCss.bottom = '0px'; |
||||
plotCss.left = '-5px'; |
||||
plotCss.width = width - 10 + 'px'; |
||||
plotCss.height = Math.floor(height * 0.25) + 'px'; |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className="big-value" style={{ width, height, backgroundColor }}> |
||||
<span className="big-value__value"> |
||||
{this.renderText(prefix, '0px 2px 0px 0px')} |
||||
{this.renderText(value)} |
||||
{this.renderText(suffix)} |
||||
</span> |
||||
|
||||
{sparkline && <div style={plotCss} ref={element => (this.canvasElement = element)} />} |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,15 @@ |
||||
.big-value { |
||||
position: relative; |
||||
display: table; |
||||
} |
||||
|
||||
.big-value__value { |
||||
line-height: 1; |
||||
display: table-cell; |
||||
vertical-align: middle; |
||||
text-align: center; |
||||
position: relative; |
||||
z-index: 1; |
||||
font-size: 3em; |
||||
font-weight: $font-weight-semi-bold; |
||||
} |
@ -0,0 +1,122 @@ |
||||
import cloneDeep from 'lodash/cloneDeep'; |
||||
import { |
||||
ValueMapping, |
||||
Threshold, |
||||
VizOrientation, |
||||
PanelModel, |
||||
DisplayValue, |
||||
FieldType, |
||||
NullValueMode, |
||||
GrafanaTheme, |
||||
SeriesData, |
||||
InterpolateFunction, |
||||
} from '../../types'; |
||||
import { getStatsCalculators, calculateStats } from '../../utils/statsCalculator'; |
||||
import { getDisplayProcessor } from '../../utils/displayValue'; |
||||
export { SingleStatValueEditor } from './SingleStatValueEditor'; |
||||
|
||||
export interface SingleStatBaseOptions { |
||||
valueMappings: ValueMapping[]; |
||||
thresholds: Threshold[]; |
||||
valueOptions: SingleStatValueOptions; |
||||
orientation: VizOrientation; |
||||
} |
||||
|
||||
export interface SingleStatValueOptions { |
||||
unit: string; |
||||
suffix: string; |
||||
stat: string; |
||||
prefix: string; |
||||
decimals?: number | null; |
||||
} |
||||
|
||||
export interface GetSingleStatDisplayValueOptions { |
||||
data: SeriesData[]; |
||||
theme: GrafanaTheme; |
||||
valueMappings: ValueMapping[]; |
||||
thresholds: Threshold[]; |
||||
valueOptions: SingleStatValueOptions; |
||||
replaceVariables: InterpolateFunction; |
||||
} |
||||
|
||||
export const getSingleStatDisplayValues = (options: GetSingleStatDisplayValueOptions): DisplayValue[] => { |
||||
const { data, replaceVariables, valueOptions } = options; |
||||
const { unit, decimals, stat } = valueOptions; |
||||
|
||||
const display = getDisplayProcessor({ |
||||
unit, |
||||
decimals, |
||||
mappings: options.valueMappings, |
||||
thresholds: options.thresholds, |
||||
prefix: replaceVariables(valueOptions.prefix), |
||||
suffix: replaceVariables(valueOptions.suffix), |
||||
theme: options.theme, |
||||
}); |
||||
|
||||
const values: DisplayValue[] = []; |
||||
|
||||
for (const series of data) { |
||||
if (stat === 'name') { |
||||
values.push(display(series.name)); |
||||
} |
||||
|
||||
for (let i = 0; i < series.fields.length; i++) { |
||||
const column = series.fields[i]; |
||||
|
||||
// Show all fields that are not 'time'
|
||||
if (column.type === FieldType.number) { |
||||
const stats = calculateStats({ |
||||
series, |
||||
fieldIndex: i, |
||||
stats: [stat], // The stats to calculate
|
||||
nullValueMode: NullValueMode.Null, |
||||
}); |
||||
const displayValue = display(stats[stat]); |
||||
values.push(displayValue); |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (values.length === 0) { |
||||
values.push({ |
||||
numeric: 0, |
||||
text: 'No data', |
||||
}); |
||||
} |
||||
|
||||
return values; |
||||
}; |
||||
|
||||
const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings']; |
||||
|
||||
export const sharedSingleStatOptionsCheck = ( |
||||
options: Partial<SingleStatBaseOptions> | any, |
||||
prevPluginId: string, |
||||
prevOptions: any |
||||
) => { |
||||
for (const k of optionsToKeep) { |
||||
if (prevOptions.hasOwnProperty(k)) { |
||||
options[k] = cloneDeep(prevOptions[k]); |
||||
} |
||||
} |
||||
return options; |
||||
}; |
||||
|
||||
export const sharedSingleStatMigrationCheck = (panel: PanelModel<SingleStatBaseOptions>) => { |
||||
const options = panel.options; |
||||
|
||||
if (!options) { |
||||
// This happens on the first load or when migrating from angular
|
||||
return {}; |
||||
} |
||||
|
||||
if (options.valueOptions) { |
||||
// 6.1 renamed some stats, This makes sure they are up to date
|
||||
// avg -> mean, current -> last, total -> sum
|
||||
const { valueOptions } = options; |
||||
if (valueOptions && valueOptions.stat) { |
||||
valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0]; |
||||
} |
||||
} |
||||
return options; |
||||
}; |
@ -1,11 +1,10 @@ |
||||
import { ReactPanelPlugin } from '@grafana/ui'; |
||||
import { ReactPanelPlugin, sharedSingleStatOptionsCheck } from '@grafana/ui'; |
||||
|
||||
import { BarGaugePanel } from './BarGaugePanel'; |
||||
import { BarGaugePanelEditor } from './BarGaugePanelEditor'; |
||||
import { BarGaugeOptions, defaults } from './types'; |
||||
import { singleStatBaseOptionsCheck } from '../singlestat2/module'; |
||||
|
||||
export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel) |
||||
.setDefaults(defaults) |
||||
.setEditor(BarGaugePanelEditor) |
||||
.setPanelChangeHandler(singleStatBaseOptionsCheck); |
||||
.setPanelChangeHandler(sharedSingleStatOptionsCheck); |
||||
|
@ -1,12 +1,10 @@ |
||||
import { ReactPanelPlugin } from '@grafana/ui'; |
||||
|
||||
import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui'; |
||||
import { GaugePanelEditor } from './GaugePanelEditor'; |
||||
import { GaugePanel } from './GaugePanel'; |
||||
import { GaugeOptions, defaults } from './types'; |
||||
import { singleStatBaseOptionsCheck, singleStatMigrationCheck } from '../singlestat2/module'; |
||||
|
||||
export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel) |
||||
.setDefaults(defaults) |
||||
.setEditor(GaugePanelEditor) |
||||
.setPanelChangeHandler(singleStatBaseOptionsCheck) |
||||
.setMigrationHandler(singleStatMigrationCheck); |
||||
.setPanelChangeHandler(sharedSingleStatOptionsCheck) |
||||
.setMigrationHandler(sharedSingleStatMigrationCheck); |
||||
|
@ -0,0 +1,68 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
// Components
|
||||
import { Switch, PanelOptionsGroup } from '@grafana/ui'; |
||||
|
||||
// Types
|
||||
import { SingleStatOptions } from './types'; |
||||
|
||||
const labelWidth = 6; |
||||
|
||||
export interface Props { |
||||
options: SingleStatOptions; |
||||
onChange: (options: SingleStatOptions) => void; |
||||
} |
||||
|
||||
// colorBackground?: boolean;
|
||||
// colorValue?: boolean;
|
||||
// colorPrefix?: boolean;
|
||||
// colorPostfix?: boolean;
|
||||
|
||||
export class ColoringEditor extends PureComponent<Props> { |
||||
onToggleColorBackground = () => |
||||
this.props.onChange({ ...this.props.options, colorBackground: !this.props.options.colorBackground }); |
||||
|
||||
onToggleColorValue = () => this.props.onChange({ ...this.props.options, colorValue: !this.props.options.colorValue }); |
||||
|
||||
onToggleColorPrefix = () => |
||||
this.props.onChange({ ...this.props.options, colorPrefix: !this.props.options.colorPrefix }); |
||||
|
||||
onToggleColorPostfix = () => |
||||
this.props.onChange({ ...this.props.options, colorPostfix: !this.props.options.colorPostfix }); |
||||
|
||||
render() { |
||||
const { colorBackground, colorValue, colorPrefix, colorPostfix } = this.props.options; |
||||
|
||||
return ( |
||||
<PanelOptionsGroup title="Coloring"> |
||||
<Switch |
||||
label="Background" |
||||
labelClass={`width-${labelWidth}`} |
||||
checked={colorBackground} |
||||
onChange={this.onToggleColorBackground} |
||||
/> |
||||
|
||||
<Switch |
||||
label="Value" |
||||
labelClass={`width-${labelWidth}`} |
||||
checked={colorValue} |
||||
onChange={this.onToggleColorValue} |
||||
/> |
||||
|
||||
<Switch |
||||
label="Prefix" |
||||
labelClass={`width-${labelWidth}`} |
||||
checked={colorPrefix} |
||||
onChange={this.onToggleColorPrefix} |
||||
/> |
||||
<Switch |
||||
label="Postfix" |
||||
labelClass={`width-${labelWidth}`} |
||||
checked={colorPostfix} |
||||
onChange={this.onToggleColorPostfix} |
||||
/> |
||||
</PanelOptionsGroup> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,67 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
// Components
|
||||
import { FormLabel, Select, PanelOptionsGroup, SelectOptionItem } from '@grafana/ui'; |
||||
|
||||
// Types
|
||||
import { SingleStatOptions } from './types'; |
||||
|
||||
const labelWidth = 6; |
||||
|
||||
export interface Props { |
||||
options: SingleStatOptions; |
||||
onChange: (options: SingleStatOptions) => void; |
||||
} |
||||
|
||||
const percents = ['20%', '30%', '50%', '70%', '80%', '100%', '110%', '120%', '150%', '170%', '200%']; |
||||
const fontSizeOptions = percents.map(v => { |
||||
return { value: v, label: v }; |
||||
}); |
||||
|
||||
export class FontSizeEditor extends PureComponent<Props> { |
||||
setPrefixFontSize = (v: SelectOptionItem) => this.props.onChange({ ...this.props.options, prefixFontSize: v.value }); |
||||
|
||||
setValueFontSize = (v: SelectOptionItem) => this.props.onChange({ ...this.props.options, valueFontSize: v.value }); |
||||
|
||||
setPostfixFontSize = (v: SelectOptionItem) => |
||||
this.props.onChange({ ...this.props.options, postfixFontSize: v.value }); |
||||
|
||||
render() { |
||||
const { prefixFontSize, valueFontSize, postfixFontSize } = this.props.options; |
||||
|
||||
return ( |
||||
<PanelOptionsGroup title="Font Size"> |
||||
<div className="gf-form"> |
||||
<FormLabel width={labelWidth}>Prefix</FormLabel> |
||||
<Select |
||||
width={12} |
||||
options={fontSizeOptions} |
||||
onChange={this.setPrefixFontSize} |
||||
value={fontSizeOptions.find(option => option.value === prefixFontSize)} |
||||
/> |
||||
</div> |
||||
|
||||
<div className="gf-form"> |
||||
<FormLabel width={labelWidth}>Value</FormLabel> |
||||
<Select |
||||
width={12} |
||||
options={fontSizeOptions} |
||||
onChange={this.setValueFontSize} |
||||
value={fontSizeOptions.find(option => option.value === valueFontSize)} |
||||
/> |
||||
</div> |
||||
|
||||
<div className="gf-form"> |
||||
<FormLabel width={labelWidth}>Postfix</FormLabel> |
||||
<Select |
||||
width={12} |
||||
options={fontSizeOptions} |
||||
onChange={this.setPostfixFontSize} |
||||
value={fontSizeOptions.find(option => option.value === postfixFontSize)} |
||||
/> |
||||
</div> |
||||
</PanelOptionsGroup> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,33 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
// Components
|
||||
import { Switch, PanelOptionsGroup } from '@grafana/ui'; |
||||
|
||||
// Types
|
||||
import { SparklineOptions } from './types'; |
||||
|
||||
const labelWidth = 6; |
||||
|
||||
export interface Props { |
||||
options: SparklineOptions; |
||||
onChange: (options: SparklineOptions) => void; |
||||
} |
||||
|
||||
export class SparklineEditor extends PureComponent<Props> { |
||||
onToggleShow = () => this.props.onChange({ ...this.props.options, show: !this.props.options.show }); |
||||
|
||||
onToggleFull = () => this.props.onChange({ ...this.props.options, full: !this.props.options.full }); |
||||
|
||||
render() { |
||||
const { show, full } = this.props.options; |
||||
|
||||
return ( |
||||
<PanelOptionsGroup title="Sparkline"> |
||||
<Switch label="Show" labelClass={`width-${labelWidth}`} checked={show} onChange={this.onToggleShow} /> |
||||
|
||||
<Switch label="Full Height" labelClass={`width-${labelWidth}`} checked={full} onChange={this.onToggleFull} /> |
||||
</PanelOptionsGroup> |
||||
); |
||||
} |
||||
} |
@ -1,45 +1,10 @@ |
||||
import { ReactPanelPlugin, getStatsCalculators, PanelModel } from '@grafana/ui'; |
||||
import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types'; |
||||
import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui'; |
||||
import { SingleStatOptions, defaults } from './types'; |
||||
import { SingleStatPanel } from './SingleStatPanel'; |
||||
import cloneDeep from 'lodash/cloneDeep'; |
||||
import { SingleStatEditor } from './SingleStatEditor'; |
||||
|
||||
const optionsToKeep = ['valueOptions', 'stat', 'maxValue', 'maxValue', 'thresholds', 'valueMappings']; |
||||
|
||||
export const singleStatBaseOptionsCheck = ( |
||||
options: Partial<SingleStatBaseOptions>, |
||||
prevPluginId: string, |
||||
prevOptions: any |
||||
) => { |
||||
for (const k of optionsToKeep) { |
||||
if (prevOptions.hasOwnProperty(k)) { |
||||
options[k] = cloneDeep(prevOptions[k]); |
||||
} |
||||
} |
||||
return options; |
||||
}; |
||||
|
||||
export const singleStatMigrationCheck = (panel: PanelModel<SingleStatOptions>) => { |
||||
const options = panel.options; |
||||
|
||||
if (!options) { |
||||
// This happens on the first load or when migrating from angular
|
||||
return {}; |
||||
} |
||||
|
||||
if (options.valueOptions) { |
||||
// 6.1 renamed some stats, This makes sure they are up to date
|
||||
// avg -> mean, current -> last, total -> sum
|
||||
const { valueOptions } = options; |
||||
if (valueOptions && valueOptions.stat) { |
||||
valueOptions.stat = getStatsCalculators([valueOptions.stat]).map(s => s.id)[0]; |
||||
} |
||||
} |
||||
return options; |
||||
}; |
||||
|
||||
export const reactPanel = new ReactPanelPlugin<SingleStatOptions>(SingleStatPanel) |
||||
.setDefaults(defaults) |
||||
.setEditor(SingleStatEditor) |
||||
.setPanelChangeHandler(singleStatMigrationCheck) |
||||
.setMigrationHandler(singleStatMigrationCheck); |
||||
.setPanelChangeHandler(sharedSingleStatOptionsCheck) |
||||
.setMigrationHandler(sharedSingleStatMigrationCheck); |
||||
|
Loading…
Reference in new issue