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

@@ -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;
}