mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
committed by
Torkel Ödegaard
parent
493bf0c7b3
commit
073c84179f
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { FieldDisplayEditor } from './FieldDisplayEditor';
|
||||
export { FieldPropertiesEditor } from './FieldPropertiesEditor';
|
||||
|
||||
export {
|
||||
SingleStatBaseOptions,
|
||||
sharedSingleStatOptionsCheck,
|
||||
sharedSingleStatMigrationCheck,
|
||||
} from './SingleStatBaseOptions';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
132
packages/grafana-ui/src/utils/fieldDisplay.test.ts
Normal file
132
packages/grafana-ui/src/utils/fieldDisplay.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
278
packages/grafana-ui/src/utils/fieldDisplay.ts
Normal file
278
packages/grafana-ui/src/utils/fieldDisplay.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user