mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
DataLinks: allow using values from other fields in the same row (#21478)
This commit is contained in:
parent
3f957a3735
commit
76ba2db4e7
@ -11,6 +11,7 @@ import {
|
||||
ThresholdsMode,
|
||||
FieldColorMode,
|
||||
ColorScheme,
|
||||
TimeZone,
|
||||
} from '../types';
|
||||
import { fieldMatchers, ReducerID, reduceField } from '../transformations';
|
||||
import { FieldMatcher } from '../types/transformations';
|
||||
@ -34,6 +35,7 @@ export interface ApplyFieldOverrideOptions {
|
||||
fieldOptions: FieldConfigSource;
|
||||
replaceVariables: InterpolateFunction;
|
||||
theme: GrafanaTheme;
|
||||
timeZone?: TimeZone;
|
||||
autoMinMax?: boolean;
|
||||
}
|
||||
|
||||
@ -164,7 +166,11 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
type,
|
||||
};
|
||||
// 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;
|
||||
});
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { stylesFactory } from '../../themes';
|
||||
export enum VariableOrigin {
|
||||
Series = 'series',
|
||||
Field = 'field',
|
||||
Fields = 'fields',
|
||||
Value = 'value',
|
||||
BuiltIn = 'built-in',
|
||||
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 { ArrayVector, dateTime, Field, FieldType } from '@grafana/data';
|
||||
import { getLinksFromLogsField, getFieldLinksSupplier } from './linkSuppliers';
|
||||
import {
|
||||
ArrayVector,
|
||||
dateTime,
|
||||
Field,
|
||||
FieldType,
|
||||
toDataFrame,
|
||||
applyFieldOverrides,
|
||||
GrafanaTheme,
|
||||
FieldDisplay,
|
||||
DataFrameView,
|
||||
} from '@grafana/data';
|
||||
import { getLinkSrv, LinkService, LinkSrv, setLinkSrv } from './link_srv';
|
||||
import { TemplateSrv } from '../../templating/template_srv';
|
||||
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
||||
@ -58,4 +68,131 @@ describe('getLinksFromLogsField', () => {
|
||||
const links = getLinksFromLogsField(field, 2);
|
||||
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,
|
||||
Field,
|
||||
LinkModel,
|
||||
formattedValueToString,
|
||||
DisplayValue,
|
||||
} from '@grafana/data';
|
||||
import { getLinkSrv } from './link_srv';
|
||||
import { getFieldDisplayValuesProxy } from './fieldDisplayValuesProxy';
|
||||
|
||||
interface SeriesVars {
|
||||
name?: string;
|
||||
@ -29,10 +32,17 @@ interface ValueVars {
|
||||
calc?: string;
|
||||
}
|
||||
|
||||
interface DataViewVars {
|
||||
name?: string;
|
||||
refId?: string;
|
||||
fields?: Record<string, DisplayValue>;
|
||||
}
|
||||
|
||||
interface DataLinkScopedVars extends ScopedVars {
|
||||
__series?: ScopedVar<SeriesVars>;
|
||||
__field?: ScopedVar<FieldVars>;
|
||||
__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);
|
||||
scopedVars['__value'] = {
|
||||
value: {
|
||||
raw: field.values.get(value.rowIndex),
|
||||
numeric: value.display.numeric,
|
||||
text: value.display.text,
|
||||
text: formattedValueToString(value.display),
|
||||
time: timeField ? timeField.values.get(value.rowIndex) : undefined,
|
||||
},
|
||||
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 {
|
||||
// calculation
|
||||
scopedVars['__value'] = {
|
||||
value: {
|
||||
raw: value.display.numeric,
|
||||
numeric: value.display.numeric,
|
||||
text: value.display.text,
|
||||
text: formattedValueToString(value.display),
|
||||
calc: value.name,
|
||||
},
|
||||
text: 'Value',
|
||||
|
@ -6,7 +6,16 @@ import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
|
||||
import { sanitizeUrl } from 'app/core/utils/text';
|
||||
import { getConfig } from 'app/core/config';
|
||||
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 = [
|
||||
{
|
||||
@ -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[] => {
|
||||
const fieldVars = getFieldVars(dataFrames);
|
||||
const valueTimeVar = {
|
||||
value: `${DataLinkBuiltInVars.valueTime}`,
|
||||
label: 'Time',
|
||||
documentation: 'Time value of the clicked datapoint (in ms epoch)',
|
||||
origin: VariableOrigin.Value,
|
||||
};
|
||||
return [...seriesVars, ...fieldVars, ...valueVars, valueTimeVar, ...getPanelLinksVariableSuggestions()];
|
||||
return [
|
||||
...seriesVars,
|
||||
...getFieldVars(dataFrames),
|
||||
...valueVars,
|
||||
valueTimeVar,
|
||||
...getDataFrameVars(dataFrames),
|
||||
...getPanelLinksVariableSuggestions(),
|
||||
];
|
||||
};
|
||||
|
||||
export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: DataFrame[]): VariableSuggestion[] => {
|
||||
|
Loading…
Reference in New Issue
Block a user