Commit c8b21025 by Ryan McKinley Committed by Torkel Ödegaard

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
parent 1d955a87
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,
});
});
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();
});
});
// 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>
);
}
}
.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;
}
// Libraries // Libraries
import React, { PureComponent } from 'react'; import React, { PureComponent, ChangeEvent } from 'react';
// Components // Components
import { FormField, FormLabel, PanelOptionsGroup, StatsPicker, UnitPicker, StatID } from '@grafana/ui'; import {
FormField,
FormLabel,
PanelOptionsGroup,
StatsPicker,
UnitPicker,
StatID,
SelectOptionItem,
} from '@grafana/ui';
// Types // Types
import { SingleStatValueOptions } from './types'; import { SingleStatValueOptions } from './shared';
const labelWidth = 6; const labelWidth = 6;
...@@ -15,15 +23,15 @@ export interface Props { ...@@ -15,15 +23,15 @@ export interface Props {
} }
export class SingleStatValueEditor extends PureComponent<Props> { export class SingleStatValueEditor extends PureComponent<Props> {
onUnitChange = unit => this.props.onChange({ ...this.props.options, unit: unit.value }); onUnitChange = (unit: SelectOptionItem) => this.props.onChange({ ...this.props.options, unit: unit.value });
onStatsChange = stats => { onStatsChange = (stats: string[]) => {
const stat = stats[0] || StatID.mean; const stat = stats[0] || StatID.mean;
this.props.onChange({ ...this.props.options, stat }); this.props.onChange({ ...this.props.options, stat });
}; };
onDecimalChange = event => { onDecimalChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!isNaN(event.target.value)) { if (!isNaN(parseInt(event.target.value, 10))) {
this.props.onChange({ this.props.onChange({
...this.props.options, ...this.props.options,
decimals: parseInt(event.target.value, 10), decimals: parseInt(event.target.value, 10),
...@@ -36,14 +44,16 @@ export class SingleStatValueEditor extends PureComponent<Props> { ...@@ -36,14 +44,16 @@ export class SingleStatValueEditor extends PureComponent<Props> {
} }
}; };
onPrefixChange = event => this.props.onChange({ ...this.props.options, prefix: event.target.value }); onPrefixChange = (event: ChangeEvent<HTMLInputElement>) =>
onSuffixChange = event => this.props.onChange({ ...this.props.options, suffix: event.target.value }); 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() { render() {
const { stat, unit, decimals, prefix, suffix } = this.props.options; const { stat, unit, decimals, prefix, suffix } = this.props.options;
let decimalsString = ''; let decimalsString = '';
if (Number.isFinite(decimals)) { if (decimals !== null && decimals !== undefined && Number.isFinite(decimals as number)) {
decimalsString = decimals.toString(); decimalsString = decimals.toString();
} }
......
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;
};
@import 'CustomScrollbar/CustomScrollbar'; @import 'CustomScrollbar/CustomScrollbar';
@import 'BigValue/BigValue';
@import 'DeleteButton/DeleteButton'; @import 'DeleteButton/DeleteButton';
@import 'ThresholdsEditor/ThresholdsEditor'; @import 'ThresholdsEditor/ThresholdsEditor';
@import 'Table/Table'; @import 'Table/Table';
......
...@@ -33,9 +33,11 @@ export { StatsPicker } from './StatsPicker/StatsPicker'; ...@@ -33,9 +33,11 @@ export { StatsPicker } from './StatsPicker/StatsPicker';
export { Input, InputStatus } from './Input/Input'; export { Input, InputStatus } from './Input/Input';
// Visualizations // Visualizations
export { BigValue } from './BigValue/BigValue';
export { Gauge } from './Gauge/Gauge'; export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph'; export { Graph } from './Graph/Graph';
export { BarGauge } from './BarGauge/BarGauge'; export { BarGauge } from './BarGauge/BarGauge';
export { VizRepeater } from './VizRepeater/VizRepeater'; export { VizRepeater } from './VizRepeater/VizRepeater';
export * from './SingleStatShared/shared';
export { CallToActionCard } from './CallToActionCard/CallToActionCard'; export { CallToActionCard } from './CallToActionCard/CallToActionCard';
...@@ -3,6 +3,7 @@ export interface DisplayValue { ...@@ -3,6 +3,7 @@ export interface DisplayValue {
numeric: number; // Use isNaN to check if it is a real number numeric: number; // Use isNaN to check if it is a real number
color?: string; // color based on configs or Threshold color?: string; // color based on configs or Threshold
title?: string; title?: string;
fontSize?: string;
} }
export interface DecimalInfo { export interface DecimalInfo {
......
...@@ -2,12 +2,11 @@ ...@@ -2,12 +2,11 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
// Services & Utils // Services & Utils
import { DisplayValue, PanelProps, BarGauge } from '@grafana/ui'; import { DisplayValue, PanelProps, BarGauge, getSingleStatDisplayValues } from '@grafana/ui';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
// Types // Types
import { BarGaugeOptions } from './types'; import { BarGaugeOptions } from './types';
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater'; import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> { export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
...@@ -28,7 +27,14 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> { ...@@ -28,7 +27,14 @@ export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
}; };
getProcessedValues = (): DisplayValue[] => { getProcessedValues = (): DisplayValue[] => {
return getSingleStatValues(this.props); return getSingleStatDisplayValues({
valueMappings: this.props.options.valueMappings,
thresholds: this.props.options.thresholds,
valueOptions: this.props.options.valueOptions,
data: this.props.data,
theme: config.theme,
replaceVariables: this.props.replaceVariables,
});
}; };
render() { render() {
......
...@@ -2,13 +2,19 @@ ...@@ -2,13 +2,19 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
// Components // Components
import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGroup, FormField } from '@grafana/ui'; import {
ThresholdsEditor,
ValueMappingsEditor,
PanelOptionsGrid,
PanelOptionsGroup,
FormField,
SingleStatValueOptions,
SingleStatValueEditor,
} from '@grafana/ui';
// Types // Types
import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui'; import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui';
import { BarGaugeOptions, orientationOptions, displayModes } from './types'; import { BarGaugeOptions, orientationOptions, displayModes } from './types';
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
import { SingleStatValueOptions } from '../singlestat2/types';
export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> { export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) => onThresholdsChanged = (thresholds: Threshold[]) =>
......
import { ReactPanelPlugin } from '@grafana/ui'; import { ReactPanelPlugin, sharedSingleStatOptionsCheck } from '@grafana/ui';
import { BarGaugePanel } from './BarGaugePanel'; import { BarGaugePanel } from './BarGaugePanel';
import { BarGaugePanelEditor } from './BarGaugePanelEditor'; import { BarGaugePanelEditor } from './BarGaugePanelEditor';
import { BarGaugeOptions, defaults } from './types'; import { BarGaugeOptions, defaults } from './types';
import { singleStatBaseOptionsCheck } from '../singlestat2/module';
export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel) export const reactPanel = new ReactPanelPlugin<BarGaugeOptions>(BarGaugePanel)
.setDefaults(defaults) .setDefaults(defaults)
.setEditor(BarGaugePanelEditor) .setEditor(BarGaugePanelEditor)
.setPanelChangeHandler(singleStatBaseOptionsCheck); .setPanelChangeHandler(sharedSingleStatOptionsCheck);
import { VizOrientation, SelectOptionItem, StatID } from '@grafana/ui'; import { VizOrientation, SelectOptionItem, StatID, SingleStatBaseOptions } from '@grafana/ui';
import { SingleStatBaseOptions } from '../singlestat2/types';
export interface BarGaugeOptions extends SingleStatBaseOptions { export interface BarGaugeOptions extends SingleStatBaseOptions {
minValue: number; minValue: number;
......
...@@ -9,8 +9,7 @@ import { Gauge } from '@grafana/ui'; ...@@ -9,8 +9,7 @@ import { Gauge } from '@grafana/ui';
// Types // Types
import { GaugeOptions } from './types'; import { GaugeOptions } from './types';
import { DisplayValue, PanelProps } from '@grafana/ui'; import { DisplayValue, PanelProps, getSingleStatDisplayValues } from '@grafana/ui';
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater'; import { ProcessedValuesRepeater } from '../singlestat2/ProcessedValuesRepeater';
export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> { export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
...@@ -33,7 +32,14 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> { ...@@ -33,7 +32,14 @@ export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
}; };
getProcessedValues = (): DisplayValue[] => { getProcessedValues = (): DisplayValue[] => {
return getSingleStatValues(this.props); return getSingleStatDisplayValues({
valueMappings: this.props.options.valueMappings,
thresholds: this.props.options.thresholds,
valueOptions: this.props.options.valueOptions,
data: this.props.data,
theme: config.theme,
replaceVariables: this.props.replaceVariables,
});
}; };
render() { render() {
......
...@@ -7,12 +7,12 @@ import { ...@@ -7,12 +7,12 @@ import {
PanelOptionsGrid, PanelOptionsGrid,
ValueMappingsEditor, ValueMappingsEditor,
ValueMapping, ValueMapping,
SingleStatValueOptions,
SingleStatValueEditor,
} from '@grafana/ui'; } from '@grafana/ui';
import { GaugeOptionsBox } from './GaugeOptionsBox'; import { GaugeOptionsBox } from './GaugeOptionsBox';
import { GaugeOptions } from './types'; import { GaugeOptions } from './types';
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
import { SingleStatValueOptions } from '../singlestat2/types';
export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> { export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) => onThresholdsChanged = (thresholds: Threshold[]) =>
......
import { ReactPanelPlugin } from '@grafana/ui'; import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui';
import { GaugePanelEditor } from './GaugePanelEditor'; import { GaugePanelEditor } from './GaugePanelEditor';
import { GaugePanel } from './GaugePanel'; import { GaugePanel } from './GaugePanel';
import { GaugeOptions, defaults } from './types'; import { GaugeOptions, defaults } from './types';
import { singleStatBaseOptionsCheck, singleStatMigrationCheck } from '../singlestat2/module';
export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel) export const reactPanel = new ReactPanelPlugin<GaugeOptions>(GaugePanel)
.setDefaults(defaults) .setDefaults(defaults)
.setEditor(GaugePanelEditor) .setEditor(GaugePanelEditor)
.setPanelChangeHandler(singleStatBaseOptionsCheck) .setPanelChangeHandler(sharedSingleStatOptionsCheck)
.setMigrationHandler(singleStatMigrationCheck); .setMigrationHandler(sharedSingleStatMigrationCheck);
import { SingleStatBaseOptions } from '../singlestat2/types'; import { VizOrientation, StatID, SingleStatBaseOptions } from '@grafana/ui';
import { VizOrientation, StatID } from '@grafana/ui';
export interface GaugeOptions extends SingleStatBaseOptions { export interface GaugeOptions extends SingleStatBaseOptions {
maxValue: number; maxValue: number;
......
...@@ -5,20 +5,26 @@ import React, { PureComponent } from 'react'; ...@@ -5,20 +5,26 @@ import React, { PureComponent } from 'react';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
// Components // Components
import { PieChart } from '@grafana/ui'; import { PieChart, getSingleStatDisplayValues } from '@grafana/ui';
// Types // Types
import { PieChartOptions } from './types'; import { PieChartOptions } from './types';
import { PanelProps } from '@grafana/ui/src/types'; import { PanelProps } from '@grafana/ui/src/types';
import { getSingleStatValues } from '../singlestat2/SingleStatPanel';
interface Props extends PanelProps<PieChartOptions> {} interface Props extends PanelProps<PieChartOptions> {}
export class PieChartPanel extends PureComponent<Props> { export class PieChartPanel extends PureComponent<Props> {
render() { render() {
const { width, height, options } = this.props; const { width, height, options, data, replaceVariables } = this.props;
const values = getSingleStatValues(this.props); const values = getSingleStatDisplayValues({
valueMappings: options.valueMappings,
thresholds: options.thresholds,
valueOptions: options.valueOptions,
data: data,
theme: config.theme,
replaceVariables: replaceVariables,
});
return ( return (
<PieChart <PieChart
......
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { PanelEditorProps, PanelOptionsGrid, ValueMappingsEditor, ValueMapping } from '@grafana/ui'; import {
PanelEditorProps,
PanelOptionsGrid,
ValueMappingsEditor,
ValueMapping,
SingleStatValueOptions,
SingleStatValueEditor,
} from '@grafana/ui';
import { PieChartOptionsBox } from './PieChartOptionsBox'; import { PieChartOptionsBox } from './PieChartOptionsBox';
import { PieChartOptions } from './types'; import { PieChartOptions } from './types';
import { SingleStatValueEditor } from '../singlestat2/SingleStatValueEditor';
import { SingleStatValueOptions } from '../singlestat2/types';
export class PieChartPanelEditor extends PureComponent<PanelEditorProps<PieChartOptions>> { export class PieChartPanelEditor extends PureComponent<PanelEditorProps<PieChartOptions>> {
onValueMappingsChanged = (valueMappings: ValueMapping[]) => onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
......
import { PieChartType, StatID, VizOrientation } from '@grafana/ui'; import { PieChartType, StatID, VizOrientation, SingleStatBaseOptions } from '@grafana/ui';
import { SingleStatBaseOptions } from '../singlestat2/types';
export interface PieChartOptions extends SingleStatBaseOptions { export interface PieChartOptions extends SingleStatBaseOptions {
pieType: PieChartType; pieType: PieChartType;
......
// 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>
);
}
}
// 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>
);
}
}
...@@ -7,10 +7,14 @@ import { ...@@ -7,10 +7,14 @@ import {
PanelOptionsGrid, PanelOptionsGrid,
ValueMappingsEditor, ValueMappingsEditor,
ValueMapping, ValueMapping,
SingleStatValueOptions,
SingleStatValueEditor,
} from '@grafana/ui'; } from '@grafana/ui';
import { SingleStatOptions, SingleStatValueOptions } from './types'; import { SingleStatOptions, SparklineOptions } from './types';
import { SingleStatValueEditor } from './SingleStatValueEditor'; import { ColoringEditor } from './ColoringEditor';
import { FontSizeEditor } from './FontSizeEditor';
import { SparklineEditor } from './SparklineEditor';
export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> { export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) => onThresholdsChanged = (thresholds: Threshold[]) =>
...@@ -31,6 +35,12 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO ...@@ -31,6 +35,12 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
valueOptions, valueOptions,
}); });
onSparklineChanged = (sparkline: SparklineOptions) =>
this.props.onOptionsChange({
...this.props.options,
sparkline,
});
render() { render() {
const { options } = this.props; const { options } = this.props;
...@@ -38,6 +48,10 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO ...@@ -38,6 +48,10 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
<> <>
<PanelOptionsGrid> <PanelOptionsGrid>
<SingleStatValueEditor onChange={this.onValueOptionsChanged} options={options.valueOptions} /> <SingleStatValueEditor onChange={this.onValueOptionsChanged} options={options.valueOptions} />
<FontSizeEditor options={options} onChange={this.props.onOptionsChange} />
<ColoringEditor options={options} onChange={this.props.onOptionsChange} />
<SparklineEditor options={options.sparkline} onChange={this.onSparklineChanged} />
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} /> <ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
</PanelOptionsGrid> </PanelOptionsGrid>
......
// Libraries // Libraries
import React, { PureComponent, CSSProperties } from 'react'; import React, { PureComponent } from 'react';
// Types // Utils & Services
import { SingleStatOptions, SingleStatBaseOptions } from './types';
import { DisplayValue, PanelProps, NullValueMode, FieldType, calculateStats } from '@grafana/ui';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { getDisplayProcessor } from '@grafana/ui'; import { getFlotPairs } from '@grafana/ui/src/utils/flotPairs';
// Components
import { ProcessedValuesRepeater } from './ProcessedValuesRepeater'; import { ProcessedValuesRepeater } from './ProcessedValuesRepeater';
export const getSingleStatValues = (props: PanelProps<SingleStatBaseOptions>): DisplayValue[] => { // Types
const { data, replaceVariables, options } = props; import { SingleStatOptions } from './types';
const { valueOptions, valueMappings } = options; import { BigValueSparkline, BigValue } from '@grafana/ui/src/components/BigValue/BigValue';
const { unit, decimals, stat } = valueOptions; import {
DisplayValue,
const display = getDisplayProcessor({ PanelProps,
unit, getDisplayProcessor,
decimals, NullValueMode,
mappings: valueMappings, FieldType,
thresholds: options.thresholds, calculateStats,
prefix: replaceVariables(valueOptions.prefix), getFirstTimeField,
suffix: replaceVariables(valueOptions.suffix), } from '@grafana/ui';
theme: config.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++) { interface SingleStatDisplay {
const column = series.fields[i]; value: DisplayValue;
prefix?: DisplayValue;
// Show all columns that are not 'time' suffix?: DisplayValue;
if (column.type === FieldType.number) { sparkline?: BigValueSparkline;
const stats = calculateStats({ backgroundColor?: string;
series, }
fieldIndex: i,
stats: [stat], // The stats to calculate export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
nullValueMode: NullValueMode.Null, renderValue = (value: SingleStatDisplay, width: number, height: number): JSX.Element => {
}); return <BigValue {...value} width={width} height={height} theme={config.theme} />;
const displayValue = display(stats[stat]); };
values.push(displayValue);
}
}
}
if (values.length === 0) { getProcessedValues = (): SingleStatDisplay[] => {
values.push({ const { data, replaceVariables, options, timeRange } = this.props;
numeric: 0, const { valueOptions, valueMappings } = options;
text: 'No data',
const display = getDisplayProcessor({
unit: valueOptions.unit,
decimals: valueOptions.decimals,
mappings: valueMappings,
thresholds: options.thresholds,
theme: config.theme,
}); });
}
return values; const { colorBackground, colorValue, colorPrefix, colorPostfix, sparkline } = options;
}; const { stat } = valueOptions;
export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> { const values: SingleStatDisplay[] = [];
renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
const style: CSSProperties = {};
style.margin = '0 auto';
style.fontSize = '250%';
style.textAlign = 'center';
if (value.color) {
style.color = value.color;
}
return ( for (const series of data) {
<div style={{ width, height }}> const timeColumn = sparkline.show ? getFirstTimeField(series) : -1;
<div style={style}>{value.text}</div>
</div> 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 v: SingleStatDisplay = {
value: display(stats[stat]),
};
const color = v.value.color;
if (!colorValue) {
delete v.value.color;
}
if (colorBackground) {
v.backgroundColor = color;
}
if (options.valueFontSize) {
v.value.fontSize = options.valueFontSize;
}
if (valueOptions.prefix) {
v.prefix = {
text: replaceVariables(valueOptions.prefix),
numeric: NaN,
color: colorPrefix ? color : null,
fontSize: options.prefixFontSize,
};
}
if (valueOptions.suffix) {
v.suffix = {
text: replaceVariables(valueOptions.suffix),
numeric: NaN,
color: colorPostfix ? color : null,
fontSize: options.postfixFontSize,
};
}
if (sparkline.show && timeColumn >= 0) {
const points = getFlotPairs({
series,
xIndex: timeColumn,
yIndex: i,
nullValueMode: NullValueMode.Null,
});
v.sparkline = {
...sparkline,
data: points,
minX: timeRange.from.valueOf(),
maxX: timeRange.to.valueOf(),
};
}
values.push(v);
}
}
}
getProcessedValues = (): DisplayValue[] => { return values;
return getSingleStatValues(this.props);
}; };
render() { render() {
......
// 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>
);
}
}
import { ReactPanelPlugin, getStatsCalculators, PanelModel } from '@grafana/ui'; import { ReactPanelPlugin, sharedSingleStatMigrationCheck, sharedSingleStatOptionsCheck } from '@grafana/ui';
import { SingleStatOptions, defaults, SingleStatBaseOptions } from './types'; import { SingleStatOptions, defaults } from './types';
import { SingleStatPanel } from './SingleStatPanel'; import { SingleStatPanel } from './SingleStatPanel';
import cloneDeep from 'lodash/cloneDeep';
import { SingleStatEditor } from './SingleStatEditor'; 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) export const reactPanel = new ReactPanelPlugin<SingleStatOptions>(SingleStatPanel)
.setDefaults(defaults) .setDefaults(defaults)
.setEditor(SingleStatEditor) .setEditor(SingleStatEditor)
.setPanelChangeHandler(singleStatMigrationCheck) .setPanelChangeHandler(sharedSingleStatOptionsCheck)
.setMigrationHandler(singleStatMigrationCheck); .setMigrationHandler(sharedSingleStatMigrationCheck);
import { VizOrientation, ValueMapping, Threshold, StatID } from '@grafana/ui'; import { VizOrientation, StatID, SingleStatBaseOptions } from '@grafana/ui';
export interface SingleStatBaseOptions { export interface SparklineOptions {
valueMappings: ValueMapping[]; show: boolean;
thresholds: Threshold[]; full: boolean; // full height
valueOptions: SingleStatValueOptions; fillColor: string;
orientation: VizOrientation; lineColor: string;
}
export interface SingleStatValueOptions {
unit: string;
suffix: string;
stat: string;
prefix: string;
decimals?: number | null;
} }
// Structure copied from angular
export interface SingleStatOptions extends SingleStatBaseOptions { export interface SingleStatOptions extends SingleStatBaseOptions {
// TODO, fill in with options from angular prefixFontSize?: string;
valueFontSize?: string;
postfixFontSize?: string;
colorBackground?: boolean;
colorValue?: boolean;
colorPrefix?: boolean;
colorPostfix?: boolean;
sparkline: SparklineOptions;
} }
export const defaults: SingleStatOptions = { export const defaults: SingleStatOptions = {
sparkline: {
show: true,
full: false,
lineColor: 'rgb(31, 120, 193)',
fillColor: 'rgba(31, 118, 189, 0.18)',
},
valueOptions: { valueOptions: {
prefix: '', prefix: '',
suffix: '', suffix: '',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment