Commit 1aa39ee4 by Ryan McKinley Committed by GitHub

FieldConfig: support overrides model (#20986)

parent 1f73e2aa
import merge from 'lodash/merge';
import { getFieldProperties, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay';
import { getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay';
import { toDataFrame } from '../dataframe/processDataFrame';
import { ReducerID } from '../transformations/fieldReducer';
import { Threshold } from '../types/threshold';
import { GrafanaTheme } from '../types/theme';
import { MappingType } from '../types';
import { setFieldConfigDefaults } from './fieldOverrides';
describe('FieldDisplay', () => {
it('Construct simple field properties', () => {
const f0 = {
min: 0,
max: 100,
};
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');
// 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');
});
it('show first numeric values', () => {
const options = createDisplayOptions({
fieldOptions: {
......@@ -89,7 +63,7 @@ describe('FieldDisplay', () => {
});
it('should restore -Infinity value for base threshold', () => {
const field = getFieldProperties({
const field = {
thresholds: [
({
color: '#73BF69',
......@@ -100,7 +74,8 @@ describe('FieldDisplay', () => {
value: 50,
},
],
});
};
setFieldConfigDefaults(field);
expect(field.thresholds!.length).toEqual(2);
expect(field.thresholds![0].value).toBe(-Infinity);
});
......@@ -130,7 +105,7 @@ describe('FieldDisplay', () => {
const mapEmptyToText = '0';
const options = createEmptyDisplayOptions({
fieldOptions: {
override: {
defaults: {
mappings: [
{
id: 1,
......@@ -203,8 +178,8 @@ function createDisplayOptions(extend = {}): GetFieldDisplayValuesOptions {
},
fieldOptions: {
calcs: [],
override: {},
defaults: {},
overrides: [],
},
theme: {} as GrafanaTheme,
};
......
import toNumber from 'lodash/toNumber';
import toString from 'lodash/toString';
import isEmpty from 'lodash/isEmpty';
import { getDisplayProcessor } from './displayProcessor';
import { getFlotPairs } from '../utils/flotPairs';
import { FieldConfig, DataFrame, FieldType } from '../types/dataFrame';
import { InterpolateFunction } from '../types/panel';
import {
FieldConfig,
DataFrame,
FieldType,
DisplayValue,
DisplayValueAlignmentFactors,
FieldConfigSource,
InterpolateFunction,
} from '../types';
import { DataFrameView } from '../dataframe/DataFrameView';
import { GraphSeriesValue } from '../types/graph';
import { DisplayValue, DisplayValueAlignmentFactors } from '../types/displayValue';
import { GrafanaTheme } from '../types/theme';
import { ReducerID, reduceField } from '../transformations/fieldReducer';
import { ScopedVars } from '../types/ScopedVars';
import { getTimeField } from '../dataframe/processDataFrame';
import { applyFieldOverrides } from './fieldOverrides';
export interface FieldDisplayOptions {
export interface FieldDisplayOptions extends FieldConfigSource {
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: FieldConfig; // Use these values unless otherwise stated
override: FieldConfig; // Set these values regardless of the source
}
// TODO: use built in variables, same as for data links?
......@@ -81,27 +84,21 @@ export interface GetFieldDisplayValuesOptions {
export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25;
export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => {
const { data, replaceVariables, fieldOptions } = options;
const { defaults, override } = fieldOptions;
const { replaceVariables, fieldOptions } = options;
const calcs = fieldOptions.calcs.length ? fieldOptions.calcs : [ReducerID.last];
const values: FieldDisplay[] = [];
if (data) {
if (options.data) {
const data = applyFieldOverrides(options.data, fieldOptions, replaceVariables, options.theme);
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}]`,
};
}
const series = data[s]; // Name is already set
scopedVars['__series'] = { text: 'Series', value: { name: series.name } };
const { timeField } = getTimeField(series);
......@@ -114,7 +111,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
if (field.type !== FieldType.number) {
continue;
}
const config = getFieldProperties(defaults, field.config || {}, override);
const config = field.config; // already set by the prepare task
let name = field.name;
if (!name) {
......@@ -123,7 +120,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
scopedVars['__field'] = { text: 'Field', value: { name } };
const display = getDisplayProcessor({
const display =
field.display ??
getDisplayProcessor({
config,
theme: options.theme,
type: field.type,
......@@ -206,72 +205,6 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
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: FieldConfig, props?: FieldConfig): FieldConfig {
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 FieldConfig;
}
export function getFieldProperties(...props: FieldConfig[]): FieldConfig {
let field = props[0] as FieldConfig;
for (let i = 1; i < props.length; i++) {
field = applyFieldProperties(field, props[i]);
}
// First value is always -Infinity
if (field.thresholds && field.thresholds.length) {
field.thresholds[0].value = -Infinity;
}
// 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;
}
export function getDisplayValueAlignmentFactors(values: FieldDisplay[]): DisplayValueAlignmentFactors {
const info: DisplayValueAlignmentFactors = {
title: '',
......@@ -308,11 +241,10 @@ export function getDisplayValueAlignmentFactors(values: FieldDisplay[]): Display
function createNoValuesFieldDisplay(options: GetFieldDisplayValuesOptions): FieldDisplay {
const displayName = 'No data';
const { fieldOptions } = options;
const { defaults, override } = fieldOptions;
const { defaults } = fieldOptions;
const config = getFieldProperties(defaults, {}, override);
const displayProcessor = getDisplayProcessor({
config,
config: defaults,
theme: options.theme,
type: FieldType.other,
});
......
import { setFieldConfigDefaults, findNumericFieldMinMax, applyFieldOverrides } from './fieldOverrides';
import { MutableDataFrame } from '../dataframe';
import { FieldConfig, FieldConfigSource, InterpolateFunction, GrafanaTheme } from '../types';
import { FieldMatcherID } from '../transformations';
describe('FieldOverrides', () => {
it('will merge FieldConfig with default values', () => {
const field: FieldConfig = {
min: 0,
max: 100,
};
const f1 = {
unit: 'ms',
dateFormat: '', // should be ignored
max: parseFloat('NOPE'), // should be ignored
min: null, // should alo be ignored!
};
setFieldConfigDefaults(field, f1 as FieldConfig);
expect(field.min).toEqual(0);
expect(field.max).toEqual(100);
expect(field.unit).toEqual('ms');
});
it('will apply field overrides', () => {
const f0 = new MutableDataFrame();
f0.add({ title: 'AAA', value: 100, value2: 1234 }, true);
f0.add({ title: 'BBB', value: -20 }, true);
f0.add({ title: 'CCC', value: 200, value2: 1000 }, true);
expect(f0.length).toEqual(3);
// Hardcode the max value
f0.fields[1].config.max = 0;
f0.fields[1].config.decimals = 6;
const src: FieldConfigSource = {
defaults: {
unit: 'xyz',
decimals: 2,
},
overrides: [
{
matcher: { id: FieldMatcherID.numeric },
properties: [
{ path: 'decimals', value: 1 }, // Numeric
{ path: 'title', value: 'Kittens' }, // Text
],
},
],
};
const data = applyFieldOverrides(
[f0], // the frame
src, // defaults + overrides
(undefined as any) as InterpolateFunction,
(undefined as any) as GrafanaTheme
)[0];
const valueColumn = data.fields[1];
const config = valueColumn.config;
// Keep max from the original setting
expect(config.max).toEqual(0);
// Automatically pick the min value
expect(config.min).toEqual(-20);
// The default value applied
expect(config.unit).toEqual('xyz');
// The default value applied
expect(config.title).toEqual('Kittens');
// The override applied
expect(config.decimals).toEqual(1);
});
});
describe('Global MinMax', () => {
it('find global min max', () => {
const f0 = new MutableDataFrame();
f0.add({ title: 'AAA', value: 100, value2: 1234 }, true);
f0.add({ title: 'BBB', value: -20 }, true);
f0.add({ title: 'CCC', value: 200, value2: 1000 }, true);
expect(f0.length).toEqual(3);
const minmax = findNumericFieldMinMax([f0]);
expect(minmax.min).toEqual(-20);
expect(minmax.max).toEqual(1234);
});
});
import set from 'lodash/set';
import {
DynamicConfigValue,
FieldConfigSource,
FieldConfig,
InterpolateFunction,
GrafanaTheme,
DataFrame,
Field,
FieldType,
} from '../types';
import { fieldMatchers, ReducerID, reduceField } from '../transformations';
import { FieldMatcher } from '../types/transformations';
import isNumber from 'lodash/isNumber';
import toNumber from 'lodash/toNumber';
import { getDisplayProcessor } from './displayProcessor';
interface OverrideProps {
match: FieldMatcher;
properties: DynamicConfigValue[];
}
interface GlobalMinMax {
min: number;
max: number;
}
export function findNumericFieldMinMax(data: DataFrame[]): GlobalMinMax {
let min = Number.MAX_VALUE;
let max = Number.MIN_VALUE;
const reducers = [ReducerID.min, ReducerID.max];
for (const frame of data) {
for (const field of frame.fields) {
if (field.type === FieldType.number) {
const stats = reduceField({ field, reducers });
if (stats[ReducerID.min] < min) {
min = stats[ReducerID.min];
}
if (stats[ReducerID.max] > max) {
max = stats[ReducerID.max];
}
}
}
}
return { min, max };
}
/**
* Return a copy of the DataFrame with all rules applied
*/
export function applyFieldOverrides(
data: DataFrame[],
source: FieldConfigSource,
replaceVariables: InterpolateFunction,
theme: GrafanaTheme,
isUtc?: boolean
): DataFrame[] {
if (!source) {
return data;
}
let range: GlobalMinMax | undefined = undefined;
// Prepare the Matchers
const override: OverrideProps[] = [];
if (source.overrides) {
for (const rule of source.overrides) {
const info = fieldMatchers.get(rule.matcher.id);
if (info) {
override.push({
match: info.get(rule.matcher),
properties: rule.properties,
});
}
}
}
return data.map((frame, index) => {
let name = frame.name;
if (!name) {
name = `Series[${index}]`;
}
const fields = frame.fields.map(field => {
// Config is mutable within this scope
const config: FieldConfig = { ...field.config } || {};
if (field.type === FieldType.number) {
setFieldConfigDefaults(config, source.defaults);
}
// Find any matching rules and then override
for (const rule of override) {
if (rule.match(field)) {
for (const prop of rule.properties) {
setDynamicConfigValue(config, {
value: prop,
config,
field,
data: frame,
replaceVariables,
});
}
}
}
// Set the Min/Max value automatically
if (field.type === FieldType.number) {
if (!isNumber(config.min) || !isNumber(config.max)) {
if (!range) {
range = findNumericFieldMinMax(data);
}
if (!isNumber(config.min)) {
config.min = range.min;
}
if (!isNumber(config.max)) {
config.max = range.max;
}
}
}
return {
...field,
// Overwrite the configs
config,
// Set the display processor
processor: getDisplayProcessor({
type: field.type,
config: config,
theme,
isUtc,
}),
};
});
return {
...frame,
fields,
name,
};
});
}
interface DynamicConfigValueOptions {
value: DynamicConfigValue;
config: FieldConfig;
field: Field;
data: DataFrame;
replaceVariables: InterpolateFunction;
}
const numericFieldProps: any = {
decimals: true,
min: true,
max: true,
};
function prepareConfigValue(key: string, input: any, options?: DynamicConfigValueOptions): any {
if (options) {
// TODO template variables etc
}
if (numericFieldProps[key]) {
const num = toNumber(input);
if (isNaN(num)) {
return null;
}
return num;
} else if (input) {
// skips empty string
if (key === 'unit' && input === 'none') {
return null;
}
}
return input;
}
export function setDynamicConfigValue(config: FieldConfig, options: DynamicConfigValueOptions) {
const { value } = options;
const v = prepareConfigValue(value.path, value.value, options);
set(config, value.path, v);
}
/**
* For numeric values, only valid numbers will be applied
* for units, 'none' will be skipped
*/
export function setFieldConfigDefaults(config: FieldConfig, props?: FieldConfig) {
if (props) {
const keys = Object.keys(props);
for (const key of keys) {
const val = prepareConfigValue(key, (props as any)[key]);
if (val === null || val === undefined) {
continue;
}
set(config, key, val);
}
}
// First value is always -Infinity
if (config.thresholds && config.thresholds.length) {
config.thresholds[0].value = -Infinity;
}
// Verify that max > min (swap if necessary)
if (config.hasOwnProperty('min') && config.hasOwnProperty('max') && config.min! > config.max!) {
const tmp = config.max;
config.max = config.min;
config.min = tmp;
}
}
export * from './fieldDisplay';
export * from './displayProcessor';
export { applyFieldOverrides } from './fieldOverrides';
import { MatcherConfig, FieldConfig } from '../types';
export interface DynamicConfigValue {
path: string;
value: any;
}
export interface ConfigOverrideRule {
matcher: MatcherConfig;
properties: DynamicConfigValue[];
}
export interface FieldConfigSource {
// Defatuls applied to all numeric fields
defaults: FieldConfig;
// Rules to override individual values
overrides: ConfigOverrideRule[];
}
......@@ -12,6 +12,7 @@ export * from './displayValue';
export * from './graph';
export * from './ScopedVars';
export * from './transformations';
export * from './fieldOverrides';
export * from './vector';
export * from './app';
export * from './datasource';
......
......@@ -36,4 +36,30 @@ describe('sharedSingleStatMigrationHandler', () => {
expect(sharedSingleStatMigrationHandler(panel as any)).toMatchSnapshot();
});
it('Remove unused `overrides` option', () => {
const panel = {
options: {
fieldOptions: {
unit: 'watt',
stat: 'last',
decimals: 5,
defaults: {
min: 0,
max: 100,
mappings: [],
},
override: {
min: 0,
max: 100,
mappings: [],
},
},
},
title: 'Usage',
type: 'bargauge',
};
expect(sharedSingleStatMigrationHandler(panel as any)).toMatchSnapshot();
});
});
......@@ -12,6 +12,7 @@ import {
VizOrientation,
PanelModel,
FieldDisplayOptions,
ConfigOverrideRule,
} from '@grafana/data';
export interface SingleStatBaseOptions {
......@@ -33,7 +34,7 @@ export function sharedSingleStatPanelChangedHandler(
const options = {
fieldOptions: {
defaults: {} as FieldConfig,
override: {} as FieldConfig,
overrides: [] as ConfigOverrideRule[],
calcs: [reducer ? reducer.id : ReducerID.mean],
},
orientation: VizOrientation.Horizontal,
......@@ -110,6 +111,20 @@ export function sharedSingleStatMigrationHandler(panel: PanelModel<SingleStatBas
options = moveThresholdsAndMappingsToField(options);
}
if (previousVersion < 6.6) {
// discard the old `override` options and enter an empty array
if (options.fieldOptions && options.fieldOptions.override) {
const { override, ...rest } = options.fieldOptions;
options = {
...options,
fieldOptions: {
...rest,
overrides: [],
},
};
}
}
return options as SingleStatBaseOptions;
}
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`sharedSingleStatMigrationHandler Remove unused \`overrides\` option 1`] = `
Object {
"fieldOptions": Object {
"decimals": 5,
"defaults": Object {
"mappings": undefined,
"max": 100,
"min": 0,
"thresholds": undefined,
},
"overrides": Array [],
"stat": "last",
"unit": "watt",
},
}
`;
exports[`sharedSingleStatMigrationHandler from old valueOptions model without pluginVersion 1`] = `
Object {
"fieldOptions": Object {
......
......@@ -28,7 +28,7 @@ Object {
],
"unit": "watt",
},
"override": Object {},
"overrides": Array [],
"values": false,
},
"orientation": "vertical",
......
......@@ -35,7 +35,7 @@ export const standardFieldDisplayOptions: FieldDisplayOptions = {
],
mappings: [],
},
override: {},
overrides: [],
};
export const defaults: StatPanelOptions = {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment