diff --git a/packages/grafana-data/src/field/fieldDisplay.test.ts b/packages/grafana-data/src/field/fieldDisplay.test.ts index c0c45cd6226..b866fe562a2 100644 --- a/packages/grafana-data/src/field/fieldDisplay.test.ts +++ b/packages/grafana-data/src/field/fieldDisplay.test.ts @@ -1,39 +1,13 @@ 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, }; diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts index 120d0815e78..4ae028db8b1 100644 --- a/packages/grafana-data/src/field/fieldDisplay.ts +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -1,26 +1,29 @@ -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,11 +120,13 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi scopedVars['__field'] = { text: 'Field', value: { name } }; - const display = getDisplayProcessor({ - config, - theme: options.theme, - type: field.type, - }); + const display = + field.display ?? + getDisplayProcessor({ + config, + theme: options.theme, + type: field.type, + }); const title = config.title ? config.title : defaultTitle; // Show all rows @@ -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, }); diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts new file mode 100644 index 00000000000..9aacc8e978a --- /dev/null +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -0,0 +1,89 @@ +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); + }); +}); diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts new file mode 100644 index 00000000000..b5e6c58082e --- /dev/null +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -0,0 +1,213 @@ +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; + } +} diff --git a/packages/grafana-data/src/field/index.ts b/packages/grafana-data/src/field/index.ts index 1e18439d6dd..d9dcaeafc36 100644 --- a/packages/grafana-data/src/field/index.ts +++ b/packages/grafana-data/src/field/index.ts @@ -1,2 +1,4 @@ export * from './fieldDisplay'; export * from './displayProcessor'; + +export { applyFieldOverrides } from './fieldOverrides'; diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts new file mode 100644 index 00000000000..c2b2c670f85 --- /dev/null +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -0,0 +1,19 @@ +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[]; +} diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 8169e8094dd..f51d76f75f1 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts index 3a467e8ba29..72927c241ec 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts @@ -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(); + }); }); diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index 63510fbbf7f..ac6aa7de684 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -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