FieldOverrides: Apply field overrides in PanelQueryRunner (#22439)

* Apply field overrides in PanelChrome

* Move applyFieldOverrides to panel query runner

* Review updates

* Make sure overrides are applied back on souce panel when exiting the new edit mode

* TS ignores in est

* Make field display work in viz repeater

* Review updates

* Review and test updates

* Change the way overrides and trransformations are retrieved in PQR

* Minor updates after review

* Fix null checks
This commit is contained in:
Dominik Prokop 2020-03-16 14:26:03 +01:00 committed by GitHub
parent ab0238eced
commit 642c1a16dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 315 additions and 138 deletions

View File

@ -19,7 +19,6 @@ describe('FieldDisplay', () => {
shouldApply: () => true, shouldApply: () => true,
} as any; } as any;
console.log('Init tegistry');
standardFieldConfigEditorRegistry.setInit(() => { standardFieldConfigEditorRegistry.setInit(() => {
return [mappings]; return [mappings];
}); });
@ -168,48 +167,57 @@ describe('FieldDisplay', () => {
describe('Value mapping', () => { describe('Value mapping', () => {
it('should apply value mapping', () => { it('should apply value mapping', () => {
const options = createDisplayOptions({ const mappingConfig = [
fieldOptions: {
calcs: [ReducerID.first],
override: {},
defaults: {
mappings: [
{ {
id: 1, id: 1,
operator: '', operator: '',
text: 'Value mapped to text', text: 'Value mapped to text',
type: MappingType.ValueToText, type: MappingType.ValueToText,
value: 1, value: '1',
}, },
], ];
const options = createDisplayOptions({
fieldOptions: {
calcs: [ReducerID.first],
override: {},
defaults: {
mappings: mappingConfig,
}, },
}, },
}); });
options.data![0].fields[1]!.config = { mappings: mappingConfig };
options.data![0].fields[2]!.config = { mappings: mappingConfig };
const result = getFieldDisplayValues(options); const result = getFieldDisplayValues(options);
expect(result[0].display.text).toEqual('Value mapped to text'); expect(result[0].display.text).toEqual('Value mapped to text');
}); });
it('should apply range value mapping', () => { it('should apply range value mapping', () => {
const mappedValue = 'Range mapped to text'; const mappedValue = 'Range mapped to text';
const options = createDisplayOptions({ const mappingConfig = [
fieldOptions: {
values: true,
override: {},
defaults: {
mappings: [
{ {
id: 1, id: 1,
operator: '', operator: '',
text: mappedValue, text: mappedValue,
type: MappingType.RangeToText, type: MappingType.RangeToText,
value: 1, value: 1,
from: 1, from: '1',
to: 3, to: '3',
}, },
], ];
const options = createDisplayOptions({
fieldOptions: {
values: true,
override: {},
defaults: {
mappings: mappingConfig,
}, },
}, },
}); });
options.data![0].fields[1]!.config = { mappings: mappingConfig };
options.data![0].fields[2]!.config = { mappings: mappingConfig };
const result = getFieldDisplayValues(options); const result = getFieldDisplayValues(options);
expect(result[0].display.text).toEqual(mappedValue); expect(result[0].display.text).toEqual(mappedValue);

View File

@ -18,7 +18,6 @@ import { GrafanaTheme } from '../types/theme';
import { ReducerID, reduceField } from '../transformations/fieldReducer'; import { ReducerID, reduceField } from '../transformations/fieldReducer';
import { ScopedVars } from '../types/ScopedVars'; import { ScopedVars } from '../types/ScopedVars';
import { getTimeField } from '../dataframe/processDataFrame'; import { getTimeField } from '../dataframe/processDataFrame';
import { applyFieldOverrides } from './fieldOverrides';
export interface FieldDisplayOptions extends FieldConfigSource { export interface FieldDisplayOptions extends FieldConfigSource {
values?: boolean; // If true show each row value values?: boolean; // If true show each row value
@ -91,8 +90,8 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
const values: FieldDisplay[] = []; const values: FieldDisplay[] = [];
if (options.data) { if (options.data) {
const data = applyFieldOverrides(options); // Field overrides are applied already
const data = options.data;
let hitLimit = false; let hitLimit = false;
const limit = fieldOptions.limit ? fieldOptions.limit : DEFAULT_FIELD_DISPLAY_VALUES_LIMIT; const limit = fieldOptions.limit ? fieldOptions.limit : DEFAULT_FIELD_DISPLAY_VALUES_LIMIT;
const defaultTitle = getTitleTemplate(fieldOptions.defaults.title, calcs, data); const defaultTitle = getTitleTemplate(fieldOptions.defaults.title, calcs, data);

View File

@ -1,19 +1,16 @@
import { import {
GrafanaTheme,
DynamicConfigValue, DynamicConfigValue,
FieldConfig, FieldConfig,
InterpolateFunction,
DataFrame, DataFrame,
Field, Field,
FieldType, FieldType,
FieldConfigSource,
ThresholdsMode, ThresholdsMode,
FieldColorMode, FieldColorMode,
ColorScheme, ColorScheme,
TimeZone,
FieldConfigEditorRegistry, FieldConfigEditorRegistry,
FieldOverrideContext, FieldOverrideContext,
ScopedVars, ScopedVars,
ApplyFieldOverrideOptions,
} from '../types'; } from '../types';
import { fieldMatchers, ReducerID, reduceField } from '../transformations'; import { fieldMatchers, ReducerID, reduceField } from '../transformations';
import { FieldMatcher } from '../types/transformations'; import { FieldMatcher } from '../types/transformations';
@ -32,17 +29,6 @@ interface GlobalMinMax {
max: number; max: number;
} }
export interface ApplyFieldOverrideOptions {
data?: DataFrame[];
fieldOptions: FieldConfigSource;
replaceVariables: InterpolateFunction;
theme: GrafanaTheme;
timeZone?: TimeZone;
autoMinMax?: boolean;
standard?: FieldConfigEditorRegistry;
custom?: FieldConfigEditorRegistry;
}
export function findNumericFieldMinMax(data: DataFrame[]): GlobalMinMax { export function findNumericFieldMinMax(data: DataFrame[]): GlobalMinMax {
let min = Number.MAX_VALUE; let min = Number.MAX_VALUE;
let max = Number.MIN_VALUE; let max = Number.MIN_VALUE;

View File

@ -1,5 +1,11 @@
import { DataTransformerConfig } from './transformations';
import { ApplyFieldOverrideOptions } from './fieldOverrides';
export type KeyValue<T = any> = { [s: string]: T }; export type KeyValue<T = any> = { [s: string]: T };
/**
* Represent panel data loading state.
*/
export enum LoadingState { export enum LoadingState {
NotStarted = 'NotStarted', NotStarted = 'NotStarted',
Loading = 'Loading', Loading = 'Loading',
@ -90,3 +96,11 @@ export interface AnnotationEvent {
// Currently used to merge annotations from alerts and dashboard // Currently used to merge annotations from alerts and dashboard
source?: any; // source.type === 'dashboard' source?: any; // source.type === 'dashboard'
} }
/**
* Describes and API for exposing panel specific data configurations.
*/
export interface DataConfigSource {
getTransformations: () => DataTransformerConfig[] | undefined;
getFieldOverrideOptions: () => ApplyFieldOverrideOptions | undefined;
}

View File

@ -1,5 +1,14 @@
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import { MatcherConfig, FieldConfig, Field, DataFrame, VariableSuggestionsScope, VariableSuggestion } from '../types'; import {
MatcherConfig,
FieldConfig,
Field,
DataFrame,
VariableSuggestionsScope,
VariableSuggestion,
GrafanaTheme,
TimeZone,
} from '../types';
import { Registry, RegistryItem } from '../utils'; import { Registry, RegistryItem } from '../utils';
import { InterpolateFunction } from './panel'; import { InterpolateFunction } from './panel';
@ -62,3 +71,14 @@ export interface FieldPropertyEditorItem<TValue = any, TSettings = any> extends
} }
export type FieldConfigEditorRegistry = Registry<FieldPropertyEditorItem>; export type FieldConfigEditorRegistry = Registry<FieldPropertyEditorItem>;
export interface ApplyFieldOverrideOptions {
data?: DataFrame[];
fieldOptions: FieldConfigSource;
replaceVariables: InterpolateFunction;
theme: GrafanaTheme;
timeZone?: TimeZone;
autoMinMax?: boolean;
standard?: FieldConfigEditorRegistry;
custom?: FieldConfigEditorRegistry;
}

View File

@ -17,11 +17,16 @@ export interface PanelPluginMeta extends PluginMeta {
export interface PanelData { export interface PanelData {
state: LoadingState; state: LoadingState;
/**
* Contains data frames with field overrides applied
*/
series: DataFrame[]; series: DataFrame[];
request?: DataQueryRequest; request?: DataQueryRequest;
timings?: DataQueryTimings; timings?: DataQueryTimings;
error?: DataQueryError; error?: DataQueryError;
// Contains the range from the request or a shifted time range if a request uses relative time /**
* Contains the range from the request or a shifted time range if a request uses relative time
*/
timeRange: TimeRange; timeRange: TimeRange;
} }

View File

@ -22,6 +22,11 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
const item = editorsRegistry?.getIfExists(property.prop); const item = editorsRegistry?.getIfExists(property.prop);
if (!item) {
return null;
}
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<OverrideHeader onRemove={onRemove} title={item.name} description={item.description} /> <OverrideHeader onRemove={onRemove} title={item.name} description={item.description} />

View File

@ -92,6 +92,10 @@ describe('panelEditor actions', () => {
it('should discard changes when shouldDiscardChanges is true', async () => { it('should discard changes when shouldDiscardChanges is true', async () => {
const sourcePanel = new PanelModel({ id: 12, type: 'graph' }); const sourcePanel = new PanelModel({ id: 12, type: 'graph' });
sourcePanel.plugin = {
customFieldConfigs: {},
} as any;
const dashboard = new DashboardModel({ const dashboard = new DashboardModel({
panels: [{ id: 12, type: 'graph' }], panels: [{ id: 12, type: 'graph' }],
}); });

View File

@ -35,7 +35,6 @@ export function panelEditorCleanUp(): ThunkResult<void> {
return (dispatch, getStore) => { return (dispatch, getStore) => {
const dashboard = getStore().dashboard.getModel(); const dashboard = getStore().dashboard.getModel();
const { getPanel, getSourcePanel, querySubscription, shouldDiscardChanges } = getStore().panelEditorNew; const { getPanel, getSourcePanel, querySubscription, shouldDiscardChanges } = getStore().panelEditorNew;
if (!shouldDiscardChanges) { if (!shouldDiscardChanges) {
const panel = getPanel(); const panel = getPanel();
const modifiedSaveModel = panel.getSaveModel(); const modifiedSaveModel = panel.getSaveModel();

View File

@ -68,6 +68,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -185,6 +186,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -282,6 +284,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -411,6 +414,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -526,6 +530,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -626,6 +631,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -723,6 +729,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
}, },
"id": 1, "id": 1,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",

View File

@ -1,28 +0,0 @@
import { PanelChrome } from './PanelChrome';
describe('PanelChrome', () => {
let chrome: PanelChrome;
beforeEach(() => {
chrome = new PanelChrome({
panel: {
scopedVars: {
aaa: { value: 'AAA', text: 'upperA' },
bbb: { value: 'BBB', text: 'upperB' },
},
},
isFullscreen: false,
} as any);
});
it('Should replace a panel variable', () => {
const out = chrome.replaceVariables('hello $aaa');
expect(out).toBe('hello AAA');
});
it('But it should prefer the local variable value', () => {
const extra = { aaa: { text: '???', value: 'XXX' } };
const out = chrome.replaceVariables('hello $aaa and $bbb', extra);
expect(out).toBe('hello XXX and BBB');
});
});

View File

@ -10,14 +10,12 @@ import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { profiler } from 'app/core/profiler'; import { profiler } from 'app/core/profiler';
import { getProcessedDataFrames } from '../state/runRequest'; import { getProcessedDataFrames } from '../state/runRequest';
import templateSrv from 'app/features/templating/template_srv';
import config from 'app/core/config'; import config from 'app/core/config';
// Types // Types
import { DashboardModel, PanelModel } from '../state'; import { DashboardModel, PanelModel } from '../state';
import { PANEL_BORDER } from 'app/core/constants'; import { PANEL_BORDER } from 'app/core/constants';
import { import {
LoadingState, LoadingState,
ScopedVars,
AbsoluteTimeRange, AbsoluteTimeRange,
DefaultTimeRange, DefaultTimeRange,
toUtc, toUtc,
@ -212,7 +210,6 @@ export class PanelChrome extends PureComponent<Props, State> {
onRender = () => { onRender = () => {
const stateUpdate = { renderCounter: this.state.renderCounter + 1 }; const stateUpdate = { renderCounter: this.state.renderCounter + 1 };
this.setState(stateUpdate); this.setState(stateUpdate);
}; };
@ -220,14 +217,6 @@ export class PanelChrome extends PureComponent<Props, State> {
this.props.panel.updateOptions(options); this.props.panel.updateOptions(options);
}; };
replaceVariables = (value: string, extraVars?: ScopedVars, format?: string) => {
let vars = this.props.panel.scopedVars;
if (extraVars) {
vars = vars ? { ...vars, ...extraVars } : extraVars;
}
return templateSrv.replace(value, vars, format);
};
onPanelError = (message: string) => { onPanelError = (message: string) => {
if (this.state.errorMessage !== message) { if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message }); this.setState({ errorMessage: message });
@ -273,16 +262,15 @@ export class PanelChrome extends PureComponent<Props, State> {
const PanelComponent = plugin.panel; const PanelComponent = plugin.panel;
const timeRange = data.timeRange || this.timeSrv.timeRange(); const timeRange = data.timeRange || this.timeSrv.timeRange();
const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight; const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight;
const chromePadding = plugin.noPadding ? 0 : theme.panelPadding; const chromePadding = plugin.noPadding ? 0 : theme.panelPadding;
const panelWidth = width - chromePadding * 2 - PANEL_BORDER; const panelWidth = width - chromePadding * 2 - PANEL_BORDER;
const innerPanelHeight = height - headerHeight - chromePadding * 2 - PANEL_BORDER; const innerPanelHeight = height - headerHeight - chromePadding * 2 - PANEL_BORDER;
const panelContentClassNames = classNames({ const panelContentClassNames = classNames({
'panel-content': true, 'panel-content': true,
'panel-content--no-padding': plugin.noPadding, 'panel-content--no-padding': plugin.noPadding,
}); });
const panelOptions = panel.getOptions();
return ( return (
<> <>
@ -292,12 +280,12 @@ export class PanelChrome extends PureComponent<Props, State> {
data={data} data={data}
timeRange={timeRange} timeRange={timeRange}
timeZone={this.props.dashboard.getTimezone()} timeZone={this.props.dashboard.getTimezone()}
options={panel.getOptions()} options={panelOptions}
transparent={panel.transparent} transparent={panel.transparent}
width={panelWidth} width={panelWidth}
height={innerPanelHeight} height={innerPanelHeight}
renderCounter={renderCounter} renderCounter={renderCounter}
replaceVariables={this.replaceVariables} replaceVariables={panel.replaceVariables}
onOptionsChange={this.onOptionsChange} onOptionsChange={this.onOptionsChange}
onChangeTimeRange={this.onChangeTimeRange} onChangeTimeRange={this.onChangeTimeRange}
/> />

View File

@ -144,6 +144,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -171,6 +172,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -198,6 +200,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -225,6 +228,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -278,6 +282,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -390,6 +395,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -417,6 +423,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -444,6 +451,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -471,6 +479,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -524,6 +533,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -636,6 +646,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -663,6 +674,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -690,6 +702,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -717,6 +730,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -770,6 +784,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -882,6 +897,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 1, "id": 1,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -909,6 +925,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 2, "id": 2,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -936,6 +953,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 3, "id": 3,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -963,6 +981,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",
@ -1016,6 +1035,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
"id": 4, "id": 4,
"isInView": false, "isInView": false,
"options": Object {}, "options": Object {},
"replaceVariables": [Function],
"targets": Array [ "targets": Array [
Object { Object {
"refId": "A", "refId": "A",

View File

@ -111,6 +111,25 @@ describe('PanelModel', () => {
expect(saveModel.events).toBe(undefined); expect(saveModel.events).toBe(undefined);
}); });
describe('variables interpolation', () => {
beforeEach(() => {
model.scopedVars = {
aaa: { value: 'AAA', text: 'upperA' },
bbb: { value: 'BBB', text: 'upperB' },
};
});
it('should interpolate variables', () => {
const out = model.replaceVariables('hello $aaa');
expect(out).toBe('hello AAA');
});
it('should prefer the local variable value', () => {
const extra = { aaa: { text: '???', value: 'XXX' } };
const out = model.replaceVariables('hello $aaa and $bbb', extra);
expect(out).toBe('hello XXX and BBB');
});
});
describe('when changing panel type', () => { describe('when changing panel type', () => {
const newPanelPluginDefaults = { const newPanelPluginDefaults = {
showThresholdLabels: false, showThresholdLabels: false,
@ -141,11 +160,6 @@ describe('PanelModel', () => {
model.changePlugin(getPanelPlugin({ id: 'table' })); model.changePlugin(getPanelPlugin({ id: 'table' }));
expect(model.alert).toBe(undefined); expect(model.alert).toBe(undefined);
}); });
it('panelQueryRunner should be cleared', () => {
const panelQueryRunner = (model as any).queryRunner;
expect(panelQueryRunner).toBeFalsy();
});
}); });
describe('when changing to react panel from angular panel', () => { describe('when changing to react panel from angular panel', () => {
@ -171,5 +185,29 @@ describe('PanelModel', () => {
expect(panelQueryRunner).toBe(sameQueryRunner); expect(panelQueryRunner).toBe(sameQueryRunner);
}); });
}); });
describe('variables interpolation', () => {
let panelQueryRunner: any;
const onPanelTypeChanged = jest.fn();
const reactPlugin = getPanelPlugin({ id: 'react' }).setPanelChangeHandler(onPanelTypeChanged as any);
beforeEach(() => {
model.changePlugin(reactPlugin);
panelQueryRunner = model.getQueryRunner();
});
it('should call react onPanelTypeChanged', () => {
expect(onPanelTypeChanged.mock.calls.length).toBe(1);
expect(onPanelTypeChanged.mock.calls[0][1]).toBe('table');
expect(onPanelTypeChanged.mock.calls[0][2].angular).toBeDefined();
});
it('getQueryRunner() should return same instance after changing to another react panel', () => {
model.changePlugin(getPanelPlugin({ id: 'react2' }));
const sameQueryRunner = model.getQueryRunner();
expect(panelQueryRunner).toBe(sameQueryRunner);
});
});
}); });
}); });

View File

@ -3,8 +3,10 @@ import _ from 'lodash';
// Utils // Utils
import { Emitter } from 'app/core/utils/emitter'; import { Emitter } from 'app/core/utils/emitter';
import { getNextRefIdChar } from 'app/core/utils/query'; import { getNextRefIdChar } from 'app/core/utils/query';
import templateSrv from 'app/features/templating/template_srv';
// Types // Types
import { import {
DataConfigSource,
DataLink, DataLink,
DataQuery, DataQuery,
DataQueryResponseData, DataQueryResponseData,
@ -41,6 +43,7 @@ const notPersistedProperties: { [str: string]: boolean } = {
cachedPluginOptions: true, cachedPluginOptions: true,
plugin: true, plugin: true,
queryRunner: true, queryRunner: true,
replaceVariables: true,
}; };
// For angular panels we need to clean up properties when changing type // For angular panels we need to clean up properties when changing type
@ -88,7 +91,7 @@ const defaults: any = {
options: {}, options: {},
}; };
export class PanelModel { export class PanelModel implements DataConfigSource {
/* persisted id, used in URL to identify a panel */ /* persisted id, used in URL to identify a panel */
id: number; id: number;
gridPos: GridPos; gridPos: GridPos;
@ -144,6 +147,7 @@ export class PanelModel {
// this should not be removed in save model as exporter needs to templatize it // this should not be removed in save model as exporter needs to templatize it
this.datasource = null; this.datasource = null;
this.restoreModel(model); this.restoreModel(model);
this.replaceVariables = this.replaceVariables.bind(this);
} }
/** Given a persistened PanelModel restores property values */ /** Given a persistened PanelModel restores property values */
@ -176,6 +180,7 @@ export class PanelModel {
updateOptions(options: object) { updateOptions(options: object) {
this.options = options; this.options = options;
this.resendLastResult();
this.render(); this.render();
} }
@ -283,6 +288,7 @@ export class PanelModel {
} }
this.applyPluginOptionDefaults(plugin); this.applyPluginOptionDefaults(plugin);
this.resendLastResult();
} }
changePlugin(newPlugin: PanelPlugin) { changePlugin(newPlugin: PanelPlugin) {
@ -319,6 +325,9 @@ export class PanelModel {
// switch // switch
this.type = pluginId; this.type = pluginId;
this.plugin = newPlugin; this.plugin = newPlugin;
// For some reason I need to rebind replace variables here, otherwise the viz repeater does not work
this.replaceVariables = this.replaceVariables.bind(this);
this.applyPluginOptionDefaults(newPlugin); this.applyPluginOptionDefaults(newPlugin);
if (newPlugin.onPanelMigration) { if (newPlugin.onPanelMigration) {
@ -363,10 +372,26 @@ export class PanelModel {
return clone; return clone;
} }
getTransformations() {
return this.transformations;
}
getFieldOverrideOptions() {
if (!this.plugin) {
return undefined;
}
return {
fieldOptions: this.options.fieldOptions,
replaceVariables: this.replaceVariables,
custom: this.plugin.customFieldConfigs,
theme: config.theme,
};
}
getQueryRunner(): PanelQueryRunner { getQueryRunner(): PanelQueryRunner {
if (!this.queryRunner) { if (!this.queryRunner) {
this.queryRunner = new PanelQueryRunner(); this.queryRunner = new PanelQueryRunner(this);
this.setTransformations(this.transformations);
} }
return this.queryRunner; return this.queryRunner;
} }
@ -390,7 +415,22 @@ export class PanelModel {
setTransformations(transformations: DataTransformerConfig[]) { setTransformations(transformations: DataTransformerConfig[]) {
this.transformations = transformations; this.transformations = transformations;
this.getQueryRunner().setTransformations(transformations); }
replaceVariables(value: string, extraVars?: ScopedVars, format?: string) {
let vars = this.scopedVars;
if (extraVars) {
vars = vars ? { ...vars, ...extraVars } : extraVars;
}
return templateSrv.replace(value, vars, format);
}
resendLastResult() {
if (!this.plugin) {
return;
}
this.getQueryRunner().resendLastResult();
} }
} }

View File

@ -1,10 +1,18 @@
import { PanelQueryRunner } from './PanelQueryRunner'; import { PanelQueryRunner } from './PanelQueryRunner';
import { DataQueryRequest, dateTime, PanelData, ScopedVars } from '@grafana/data'; // Importing this way to be able to spy on grafana/data
import * as grafanaData from '@grafana/data';
import { DataConfigSource, DataQueryRequest, GrafanaTheme, PanelData, ScopedVars } from '@grafana/data';
import { DashboardModel } from './index'; import { DashboardModel } from './index';
import { setEchoSrv } from '@grafana/runtime'; import { setEchoSrv } from '@grafana/runtime';
import { Echo } from '../../../core/services/echo/Echo'; import { Echo } from '../../../core/services/echo/Echo';
jest.mock('app/core/services/backend_srv'); jest.mock('app/core/services/backend_srv');
jest.mock('app/core/config', () => ({
config: { featureToggles: { transformations: true } },
getConfig: () => ({
featureToggles: {},
}),
}));
const dashboardModel = new DashboardModel({ const dashboardModel = new DashboardModel({
panels: [{ id: 1, type: 'graph' }], panels: [{ id: 1, type: 'graph' }],
@ -37,16 +45,19 @@ interface ScenarioContext {
type ScenarioFn = (ctx: ScenarioContext) => void; type ScenarioFn = (ctx: ScenarioContext) => void;
function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn) { function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn, panelConfig?: DataConfigSource) {
describe(description, () => { describe(description, () => {
let setupFn = () => {}; let setupFn = () => {};
const defaultPanelConfig: DataConfigSource = {
getFieldOverrideOptions: () => undefined,
getTransformations: () => undefined,
};
const ctx: ScenarioContext = { const ctx: ScenarioContext = {
widthPixels: 200, widthPixels: 200,
scopedVars: { scopedVars: {
server: { text: 'Server1', value: 'server-1' }, server: { text: 'Server1', value: 'server-1' },
}, },
runner: new PanelQueryRunner(), runner: new PanelQueryRunner(panelConfig || defaultPanelConfig),
setup: (fn: () => void) => { setup: (fn: () => void) => {
setupFn = fn; setupFn = fn;
}, },
@ -85,15 +96,15 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn
widthPixels: ctx.widthPixels, widthPixels: ctx.widthPixels,
maxDataPoints: ctx.maxDataPoints, maxDataPoints: ctx.maxDataPoints,
timeRange: { timeRange: {
from: dateTime().subtract(1, 'days'), from: grafanaData.dateTime().subtract(1, 'days'),
to: dateTime(), to: grafanaData.dateTime(),
raw: { from: '1h', to: 'now' }, raw: { from: '1h', to: 'now' },
}, },
panelId: 1, panelId: 1,
queries: [{ refId: 'A', test: 1 }], queries: [{ refId: 'A', test: 1 }],
}; };
ctx.runner = new PanelQueryRunner(); ctx.runner = new PanelQueryRunner(panelConfig || defaultPanelConfig);
ctx.runner.getData().subscribe({ ctx.runner.getData().subscribe({
next: (data: PanelData) => { next: (data: PanelData) => {
ctx.res = data; ctx.res = data;
@ -182,4 +193,56 @@ describe('PanelQueryRunner', () => {
expect(ctx.queryCalledWith?.maxDataPoints).toBe(10); expect(ctx.queryCalledWith?.maxDataPoints).toBe(10);
}); });
}); });
describeQueryRunnerScenario(
'field overrides',
ctx => {
it('should apply when field override options are set', async () => {
const spy = jest.spyOn(grafanaData, 'applyFieldOverrides');
ctx.runner.getData().subscribe({
next: (data: PanelData) => {
return data;
},
});
expect(spy).toBeCalled();
});
},
{
getFieldOverrideOptions: () => ({
fieldOptions: {
defaults: {
unit: 'm/s',
},
// @ts-ignore
overrides: [],
},
replaceVariables: v => v,
theme: {} as GrafanaTheme,
}),
getTransformations: () => undefined,
}
);
describeQueryRunnerScenario(
'transformations',
ctx => {
it('should apply when transformations are set', async () => {
const spy = jest.spyOn(grafanaData, 'transformDataFrame');
ctx.runner.getData().subscribe({
next: (data: PanelData) => {
return data;
},
});
expect(spy).toBeCalled();
});
},
{
getFieldOverrideOptions: () => undefined,
// @ts-ignore
getTransformations: () => [{}],
}
);
}); });

View File

@ -23,6 +23,8 @@ import {
DataTransformerConfig, DataTransformerConfig,
transformDataFrame, transformDataFrame,
ScopedVars, ScopedVars,
applyFieldOverrides,
DataConfigSource,
} from '@grafana/data'; } from '@grafana/data';
export interface QueryRunnerOptions< export interface QueryRunnerOptions<
@ -53,36 +55,51 @@ function getNextRequestId() {
export class PanelQueryRunner { export class PanelQueryRunner {
private subject?: ReplaySubject<PanelData>; private subject?: ReplaySubject<PanelData>;
private subscription?: Unsubscribable; private subscription?: Unsubscribable;
private transformations?: DataTransformerConfig[];
private lastResult?: PanelData; private lastResult?: PanelData;
private dataConfigSource: DataConfigSource;
constructor() { constructor(dataConfigSource: DataConfigSource) {
this.subject = new ReplaySubject(1); this.subject = new ReplaySubject(1);
this.dataConfigSource = dataConfigSource;
} }
/** /**
* Returns an observable that subscribes to the shared multi-cast subject (that reply last result). * Returns an observable that subscribes to the shared multi-cast subject (that reply last result).
*/ */
getData(transform = true): Observable<PanelData> { getData(transform = true): Observable<PanelData> {
if (transform) {
return this.subject.pipe( return this.subject.pipe(
map((data: PanelData) => { map((data: PanelData) => {
if (this.hasTransformations()) { let processedData = data;
const newSeries = transformDataFrame(this.transformations, data.series); // apply transformations
return { ...data, series: newSeries }; if (transform && this.hasTransformations()) {
processedData = {
...processedData,
series: transformDataFrame(this.dataConfigSource.getTransformations(), data.series),
};
} }
return data; // apply overrides
if (this.hasFieldOverrideOptions()) {
processedData = {
...processedData,
series: applyFieldOverrides({
data: processedData.series,
...this.dataConfigSource.getFieldOverrideOptions(),
}),
};
}
return processedData;
}) })
); );
} }
// Just pass it directly hasTransformations = () => {
return this.subject.pipe(); const transformations = this.dataConfigSource.getTransformations();
} return config.featureToggles.transformations && transformations && transformations.length > 0;
};
hasTransformations() { hasFieldOverrideOptions = () => {
return config.featureToggles.transformations && this.transformations && this.transformations.length > 0; return this.dataConfigSource.getFieldOverrideOptions();
} };
async run(options: QueryRunnerOptions) { async run(options: QueryRunnerOptions) {
const { const {
@ -98,7 +115,6 @@ export class PanelQueryRunner {
maxDataPoints, maxDataPoints,
scopedVars, scopedVars,
minInterval, minInterval,
// delayStateNotification,
} = options; } = options;
if (isSharedDashboardQuery(datasource)) { if (isSharedDashboardQuery(datasource)) {
@ -164,6 +180,7 @@ export class PanelQueryRunner {
this.subscription = observable.subscribe({ this.subscription = observable.subscribe({
next: (data: PanelData) => { next: (data: PanelData) => {
this.lastResult = preProcessPanelData(data, this.lastResult); this.lastResult = preProcessPanelData(data, this.lastResult);
// Store preprocessed query results for applying overrides later on in the pipeline
this.subject.next(this.lastResult); this.subject.next(this.lastResult);
}, },
}); });
@ -174,9 +191,11 @@ export class PanelQueryRunner {
this.lastResult = data; this.lastResult = data;
}; };
setTransformations(transformations?: DataTransformerConfig[]) { resendLastResult = () => {
this.transformations = transformations; if (this.lastResult) {
this.subject.next(this.lastResult);
} }
};
/** /**
* Called when the panel is closed * Called when the panel is closed

View File

@ -3,10 +3,8 @@ import React, { Component } from 'react';
// Types // Types
import { Table } from '@grafana/ui'; import { Table } from '@grafana/ui';
import { PanelProps, applyFieldOverrides } from '@grafana/data'; import { PanelProps } from '@grafana/data';
import { Options } from './types'; import { Options } from './types';
import { config } from 'app/core/config';
import { tableFieldRegistry } from './custom';
interface Props extends PanelProps<Options> {} interface Props extends PanelProps<Options> {}
@ -18,20 +16,12 @@ export class TablePanel extends Component<Props> {
} }
render() { render() {
const { data, height, width, replaceVariables, options } = this.props; const { data, height, width } = this.props;
if (data.series.length < 1) { if (data.series.length < 1) {
return <div>No Table Data...</div>; return <div>No Table Data...</div>;
} }
const dataProcessed = applyFieldOverrides({ return <Table height={height - paddingBottom} width={width} data={data.series[0]} />;
data: data.series,
fieldOptions: options.fieldOptions,
theme: config.theme,
replaceVariables,
custom: tableFieldRegistry,
})[0];
return <Table height={height - paddingBottom} width={width} data={dataProcessed} />;
} }
} }

View File

@ -3,7 +3,7 @@
echo -e "Collecting code stats (typescript errors & more)" echo -e "Collecting code stats (typescript errors & more)"
ERROR_COUNT_LIMIT=824 ERROR_COUNT_LIMIT=821
DIRECTIVES_LIMIT=172 DIRECTIVES_LIMIT=172
CONTROLLERS_LIMIT=139 CONTROLLERS_LIMIT=139