mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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 func
This commit is contained in:
committed by
Torkel Ödegaard
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();
|
||||
});
|
||||
});
|
||||
134
packages/grafana-ui/src/components/BigValue/BigValue.tsx
Normal file
134
packages/grafana-ui/src/components/BigValue/BigValue.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
15
packages/grafana-ui/src/components/BigValue/_BigValue.scss
Normal file
15
packages/grafana-ui/src/components/BigValue/_BigValue.scss
Normal file
@@ -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,90 @@
|
||||
// Libraries
|
||||
import React, { PureComponent, ChangeEvent } from 'react';
|
||||
|
||||
// Components
|
||||
import {
|
||||
FormField,
|
||||
FormLabel,
|
||||
PanelOptionsGroup,
|
||||
StatsPicker,
|
||||
UnitPicker,
|
||||
StatID,
|
||||
SelectOptionItem,
|
||||
} from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { SingleStatValueOptions } from './shared';
|
||||
|
||||
const labelWidth = 6;
|
||||
|
||||
export interface Props {
|
||||
options: SingleStatValueOptions;
|
||||
onChange: (valueOptions: SingleStatValueOptions) => void;
|
||||
}
|
||||
|
||||
export class SingleStatValueEditor extends PureComponent<Props> {
|
||||
onUnitChange = (unit: SelectOptionItem) => this.props.onChange({ ...this.props.options, unit: unit.value });
|
||||
|
||||
onStatsChange = (stats: string[]) => {
|
||||
const stat = stats[0] || StatID.mean;
|
||||
this.props.onChange({ ...this.props.options, stat });
|
||||
};
|
||||
|
||||
onDecimalChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!isNaN(parseInt(event.target.value, 10))) {
|
||||
this.props.onChange({
|
||||
...this.props.options,
|
||||
decimals: parseInt(event.target.value, 10),
|
||||
});
|
||||
} else {
|
||||
this.props.onChange({
|
||||
...this.props.options,
|
||||
decimals: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onPrefixChange = (event: ChangeEvent<HTMLInputElement>) =>
|
||||
this.props.onChange({ ...this.props.options, prefix: event.target.value });
|
||||
onSuffixChange = (event: ChangeEvent<HTMLInputElement>) =>
|
||||
this.props.onChange({ ...this.props.options, suffix: event.target.value });
|
||||
|
||||
render() {
|
||||
const { stat, unit, decimals, prefix, suffix } = this.props.options;
|
||||
|
||||
let decimalsString = '';
|
||||
if (decimals !== null && decimals !== undefined && Number.isFinite(decimals as number)) {
|
||||
decimalsString = decimals.toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Value">
|
||||
<div className="gf-form">
|
||||
<FormLabel width={labelWidth}>Show</FormLabel>
|
||||
<StatsPicker
|
||||
width={12}
|
||||
placeholder="Choose Stat"
|
||||
defaultStat={StatID.mean}
|
||||
allowMultiple={false}
|
||||
stats={[stat]}
|
||||
onChange={this.onStatsChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<FormLabel width={labelWidth}>Unit</FormLabel>
|
||||
<UnitPicker defaultValue={unit} onChange={this.onUnitChange} />
|
||||
</div>
|
||||
<FormField
|
||||
label="Decimals"
|
||||
labelWidth={labelWidth}
|
||||
placeholder="auto"
|
||||
onChange={this.onDecimalChange}
|
||||
value={decimalsString}
|
||||
type="number"
|
||||
/>
|
||||
<FormField label="Prefix" labelWidth={labelWidth} onChange={this.onPrefixChange} value={prefix || ''} />
|
||||
<FormField label="Suffix" labelWidth={labelWidth} onChange={this.onSuffixChange} value={suffix || ''} />
|
||||
</PanelOptionsGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
122
packages/grafana-ui/src/components/SingleStatShared/shared.ts
Normal file
122
packages/grafana-ui/src/components/SingleStatShared/shared.ts
Normal file
@@ -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,4 +1,5 @@
|
||||
@import 'CustomScrollbar/CustomScrollbar';
|
||||
@import 'BigValue/BigValue';
|
||||
@import 'DeleteButton/DeleteButton';
|
||||
@import 'ThresholdsEditor/ThresholdsEditor';
|
||||
@import 'Table/Table';
|
||||
|
||||
@@ -33,9 +33,11 @@ export { StatsPicker } from './StatsPicker/StatsPicker';
|
||||
export { Input, InputStatus } from './Input/Input';
|
||||
|
||||
// Visualizations
|
||||
export { BigValue } from './BigValue/BigValue';
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { BarGauge } from './BarGauge/BarGauge';
|
||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||
export * from './SingleStatShared/shared';
|
||||
|
||||
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface DisplayValue {
|
||||
numeric: number; // Use isNaN to check if it is a real number
|
||||
color?: string; // color based on configs or Threshold
|
||||
title?: string;
|
||||
fontSize?: string;
|
||||
}
|
||||
|
||||
export interface DecimalInfo {
|
||||
|
||||
Reference in New Issue
Block a user