From 76ba2db4e7f4093287de652625cd2fd1fec31a21 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 15 Jan 2020 12:02:52 -0800 Subject: [PATCH] DataLinks: allow using values from other fields in the same row (#21478) --- .../grafana-data/src/field/fieldOverrides.ts | 8 +- .../DataLinks/DataLinkSuggestions.tsx | 1 + .../fieldDisplayValuesProxy.test.ts | 66 ++++++++ .../panellinks/fieldDisplayValuesProxy.ts | 35 +++++ .../panel/panellinks/linkSuppliers.test.ts | 141 +++++++++++++++++- .../panel/panellinks/linkSuppliers.ts | 28 +++- .../app/features/panel/panellinks/link_srv.ts | 81 +++++++++- 7 files changed, 351 insertions(+), 9 deletions(-) create mode 100644 public/app/features/panel/panellinks/fieldDisplayValuesProxy.test.ts create mode 100644 public/app/features/panel/panellinks/fieldDisplayValuesProxy.ts diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index d7a37d15d32..4d4bfc87113 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -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; }); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx index 909712242b6..7295143a326 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkSuggestions.tsx @@ -11,6 +11,7 @@ import { stylesFactory } from '../../themes'; export enum VariableOrigin { Series = 'series', Field = 'field', + Fields = 'fields', Value = 'value', BuiltIn = 'built-in', Template = 'template', diff --git a/public/app/features/panel/panellinks/fieldDisplayValuesProxy.test.ts b/public/app/features/panel/panellinks/fieldDisplayValuesProxy.test.ts new file mode 100644 index 00000000000..1187350cd94 --- /dev/null +++ b/public/app/features/panel/panellinks/fieldDisplayValuesProxy.test.ts @@ -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(); + }); +}); diff --git a/public/app/features/panel/panellinks/fieldDisplayValuesProxy.ts b/public/app/features/panel/panellinks/fieldDisplayValuesProxy.ts new file mode 100644 index 00000000000..ec7f06dea8e --- /dev/null +++ b/public/app/features/panel/panellinks/fieldDisplayValuesProxy.ts @@ -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 { + return new Proxy({} as Record, { + 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; + }, + }); +} diff --git a/public/app/features/panel/panellinks/linkSuppliers.test.ts b/public/app/features/panel/panellinks/linkSuppliers.test.ts index 85f1ea45932..4d7d4d7217e 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.test.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.test.ts @@ -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", + }, + ] + `); + }); }); diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts index 84f0d140be7..cec752db27d 100644 --- a/public/app/features/panel/panellinks/linkSuppliers.ts +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -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; +} + interface DataLinkScopedVars extends ScopedVars { __series?: ScopedVar; __field?: ScopedVar; __value?: ScopedVar; + __data?: ScopedVar; } /** @@ -71,24 +81,36 @@ export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier { })), ]; }; + +const getDataFrameVars = (dataFrames: DataFrame[]) => { + let numeric: Field = undefined; + let title: Field = undefined; + const suggestions: VariableSuggestion[] = []; + const keys: KeyValue = {}; + 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[] => {