FieldDisplay: shared options model for singlestat panels (#16703)

* update single stat data model

* update single stat data model

* update single stat data model

* show limit default

* merge master

* change stat selector to single until #15954

* add tooltip

* begin children

* move options under display

* align gauge options

* add migration tests

* Docs: Updated changelog

* SingleStatPanels: show title if manual specified

* FieldPropEditor: Max should change max

* change stats to calcs in config

* remove prefix/suffix

* add test

* abort field cycle when passed the limit

* stub a better test

* move title to Field

* remove title
This commit is contained in:
Ryan McKinley
2019-05-04 01:08:48 -07:00
committed by Torkel Ödegaard
parent 493bf0c7b3
commit 073c84179f
32 changed files with 1154 additions and 562 deletions

View File

@@ -1,5 +1,9 @@
# 6.2.0 (unreleased)
### Breaking changes
* **Gauge Panel**: The suffix / prefix options have been removed from the new Guage Panel (introduced in v6.0). [#16870](https://github.com/grafana/grafana/issues/16870).
# 6.1.6 (2019-04-29)
### Features / Enhancements
* **Security**: Bump jQuery to 3.4.0 . [#16761](https://github.com/grafana/grafana/pull/16761), [@dprokop](https://github.com/dprokop)

View File

@@ -0,0 +1,98 @@
// Libraries
import React, { PureComponent, ChangeEvent } from 'react';
// Components
import { FormField, FormLabel, PanelOptionsGroup, StatsPicker, ReducerID } from '@grafana/ui';
// Types
import { FieldDisplayOptions, DEFAULT_FIELD_DISPLAY_VALUES_LIMIT } from '../../utils/fieldDisplay';
import { Field } from '../../types/data';
import Select, { SelectOptionItem } from '../Select/Select';
import { toNumberString, toIntegerOrUndefined } from '../../utils';
const showOptions: Array<SelectOptionItem<boolean>> = [
{
value: true,
label: 'All Values',
description: 'Each row in the response data',
},
{
value: false,
label: 'Calculation',
description: 'Calculate a value based on the response',
},
];
export interface Props {
options: FieldDisplayOptions;
onChange: (valueOptions: FieldDisplayOptions) => void;
labelWidth?: number;
children?: JSX.Element[];
}
export class FieldDisplayEditor extends PureComponent<Props> {
onShowValuesChange = (item: SelectOptionItem<boolean>) => {
const val = item.value === true;
this.props.onChange({ ...this.props.options, values: val });
};
onCalcsChange = (calcs: string[]) => {
this.props.onChange({ ...this.props.options, calcs });
};
onDefaultsChange = (value: Partial<Field>) => {
this.props.onChange({ ...this.props.options, defaults: value });
};
onLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onChange({
...this.props.options,
limit: toIntegerOrUndefined(event.target.value),
});
};
render() {
const { options, children } = this.props;
const { calcs, values, limit } = options;
const labelWidth = this.props.labelWidth || 5;
return (
<PanelOptionsGroup title="Display">
<>
<div className="gf-form">
<FormLabel width={labelWidth}>Show</FormLabel>
<Select
options={showOptions}
value={values ? showOptions[0] : showOptions[1]}
onChange={this.onShowValuesChange}
/>
</div>
{values ? (
<FormField
label="Limit"
labelWidth={labelWidth}
placeholder={`${DEFAULT_FIELD_DISPLAY_VALUES_LIMIT}`}
onChange={this.onLimitChange}
value={toNumberString(limit)}
type="number"
/>
) : (
<div className="gf-form">
<FormLabel width={labelWidth}>Calc</FormLabel>
<StatsPicker
width={12}
placeholder="Choose Stat"
defaultStat={ReducerID.mean}
allowMultiple={false}
stats={calcs}
onChange={this.onCalcsChange}
/>
</div>
)}
{children}
</>
</PanelOptionsGroup>
);
}
}

View File

@@ -0,0 +1,112 @@
// Libraries
import React, { PureComponent, ChangeEvent } from 'react';
// Components
import { FormField, FormLabel, PanelOptionsGroup, UnitPicker, SelectOptionItem } from '@grafana/ui';
// Types
import { Field } from '../../types/data';
import { toNumberString, toIntegerOrUndefined } from '../../utils';
import { VAR_SERIES_NAME, VAR_FIELD_NAME, VAR_CALC, VAR_CELL_PREFIX } from '../../utils/fieldDisplay';
const labelWidth = 6;
export interface Props {
title: string;
options: Partial<Field>;
onChange: (fieldProperties: Partial<Field>) => void;
showMinMax: boolean;
}
export class FieldPropertiesEditor extends PureComponent<Props> {
onTitleChange = (event: ChangeEvent<HTMLInputElement>) =>
this.props.onChange({ ...this.props.options, title: event.target.value });
// @ts-ignore
onUnitChange = (unit: SelectOptionItem<string>) => this.props.onChange({ ...this.props.value, unit: unit.value });
onDecimalChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onChange({
...this.props.options,
decimals: toIntegerOrUndefined(event.target.value),
});
};
onMinChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onChange({
...this.props.options,
min: toIntegerOrUndefined(event.target.value),
});
};
onMaxChange = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onChange({
...this.props.options,
max: toIntegerOrUndefined(event.target.value),
});
};
render() {
const { showMinMax, title } = this.props;
const { unit, decimals, min, max } = this.props.options;
const titleTooltip = (
<div>
Template Variables:
<br />
{'$' + VAR_SERIES_NAME}
<br />
{'$' + VAR_FIELD_NAME}
<br />
{'$' + VAR_CELL_PREFIX + '{N}'} / {'$' + VAR_CALC}
</div>
);
return (
<PanelOptionsGroup title={title}>
<>
<FormField
label="Title"
labelWidth={labelWidth}
onChange={this.onTitleChange}
value={this.props.options.title}
tooltip={titleTooltip}
placeholder="Auto"
/>
<div className="gf-form">
<FormLabel width={labelWidth}>Unit</FormLabel>
<UnitPicker defaultValue={unit} onChange={this.onUnitChange} />
</div>
{showMinMax && (
<>
<FormField
label="Min"
labelWidth={labelWidth}
onChange={this.onMinChange}
value={toNumberString(min)}
type="number"
/>
<FormField
label="Max"
labelWidth={labelWidth}
onChange={this.onMaxChange}
value={toNumberString(max)}
type="number"
/>
</>
)}
<FormField
label="Decimals"
labelWidth={labelWidth}
placeholder="auto"
onChange={this.onDecimalChange}
value={toNumberString(decimals)}
type="number"
/>
</>
</PanelOptionsGroup>
);
}
}

View File

@@ -0,0 +1,62 @@
import cloneDeep from 'lodash/cloneDeep';
import omit from 'lodash/omit';
import { VizOrientation, PanelModel } from '../../types/panel';
import { FieldDisplayOptions } from '../../utils/fieldDisplay';
import { Field } from '../../types';
import { getFieldReducers } from '../../utils/index';
export interface SingleStatBaseOptions {
fieldOptions: FieldDisplayOptions;
orientation: VizOrientation;
}
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>) => {
if (!panel.options) {
// This happens on the first load or when migrating from angular
return {};
}
// This migration aims to keep the most recent changes up-to-date
// Plugins should explicitly migrate for known version changes and only use this
// as a backup
const old = panel.options as any;
if (old.valueOptions) {
const { valueOptions } = old;
const fieldOptions = (old.fieldOptions = {} as FieldDisplayOptions);
fieldOptions.mappings = old.valueMappings;
fieldOptions.thresholds = old.thresholds;
const field = (fieldOptions.defaults = {} as Field);
if (valueOptions) {
field.unit = valueOptions.unit;
field.decimals = valueOptions.decimals;
// Make sure the stats have a valid name
if (valueOptions.stat) {
fieldOptions.calcs = getFieldReducers([valueOptions.stat]).map(s => s.id);
}
}
field.min = old.minValue;
field.max = old.maxValue;
return omit(old, 'valueMappings', 'thresholds', 'valueOptions');
}
return panel.options;
};

View File

@@ -1,91 +0,0 @@
// Libraries
import React, { PureComponent, ChangeEvent } from 'react';
// Components
import {
FormField,
FormLabel,
PanelOptionsGroup,
StatsPicker,
UnitPicker,
ReducerID,
SelectOptionItem,
} from '@grafana/ui';
// Types
import { SingleStatValueOptions } from './shared';
const labelWidth = 6;
export interface Props {
value: SingleStatValueOptions;
onChange: (valueOptions: SingleStatValueOptions) => void;
}
export class SingleStatValueEditor extends PureComponent<Props> {
// @ts-ignore
onUnitChange = (unit: SelectOptionItem<string>) => this.props.onChange({ ...this.props.value, unit: unit.value });
onStatsChange = (stats: string[]) => {
const stat = stats[0] || ReducerID.mean;
this.props.onChange({ ...this.props.value, stat });
};
onDecimalChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!isNaN(parseInt(event.target.value, 10))) {
this.props.onChange({
...this.props.value,
decimals: parseInt(event.target.value, 10),
});
} else {
this.props.onChange({
...this.props.value,
decimals: null,
});
}
};
onPrefixChange = (event: ChangeEvent<HTMLInputElement>) =>
this.props.onChange({ ...this.props.value, prefix: event.target.value });
onSuffixChange = (event: ChangeEvent<HTMLInputElement>) =>
this.props.onChange({ ...this.props.value, suffix: event.target.value });
render() {
const { stat, unit, decimals, prefix, suffix } = this.props.value;
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={ReducerID.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>
);
}
}

View File

@@ -0,0 +1,8 @@
export { FieldDisplayEditor } from './FieldDisplayEditor';
export { FieldPropertiesEditor } from './FieldPropertiesEditor';
export {
SingleStatBaseOptions,
sharedSingleStatOptionsCheck,
sharedSingleStatMigrationCheck,
} from './SingleStatBaseOptions';

View File

@@ -1,131 +0,0 @@
import cloneDeep from 'lodash/cloneDeep';
import {
ValueMapping,
Threshold,
VizOrientation,
PanelModel,
DisplayValue,
FieldType,
NullValueMode,
GrafanaTheme,
SeriesData,
InterpolateFunction,
} from '../../types';
import { getFieldReducers, reduceField } from '../../utils/fieldReducer';
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[] = [];
if (data) {
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 = reduceField({
series,
fieldIndex: i,
reducers: [stat], // The stats to calculate
nullValueMode: NullValueMode.Null,
});
const displayValue = display(stats[stat]);
if (series.name) {
displayValue.title = replaceVariables(series.name);
}
values.push(displayValue);
}
}
}
}
if (values.length === 0) {
values.push({
numeric: 0,
text: 'No data',
});
} else if (values.length === 1) {
// Don't show title for single item
values[0].title = undefined;
}
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 = getFieldReducers([valueOptions.stat]).map(s => s.id)[0];
}
}
return options;
};

View File

@@ -51,5 +51,5 @@ export { LegendOptions, LegendBasicOptions, LegendRenderOptions, LegendList, Leg
// Panel editors
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
export * from './SingleStatShared/shared';
export * from './SingleStatShared/index';
export { CallToActionCard } from './CallToActionCard/CallToActionCard';

View File

@@ -41,10 +41,15 @@ export interface QueryResultBase {
export interface Field {
name: string; // The column name
title?: string; // The display value for this field. This supports template variables blank is auto
type?: FieldType;
filterable?: boolean;
unit?: string;
dateFormat?: string; // Source data format
decimals?: number | null; // Significant digits (for display)
color?: string;
min?: number | null;
max?: number | null;
}
export interface Labels {

View File

@@ -18,10 +18,10 @@ describe('Process simple display values', () => {
getDisplayProcessor(),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ color: '#FFF' }),
getDisplayProcessor({ field: { color: '#FFF' } }),
// Add a simple option that is not used (uses a different base class)
getDisplayProcessor({ unit: 'locale' }),
getDisplayProcessor({ field: { unit: 'locale' } }),
];
it('support null', () => {
@@ -73,17 +73,6 @@ describe('Process simple display values', () => {
});
});
describe('Processor with more configs', () => {
it('support prefix & suffix', () => {
const processor = getDisplayProcessor({
prefix: 'AA_',
suffix: '_ZZ',
});
expect(processor('XXX').text).toEqual('AA_XXX_ZZ');
});
});
describe('Get color from threshold', () => {
it('should get first threshold color when only one threshold', () => {
const thresholds = [{ index: 0, value: -Infinity, color: '#7EB26D' }];
@@ -124,7 +113,7 @@ describe('Format value', () => {
const valueMappings: ValueMapping[] = [];
const value = '6';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
const instance = getDisplayProcessor({ mappings: valueMappings, field: { decimals: 1 } });
const result = instance(value);
@@ -137,7 +126,7 @@ describe('Format value', () => {
{ id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
];
const value = '10';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
const instance = getDisplayProcessor({ mappings: valueMappings, field: { decimals: 1 } });
const result = instance(value);
@@ -146,21 +135,21 @@ describe('Format value', () => {
it('should set auto decimals, 1 significant', () => {
const value = '1.23';
const instance = getDisplayProcessor({ decimals: null });
const instance = getDisplayProcessor({ field: { decimals: null } });
expect(instance(value).text).toEqual('1.2');
});
it('should set auto decimals, 2 significant', () => {
const value = '0.0245';
const instance = getDisplayProcessor({ decimals: null });
const instance = getDisplayProcessor({ field: { decimals: null } });
expect(instance(value).text).toEqual('0.02');
});
it('should use override decimals', () => {
const value = 100030303;
const instance = getDisplayProcessor({ decimals: 2, unit: 'bytes' });
const instance = getDisplayProcessor({ field: { decimals: 2, unit: 'bytes' } });
expect(instance(value).text).toEqual('95.40 MiB');
});
@@ -170,7 +159,7 @@ describe('Format value', () => {
{ id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
];
const value = '11';
const instance = getDisplayProcessor({ mappings: valueMappings, decimals: 1 });
const instance = getDisplayProcessor({ mappings: valueMappings, field: { decimals: 1 } });
expect(instance(value).text).toEqual('1-20');
});

View File

@@ -16,20 +16,16 @@ import {
GrafanaTheme,
GrafanaThemeType,
DecimalCount,
Field,
} from '../types';
export type DisplayProcessor = (value: any) => DisplayValue;
export interface DisplayValueOptions {
unit?: string;
decimals?: DecimalCount;
dateFormat?: string; // If set try to convert numbers to date
field?: Partial<Field>;
color?: string;
mappings?: ValueMapping[];
thresholds?: Threshold[];
prefix?: string;
suffix?: string;
// Alternative to empty string
noValue?: string;
@@ -41,11 +37,12 @@ export interface DisplayValueOptions {
export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProcessor {
if (options && !_.isEmpty(options)) {
const formatFunc = getValueFormat(options.unit || 'none');
const field = options.field ? options.field : {};
const formatFunc = getValueFormat(field.unit || 'none');
return (value: any) => {
const { prefix, suffix, mappings, thresholds, theme } = options;
let color = options.color;
const { mappings, thresholds, theme } = options;
let color = field.color;
let text = _.toString(value);
let numeric = toNumber(value);
@@ -66,17 +63,17 @@ export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProce
}
}
if (options.dateFormat) {
const date = toMoment(value, numeric, options.dateFormat);
if (field.dateFormat) {
const date = toMoment(value, numeric, field.dateFormat);
if (date.isValid()) {
text = date.format(options.dateFormat);
text = date.format(field.dateFormat);
shouldFormat = false;
}
}
if (!isNaN(numeric)) {
if (shouldFormat && !_.isBoolean(value)) {
const { decimals, scaledDecimals } = getDecimalsForValue(value, options.decimals);
const { decimals, scaledDecimals } = getDecimalsForValue(value, field.decimals);
text = formatFunc(numeric, decimals, scaledDecimals, options.isUtc);
}
if (thresholds && thresholds.length > 0) {
@@ -87,12 +84,6 @@ export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProce
if (!text) {
text = options.noValue ? options.noValue : '';
}
if (prefix) {
text = prefix + text;
}
if (suffix) {
text = text + suffix;
}
return { text, numeric, color };
};
}

View File

@@ -0,0 +1,132 @@
import { getFieldProperties, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay';
import { FieldType } from '../types/data';
import { ReducerID } from './fieldReducer';
import { GrafanaThemeType } from '../types/theme';
import { getTheme } from '../themes/index';
describe('FieldDisplay', () => {
it('Construct simple field properties', () => {
const f0 = {
min: 0,
max: 100,
dateFormat: 'YYYY',
};
const f1 = {
unit: 'ms',
dateFormat: '', // should be ignored
max: parseFloat('NOPE'), // should be ignored
min: null,
};
let field = getFieldProperties(f0, f1);
expect(field.min).toEqual(0);
expect(field.max).toEqual(100);
expect(field.unit).toEqual('ms');
expect(field.dateFormat).toEqual('YYYY');
// last one overrieds
const f2 = {
unit: 'none', // ignore 'none'
max: -100, // lower than min! should flip min/max
};
field = getFieldProperties(f0, f1, f2);
expect(field.max).toEqual(0);
expect(field.min).toEqual(-100);
expect(field.unit).toEqual('ms');
});
// Simple test dataset
const options: GetFieldDisplayValuesOptions = {
data: [
{
name: 'Series Name',
fields: [
{ name: 'Field 1', type: FieldType.string },
{ name: 'Field 2', type: FieldType.number },
{ name: 'Field 3', type: FieldType.number },
],
rows: [
['a', 1, 2], // 0
['b', 3, 4], // 1
['c', 5, 6], // 2
],
},
],
replaceVariables: (value: string) => {
return value; // Return it unchanged
},
fieldOptions: {
calcs: [],
mappings: [],
thresholds: [],
override: {},
defaults: {},
},
theme: getTheme(GrafanaThemeType.Dark),
};
it('show first numeric values', () => {
const display = getFieldDisplayValues({
...options,
fieldOptions: {
calcs: [ReducerID.first],
mappings: [],
thresholds: [],
override: {},
defaults: {
title: '$__cell_0 * $__field_name * $__series_name',
},
},
});
expect(display.map(v => v.display.text)).toEqual(['1', '2']);
// expect(display.map(v => v.display.title)).toEqual([
// 'a * Field 1 * Series Name', // 0
// 'b * Field 2 * Series Name', // 1
// ]);
});
it('show last numeric values', () => {
const display = getFieldDisplayValues({
...options,
fieldOptions: {
calcs: [ReducerID.last],
mappings: [],
thresholds: [],
override: {},
defaults: {},
},
});
expect(display.map(v => v.display.numeric)).toEqual([5, 6]);
});
it('show all numeric values', () => {
const display = getFieldDisplayValues({
...options,
fieldOptions: {
values: true, //
limit: 1000,
calcs: [],
mappings: [],
thresholds: [],
override: {},
defaults: {},
},
});
expect(display.map(v => v.display.numeric)).toEqual([1, 3, 5, 2, 4, 6]);
});
it('show 2 numeric values (limit)', () => {
const display = getFieldDisplayValues({
...options,
fieldOptions: {
values: true, //
limit: 2,
calcs: [],
mappings: [],
thresholds: [],
override: {},
defaults: {},
},
});
expect(display.map(v => v.display.numeric)).toEqual([1, 3]); // First 2 are from the first field
});
});

View File

@@ -0,0 +1,278 @@
import toNumber from 'lodash/toNumber';
import toString from 'lodash/toString';
import {
ValueMapping,
Threshold,
DisplayValue,
FieldType,
NullValueMode,
GrafanaTheme,
SeriesData,
InterpolateFunction,
Field,
ScopedVars,
GraphSeriesValue,
} from '../types/index';
import { getDisplayProcessor } from './displayValue';
import { getFlotPairs } from './flotPairs';
import { ReducerID, reduceField } from './fieldReducer';
export interface FieldDisplayOptions {
values?: boolean; // If true show each row value
limit?: number; // if showing all values limit
calcs: string[]; // when !values, pick one value for the whole field
defaults: Partial<Field>; // Use these values unless otherwise stated
override: Partial<Field>; // Set these values regardless of the source
// Could these be data driven also?
thresholds: Threshold[];
mappings: ValueMapping[];
}
export const VAR_SERIES_NAME = '__series_name';
export const VAR_FIELD_NAME = '__field_name';
export const VAR_CALC = '__calc';
export const VAR_CELL_PREFIX = '__cell_'; // consistent with existing table templates
function getTitleTemplate(title: string | undefined, stats: string[], data?: SeriesData[]): string {
// If the title exists, use it as a template variable
if (title) {
return title;
}
if (!data || !data.length) {
return 'No Data';
}
let fieldCount = 0;
for (const field of data[0].fields) {
if (field.type === FieldType.number) {
fieldCount++;
}
}
const parts: string[] = [];
if (stats.length > 1) {
parts.push('$' + VAR_CALC);
}
if (data.length > 1) {
parts.push('$' + VAR_SERIES_NAME);
}
if (fieldCount > 1 || !parts.length) {
parts.push('$' + VAR_FIELD_NAME);
}
return parts.join(' ');
}
export interface FieldDisplay {
field: Field;
display: DisplayValue;
sparkline?: GraphSeriesValue[][];
}
export interface GetFieldDisplayValuesOptions {
data?: SeriesData[];
fieldOptions: FieldDisplayOptions;
replaceVariables: InterpolateFunction;
sparkline?: boolean; // Calculate the sparkline
theme: GrafanaTheme;
}
export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25;
export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => {
const { data, replaceVariables, fieldOptions, sparkline } = options;
const { defaults, override } = fieldOptions;
const calcs = fieldOptions.calcs.length ? fieldOptions.calcs : [ReducerID.last];
const values: FieldDisplay[] = [];
if (data) {
let hitLimit = false;
const limit = fieldOptions.limit ? fieldOptions.limit : DEFAULT_FIELD_DISPLAY_VALUES_LIMIT;
const defaultTitle = getTitleTemplate(fieldOptions.defaults.title, calcs, data);
const scopedVars: ScopedVars = {};
for (let s = 0; s < data.length && !hitLimit; s++) {
let series = data[s];
if (!series.name) {
series = {
...series,
name: series.refId ? series.refId : `Series[${s}]`,
};
}
scopedVars[VAR_SERIES_NAME] = { text: 'Series', value: series.name };
let timeColumn = -1;
if (sparkline) {
for (let i = 0; i < series.fields.length; i++) {
if (series.fields[i].type === FieldType.time) {
timeColumn = i;
break;
}
}
}
for (let i = 0; i < series.fields.length && !hitLimit; i++) {
const field = getFieldProperties(defaults, series.fields[i], override);
// Show all number fields
if (field.type !== FieldType.number) {
continue;
}
if (!field.name) {
field.name = `Field[${s}]`; // it is a copy, so safe to edit
}
scopedVars[VAR_FIELD_NAME] = { text: 'Field', value: field.name };
const display = getDisplayProcessor({
field,
mappings: fieldOptions.mappings,
thresholds: fieldOptions.thresholds,
theme: options.theme,
});
const title = field.title ? field.title : defaultTitle;
// Show all number fields
if (fieldOptions.values) {
const usesCellValues = title.indexOf(VAR_CELL_PREFIX) >= 0;
for (const row of series.rows) {
// Add all the row variables
if (usesCellValues) {
for (let j = 0; j < series.fields.length; j++) {
scopedVars[VAR_CELL_PREFIX + j] = {
value: row[j],
text: toString(row[j]),
};
}
}
const displayValue = display(row[i]);
displayValue.title = replaceVariables(title, scopedVars);
values.push({
field,
display: displayValue,
});
if (values.length >= limit) {
hitLimit = true;
break;
}
}
} else {
const results = reduceField({
series,
fieldIndex: i,
reducers: calcs, // The stats to calculate
nullValueMode: NullValueMode.Null,
});
// Single sparkline for a field
const points =
timeColumn < 0
? undefined
: getFlotPairs({
series,
xIndex: timeColumn,
yIndex: i,
nullValueMode: NullValueMode.Null,
});
for (const calc of calcs) {
scopedVars[VAR_CALC] = { value: calc, text: calc };
const displayValue = display(results[calc]);
displayValue.title = replaceVariables(title, scopedVars);
values.push({
field,
display: displayValue,
sparkline: points,
});
}
}
}
}
}
if (values.length === 0) {
values.push({
field: { name: 'No Data' },
display: {
numeric: 0,
text: 'No data',
},
});
} else if (values.length === 1 && !fieldOptions.defaults.title) {
// Don't show title for single item
values[0].display.title = undefined;
}
return values;
};
const numericFieldProps: any = {
decimals: true,
min: true,
max: true,
};
/**
* Returns a version of the field with the overries applied. Any property with
* value: null | undefined | empty string are skipped.
*
* For numeric values, only valid numbers will be applied
* for units, 'none' will be skipped
*/
export function applyFieldProperties(field: Field, props?: Partial<Field>): Field {
if (!props) {
return field;
}
const keys = Object.keys(props);
if (!keys.length) {
return field;
}
const copy = { ...field } as any; // make a copy that we will manipulate directly
for (const key of keys) {
const val = (props as any)[key];
if (val === null || val === undefined) {
continue;
}
if (numericFieldProps[key]) {
const num = toNumber(val);
if (!isNaN(num)) {
copy[key] = num;
}
} else if (val) {
// skips empty string
if (key === 'unit' && val === 'none') {
continue;
}
copy[key] = val;
}
}
return copy as Field;
}
type PartialField = Partial<Field>;
export function getFieldProperties(...props: PartialField[]): Field {
let field = props[0] as Field;
for (let i = 1; i < props.length; i++) {
field = applyFieldProperties(field, props[i]);
}
// Verify that max > min
if (field.hasOwnProperty('min') && field.hasOwnProperty('max') && field.min! > field.max!) {
return {
...field,
min: field.max,
max: field.min,
};
}
return field;
}

View File

@@ -7,6 +7,7 @@ export * from './string';
export * from './csv';
export * from './fieldReducer';
export * from './displayValue';
export * from './fieldDisplay';
export * from './deprecationWarning';
export * from './logs';
export * from './labels';

View File

@@ -49,3 +49,18 @@ export function getIntervalFromString(strInterval: string): SelectOptionItem<num
value: stringToMs(strInterval),
};
}
export function toNumberString(value: number | undefined | null): string {
if (value !== null && value !== undefined && Number.isFinite(value as number)) {
return value.toString();
}
return '';
}
export function toIntegerOrUndefined(value: string): number | undefined {
if (!value) {
return undefined;
}
const v = parseInt(value, 10);
return isNaN(v) ? undefined : v;
}

View File

@@ -5,33 +5,37 @@ import React, { PureComponent } from 'react';
import { config } from 'app/core/config';
// Components
import { BarGauge, VizRepeater, getSingleStatDisplayValues } from '@grafana/ui/src/components';
import { BarGauge, VizRepeater, getFieldDisplayValues, FieldDisplay } from '@grafana/ui';
// Types
import { BarGaugeOptions } from './types';
import { PanelProps, DisplayValue } from '@grafana/ui/src/types';
import { PanelProps } from '@grafana/ui/src/types';
export class BarGaugePanel extends PureComponent<PanelProps<BarGaugeOptions>> {
renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
const { options } = this.props;
const { fieldOptions } = options;
const { field, display } = value;
return (
<BarGauge
value={value}
value={display}
width={width}
height={height}
orientation={options.orientation}
thresholds={options.thresholds}
thresholds={fieldOptions.thresholds}
theme={config.theme}
itemSpacing={this.getItemSpacing()}
displayMode={options.displayMode}
minValue={field.min}
maxValue={field.max}
/>
);
};
getValues = (): DisplayValue[] => {
getValues = (): FieldDisplay[] => {
const { data, options, replaceVariables } = this.props;
return getSingleStatDisplayValues({
return getFieldDisplayValues({
...options,
replaceVariables,
theme: config.theme,

View File

@@ -6,10 +6,10 @@ import {
ThresholdsEditor,
ValueMappingsEditor,
PanelOptionsGrid,
PanelOptionsGroup,
FormField,
SingleStatValueOptions,
SingleStatValueEditor,
FieldDisplayEditor,
FieldDisplayOptions,
Field,
FieldPropertiesEditor,
} from '@grafana/ui';
// Types
@@ -18,40 +18,45 @@ import { BarGaugeOptions, orientationOptions, displayModes } from './types';
export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGaugeOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) =>
this.props.onOptionsChange({
...this.props.options,
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
thresholds,
});
onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
this.props.onOptionsChange({
...this.props.options,
valueMappings,
onValueMappingsChanged = (mappings: ValueMapping[]) =>
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
mappings,
});
onValueOptionsChanged = (valueOptions: SingleStatValueOptions) =>
onDisplayOptionsChanged = (fieldOptions: FieldDisplayOptions) =>
this.props.onOptionsChange({
...this.props.options,
valueOptions,
fieldOptions,
});
onMinValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, minValue: target.value });
onMaxValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, maxValue: target.value });
onDefaultsChange = (field: Partial<Field>) => {
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
defaults: field,
});
};
onOrientationChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, orientation: value });
onDisplayModeChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, displayMode: value });
render() {
const { options } = this.props;
const { fieldOptions } = options;
const labelWidth = 6;
return (
<>
<PanelOptionsGrid>
<SingleStatValueEditor onChange={this.onValueOptionsChanged} value={options.valueOptions} />
<PanelOptionsGroup title="Gauge">
<FormField label="Min value" labelWidth={8} onChange={this.onMinValueChange} value={options.minValue} />
<FormField label="Max value" labelWidth={8} onChange={this.onMaxValueChange} value={options.maxValue} />
<FieldDisplayEditor onChange={this.onDisplayOptionsChanged} options={fieldOptions} labelWidth={labelWidth}>
<div className="form-field">
<FormLabel width={8}>Orientation</FormLabel>
<FormLabel width={labelWidth}>Orientation</FormLabel>
<Select
width={12}
options={orientationOptions}
@@ -61,7 +66,7 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
/>
</div>
<div className="form-field">
<FormLabel width={8}>Display Mode</FormLabel>
<FormLabel width={labelWidth}>Mode</FormLabel>
<Select
width={12}
options={displayModes}
@@ -70,11 +75,19 @@ export class BarGaugePanelEditor extends PureComponent<PanelEditorProps<BarGauge
value={displayModes.find(item => item.value === options.displayMode)}
/>
</div>
</PanelOptionsGroup>
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
</FieldDisplayEditor>
<FieldPropertiesEditor
title="Field"
showMinMax={true}
onChange={this.onDefaultsChange}
options={fieldOptions.defaults}
/>
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={fieldOptions.thresholds} />
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={fieldOptions.mappings} />
</>
);
}

View File

@@ -1,8 +1,7 @@
import { VizOrientation, SelectOptionItem, ReducerID, SingleStatBaseOptions } from '@grafana/ui';
import { VizOrientation, SelectOptionItem, SingleStatBaseOptions } from '@grafana/ui';
import { standardGaugeFieldOptions } from '../gauge/types';
export interface BarGaugeOptions extends SingleStatBaseOptions {
minValue: number;
maxValue: number;
displayMode: 'basic' | 'lcd' | 'gradient';
}
@@ -18,17 +17,7 @@ export const orientationOptions: Array<SelectOptionItem<VizOrientation>> = [
];
export const defaults: BarGaugeOptions = {
minValue: 0,
maxValue: 100,
displayMode: 'lcd',
orientation: VizOrientation.Horizontal,
valueOptions: {
unit: 'none',
stat: ReducerID.mean,
prefix: '',
suffix: '',
decimals: null,
},
thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
valueMappings: [],
fieldOptions: standardGaugeFieldOptions,
};

View File

@@ -0,0 +1,82 @@
import { PanelModel } from '@grafana/ui';
import { gaugePanelMigrationCheck } from './GaugeMigrations';
describe('Gauge Panel Migrations', () => {
it('from 6.1.1', () => {
const panel = {
datasource: '-- Grafana --',
gridPos: {
h: 9,
w: 12,
x: 0,
y: 0,
},
id: 2,
options: {
maxValue: '50',
minValue: '-50',
orientation: 'auto',
showThresholdLabels: true,
showThresholdMarkers: true,
thresholds: [
{
color: 'green',
index: 0,
value: null,
},
{
color: '#EAB839',
index: 1,
value: -25,
},
{
color: '#6ED0E0',
index: 2,
value: 0,
},
{
color: 'red',
index: 3,
value: 25,
},
],
valueMappings: [
{
id: 1,
operator: '',
value: '',
text: 'BIG',
type: 2,
from: '50',
to: '1000',
},
],
valueOptions: {
decimals: 3,
prefix: 'XX',
stat: 'last',
suffix: 'YY',
unit: 'accMS2',
},
},
pluginVersion: '6.1.6',
targets: [
{
refId: 'A',
},
{
refId: 'B',
},
{
refId: 'C',
},
],
timeFrom: null,
timeShift: null,
title: 'Panel Title',
type: 'gauge',
} as PanelModel;
expect(gaugePanelMigrationCheck(panel)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,43 @@
import { PanelModel, Field, getFieldReducers } from '@grafana/ui';
import { GaugeOptions } from './types';
import { sharedSingleStatMigrationCheck } from '@grafana/ui/src/components/SingleStatShared/SingleStatBaseOptions';
import { FieldDisplayOptions } from '@grafana/ui/src/utils/fieldDisplay';
export const gaugePanelMigrationCheck = (panel: PanelModel<GaugeOptions>): Partial<GaugeOptions> => {
if (!panel.options) {
// This happens on the first load or when migrating from angular
return {};
}
if (!panel.pluginVersion || panel.pluginVersion.startsWith('6.1')) {
const old = panel.options as any;
const { valueOptions } = old;
const options = {} as GaugeOptions;
options.showThresholdLabels = old.showThresholdLabels;
options.showThresholdMarkers = old.showThresholdMarkers;
options.orientation = old.orientation;
const fieldOptions = (options.fieldOptions = {} as FieldDisplayOptions);
fieldOptions.mappings = old.valueMappings;
fieldOptions.thresholds = old.thresholds;
const field = (fieldOptions.defaults = {} as Field);
if (valueOptions) {
field.unit = valueOptions.unit;
field.decimals = valueOptions.decimals;
// Make sure the stats have a valid name
if (valueOptions.stat) {
fieldOptions.calcs = getFieldReducers([valueOptions.stat]).map(s => s.id);
}
}
field.min = old.minValue;
field.max = old.maxValue;
return options;
}
// Default to the standard migration path
return sharedSingleStatMigrationCheck(panel);
};

View File

@@ -1,50 +0,0 @@
// Libraries
import React, { PureComponent } from 'react';
// Components
import { Switch, PanelOptionsGroup } from '@grafana/ui';
// Types
import { FormField, PanelEditorProps } from '@grafana/ui';
import { GaugeOptions } from './types';
export class GaugeOptionsBox extends PureComponent<PanelEditorProps<GaugeOptions>> {
labelWidth = 8;
onToggleThresholdLabels = () =>
this.props.onOptionsChange({ ...this.props.options, showThresholdLabels: !this.props.options.showThresholdLabels });
onToggleThresholdMarkers = () =>
this.props.onOptionsChange({
...this.props.options,
showThresholdMarkers: !this.props.options.showThresholdMarkers,
});
onMinValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, minValue: target.value });
onMaxValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, maxValue: target.value });
render() {
const { options } = this.props;
const { maxValue, minValue, showThresholdLabels, showThresholdMarkers } = options;
return (
<PanelOptionsGroup title="Gauge">
<FormField label="Min value" labelWidth={this.labelWidth} onChange={this.onMinValueChange} value={minValue} />
<FormField label="Max value" labelWidth={this.labelWidth} onChange={this.onMaxValueChange} value={maxValue} />
<Switch
label="Show labels"
labelClass={`width-${this.labelWidth}`}
checked={showThresholdLabels}
onChange={this.onToggleThresholdLabels}
/>
<Switch
label="Show markers"
labelClass={`width-${this.labelWidth}`}
checked={showThresholdMarkers}
onChange={this.onToggleThresholdMarkers}
/>
</PanelOptionsGroup>
);
}
}

View File

@@ -5,35 +5,37 @@ import React, { PureComponent } from 'react';
import { config } from 'app/core/config';
// Components
import { Gauge } from '@grafana/ui';
import { Gauge, FieldDisplay, getFieldDisplayValues } from '@grafana/ui';
// Types
import { GaugeOptions } from './types';
import { DisplayValue, PanelProps, getSingleStatDisplayValues, VizRepeater } from '@grafana/ui';
import { PanelProps, VizRepeater } from '@grafana/ui';
export class GaugePanel extends PureComponent<PanelProps<GaugeOptions>> {
renderValue = (value: DisplayValue, width: number, height: number): JSX.Element => {
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
const { options } = this.props;
const { fieldOptions } = options;
const { field, display } = value;
return (
<Gauge
value={value}
value={display}
width={width}
height={height}
thresholds={options.thresholds}
thresholds={fieldOptions.thresholds}
showThresholdLabels={options.showThresholdLabels}
showThresholdMarkers={options.showThresholdMarkers}
minValue={options.minValue}
maxValue={options.maxValue}
minValue={field.min}
maxValue={field.max}
theme={config.theme}
/>
);
};
getValues = (): DisplayValue[] => {
getValues = (): FieldDisplay[] => {
const { data, options, replaceVariables } = this.props;
return getSingleStatDisplayValues({
...options,
return getFieldDisplayValues({
fieldOptions: options.fieldOptions,
replaceVariables,
theme: config.theme,
data: data.series,

View File

@@ -7,44 +7,89 @@ import {
PanelOptionsGrid,
ValueMappingsEditor,
ValueMapping,
SingleStatValueOptions,
SingleStatValueEditor,
FieldDisplayOptions,
FieldDisplayEditor,
Field,
FieldPropertiesEditor,
Switch,
} from '@grafana/ui';
import { GaugeOptionsBox } from './GaugeOptionsBox';
import { GaugeOptions } from './types';
export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) =>
labelWidth = 6;
onToggleThresholdLabels = () =>
this.props.onOptionsChange({ ...this.props.options, showThresholdLabels: !this.props.options.showThresholdLabels });
onToggleThresholdMarkers = () =>
this.props.onOptionsChange({
...this.props.options,
showThresholdMarkers: !this.props.options.showThresholdMarkers,
});
onThresholdsChanged = (thresholds: Threshold[]) =>
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
thresholds,
});
onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
this.props.onOptionsChange({
...this.props.options,
valueMappings,
onValueMappingsChanged = (mappings: ValueMapping[]) =>
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
mappings,
});
onValueOptionsChanged = (valueOptions: SingleStatValueOptions) =>
onDisplayOptionsChanged = (fieldOptions: FieldDisplayOptions) =>
this.props.onOptionsChange({
...this.props.options,
valueOptions,
fieldOptions,
});
onDefaultsChange = (field: Partial<Field>) => {
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
defaults: field,
});
};
render() {
const { onOptionsChange, options } = this.props;
const { options } = this.props;
const { fieldOptions, showThresholdLabels, showThresholdMarkers } = options;
return (
<>
<PanelOptionsGrid>
<SingleStatValueEditor onChange={this.onValueOptionsChanged} value={options.valueOptions} />
<GaugeOptionsBox onOptionsChange={onOptionsChange} options={options} />
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={options.thresholds} />
<FieldDisplayEditor
onChange={this.onDisplayOptionsChanged}
options={fieldOptions}
labelWidth={this.labelWidth}
>
<Switch
label="Labels"
labelClass={`width-${this.labelWidth}`}
checked={showThresholdLabels}
onChange={this.onToggleThresholdLabels}
/>
<Switch
label="Markers"
labelClass={`width-${this.labelWidth}`}
checked={showThresholdMarkers}
onChange={this.onToggleThresholdMarkers}
/>
</FieldDisplayEditor>
<FieldPropertiesEditor
title="Field"
showMinMax={true}
onChange={this.onDefaultsChange}
options={fieldOptions.defaults}
/>
<ThresholdsEditor onChange={this.onThresholdsChanged} thresholds={fieldOptions.thresholds} />
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={fieldOptions.mappings} />
</>
);
}

View File

@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Gauge Panel Migrations from 6.1.1 1`] = `
Object {
"fieldOptions": Object {
"calcs": Array [
"last",
],
"defaults": Object {
"decimals": 3,
"max": "50",
"min": "-50",
"unit": "accMS2",
},
"mappings": Array [
Object {
"from": "50",
"id": 1,
"operator": "",
"text": "BIG",
"to": "1000",
"type": 2,
"value": "",
},
],
"thresholds": Array [
Object {
"color": "green",
"index": 0,
"value": null,
},
Object {
"color": "#EAB839",
"index": 1,
"value": -25,
},
Object {
"color": "#6ED0E0",
"index": 2,
"value": 0,
},
Object {
"color": "red",
"index": 3,
"value": 25,
},
],
},
"orientation": "auto",
"showThresholdLabels": true,
"showThresholdMarkers": true,
}
`;

View File

@@ -1,25 +1,23 @@
import { VizOrientation, ReducerID, SingleStatBaseOptions } from '@grafana/ui';
import { VizOrientation, FieldDisplayOptions } from '@grafana/ui';
import { SingleStatBaseOptions } from '@grafana/ui/src/components/SingleStatShared/SingleStatBaseOptions';
import { standardFieldDisplayOptions } from '../singlestat2/types';
export interface GaugeOptions extends SingleStatBaseOptions {
maxValue: number;
minValue: number;
showThresholdLabels: boolean;
showThresholdMarkers: boolean;
}
export const standardGaugeFieldOptions: FieldDisplayOptions = {
...standardFieldDisplayOptions,
defaults: {
min: 0,
max: 100,
},
};
export const defaults: GaugeOptions = {
minValue: 0,
maxValue: 100,
showThresholdMarkers: true,
showThresholdLabels: false,
valueOptions: {
prefix: '',
suffix: '',
decimals: null,
stat: ReducerID.mean,
unit: 'none',
},
valueMappings: [],
thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
fieldOptions: standardGaugeFieldOptions,
orientation: VizOrientation.Auto,
};

View File

@@ -23,7 +23,9 @@ export const getGraphSeriesModel = (
const graphs: GraphSeriesXY[] = [];
const displayProcessor = getDisplayProcessor({
field: {
decimals: legendOptions.decimals,
},
});
for (const series of data.series) {

View File

@@ -5,7 +5,7 @@ import React, { PureComponent } from 'react';
import { config } from 'app/core/config';
// Components
import { PieChart, getSingleStatDisplayValues } from '@grafana/ui';
import { PieChart, getFieldDisplayValues } from '@grafana/ui';
// Types
import { PieChartOptions } from './types';
@@ -17,14 +17,12 @@ export class PieChartPanel extends PureComponent<Props> {
render() {
const { width, height, options, data, replaceVariables } = this.props;
const values = getSingleStatDisplayValues({
valueMappings: options.valueMappings,
thresholds: options.thresholds,
valueOptions: options.valueOptions,
const values = getFieldDisplayValues({
fieldOptions: options.fieldOptions,
data: data.series,
theme: config.theme,
replaceVariables: replaceVariables,
});
}).map(v => v.display);
return (
<PieChart

View File

@@ -4,37 +4,55 @@ import {
PanelOptionsGrid,
ValueMappingsEditor,
ValueMapping,
SingleStatValueOptions,
SingleStatValueEditor,
FieldDisplayEditor,
FieldDisplayOptions,
FieldPropertiesEditor,
Field,
} from '@grafana/ui';
import { PieChartOptionsBox } from './PieChartOptionsBox';
import { PieChartOptions } from './types';
export class PieChartPanelEditor extends PureComponent<PanelEditorProps<PieChartOptions>> {
onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
this.props.onOptionsChange({
...this.props.options,
valueMappings,
onValueMappingsChanged = (mappings: ValueMapping[]) =>
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
mappings,
});
onValueOptionsChanged = (valueOptions: SingleStatValueOptions) =>
onDisplayOptionsChanged = (fieldOptions: FieldDisplayOptions) =>
this.props.onOptionsChange({
...this.props.options,
valueOptions,
fieldOptions,
});
onDefaultsChange = (field: Partial<Field>) => {
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
defaults: field,
});
};
render() {
const { onOptionsChange, options } = this.props;
const { fieldOptions } = options;
return (
<>
<PanelOptionsGrid>
<SingleStatValueEditor onChange={this.onValueOptionsChanged} value={options.valueOptions} />
<FieldDisplayEditor onChange={this.onDisplayOptionsChanged} options={fieldOptions} />
<FieldPropertiesEditor
title="Field (default)"
showMinMax={true}
onChange={this.onDefaultsChange}
options={fieldOptions.defaults}
/>
<PieChartOptionsBox onOptionsChange={onOptionsChange} options={options} />
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={fieldOptions.mappings} />
</>
);
}

View File

@@ -1,4 +1,5 @@
import { PieChartType, ReducerID, VizOrientation, SingleStatBaseOptions } from '@grafana/ui';
import { standardFieldDisplayOptions } from '../singlestat2/types';
export interface PieChartOptions extends SingleStatBaseOptions {
pieType: PieChartType;
@@ -8,13 +9,12 @@ export interface PieChartOptions extends SingleStatBaseOptions {
export const defaults: PieChartOptions = {
pieType: PieChartType.PIE,
strokeWidth: 1,
valueOptions: {
unit: 'short',
stat: ReducerID.last,
suffix: '',
prefix: '',
},
valueMappings: [],
thresholds: [],
orientation: VizOrientation.Auto,
fieldOptions: {
...standardFieldDisplayOptions,
calcs: [ReducerID.last],
defaults: {
unit: 'short',
},
},
};

View File

@@ -7,8 +7,10 @@ import {
PanelOptionsGrid,
ValueMappingsEditor,
ValueMapping,
SingleStatValueOptions,
SingleStatValueEditor,
FieldDisplayOptions,
FieldDisplayEditor,
FieldPropertiesEditor,
Field,
} from '@grafana/ui';
import { SingleStatOptions, SparklineOptions } from './types';
@@ -18,21 +20,21 @@ import { SparklineEditor } from './SparklineEditor';
export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatOptions>> {
onThresholdsChanged = (thresholds: Threshold[]) =>
this.props.onOptionsChange({
...this.props.options,
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
thresholds,
});
onValueMappingsChanged = (valueMappings: ValueMapping[]) =>
this.props.onOptionsChange({
...this.props.options,
valueMappings,
onValueMappingsChanged = (mappings: ValueMapping[]) =>
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
mappings,
});
onValueOptionsChanged = (valueOptions: SingleStatValueOptions) =>
onDisplayOptionsChanged = (fieldOptions: FieldDisplayOptions) =>
this.props.onOptionsChange({
...this.props.options,
valueOptions,
fieldOptions,
});
onSparklineChanged = (sparkline: SparklineOptions) =>
@@ -41,21 +43,37 @@ export class SingleStatEditor extends PureComponent<PanelEditorProps<SingleStatO
sparkline,
});
onDefaultsChange = (field: Partial<Field>) => {
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
override: field,
});
};
render() {
const { options } = this.props;
const { fieldOptions } = options;
return (
<>
<PanelOptionsGrid>
<SingleStatValueEditor onChange={this.onValueOptionsChanged} value={options.valueOptions} />
<FieldDisplayEditor onChange={this.onDisplayOptionsChanged} options={fieldOptions} />
<FieldPropertiesEditor
title="Field (default)"
showMinMax={true}
onChange={this.onDefaultsChange}
options={fieldOptions.defaults}
/>
<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={fieldOptions.thresholds} />
</PanelOptionsGrid>
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={options.valueMappings} />
<ValueMappingsEditor onChange={this.onValueMappingsChanged} valueMappings={fieldOptions.mappings} />
</>
);
}

View File

@@ -3,134 +3,38 @@ import React, { PureComponent } from 'react';
// Utils & Services
import { config } from 'app/core/config';
import { getFlotPairs } from '@grafana/ui/src/utils/flotPairs';
// Components
import { VizRepeater } from '@grafana/ui/src/components';
import { BigValueSparkline, BigValue } from '@grafana/ui/src/components/BigValue/BigValue';
// Types
import { SingleStatOptions } from './types';
import {
DisplayValue,
PanelProps,
getDisplayProcessor,
NullValueMode,
reduceField,
FieldCache,
FieldType,
} from '@grafana/ui';
interface SingleStatDisplay {
value: DisplayValue;
prefix?: DisplayValue;
suffix?: DisplayValue;
sparkline?: BigValueSparkline;
backgroundColor?: string;
}
import { PanelProps, getFieldDisplayValues, VizRepeater, FieldDisplay, BigValue } from '@grafana/ui';
import { BigValueSparkline } from '@grafana/ui/src/components/BigValue/BigValue';
export class SingleStatPanel extends PureComponent<PanelProps<SingleStatOptions>> {
renderValue = (value: SingleStatDisplay, width: number, height: number): JSX.Element => {
return <BigValue {...value} width={width} height={height} theme={config.theme} />;
};
renderValue = (value: FieldDisplay, width: number, height: number): JSX.Element => {
let sparkline: BigValueSparkline;
if (value.sparkline) {
const { timeRange, options } = this.props;
getValues = (): SingleStatDisplay[] => {
const { data, replaceVariables, options, timeRange } = this.props;
const { valueOptions, valueMappings } = options;
const display = getDisplayProcessor({
unit: valueOptions.unit,
decimals: valueOptions.decimals,
mappings: valueMappings,
thresholds: options.thresholds,
theme: config.theme,
});
const { colorBackground, colorValue, colorPrefix, colorPostfix, sparkline } = options;
const { stat } = valueOptions;
const values: SingleStatDisplay[] = [];
for (const series of data.series) {
const fieldCache = new FieldCache(series.fields);
const timeColumn = sparkline.show ? fieldCache.getFirstFieldOfType(FieldType.time) : null;
const numberFields = fieldCache.getFields(FieldType.number);
for (let i = 0; i < numberFields.length; i++) {
const field = numberFields[i];
const stats = reduceField({
series,
fieldIndex: field.index,
reducers: [stat], // The stats to calculate
nullValueMode: NullValueMode.Null,
});
const v: SingleStatDisplay = {
value: display(stats[stat]),
};
v.value.title = replaceVariables(field.name);
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) {
const points = getFlotPairs({
series,
xIndex: timeColumn.index,
yIndex: field.index,
nullValueMode: NullValueMode.Null,
});
v.sparkline = {
...sparkline,
data: points,
sparkline = {
...options.sparkline,
data: value.sparkline,
minX: timeRange.from.valueOf(),
maxX: timeRange.to.valueOf(),
};
}
values.push(v);
}
}
return <BigValue value={value.display} sparkline={sparkline} width={width} height={height} theme={config.theme} />;
};
if (values.length === 0) {
values.push({
value: {
numeric: 0,
text: 'No data',
},
getValues = (): FieldDisplay[] => {
const { data, options, replaceVariables } = this.props;
return getFieldDisplayValues({
...options,
replaceVariables,
theme: config.theme,
data: data.series,
sparkline: options.sparkline.show,
});
} else if (values.length === 1) {
// Don't show title for single item
values[0].value.title = null;
}
return values;
};
render() {

View File

@@ -1,4 +1,4 @@
import { VizOrientation, ReducerID, SingleStatBaseOptions } from '@grafana/ui';
import { VizOrientation, ReducerID, SingleStatBaseOptions, FieldDisplayOptions } from '@grafana/ui';
export interface SparklineOptions {
show: boolean;
@@ -21,6 +21,15 @@ export interface SingleStatOptions extends SingleStatBaseOptions {
sparkline: SparklineOptions;
}
export const standardFieldDisplayOptions: FieldDisplayOptions = {
values: false,
calcs: [ReducerID.mean],
defaults: {},
override: {},
mappings: [],
thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
};
export const defaults: SingleStatOptions = {
sparkline: {
show: true,
@@ -28,15 +37,6 @@ export const defaults: SingleStatOptions = {
lineColor: 'rgb(31, 120, 193)',
fillColor: 'rgba(31, 118, 189, 0.18)',
},
valueOptions: {
prefix: '',
suffix: '',
decimals: null,
stat: ReducerID.mean,
unit: 'none',
},
valueMappings: [],
thresholds: [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 80, color: 'red' }],
fieldOptions: standardFieldDisplayOptions,
orientation: VizOrientation.Auto,
};