DataLinks: allow using values from other fields in the same row (#21478)
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
ThresholdsMode,
|
ThresholdsMode,
|
||||||
FieldColorMode,
|
FieldColorMode,
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
|
TimeZone,
|
||||||
} 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';
|
||||||
@@ -34,6 +35,7 @@ export interface ApplyFieldOverrideOptions {
|
|||||||
fieldOptions: FieldConfigSource;
|
fieldOptions: FieldConfigSource;
|
||||||
replaceVariables: InterpolateFunction;
|
replaceVariables: InterpolateFunction;
|
||||||
theme: GrafanaTheme;
|
theme: GrafanaTheme;
|
||||||
|
timeZone?: TimeZone;
|
||||||
autoMinMax?: boolean;
|
autoMinMax?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +166,11 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
|||||||
type,
|
type,
|
||||||
};
|
};
|
||||||
// and set the display processor using it
|
// and set the display processor using it
|
||||||
f.display = getDisplayProcessor({ field: f, theme: options.theme });
|
f.display = getDisplayProcessor({
|
||||||
|
field: f,
|
||||||
|
theme: options.theme,
|
||||||
|
timeZone: options.timeZone,
|
||||||
|
});
|
||||||
return f;
|
return f;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { stylesFactory } from '../../themes';
|
|||||||
export enum VariableOrigin {
|
export enum VariableOrigin {
|
||||||
Series = 'series',
|
Series = 'series',
|
||||||
Field = 'field',
|
Field = 'field',
|
||||||
|
Fields = 'fields',
|
||||||
Value = 'value',
|
Value = 'value',
|
||||||
BuiltIn = 'built-in',
|
BuiltIn = 'built-in',
|
||||||
Template = 'template',
|
Template = 'template',
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { toDataFrame, applyFieldOverrides, GrafanaTheme } from '@grafana/data';
|
||||||
|
import { getFieldDisplayValuesProxy } from './fieldDisplayValuesProxy';
|
||||||
|
|
||||||
|
describe('getFieldDisplayValuesProxy', () => {
|
||||||
|
const data = applyFieldOverrides({
|
||||||
|
data: [
|
||||||
|
toDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'Time', values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'power',
|
||||||
|
values: [100, 200, 300],
|
||||||
|
config: {
|
||||||
|
title: 'The Power',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Last',
|
||||||
|
values: ['a', 'b', 'c'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
fieldOptions: {
|
||||||
|
defaults: {},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
|
replaceVariables: (val: string) => val,
|
||||||
|
timeZone: 'utc',
|
||||||
|
theme: {} as GrafanaTheme,
|
||||||
|
autoMinMax: true,
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
it('should define all display functions', () => {
|
||||||
|
// Field display should be set
|
||||||
|
for (const field of data.fields) {
|
||||||
|
expect(field.display).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format the time values in UTC', () => {
|
||||||
|
// Test Proxies in general
|
||||||
|
const p = getFieldDisplayValuesProxy(data, 0);
|
||||||
|
const time = p.Time;
|
||||||
|
expect(time.numeric).toEqual(1);
|
||||||
|
expect(time.text).toEqual('1970-01-01 00:00:00');
|
||||||
|
|
||||||
|
// Should get to the same values by name or index
|
||||||
|
const time2 = p[0];
|
||||||
|
expect(time2.toString()).toEqual(time.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Lookup by name, index, or title', () => {
|
||||||
|
const p = getFieldDisplayValuesProxy(data, 2);
|
||||||
|
expect(p.power.numeric).toEqual(300);
|
||||||
|
expect(p['power'].numeric).toEqual(300);
|
||||||
|
expect(p['The Power'].numeric).toEqual(300);
|
||||||
|
expect(p[1].numeric).toEqual(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when missing', () => {
|
||||||
|
const p = getFieldDisplayValuesProxy(data, 0);
|
||||||
|
expect(p.xyz).toBeUndefined();
|
||||||
|
expect(p[100]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { DisplayValue, DataFrame, formattedValueToString, getDisplayProcessor } from '@grafana/data';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import toNumber from 'lodash/toNumber';
|
||||||
|
|
||||||
|
export function getFieldDisplayValuesProxy(frame: DataFrame, rowIndex: number): Record<string, DisplayValue> {
|
||||||
|
return new Proxy({} as Record<string, DisplayValue>, {
|
||||||
|
get: (obj: any, key: string) => {
|
||||||
|
// 1. Match the name
|
||||||
|
let field = frame.fields.find(f => key === f.name);
|
||||||
|
if (!field) {
|
||||||
|
// 2. Match the array index
|
||||||
|
const k = toNumber(key);
|
||||||
|
field = frame.fields[k];
|
||||||
|
}
|
||||||
|
if (!field) {
|
||||||
|
// 3. Match the title
|
||||||
|
field = frame.fields.find(f => key === f.config.title);
|
||||||
|
}
|
||||||
|
if (!field) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!field.display) {
|
||||||
|
// Lazy load the display processor
|
||||||
|
field.display = getDisplayProcessor({
|
||||||
|
field,
|
||||||
|
theme: config.theme,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const raw = field.values.get(rowIndex);
|
||||||
|
const disp = field.display(raw);
|
||||||
|
disp.toString = () => formattedValueToString(disp);
|
||||||
|
return disp;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import { getLinksFromLogsField } from './linkSuppliers';
|
import { getLinksFromLogsField, getFieldLinksSupplier } from './linkSuppliers';
|
||||||
import { ArrayVector, dateTime, Field, FieldType } from '@grafana/data';
|
import {
|
||||||
|
ArrayVector,
|
||||||
|
dateTime,
|
||||||
|
Field,
|
||||||
|
FieldType,
|
||||||
|
toDataFrame,
|
||||||
|
applyFieldOverrides,
|
||||||
|
GrafanaTheme,
|
||||||
|
FieldDisplay,
|
||||||
|
DataFrameView,
|
||||||
|
} from '@grafana/data';
|
||||||
import { getLinkSrv, LinkService, LinkSrv, setLinkSrv } from './link_srv';
|
import { getLinkSrv, LinkService, LinkSrv, setLinkSrv } from './link_srv';
|
||||||
import { TemplateSrv } from '../../templating/template_srv';
|
import { TemplateSrv } from '../../templating/template_srv';
|
||||||
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
||||||
@@ -58,4 +68,131 @@ describe('getLinksFromLogsField', () => {
|
|||||||
const links = getLinksFromLogsField(field, 2);
|
const links = getLinksFromLogsField(field, 2);
|
||||||
expect(links.length).toBe(0);
|
expect(links.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links to items on the row', () => {
|
||||||
|
const data = applyFieldOverrides({
|
||||||
|
data: [
|
||||||
|
toDataFrame({
|
||||||
|
name: 'Hello Templates',
|
||||||
|
refId: 'ZZZ',
|
||||||
|
fields: [
|
||||||
|
{ name: 'Time', values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'Power',
|
||||||
|
values: [100.2000001, 200, 300],
|
||||||
|
config: {
|
||||||
|
unit: 'kW',
|
||||||
|
decimals: 3,
|
||||||
|
title: 'TheTitle',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Last',
|
||||||
|
values: ['a', 'b', 'c'],
|
||||||
|
config: {
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'By Name',
|
||||||
|
url: 'http://go/${__data.fields.Power}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'By Index',
|
||||||
|
url: 'http://go/${__data.fields[1]}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'By Title',
|
||||||
|
url: 'http://go/${__data.fields[TheTitle]}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Numeric Value',
|
||||||
|
url: 'http://go/${__data.fields.Power.numeric}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Text (no suffix)',
|
||||||
|
url: 'http://go/${__data.fields.Power.text}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Unknown Field',
|
||||||
|
url: 'http://go/${__data.fields.XYZ}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data Frame name',
|
||||||
|
url: 'http://go/${__data.name}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data Frame refId',
|
||||||
|
url: 'http://go/${__data.refId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
fieldOptions: {
|
||||||
|
defaults: {},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
|
replaceVariables: (val: string) => val,
|
||||||
|
timeZone: 'utc',
|
||||||
|
theme: {} as GrafanaTheme,
|
||||||
|
autoMinMax: true,
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
const rowIndex = 0;
|
||||||
|
const colIndex = data.fields.length - 1;
|
||||||
|
const field = data.fields[colIndex];
|
||||||
|
const fieldDisp: FieldDisplay = {
|
||||||
|
name: 'hello',
|
||||||
|
field: field.config,
|
||||||
|
view: new DataFrameView(data),
|
||||||
|
rowIndex,
|
||||||
|
colIndex,
|
||||||
|
display: field.display!(field.values.get(rowIndex)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const supplier = getFieldLinksSupplier(fieldDisp);
|
||||||
|
const links = supplier.getLinks({}).map(m => {
|
||||||
|
return {
|
||||||
|
title: m.title,
|
||||||
|
href: m.href,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(links).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"href": "http://go/100.200 kW",
|
||||||
|
"title": "By Name",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"href": "http://go/100.200 kW",
|
||||||
|
"title": "By Index",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"href": "http://go/100.200 kW",
|
||||||
|
"title": "By Title",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"href": "http://go/100.2000001",
|
||||||
|
"title": "Numeric Value",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"href": "http://go/100.200",
|
||||||
|
"title": "Text (no suffix)",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"href": "http://go/\${__data.fields.XYZ}",
|
||||||
|
"title": "Unknown Field",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"href": "http://go/Hello Templates",
|
||||||
|
"title": "Data Frame name",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"href": "http://go/ZZZ",
|
||||||
|
"title": "Data Frame refId",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
ScopedVar,
|
ScopedVar,
|
||||||
Field,
|
Field,
|
||||||
LinkModel,
|
LinkModel,
|
||||||
|
formattedValueToString,
|
||||||
|
DisplayValue,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { getLinkSrv } from './link_srv';
|
import { getLinkSrv } from './link_srv';
|
||||||
|
import { getFieldDisplayValuesProxy } from './fieldDisplayValuesProxy';
|
||||||
|
|
||||||
interface SeriesVars {
|
interface SeriesVars {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -29,10 +32,17 @@ interface ValueVars {
|
|||||||
calc?: string;
|
calc?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DataViewVars {
|
||||||
|
name?: string;
|
||||||
|
refId?: string;
|
||||||
|
fields?: Record<string, DisplayValue>;
|
||||||
|
}
|
||||||
|
|
||||||
interface DataLinkScopedVars extends ScopedVars {
|
interface DataLinkScopedVars extends ScopedVars {
|
||||||
__series?: ScopedVar<SeriesVars>;
|
__series?: ScopedVar<SeriesVars>;
|
||||||
__field?: ScopedVar<FieldVars>;
|
__field?: ScopedVar<FieldVars>;
|
||||||
__value?: ScopedVar<ValueVars>;
|
__value?: ScopedVar<ValueVars>;
|
||||||
|
__data?: ScopedVar<DataViewVars>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,24 +81,36 @@ export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier<Fi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.rowIndex) {
|
if (!isNaN(value.rowIndex)) {
|
||||||
const { timeField } = getTimeField(dataFrame);
|
const { timeField } = getTimeField(dataFrame);
|
||||||
scopedVars['__value'] = {
|
scopedVars['__value'] = {
|
||||||
value: {
|
value: {
|
||||||
raw: field.values.get(value.rowIndex),
|
raw: field.values.get(value.rowIndex),
|
||||||
numeric: value.display.numeric,
|
numeric: value.display.numeric,
|
||||||
text: value.display.text,
|
text: formattedValueToString(value.display),
|
||||||
time: timeField ? timeField.values.get(value.rowIndex) : undefined,
|
time: timeField ? timeField.values.get(value.rowIndex) : undefined,
|
||||||
},
|
},
|
||||||
text: 'Value',
|
text: 'Value',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expose other values on the row
|
||||||
|
if (value.view) {
|
||||||
|
scopedVars['__data'] = {
|
||||||
|
value: {
|
||||||
|
name: dataFrame.name,
|
||||||
|
refId: dataFrame.refId,
|
||||||
|
fields: getFieldDisplayValuesProxy(dataFrame, value.rowIndex!),
|
||||||
|
},
|
||||||
|
text: 'Data',
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// calculation
|
// calculation
|
||||||
scopedVars['__value'] = {
|
scopedVars['__value'] = {
|
||||||
value: {
|
value: {
|
||||||
raw: value.display.numeric,
|
raw: value.display.numeric,
|
||||||
numeric: value.display.numeric,
|
numeric: value.display.numeric,
|
||||||
text: value.display.text,
|
text: formattedValueToString(value.display),
|
||||||
calc: value.name,
|
calc: value.name,
|
||||||
},
|
},
|
||||||
text: 'Value',
|
text: 'Value',
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
|
|||||||
import { sanitizeUrl } from 'app/core/utils/text';
|
import { sanitizeUrl } from 'app/core/utils/text';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
import { VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui';
|
import { VariableSuggestion, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui';
|
||||||
import { DataLink, KeyValue, deprecationWarning, LinkModel, DataFrame, ScopedVars } from '@grafana/data';
|
import {
|
||||||
|
DataLink,
|
||||||
|
KeyValue,
|
||||||
|
deprecationWarning,
|
||||||
|
LinkModel,
|
||||||
|
DataFrame,
|
||||||
|
ScopedVars,
|
||||||
|
FieldType,
|
||||||
|
Field,
|
||||||
|
} from '@grafana/data';
|
||||||
|
|
||||||
const timeRangeVars = [
|
const timeRangeVars = [
|
||||||
{
|
{
|
||||||
@@ -110,15 +119,81 @@ const getFieldVars = (dataFrames: DataFrame[]) => {
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDataFrameVars = (dataFrames: DataFrame[]) => {
|
||||||
|
let numeric: Field = undefined;
|
||||||
|
let title: Field = undefined;
|
||||||
|
const suggestions: VariableSuggestion[] = [];
|
||||||
|
const keys: KeyValue<true> = {};
|
||||||
|
for (const df of dataFrames) {
|
||||||
|
for (const f of df.fields) {
|
||||||
|
if (keys[f.name]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
suggestions.push({
|
||||||
|
value: `__data.fields[${f.name}]`,
|
||||||
|
label: `${f.name}`,
|
||||||
|
documentation: `Formatted value for ${f.name} on the same row`,
|
||||||
|
origin: VariableOrigin.Fields,
|
||||||
|
});
|
||||||
|
keys[f.name] = true;
|
||||||
|
if (!numeric && f.type === FieldType.number) {
|
||||||
|
numeric = f;
|
||||||
|
}
|
||||||
|
if (!title && f.config.title && f.config.title !== f.name) {
|
||||||
|
title = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length) {
|
||||||
|
suggestions.push({
|
||||||
|
value: `__data.fields[0]`,
|
||||||
|
label: `Select by index`,
|
||||||
|
documentation: `Enter the field order`,
|
||||||
|
origin: VariableOrigin.Fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (numeric) {
|
||||||
|
suggestions.push({
|
||||||
|
value: `__data.fields[${numeric.name}].numeric`,
|
||||||
|
label: `Show numeric value`,
|
||||||
|
documentation: `the numeric field value`,
|
||||||
|
origin: VariableOrigin.Fields,
|
||||||
|
});
|
||||||
|
suggestions.push({
|
||||||
|
value: `__data.fields[${numeric.name}].text`,
|
||||||
|
label: `Show text value`,
|
||||||
|
documentation: `the text value`,
|
||||||
|
origin: VariableOrigin.Fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (title) {
|
||||||
|
suggestions.push({
|
||||||
|
value: `__data.fields[${title.config.title}]`,
|
||||||
|
label: `Select by title`,
|
||||||
|
documentation: `Use the title to pick the field`,
|
||||||
|
origin: VariableOrigin.Fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return suggestions;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
|
export const getDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
|
||||||
const fieldVars = getFieldVars(dataFrames);
|
|
||||||
const valueTimeVar = {
|
const valueTimeVar = {
|
||||||
value: `${DataLinkBuiltInVars.valueTime}`,
|
value: `${DataLinkBuiltInVars.valueTime}`,
|
||||||
label: 'Time',
|
label: 'Time',
|
||||||
documentation: 'Time value of the clicked datapoint (in ms epoch)',
|
documentation: 'Time value of the clicked datapoint (in ms epoch)',
|
||||||
origin: VariableOrigin.Value,
|
origin: VariableOrigin.Value,
|
||||||
};
|
};
|
||||||
return [...seriesVars, ...fieldVars, ...valueVars, valueTimeVar, ...getPanelLinksVariableSuggestions()];
|
return [
|
||||||
|
...seriesVars,
|
||||||
|
...getFieldVars(dataFrames),
|
||||||
|
...valueVars,
|
||||||
|
valueTimeVar,
|
||||||
|
...getDataFrameVars(dataFrames),
|
||||||
|
...getPanelLinksVariableSuggestions(),
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
|
export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
|
||||||
|
|||||||
Reference in New Issue
Block a user