diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 0395556f8ef..d7be790cb30 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -22,6 +22,10 @@ export type TypedVariableModel = | OrgVariableModel | DashboardVariableModel; +type VarValue = string | number | boolean | undefined; + +export type VariableMap = Record; + export enum VariableRefresh { never, // removed from the UI onDashboardLoad, diff --git a/packages/grafana-runtime/src/services/templateSrv.ts b/packages/grafana-runtime/src/services/templateSrv.ts index 502e9de2f9a..b76a7c55e09 100644 --- a/packages/grafana-runtime/src/services/templateSrv.ts +++ b/packages/grafana-runtime/src/services/templateSrv.ts @@ -1,4 +1,4 @@ -import { ScopedVars, TimeRange, TypedVariableModel } from '@grafana/data'; +import { ScopedVars, TimeRange, TypedVariableModel, VariableMap } from '@grafana/data'; /** * Via the TemplateSrv consumers get access to all the available template variables @@ -18,6 +18,11 @@ export interface TemplateSrv { */ replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string; + /** + * Return the variables and values only + */ + getAllVariablesInTarget(target: string, scopedVars: ScopedVars, format?: string | Function): VariableMap; + /** * Checks if a target contains template variables. */ diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index d529243b4ef..8494d0424e0 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -1,4 +1,3 @@ -import { property } from 'lodash'; import React from 'react'; import { @@ -24,8 +23,7 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { LokiQuery } from '../../../plugins/datasource/loki/types'; -import { variableRegex } from '../../variables/utils'; -import { getFieldLinksForExplore } from '../utils/links'; +import { getFieldLinksForExplore, getVariableUsageInfo } from '../utils/links'; import { SpanLinkFunc, Trace, TraceSpan } from './components'; import { SpanLinks } from './components/types/links'; @@ -192,7 +190,13 @@ function legacyCreateSpanLinkFactory( // Check if all variables are defined and don't show if they aren't. This is usually handled by the // getQueryFor* functions but this is for case of custom query supplied by the user. - if (dataLinkHasAllVariablesDefined(dataLink.internal!.query, scopedVars)) { + if ( + getVariableUsageInfo( + dataLink.internal!.query, + scopedVars, + getTemplateSrv().getAllVariablesInTarget.bind(getTemplateSrv()) + ).allVariablesDefined + ) { const link = mapInternalLinkToExplore({ link: dataLink, internalLink: dataLink.internal!, @@ -576,65 +580,3 @@ function scopedVarsFromSpan(span: TraceSpan): ScopedVars { }, }; } - -type VarValue = string | number | boolean | undefined; - -/** - * This function takes some code from template service replace() function to figure out if all variables are - * interpolated. This is so we don't show links that do not work. This cuts a lots of corners though and that is why - * it's a local function. We sort of don't care about the dashboard template variables for example. Also we only link - * to loki/splunk/elastic, so it should be less probable that user needs part of a query that looks like a variable but - * is actually part of the query language. - * @param query - * @param scopedVars - */ -function dataLinkHasAllVariablesDefined(query: T, scopedVars: ScopedVars): boolean { - const vars = getVariablesMapInTemplate(getStringsFromObject(query), scopedVars); - return Object.values(vars).every((val) => val !== undefined); -} - -function getStringsFromObject(obj: T): string { - let acc = ''; - for (const k of Object.keys(obj)) { - // Honestly not sure how to type this to make TS happy. - // @ts-ignore - if (typeof obj[k] === 'string') { - // @ts-ignore - acc += ' ' + obj[k]; - // @ts-ignore - } else if (typeof obj[k] === 'object' && obj[k] !== null) { - // @ts-ignore - acc += ' ' + getStringsFromObject(obj[k]); - } - } - return acc; -} - -function getVariablesMapInTemplate(target: string, scopedVars: ScopedVars): Record { - const regex = new RegExp(variableRegex); - const values: Record = {}; - - target.replace(regex, (match, var1, var2, fmt2, var3, fieldPath) => { - const variableName = var1 || var2 || var3; - values[variableName] = getVariableValue(variableName, fieldPath, scopedVars); - - // Don't care about the result anyway - return ''; - }); - - return values; -} - -function getVariableValue(variableName: string, fieldPath: string | undefined, scopedVars: ScopedVars): VarValue { - const scopedVar = scopedVars[variableName]; - if (!scopedVar) { - return undefined; - } - - if (fieldPath) { - // @ts-ignore ScopedVars are typed in way that I don't think this is possible to type correctly. - return property(fieldPath)(scopedVar.value); - } - - return scopedVar.value; -} diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index e537de76d7f..c7a2e3a860f 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -16,470 +16,578 @@ import { initTemplateSrv } from '../../../../test/helpers/initTemplateSrv'; import { ContextSrv, setContextSrv } from '../../../core/services/context_srv'; import { setLinkSrv } from '../../panel/panellinks/link_srv'; -import { getFieldLinksForExplore } from './links'; - -describe('getFieldLinksForExplore', () => { - beforeEach(() => { - setTemplateSrv( - initTemplateSrv('key', [ - { type: 'custom', name: 'emptyVar', current: { value: null } }, - { type: 'custom', name: 'num', current: { value: 1 } }, - { type: 'custom', name: 'test', current: { value: 'foo' } }, - ]) - ); - }); - - it('returns correct link model for external link', () => { - const { field, range } = setup({ - title: 'external', - url: 'http://regionalhost', - }); - const links = getFieldLinksForExplore({ - field, - rowIndex: ROW_WITH_TEXT_VALUE.index, - splitOpenFn: jest.fn(), - range, +import { getFieldLinksForExplore, getVariableUsageInfo } from './links'; + +describe('explore links utils', () => { + describe('getFieldLinksForExplore', () => { + beforeEach(() => { + setTemplateSrv( + initTemplateSrv('key', [ + { type: 'custom', name: 'emptyVar', current: { value: null } }, + { type: 'custom', name: 'num', current: { value: 1 } }, + { type: 'custom', name: 'test', current: { value: 'foo' } }, + ]) + ); }); - expect(links[0].href).toBe('http://regionalhost'); - expect(links[0].title).toBe('external'); - }); - - it('returns generates title for external link', () => { - const { field, range } = setup({ - title: '', - url: 'http://regionalhost', - }); - const links = getFieldLinksForExplore({ - field, - rowIndex: ROW_WITH_TEXT_VALUE.index, - splitOpenFn: jest.fn(), - range, + it('returns correct link model for external link', () => { + const { field, range } = setup({ + title: 'external', + url: 'http://regionalhost', + }); + const links = getFieldLinksForExplore({ + field, + rowIndex: ROW_WITH_TEXT_VALUE.index, + splitOpenFn: jest.fn(), + range, + }); + + expect(links[0].href).toBe('http://regionalhost'); + expect(links[0].title).toBe('external'); }); - expect(links[0].href).toBe('http://regionalhost'); - expect(links[0].title).toBe('regionalhost'); - }); + it('returns generates title for external link', () => { + const { field, range } = setup({ + title: '', + url: 'http://regionalhost', + }); + const links = getFieldLinksForExplore({ + field, + rowIndex: ROW_WITH_TEXT_VALUE.index, + splitOpenFn: jest.fn(), + range, + }); + + expect(links[0].href).toBe('http://regionalhost'); + expect(links[0].title).toBe('regionalhost'); + }); - it('returns correct link model for internal link', () => { - const { field, range } = setup({ - title: '', - url: '', - internal: { - query: { query: 'query_1' }, + it('returns correct link model for internal link', () => { + const { field, range } = setup({ + title: '', + url: '', + internal: { + query: { query: 'query_1' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + panelsState: { + trace: { + spanId: 'abcdef', + }, + }, + }, + }); + const splitfn = jest.fn(); + const links = getFieldLinksForExplore({ + field, + rowIndex: ROW_WITH_TEXT_VALUE.index, + splitOpenFn: splitfn, + range, + }); + + expect(links[0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1"}],"panelsState":{"trace":{"spanId":"abcdef"}}}' + )}` + ); + expect(links[0].title).toBe('test_ds'); + + if (links[0].onClick) { + links[0].onClick({}); + } + + expect(splitfn).toBeCalledWith({ datasourceUid: 'uid_1', - datasourceName: 'test_ds', + query: { query: 'query_1' }, + range, panelsState: { trace: { spanId: 'abcdef', }, }, - }, + }); }); - const splitfn = jest.fn(); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, splitOpenFn: splitfn, range }); - - expect(links[0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1"}],"panelsState":{"trace":{"spanId":"abcdef"}}}' - )}` - ); - expect(links[0].title).toBe('test_ds'); - - if (links[0].onClick) { - links[0].onClick({}); - } - - expect(splitfn).toBeCalledWith({ - datasourceUid: 'uid_1', - query: { query: 'query_1' }, - range, - panelsState: { - trace: { - spanId: 'abcdef', + + it('returns correct link model for external link when user does not have access to explore', () => { + const { field, range } = setup( + { + title: 'external', + url: 'http://regionalhost', }, - }, - }); - }); + false + ); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range }); - it('returns correct link model for external link when user does not have access to explore', () => { - const { field, range } = setup( - { - title: 'external', - url: 'http://regionalhost', - }, - false - ); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range }); + expect(links[0].href).toBe('http://regionalhost'); + expect(links[0].title).toBe('external'); + }); - expect(links[0].href).toBe('http://regionalhost'); - expect(links[0].title).toBe('external'); - }); + it('returns no internal links if when user does not have access to explore', () => { + const { field, range } = setup( + { + title: '', + url: '', + internal: { + query: { query: 'query_1' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + }, + }, + false + ); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range }); + expect(links).toHaveLength(0); + }); - it('returns no internal links if when user does not have access to explore', () => { - const { field, range } = setup( - { + it('returns internal links when target contains __data template variables', () => { + const { field, range, dataFrame } = setup({ title: '', url: '', internal: { - query: { query: 'query_1' }, + query: { query: 'query_1-${__data.fields.flux-dimensions}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', }, - }, - false - ); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range }); - expect(links).toHaveLength(0); - }); - - it('returns internal links when target contains __data template variables', () => { - const { field, range, dataFrame } = setup({ - title: '', - url: '', - internal: { - query: { query: 'query_1-${__data.fields.flux-dimensions}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - }, - }); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); - expect(links).toHaveLength(1); - expect(links[0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' - )}` - ); - }); - - it('returns internal links when target contains targetField template variable', () => { - const { field, range, dataFrame } = setup({ - title: '', - url: '', - internal: { - query: { query: 'query_1-${__targetField}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - }, + }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); + expect(links).toHaveLength(1); + expect(links[0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' + )}` + ); }); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); - expect(links).toHaveLength(1); - expect(links[0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' - )}` - ); - }); - it('returns internal links when target contains field name template variable', () => { - // field cannot be hyphenated, change field name to non-hyphenated - const noHyphenLink = { - title: '', - url: '', - internal: { - query: { query: 'query_1-${fluxDimensions}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - }, - }; - const { field, range, dataFrame } = setup(noHyphenLink, true, { - name: 'fluxDimensions', - type: FieldType.string, - values: new ArrayVector([ROW_WITH_TEXT_VALUE.value, ROW_WITH_NULL_VALUE.value]), - config: { - links: [noHyphenLink], - }, + it('returns internal links when target contains targetField template variable', () => { + const { field, range, dataFrame } = setup({ + title: '', + url: '', + internal: { + query: { query: 'query_1-${__targetField}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + }, + }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); + expect(links).toHaveLength(1); + expect(links[0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' + )}` + ); }); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); - expect(links).toHaveLength(1); - expect(links[0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' - )}` - ); - }); - it('returns internal links when target contains other field name template variables', () => { - // field cannot be hyphenated, change field name to non-hyphenated - const noHyphenLink = { - title: '', - url: '', - internal: { - query: { query: 'query_1-${fluxDimensions}-${fluxDimension2}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - }, - }; - const { field, range, dataFrame } = setup( - noHyphenLink, - true, - { + it('returns internal links when target contains field name template variable', () => { + // field cannot be hyphenated, change field name to non-hyphenated + const noHyphenLink = { + title: '', + url: '', + internal: { + query: { query: 'query_1-${fluxDimensions}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + }, + }; + const { field, range, dataFrame } = setup(noHyphenLink, true, { name: 'fluxDimensions', type: FieldType.string, values: new ArrayVector([ROW_WITH_TEXT_VALUE.value, ROW_WITH_NULL_VALUE.value]), config: { links: [noHyphenLink], }, - }, - [ + }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); + expect(links).toHaveLength(1); + expect(links[0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' + )}` + ); + }); + + it('returns internal links when target contains other field name template variables', () => { + // field cannot be hyphenated, change field name to non-hyphenated + const noHyphenLink = { + title: '', + url: '', + internal: { + query: { query: 'query_1-${fluxDimensions}-${fluxDimension2}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + }, + }; + const { field, range, dataFrame } = setup( + noHyphenLink, + true, { - name: 'fluxDimension2', + name: 'fluxDimensions', type: FieldType.string, - values: new ArrayVector(['foo2', ROW_WITH_NULL_VALUE.value]), + values: new ArrayVector([ROW_WITH_TEXT_VALUE.value, ROW_WITH_NULL_VALUE.value]), config: { links: [noHyphenLink], }, }, - ] - ); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); - expect(links).toHaveLength(1); - expect(links[0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}]}' - )}` - ); - }); - - it('returns internal links with logfmt and regex transformation', () => { - const transformationLink: DataLink = { - title: '', - url: '', - internal: { - query: { query: 'http_requests{app=${application} env=${environment}}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - transformations: [ - { type: SupportedTransformationTypes.Logfmt }, - { type: SupportedTransformationTypes.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' }, - ], - }, - }; - - const { field, range, dataFrame } = setup(transformationLink, true, { - name: 'msg', - type: FieldType.string, - values: new ArrayVector(['application=foo host=dev-001', 'application=bar host=prod-003']), - config: { - links: [transformationLink], - }, + [ + { + name: 'fluxDimension2', + type: FieldType.string, + values: new ArrayVector(['foo2', ROW_WITH_NULL_VALUE.value]), + config: { + links: [noHyphenLink], + }, + }, + ] + ); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); + expect(links).toHaveLength(1); + expect(links[0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}]}' + )}` + ); }); - const links = [ - getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), - getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), - ]; - expect(links[0]).toHaveLength(1); - expect(links[0][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo env=dev}"}]}' - )}` - ); - expect(links[1]).toHaveLength(1); - expect(links[1][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar env=prod}"}]}' - )}` - ); - }); + it('returns internal links with logfmt and regex transformation', () => { + const transformationLink: DataLink = { + title: '', + url: '', + internal: { + query: { query: 'http_requests{app=${application} env=${environment}}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + transformations: [ + { type: SupportedTransformationTypes.Logfmt }, + { type: SupportedTransformationTypes.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' }, + ], + }, + }; - it('returns internal links with 2 unnamed regex transformations and use the last transformation', () => { - const transformationLink: DataLink = { - title: '', - url: '', - internal: { - query: { query: 'http_requests{env=${msg}}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - transformations: [ - { type: SupportedTransformationTypes.Regex, expression: 'fieldA=(asparagus|broccoli)' }, - { type: SupportedTransformationTypes.Regex, expression: 'fieldB=(apple|banana)' }, - ], - }, - }; - - const { field, range, dataFrame } = setup(transformationLink, true, { - name: 'msg', - type: FieldType.string, - values: new ArrayVector(['fieldA=asparagus fieldB=banana', 'fieldA=broccoli fieldB=apple']), - config: { - links: [transformationLink], - }, + const { field, range, dataFrame } = setup(transformationLink, true, { + name: 'msg', + type: FieldType.string, + values: new ArrayVector(['application=foo host=dev-001', 'application=bar host=prod-003']), + config: { + links: [transformationLink], + }, + }); + + const links = [ + getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), + getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), + ]; + expect(links[0]).toHaveLength(1); + expect(links[0][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo env=dev}"}]}' + )}` + ); + expect(links[1]).toHaveLength(1); + expect(links[1][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar env=prod}"}]}' + )}` + ); }); - const links = [ - getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), - getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), - ]; - expect(links[0]).toHaveLength(1); - expect(links[0][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=banana}"}]}' - )}` - ); - expect(links[1]).toHaveLength(1); - expect(links[1][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=apple}"}]}' - )}` - ); - }); + it('returns internal links with 2 unnamed regex transformations and use the last transformation', () => { + const transformationLink: DataLink = { + title: '', + url: '', + internal: { + query: { query: 'http_requests{env=${msg}}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + transformations: [ + { type: SupportedTransformationTypes.Regex, expression: 'fieldA=(asparagus|broccoli)' }, + { type: SupportedTransformationTypes.Regex, expression: 'fieldB=(apple|banana)' }, + ], + }, + }; - it('returns internal links with logfmt with stringified booleans', () => { - const transformationLink: DataLink = { - title: '', - url: '', - internal: { - query: { query: 'http_requests{app=${application} isOnline=${online}}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - transformations: [{ type: SupportedTransformationTypes.Logfmt }], - }, - }; - - const { field, range, dataFrame } = setup(transformationLink, true, { - name: 'msg', - type: FieldType.string, - values: new ArrayVector(['application=foo online=true', 'application=bar online=false']), - config: { - links: [transformationLink], - }, + const { field, range, dataFrame } = setup(transformationLink, true, { + name: 'msg', + type: FieldType.string, + values: new ArrayVector(['fieldA=asparagus fieldB=banana', 'fieldA=broccoli fieldB=apple']), + config: { + links: [transformationLink], + }, + }); + + const links = [ + getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), + getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), + ]; + expect(links[0]).toHaveLength(1); + expect(links[0][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=banana}"}]}' + )}` + ); + expect(links[1]).toHaveLength(1); + expect(links[1][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=apple}"}]}' + )}` + ); }); - const links = [ - getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), - getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), - ]; - expect(links[0]).toHaveLength(1); - expect(links[0][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo isOnline=true}"}]}' - )}` - ); - expect(links[1]).toHaveLength(1); - expect(links[1][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar isOnline=false}"}]}' - )}` - ); - }); + it('returns internal links with logfmt with stringified booleans', () => { + const transformationLink: DataLink = { + title: '', + url: '', + internal: { + query: { query: 'http_requests{app=${application} isOnline=${online}}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + transformations: [{ type: SupportedTransformationTypes.Logfmt }], + }, + }; - it('returns internal links with logfmt with correct data on transformation-defined field', () => { - const transformationLink: DataLink = { - title: '', - url: '', - internal: { - query: { query: 'http_requests{app=${application}}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - transformations: [{ type: SupportedTransformationTypes.Logfmt, field: 'fieldNamedInTransformation' }], - }, - }; - - // fieldWithLink has the transformation, but the transformation has defined fieldNamedInTransformation as its field to transform - const { field, range, dataFrame } = setup( - transformationLink, - true, - { - name: 'fieldWithLink', + const { field, range, dataFrame } = setup(transformationLink, true, { + name: 'msg', type: FieldType.string, - values: new ArrayVector(['application=link', 'application=link2']), + values: new ArrayVector(['application=foo online=true', 'application=bar online=false']), config: { links: [transformationLink], }, - }, - [ + }); + + const links = [ + getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), + getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), + ]; + expect(links[0]).toHaveLength(1); + expect(links[0][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo isOnline=true}"}]}' + )}` + ); + expect(links[1]).toHaveLength(1); + expect(links[1][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=bar isOnline=false}"}]}' + )}` + ); + }); + + it('returns internal links with logfmt with correct data on transformation-defined field', () => { + const transformationLink: DataLink = { + title: '', + url: '', + internal: { + query: { query: 'http_requests{app=${application}}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + transformations: [{ type: SupportedTransformationTypes.Logfmt, field: 'fieldNamedInTransformation' }], + }, + }; + + // fieldWithLink has the transformation, but the transformation has defined fieldNamedInTransformation as its field to transform + const { field, range, dataFrame } = setup( + transformationLink, + true, { - name: 'fieldNamedInTransformation', + name: 'fieldWithLink', type: FieldType.string, - values: new ArrayVector(['application=transform', 'application=transform2']), - config: {}, + values: new ArrayVector(['application=link', 'application=link2']), + config: { + links: [transformationLink], + }, }, - ] - ); - - const links = [ - getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), - getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), - ]; - expect(links[0]).toHaveLength(1); - expect(links[0][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform}"}]}' - )}` - ); - expect(links[1]).toHaveLength(1); - expect(links[1][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform2}"}]}' - )}` - ); - }); - - it('returns internal links with regex named capture groups', () => { - const transformationLink: DataLink = { - title: '', - url: '', - internal: { - query: { query: 'http_requests{app=${application} env=${environment}}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - transformations: [ + [ { - type: SupportedTransformationTypes.Regex, - expression: '(?=.*(?(grafana|loki)))(?=.*(?(dev|prod)))', + name: 'fieldNamedInTransformation', + type: FieldType.string, + values: new ArrayVector(['application=transform', 'application=transform2']), + config: {}, }, - ], - }, - }; - - const { field, range, dataFrame } = setup(transformationLink, true, { - name: 'msg', - type: FieldType.string, - values: new ArrayVector(['foo loki prod', 'dev bar grafana', 'prod grafana foo']), - config: { - links: [transformationLink], - }, + ] + ); + + const links = [ + getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), + getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), + ]; + expect(links[0]).toHaveLength(1); + expect(links[0][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform}"}]}' + )}` + ); + expect(links[1]).toHaveLength(1); + expect(links[1][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=transform2}"}]}' + )}` + ); + }); + + it('returns internal links with regex named capture groups', () => { + const transformationLink: DataLink = { + title: '', + url: '', + internal: { + query: { query: 'http_requests{app=${application} env=${environment}}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + transformations: [ + { + type: SupportedTransformationTypes.Regex, + expression: '(?=.*(?(grafana|loki)))(?=.*(?(dev|prod)))', + }, + ], + }, + }; + + const { field, range, dataFrame } = setup(transformationLink, true, { + name: 'msg', + type: FieldType.string, + values: new ArrayVector(['foo loki prod', 'dev bar grafana', 'prod grafana foo']), + config: { + links: [transformationLink], + }, + }); + + const links = [ + getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), + getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), + getFieldLinksForExplore({ field, rowIndex: 2, range, dataFrame }), + ]; + expect(links[0]).toHaveLength(1); + expect(links[0][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=loki env=prod}"}]}' + )}` + ); + expect(links[1]).toHaveLength(1); + expect(links[1][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=dev}"}]}' + )}` + ); + + expect(links[2]).toHaveLength(1); + expect(links[2][0].href).toBe( + `/explore?left=${encodeURIComponent( + '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=prod}"}]}' + )}` + ); + }); + + it('returns no internal links when target contains empty template variables', () => { + const { field, range, dataFrame } = setup({ + title: '', + url: '', + internal: { + query: { query: 'query_1-${__data.fields.flux-dimensions}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + }, + }); + const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_NULL_VALUE.index, range, dataFrame }); + expect(links).toHaveLength(0); }); - const links = [ - getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), - getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), - getFieldLinksForExplore({ field, rowIndex: 2, range, dataFrame }), - ]; - expect(links[0]).toHaveLength(1); - expect(links[0][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=loki env=prod}"}]}' - )}` - ); - expect(links[1]).toHaveLength(1); - expect(links[1][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=dev}"}]}' - )}` - ); - - expect(links[2]).toHaveLength(1); - expect(links[2][0].href).toBe( - `/explore?left=${encodeURIComponent( - '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=prod}"}]}' - )}` - ); + it('does not return internal links when not all query variables are matched', () => { + const transformationLink: DataLink = { + title: '', + url: '', + internal: { + query: { query: 'http_requests{app=${application} env=${diffVar}}' }, + datasourceUid: 'uid_1', + datasourceName: 'test_ds', + transformations: [{ type: SupportedTransformationTypes.Logfmt }], + }, + }; + + const { field, range, dataFrame } = setup(transformationLink, true, { + name: 'msg', + type: FieldType.string, + values: new ArrayVector(['application=foo host=dev-001']), + config: { + links: [transformationLink], + }, + }); + + const links = [getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame })]; + expect(links[0]).toHaveLength(0); + }); }); - it('returns no internal links when target contains empty template variables', () => { - const { field, range, dataFrame } = setup({ - title: '', - url: '', - internal: { - query: { query: 'query_1-${__data.fields.flux-dimensions}' }, - datasourceUid: 'uid_1', - datasourceName: 'test_ds', - }, + describe('getVariableUsageInfo', () => { + it('returns true when query contains variables and all variables are used', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: 'test ${testVal}' }, + }, + }; + const scopedVars = { + testVal: { text: '', value: 'val1' }, + }; + const varMapMock = jest.fn().mockReturnValue({ testVal: scopedVars.testVal.value }); + const dataLinkRtnVal = getVariableUsageInfo(dataLink, scopedVars, varMapMock).allVariablesDefined; + + expect(dataLinkRtnVal).toBe(true); + }); + + it('returns false when query contains variables and no variables are used', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: 'test ${diffVar}' }, + }, + }; + const scopedVars = { + testVal: { text: '', value: 'val1' }, + }; + const varMapMock = jest.fn().mockReturnValue({ diffVar: null }); + const dataLinkRtnVal = getVariableUsageInfo(dataLink, scopedVars, varMapMock).allVariablesDefined; + + expect(dataLinkRtnVal).toBe(false); + }); + + it('returns false when query contains variables and some variables are used', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: 'test ${testVal} ${diffVar}' }, + }, + }; + const scopedVars = { + testVal: { text: '', value: 'val1' }, + }; + const varMapMock = jest.fn().mockReturnValue({ testVal: 'val1', diffVar: null }); + const dataLinkRtnVal = getVariableUsageInfo(dataLink, scopedVars, varMapMock).allVariablesDefined; + expect(dataLinkRtnVal).toBe(false); + }); + + it('returns true when query contains no variables', () => { + const dataLink = { + url: '', + title: '', + internal: { + datasourceUid: 'uid', + datasourceName: 'dsName', + query: { query: 'test' }, + }, + }; + const scopedVars = { + testVal: { text: '', value: 'val1' }, + }; + const varMapMock = jest.fn().mockReturnValue({}); + const dataLinkRtnVal = getVariableUsageInfo(dataLink, scopedVars, varMapMock).allVariablesDefined; + expect(dataLinkRtnVal).toBe(true); }); - const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_NULL_VALUE.index, range, dataFrame }); - expect(links).toHaveLength(0); }); }); diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index 7e88f20222e..fa129d13078 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -12,6 +12,7 @@ import { SplitOpen, DataLink, DisplayValue, + VariableMap, } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; @@ -21,40 +22,26 @@ import { getLinkSrv } from '../../panel/panellinks/link_srv'; type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean; -const dataLinkHasRequiredPermissions = (link: DataLink) => { +const dataLinkHasRequiredPermissionsFilter = (link: DataLink) => { return !link.internal || contextSrv.hasAccessToExplore(); }; /** - * Check if every variable in the link has a value. If not this returns false. If there are no variables in the link - * this will return true. - * @param link - * @param scopedVars + * Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not + * be passed back to the visualization. */ -const dataLinkHasAllVariablesDefined = (link: DataLink, scopedVars: ScopedVars) => { - let hasAllRequiredVarDefined = true; - - if (link.internal) { - let stringifiedQuery = ''; - try { - stringifiedQuery = JSON.stringify(link.internal.query || {}); - // Hook into format function to verify if all values are non-empty - // Format function is run on all existing field values allowing us to check it's value is non-empty - getTemplateSrv().replace(stringifiedQuery, scopedVars, (f: string) => { - hasAllRequiredVarDefined = hasAllRequiredVarDefined && !!f; - return ''; - }); - } catch (err) {} - } - - return hasAllRequiredVarDefined; -}; +const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilter]; /** - * Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not - * be passed back to the visualization. + * This extension of the LinkModel was done to support correlations, which need the variables' names + * and values split out for display purposes + * + * Correlations are internal links only so the variables property will always be defined (but possibly empty) + * for internal links and undefined for non-internal links */ -const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasAllVariablesDefined, dataLinkHasRequiredPermissions]; +export interface ExploreFieldLinkModel extends LinkModel { + variables?: VariableMap; +} /** * Get links from the field of a dataframe and in addition check if there is associated @@ -70,7 +57,7 @@ export const getFieldLinksForExplore = (options: { range: TimeRange; vars?: ScopedVars; dataFrame?: DataFrame; -}): Array> => { +}): ExploreFieldLinkModel[] => { const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options; const scopedVars: ScopedVars = { ...(vars || {}) }; scopedVars['__value'] = { @@ -117,7 +104,7 @@ export const getFieldLinksForExplore = (options: { return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars)); }); - return links.map((link) => { + const fieldLinks = links.map((link) => { if (!link.internal) { const replace: InterpolateFunction = (value, vars) => getTemplateSrv().replace(value, { ...vars, ...scopedVars }); @@ -146,19 +133,35 @@ export const getFieldLinksForExplore = (options: { }); } - return mapInternalLinkToExplore({ - link, - internalLink: link.internal, - scopedVars: { ...scopedVars, ...internalLinkSpecificVars }, - range, - field, - onClickFn: splitOpenFn, - replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), - }); + const allVars = { ...scopedVars, ...internalLinkSpecificVars }; + const varMapFn = getTemplateSrv().getAllVariablesInTarget.bind(getTemplateSrv()); + const variableData = getVariableUsageInfo(link, allVars, varMapFn); + let variables: VariableMap = {}; + if (Object.keys(variableData.variables).length === 0) { + const fieldName = field.name.toString(); + variables[fieldName] = ''; + } else { + variables = variableData.variables; + } + + if (variableData.allVariablesDefined) { + const internalLink = mapInternalLinkToExplore({ + link, + internalLink: link.internal, + scopedVars: allVars, + range, + field, + onClickFn: splitOpenFn, + replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), + }); + return { ...internalLink, variables: variables }; + } else { + return undefined; + } } }); + return fieldLinks.filter((link): link is ExploreFieldLinkModel => !!link); } - return []; }; @@ -204,3 +207,36 @@ export function useLinks(range: TimeRange, splitOpenFn?: SplitOpen) { [range, splitOpenFn] ); } + +/** + * Use variable map from templateSrv to determine if all variables have values + * @param query + * @param scopedVars + * @param getVarMap + */ +export function getVariableUsageInfo( + query: T, + scopedVars: ScopedVars, + getVarMap: Function +): { variables: VariableMap; allVariablesDefined: boolean } { + const vars = getVarMap(getStringsFromObject(query), scopedVars); + // the string processor will convert null to '' but is not ran in all scenarios + return { + variables: vars, + allVariablesDefined: Object.values(vars).every((val) => val !== undefined && val !== null && val !== ''), + }; +} + +function getStringsFromObject(obj: Object): string { + let acc = ''; + let k: keyof typeof obj; + + for (k in obj) { + if (typeof obj[k] === 'string') { + acc += ' ' + obj[k]; + } else if (typeof obj[k] === 'object') { + acc += ' ' + getStringsFromObject(obj[k]); + } + } + return acc; +} diff --git a/public/app/features/logs/components/LogDetails.tsx b/public/app/features/logs/components/LogDetails.tsx index 73f4a075500..1289b23991f 100644 --- a/public/app/features/logs/components/LogDetails.tsx +++ b/public/app/features/logs/components/LogDetails.tsx @@ -8,7 +8,7 @@ import { calculateLogsLabelStats, calculateStats } from '../utils'; import { LogDetailsRow } from './LogDetailsRow'; import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles'; -import { getAllFields } from './logParser'; +import { getAllFields, createLogLineLinks } from './logParser'; export interface Props extends Themeable2 { row: LogRowModel; @@ -51,10 +51,17 @@ class UnThemedLogDetails extends PureComponent { const labels = row.labels ? row.labels : {}; const labelsAvailable = Object.keys(labels).length > 0; const fieldsAndLinks = getAllFields(row, getFieldLinks); - const links = fieldsAndLinks.filter((f) => f.links?.length).sort(); - const fields = fieldsAndLinks.filter((f) => f.links?.length === 0).sort(); + let fieldsWithLinks = fieldsAndLinks.filter((f) => f.links?.length); + const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== row.entryFieldIndex).sort(); + const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === row.entryFieldIndex).sort(); + const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks); + + // do not show the log message unless there is a link attached + const fields = fieldsAndLinks.filter((f) => f.links?.length === 0 && f.fieldIndex !== row.entryFieldIndex).sort(); const fieldsAvailable = fields && fields.length > 0; - const linksAvailable = links && links.length > 0; + const fieldsWithLinksAvailable = + (displayedFieldsWithLinks && displayedFieldsWithLinks.length > 0) || + (fieldsWithLinksFromVariableMap && fieldsWithLinksFromVariableMap.length > 0); // If logs with error, we are not showing the level color const levelClassName = hasError @@ -78,13 +85,13 @@ class UnThemedLogDetails extends PureComponent { )} {Object.keys(labels) .sort() - .map((key) => { + .map((key, i) => { const value = labels[key]; return ( calculateLogsLabelStats(getRows(), key)} onClickFilterOutLabel={onClickFilterOutLabel} @@ -95,16 +102,17 @@ class UnThemedLogDetails extends PureComponent { app={app} wrapLogMessage={wrapLogMessage} displayedFields={displayedFields} + disableActions={false} /> ); })} - {fields.map((field) => { - const { key, value, fieldIndex } = field; + {fields.map((field, i) => { + const { keys, values, fieldIndex } = field; return ( { wrapLogMessage={wrapLogMessage} row={row} app={app} + disableActions={false} /> ); })} - {linksAvailable && ( + {fieldsWithLinksAvailable && ( Links )} - {links.map((field) => { - const { key, value, links, fieldIndex } = field; + {displayedFieldsWithLinks.map((field, i) => { + const { keys, values, links, fieldIndex } = field; return ( { wrapLogMessage={wrapLogMessage} row={row} app={app} + disableActions={false} /> ); })} - {!fieldsAvailable && !labelsAvailable && !linksAvailable && ( + {fieldsWithLinksFromVariableMap?.map((field, i) => { + const { keys, values, links, fieldIndex } = field; + return ( + calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())} + displayedFields={displayedFields} + wrapLogMessage={wrapLogMessage} + row={row} + app={app} + disableActions={true} + /> + ); + })} + + {!fieldsAvailable && !labelsAvailable && !fieldsWithLinksAvailable && ( No details available diff --git a/public/app/features/logs/components/LogDetailsRow.test.tsx b/public/app/features/logs/components/LogDetailsRow.test.tsx index e4bda4cf3e8..17fbbc810fd 100644 --- a/public/app/features/logs/components/LogDetailsRow.test.tsx +++ b/public/app/features/logs/components/LogDetailsRow.test.tsx @@ -9,8 +9,8 @@ type Props = ComponentProps; const setup = (propOverrides?: Partial) => { const props: Props = { - parsedValue: '', - parsedKey: '', + parsedValues: [''], + parsedKeys: [''], isLabel: true, wrapLogMessage: false, getStats: () => null, @@ -20,6 +20,7 @@ const setup = (propOverrides?: Partial) => { onClickHideField: () => {}, displayedFields: [], row: {} as LogRowModel, + disableActions: false, }; Object.assign(props, propOverrides); @@ -40,11 +41,11 @@ jest.mock('@grafana/runtime', () => ({ describe('LogDetailsRow', () => { it('should render parsed key', () => { - setup({ parsedKey: 'test key' }); + setup({ parsedKeys: ['test key'] }); expect(screen.getByText('test key')).toBeInTheDocument(); }); it('should render parsed value', () => { - setup({ parsedValue: 'test value' }); + setup({ parsedValues: ['test value'] }); expect(screen.getByText('test value')).toBeInTheDocument(); }); @@ -73,8 +74,8 @@ describe('LogDetailsRow', () => { it('should render stats when stats icon is clicked', () => { setup({ - parsedKey: 'key', - parsedValue: 'value', + parsedKeys: ['key'], + parsedValues: ['value'], getStats: () => { return [ { diff --git a/public/app/features/logs/components/LogDetailsRow.tsx b/public/app/features/logs/components/LogDetailsRow.tsx index f1230d64001..38ee2727e63 100644 --- a/public/app/features/logs/components/LogDetailsRow.tsx +++ b/public/app/features/logs/components/LogDetailsRow.tsx @@ -13,8 +13,9 @@ import { getLogRowStyles } from './getLogRowStyles'; //Components export interface Props extends Themeable2 { - parsedValue: string; - parsedKey: string; + parsedValues: string[]; + parsedKeys: string[]; + disableActions: boolean; wrapLogMessage?: boolean; isLabel?: boolean; onClickFilterLabel?: (key: string, value: string) => void; @@ -60,6 +61,9 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => { } } `, + adjoiningLinkButton: css` + margin-left: ${theme.spacing(1)}; + `, wrapLine: css` label: wrapLine; white-space: pre-wrap; @@ -68,8 +72,8 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => { padding: 0 ${theme.spacing(1)}; `, logDetailsValue: css` - display: table-cell; - vertical-align: middle; + display: flex; + align-items: center; line-height: 22px; .show-on-hover { @@ -105,9 +109,9 @@ class UnThemedLogDetailsRow extends PureComponent { } showField = () => { - const { onClickShowField: onClickShowDetectedField, parsedKey, row } = this.props; + const { onClickShowField: onClickShowDetectedField, parsedKeys, row } = this.props; if (onClickShowDetectedField) { - onClickShowDetectedField(parsedKey); + onClickShowDetectedField(parsedKeys[0]); } reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { @@ -118,9 +122,9 @@ class UnThemedLogDetailsRow extends PureComponent { }; hideField = () => { - const { onClickHideField: onClickHideDetectedField, parsedKey, row } = this.props; + const { onClickHideField: onClickHideDetectedField, parsedKeys, row } = this.props; if (onClickHideDetectedField) { - onClickHideDetectedField(parsedKey); + onClickHideDetectedField(parsedKeys[0]); } reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { @@ -131,9 +135,9 @@ class UnThemedLogDetailsRow extends PureComponent { }; filterLabel = () => { - const { onClickFilterLabel, parsedKey, parsedValue, row } = this.props; + const { onClickFilterLabel, parsedKeys, parsedValues, row } = this.props; if (onClickFilterLabel) { - onClickFilterLabel(parsedKey, parsedValue); + onClickFilterLabel(parsedKeys[0], parsedValues[0]); } reportInteraction('grafana_explore_logs_log_details_filter_clicked', { @@ -144,9 +148,9 @@ class UnThemedLogDetailsRow extends PureComponent { }; filterOutLabel = () => { - const { onClickFilterOutLabel, parsedKey, parsedValue, row } = this.props; + const { onClickFilterOutLabel, parsedKeys, parsedValues, row } = this.props; if (onClickFilterOutLabel) { - onClickFilterOutLabel(parsedKey, parsedValue); + onClickFilterOutLabel(parsedKeys[0], parsedValues[0]); } reportInteraction('grafana_explore_logs_log_details_filter_clicked', { @@ -190,25 +194,68 @@ class UnThemedLogDetailsRow extends PureComponent { }); } + generateClipboardButton(val: string) { + const { theme } = this.props; + const styles = getStyles(theme); + + return ( +
+ val} + title="Copy value to clipboard" + fill="text" + variant="secondary" + icon="copy" + size="md" + /> +
+ ); + } + + generateMultiVal(value: string[], showCopy?: boolean) { + return ( + + + {value?.map((val, i) => { + return ( + + + + ); + })} + +
+ {val} + {showCopy && val !== '' && this.generateClipboardButton(val)} +
+ ); + } + render() { const { theme, - parsedKey, - parsedValue, + parsedKeys, + parsedValues, isLabel, links, displayedFields, wrapLogMessage, onClickFilterLabel, onClickFilterOutLabel, + disableActions, } = this.props; const { showFieldsStats, fieldStats, fieldCount } = this.state; const styles = getStyles(theme); const style = getLogRowStyles(theme); - const hasFilteringFunctionality = onClickFilterLabel && onClickFilterOutLabel; + const singleKey = parsedKeys == null ? false : parsedKeys.length === 1; + const singleVal = parsedValues == null ? false : parsedValues.length === 1; + const hasFilteringFunctionality = !disableActions && onClickFilterLabel && onClickFilterOutLabel; + + const isMultiParsedValueWithNoContent = + !singleVal && parsedValues != null && !parsedValues.every((val) => val === ''); const toggleFieldButton = - displayedFields && displayedFields.includes(parsedKey) ? ( + displayedFields && parsedKeys != null && displayedFields.includes(parsedKeys[0]) ? ( ) : ( @@ -225,44 +272,37 @@ class UnThemedLogDetailsRow extends PureComponent { {hasFilteringFunctionality && ( )} - {displayedFields && toggleFieldButton} - + {!disableActions && displayedFields && toggleFieldButton} + {!disableActions && ( + + )} {/* Key - value columns */} - {parsedKey} + {singleKey ? parsedKeys[0] : this.generateMultiVal(parsedKeys)}
- {parsedValue} - -
- parsedValue} - title="Copy value to clipboard" - fill="text" - variant="secondary" - icon="copy" - size="md" - /> + {singleVal ? parsedValues[0] : this.generateMultiVal(parsedValues, true)} + {singleVal && this.generateClipboardButton(parsedValues[0])} +
+ {links?.map((link, i) => ( + + + + ))}
- - {links?.map((link) => ( - -   - - - ))}
- {showFieldsStats && ( + {showFieldsStats && singleKey && singleVal && ( {
diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index 5394beca55e..cf42745a187 100644 --- a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -22,16 +22,16 @@ class UnThemedLogRowMessageDisplayedFields extends PureComponent { : css` white-space: nowrap; `; - + // only single key/value rows are filterable, so we only need the first field key for filtering const line = showDetectedFields .map((parsedKey) => { const field = fields.find((field) => { - const { key } = field; - return key === parsedKey; + const { keys } = field; + return keys[0] === parsedKey; }); if (field !== undefined && field !== null) { - return `${parsedKey}=${field.value}`; + return `${parsedKey}=${field.values}`; } if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { diff --git a/public/app/features/logs/components/logParser.test.ts b/public/app/features/logs/components/logParser.test.ts index 7c261805832..cf6ee67ad7c 100644 --- a/public/app/features/logs/components/logParser.test.ts +++ b/public/app/features/logs/components/logParser.test.ts @@ -1,155 +1,212 @@ import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data'; +import { ExploreFieldLinkModel } from 'app/features/explore/utils/links'; import { createLogRow } from './__mocks__/logRow'; -import { getAllFields } from './logParser'; - -describe('getAllFields', () => { - it('should filter out field with labels name and other type', () => { - const logRow = createLogRow({ - entryFieldIndex: 10, - dataFrame: new MutableDataFrame({ - refId: 'A', - fields: [ - testStringField, - { - name: 'labels', - type: FieldType.other, - config: {}, - values: new ArrayVector([{ place: 'luna', source: 'data' }]), - }, - ], - }), +import { getAllFields, createLogLineLinks, FieldDef } from './logParser'; + +describe('logParser', () => { + describe('getAllFields', () => { + it('should filter out field with labels name and other type', () => { + const logRow = createLogRow({ + entryFieldIndex: 10, + dataFrame: new MutableDataFrame({ + refId: 'A', + fields: [ + testStringField, + { + name: 'labels', + type: FieldType.other, + config: {}, + values: new ArrayVector([{ place: 'luna', source: 'data' }]), + }, + ], + }), + }); + + const fields = getAllFields(logRow); + expect(fields.length).toBe(1); + expect(fields.find((field) => field.keys[0] === 'labels')).toBe(undefined); }); - const fields = getAllFields(logRow); - expect(fields.length).toBe(1); - expect(fields.find((field) => field.key === 'labels')).toBe(undefined); - }); - - it('should not filter out field with labels name and string type', () => { - const logRow = createLogRow({ - entryFieldIndex: 10, - dataFrame: new MutableDataFrame({ - refId: 'A', - fields: [ - testStringField, - { - name: 'labels', - type: FieldType.string, - config: {}, - values: new ArrayVector([{ place: 'luna', source: 'data' }]), - }, - ], - }), + it('should not filter out field with labels name and string type', () => { + const logRow = createLogRow({ + entryFieldIndex: 10, + dataFrame: new MutableDataFrame({ + refId: 'A', + fields: [ + testStringField, + { + name: 'labels', + type: FieldType.string, + config: {}, + values: new ArrayVector([{ place: 'luna', source: 'data' }]), + }, + ], + }), + }); + const fields = getAllFields(logRow); + expect(fields.length).toBe(2); + expect(fields.find((field) => field.keys[0] === 'labels')).not.toBe(undefined); }); - const fields = getAllFields(logRow); - expect(fields.length).toBe(2); - expect(fields.find((field) => field.key === 'labels')).not.toBe(undefined); - }); - it('should filter out field with id name', () => { - const logRow = createLogRow({ - entryFieldIndex: 10, - dataFrame: new MutableDataFrame({ - refId: 'A', - fields: [ - testStringField, - { - name: 'id', - type: FieldType.string, - config: {}, - values: new ArrayVector(['1659620138401000000_8b1f7688_']), - }, - ], - }), + it('should filter out field with id name', () => { + const logRow = createLogRow({ + entryFieldIndex: 10, + dataFrame: new MutableDataFrame({ + refId: 'A', + fields: [ + testStringField, + { + name: 'id', + type: FieldType.string, + config: {}, + values: new ArrayVector(['1659620138401000000_8b1f7688_']), + }, + ], + }), + }); + + const fields = getAllFields(logRow); + expect(fields.length).toBe(1); + expect(fields.find((field) => field.keys[0] === 'id')).toBe(undefined); }); - const fields = getAllFields(logRow); - expect(fields.length).toBe(1); - expect(fields.find((field) => field.key === 'id')).toBe(undefined); - }); - - it('should filter out entry field which is shown as the log message', () => { - const logRow = createLogRow({ - entryFieldIndex: 3, - dataFrame: new MutableDataFrame({ - refId: 'A', - fields: [ - testStringField, - { - name: 'labels', - type: FieldType.other, - config: {}, - values: new ArrayVector([{ place: 'luna', source: 'data' }]), - }, - { - name: 'Time', - type: FieldType.time, - config: {}, - values: new ArrayVector([1659620138401]), - }, - { - name: 'Line', - type: FieldType.string, - config: {}, - values: new ArrayVector([ - '_entry="log text with ANSI \u001b[31mpart of the text\u001b[0m [616951240]" counter=300 float=NaN label=val3 level=info', - ]), - }, - ], - }), + it('should filter out field with config hidden field', () => { + const testField = { ...testStringField }; + testField.config = { + custom: { + hidden: true, + }, + }; + const logRow = createLogRow({ + entryFieldIndex: 10, + dataFrame: new MutableDataFrame({ + refId: 'A', + fields: [{ ...testField }], + }), + }); + + const fields = getAllFields(logRow); + expect(fields.length).toBe(0); + expect(fields.find((field) => field.keys[0] === testField.name)).toBe(undefined); }); - const fields = getAllFields(logRow); - expect(fields.find((field) => field.key === 'Line')).toBe(undefined); - }); - - it('should filter out field with config hidden field', () => { - const testField = { ...testStringField }; - testField.config = { - custom: { - hidden: true, - }, - }; - const logRow = createLogRow({ - entryFieldIndex: 10, - dataFrame: new MutableDataFrame({ - refId: 'A', - fields: [{ ...testField }], - }), + it('should filter out field with null values', () => { + const logRow = createLogRow({ + entryFieldIndex: 10, + dataFrame: new MutableDataFrame({ + refId: 'A', + fields: [{ ...testFieldWithNullValue }], + }), + }); + + const fields = getAllFields(logRow); + expect(fields.length).toBe(0); + expect(fields.find((field) => field.keys[0] === testFieldWithNullValue.name)).toBe(undefined); }); - const fields = getAllFields(logRow); - expect(fields.length).toBe(0); - expect(fields.find((field) => field.key === testField.name)).toBe(undefined); + it('should not filter out field with string values', () => { + const logRow = createLogRow({ + entryFieldIndex: 10, + dataFrame: new MutableDataFrame({ + refId: 'A', + fields: [{ ...testStringField }], + }), + }); + + const fields = getAllFields(logRow); + expect(fields.length).toBe(1); + expect(fields.find((field) => field.keys[0] === testStringField.name)).not.toBe(undefined); + }); }); - it('should filter out field with null values', () => { - const logRow = createLogRow({ - entryFieldIndex: 10, - dataFrame: new MutableDataFrame({ - refId: 'A', - fields: [{ ...testFieldWithNullValue }], - }), + describe('createLogLineLinks', () => { + it('should change FieldDef to have keys of variable keys', () => { + const variableLink: ExploreFieldLinkModel = { + href: 'test', + onClick: () => {}, + origin: { + config: { links: [] }, + name: 'Line', + type: FieldType.string, + values: new ArrayVector(['a', 'b']), + }, + title: 'test', + target: '_self', + variables: { path: 'test', msg: 'test msg' }, + }; + + const fieldWithVarLink: FieldDef = { + fieldIndex: 2, + keys: ['Line'], + values: ['level=info msg="test msg" status_code=200 url=http://test'], + links: [variableLink], + }; + + const fields = createLogLineLinks([fieldWithVarLink]); + expect(fields.length).toBe(1); + expect(fields[0].keys.length).toBe(2); + expect(fields[0].keys[0]).toBe('path'); + expect(fields[0].values[0]).toBe('test'); + expect(fields[0].keys[1]).toBe('msg'); + expect(fields[0].values[1]).toBe('test msg'); }); - const fields = getAllFields(logRow); - expect(fields.length).toBe(0); - expect(fields.find((field) => field.key === testFieldWithNullValue.name)).toBe(undefined); - }); - - it('should not filter out field with string values', () => { - const logRow = createLogRow({ - entryFieldIndex: 10, - dataFrame: new MutableDataFrame({ - refId: 'A', - fields: [{ ...testStringField }], - }), + it('should convert null value to empty string and non string to string', () => { + const variableLink: ExploreFieldLinkModel = { + href: 'test', + onClick: () => {}, + origin: { + config: { links: [] }, + name: 'Line', + type: FieldType.string, + values: new ArrayVector(['a', 'b']), + }, + title: 'test', + target: '_self', + variables: { path: undefined, message: false }, + }; + + const fieldWithVarLink: FieldDef = { + fieldIndex: 2, + keys: ['Line'], + values: ['level=info msg="test msg" status_code=200 url=http://test'], + links: [variableLink], + }; + + const fields = createLogLineLinks([fieldWithVarLink]); + expect(fields.length).toBe(1); + expect(fields[0].keys.length).toBe(2); + expect(fields[0].keys[0]).toBe('path'); + expect(fields[0].values[0]).toBe(''); + expect(fields[0].keys[1]).toBe('message'); + expect(fields[0].values[1]).toBe('false'); }); - const fields = getAllFields(logRow); - expect(fields.length).toBe(1); - expect(fields.find((field) => field.key === testStringField.name)).not.toBe(undefined); + it('should return empty array if no variables', () => { + const variableLink: ExploreFieldLinkModel = { + href: 'test', + onClick: () => {}, + origin: { + config: { links: [] }, + name: 'Line', + type: FieldType.string, + values: new ArrayVector(['a', 'b']), + }, + title: 'test', + target: '_self', + }; + + const fieldWithVarLink: FieldDef = { + fieldIndex: 2, + keys: ['Line'], + values: ['level=info msg="test msg" status_code=200 url=http://test'], + links: [variableLink], + }; + + const fields = createLogLineLinks([fieldWithVarLink]); + expect(fields.length).toBe(0); + }); }); }); diff --git a/public/app/features/logs/components/logParser.ts b/public/app/features/logs/components/logParser.ts index 22359e26080..b8296d24dd9 100644 --- a/public/app/features/logs/components/logParser.ts +++ b/public/app/features/logs/components/logParser.ts @@ -1,11 +1,12 @@ import memoizeOne from 'memoize-one'; import { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data'; +import { ExploreFieldLinkModel } from 'app/features/explore/utils/links'; -type FieldDef = { - key: string; - value: string; - links?: Array>; +export type FieldDef = { + keys: string[]; + values: string[]; + links?: Array> | ExploreFieldLinkModel[]; fieldIndex: number; }; @@ -16,7 +17,11 @@ type FieldDef = { export const getAllFields = memoizeOne( ( row: LogRowModel, - getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array> + getFieldLinks?: ( + field: Field, + rowIndex: number, + dataFrame: DataFrame + ) => Array> | ExploreFieldLinkModel[] ) => { const dataframeFields = getDataframeFields(row, getFieldLinks); @@ -24,6 +29,31 @@ export const getAllFields = memoizeOne( } ); +/** + * A log line may contain many links that would all need to go on their own logs detail row + * This iterates through and creates a FieldDef (row) per link. + */ +export const createLogLineLinks = memoizeOne((hiddenFieldsWithLinks: FieldDef[]): FieldDef[] => { + let fieldsWithLinksFromVariableMap: FieldDef[] = []; + hiddenFieldsWithLinks.forEach((linkField) => { + linkField.links?.forEach((link: ExploreFieldLinkModel) => { + if (link.variables) { + const variableKeys = Object.keys(link.variables); + const variableValues = Object.keys(link.variables).map((key) => + link.variables && link.variables[key] != null ? link.variables[key]!.toString() : '' + ); + fieldsWithLinksFromVariableMap.push({ + keys: variableKeys, + values: variableValues, + links: [link], + fieldIndex: linkField.fieldIndex, + }); + } + }); + }); + return fieldsWithLinksFromVariableMap; +}); + /** * creates fields from the dataframe-fields, adding data-links, when field.config.links exists */ @@ -38,8 +68,8 @@ export const getDataframeFields = memoizeOne( .map((field) => { const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : []; return { - key: field.name, - value: field.values.get(row.rowIndex).toString(), + keys: [field.name], + values: [field.values.get(row.rowIndex).toString()], links: links, fieldIndex: field.index, }; @@ -57,10 +87,6 @@ function shouldRemoveField(field: Field, index: number, row: LogRowModel) { if (field.name === 'id' || field.name === 'tsNs') { return true; } - // entry field which we are showing as the log message - if (row.entryFieldIndex === index) { - return true; - } const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time); if ( field.name === firstTimeField?.name && diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 66319a9931e..68cdd4c070e 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -135,7 +135,8 @@ export const escapeUnescapedString = (string: string) => export function logRowsToReadableJson(logs: LogRowModel[]) { return logs.map((log) => { const fields = getDataframeFields(log).reduce>((acc, field) => { - acc[field.key] = field.value; + const key = field.keys[0]; + acc[key] = field.values[0]; return acc; }, {}); diff --git a/public/app/features/templating/template_srv.mock.ts b/public/app/features/templating/template_srv.mock.ts index 70ab70fb988..b8c47cb2c08 100644 --- a/public/app/features/templating/template_srv.mock.ts +++ b/public/app/features/templating/template_srv.mock.ts @@ -40,6 +40,21 @@ export class TemplateSrvMock implements TemplateSrv { }); } + getAllVariablesInTarget(target: string, scopedVars: ScopedVars): Record { + const regexp = new RegExp(this.regex); + const values: Record = {}; + + target.replace(regexp, (match, var1, var2, fmt2, var3, fieldPath) => { + const variableName = var1 || var2 || var3; + values[variableName] = this.variables[variableName]; + + // Don't care about the result anyway + return ''; + }); + + return values; + } + getVariableName(expression: string) { this.regex.lastIndex = 0; const match = this.regex.exec(expression); diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index a29e7480bca..2c6bdc79e64 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -7,6 +7,7 @@ import { AdHocVariableFilter, AdHocVariableModel, TypedVariableModel, + VariableMap, } from '@grafana/data'; import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime'; import { sceneGraph, FormatRegistryID, formatRegistry, CustomFormatterFn } from '@grafana/scenes'; @@ -347,6 +348,51 @@ export class TemplateSrv implements BaseTemplateSrv { }); } + getAllVariablesInTarget(target: string, scopedVars: ScopedVars, format?: string | Function): VariableMap { + const values: VariableMap = {}; + + this.replaceInVariableRegex(target, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const variableName = var1 || var2 || var3; + const variableDisplayName = + var1 || var2 || (var3 !== undefined && fieldPath !== undefined) ? `${var3}.${fieldPath}` : var3; + const fmt = fmt2 || fmt3 || format; + const value = this.getVariableValue(variableName, fieldPath, scopedVars); + if (value !== null && value !== undefined) { + const variable = this.getVariableAtIndex(variableName); + const text = this.getVariableText(variableName, value, scopedVars); + values[variableDisplayName] = this.formatValue(value, fmt, variable, text); + } else { + values[variableDisplayName] = undefined; + } + + // Don't care about the result anyway + return ''; + }); + + return values; + } + + /** + * The replace function, for every match, will return a function that has the full match as a param + * followed by one param per capture group of the variable regex. + * + * See the definition of this.regex for further comments on the variable definitions. + */ + private replaceInVariableRegex( + text: string, + replace: ( + fullMatch: string, // $simpleVarName [[squareVarName:squareFormat]] ${curlyVarName.curlyPath:curlyFormat} + simpleVarName: string, // simpleVarName - - + squareVarName: string, // - squareVarName - + squareFormat: string, // - squareFormat - + curlyVarName: string, // - - curlyVarName + curlyPath: string, // - - curlyPath + curlyFormat: string // - - curlyFormat + ) => string + ) { + return text.replace(this.regex, replace); + } + isAllValue(value: any) { return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); } diff --git a/public/app/features/variables/utils.test.ts b/public/app/features/variables/utils.test.ts index 4c22a0fc842..5f3e21039fd 100644 --- a/public/app/features/variables/utils.test.ts +++ b/public/app/features/variables/utils.test.ts @@ -185,17 +185,19 @@ describe('ensureStringValues', () => { describe('containsVariable', () => { it.each` - value | expected - ${''} | ${false} - ${'$var'} | ${true} - ${{ thing1: '${var}' }} | ${true} - ${{ thing1: '${var:fmt}' }} | ${true} - ${{ thing1: ['1', '${var}'] }} | ${true} - ${{ thing1: ['1', '[[var]]'] }} | ${true} - ${{ thing1: ['1', '[[var:fmt]]'] }} | ${true} - ${{ thing1: { thing2: '${var}' } }} | ${true} - ${{ params: [['param', '$var']] }} | ${true} - ${{ params: [['param', '${var}']] }} | ${true} + value | expected + ${''} | ${false} + ${'$var'} | ${true} + ${{ thing1: '${var}' }} | ${true} + ${{ thing1: '${var:fmt}' }} | ${true} + ${{ thing1: '${var.fieldPath}' }} | ${true} + ${{ thing1: '${var.fieldPath:fmt}' }} | ${true} + ${{ thing1: ['1', '${var}'] }} | ${true} + ${{ thing1: ['1', '[[var]]'] }} | ${true} + ${{ thing1: ['1', '[[var:fmt]]'] }} | ${true} + ${{ thing1: { thing2: '${var}' } }} | ${true} + ${{ params: [['param', '$var']] }} | ${true} + ${{ params: [['param', '${var}']] }} | ${true} `('when called with value:$value then result should be:$expected', ({ value, expected }) => { expect(containsVariable(value, 'var')).toEqual(expected); }); diff --git a/public/app/features/variables/utils.ts b/public/app/features/variables/utils.ts index c0f2d3a865b..a73bce617a1 100644 --- a/public/app/features/variables/utils.ts +++ b/public/app/features/variables/utils.ts @@ -16,9 +16,10 @@ import { QueryVariableModel, TransactionStatus, VariableModel, VariableRefresh, /* * This regex matches 3 types of variable reference with an optional format specifier - * \$(\w+) $var1 - * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] - * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} + * There are 6 capture groups that replace will return + * \$(\w+) $var1 + * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?} ${var3} or ${var3.fieldPath} or ${var3:fmt3} (or ${var3.fieldPath:fmt3} but that is not a separate capture group) */ export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g; diff --git a/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx b/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx index 6eb14771d10..b001350e3ad 100644 --- a/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx +++ b/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx @@ -38,6 +38,9 @@ describe('DebugSection', () => { getVariables() { return []; }, + getAllVariablesInTarget(target, scopedVars) { + return {}; + }, containsTemplate() { return false; }, diff --git a/public/app/plugins/datasource/zipkin/datasource.test.ts b/public/app/plugins/datasource/zipkin/datasource.test.ts index 99cb2ff5a1c..ba9734fcd12 100644 --- a/public/app/plugins/datasource/zipkin/datasource.test.ts +++ b/public/app/plugins/datasource/zipkin/datasource.test.ts @@ -21,6 +21,7 @@ describe('ZipkinDatasource', () => { replace: jest.fn(), getVariables: jest.fn(), containsTemplate: jest.fn(), + getAllVariablesInTarget: jest.fn(), updateTimeRange: jest.fn(), };