Revert "Explore: Show log line if there is an interpolated link" (#65315)

Revert "Explore: Show log line if there is an interpolated link (#62926)"

This reverts commit aa857e2a4f.
pull/65400/head
Torkel Ödegaard 2 years ago committed by GitHub
parent 21ede347cb
commit f43ef18732
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/grafana-data/src/types/templateVars.ts
  2. 7
      packages/grafana-runtime/src/services/templateSrv.ts
  3. 74
      public/app/features/explore/TraceView/createSpanLink.tsx
  4. 914
      public/app/features/explore/utils/links.test.ts
  5. 112
      public/app/features/explore/utils/links.ts
  6. 70
      public/app/features/logs/components/LogDetails.tsx
  7. 13
      public/app/features/logs/components/LogDetailsRow.test.tsx
  8. 132
      public/app/features/logs/components/LogDetailsRow.tsx
  9. 8
      public/app/features/logs/components/LogRowMessageDisplayedFields.tsx
  10. 323
      public/app/features/logs/components/logParser.test.ts
  11. 48
      public/app/features/logs/components/logParser.ts
  12. 3
      public/app/features/logs/utils.ts
  13. 15
      public/app/features/templating/template_srv.mock.ts
  14. 46
      public/app/features/templating/template_srv.ts
  15. 24
      public/app/features/variables/utils.test.ts
  16. 7
      public/app/features/variables/utils.ts
  17. 3
      public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx
  18. 1
      public/app/plugins/datasource/zipkin/datasource.test.ts

@ -22,10 +22,6 @@ export type TypedVariableModel =
| OrgVariableModel | OrgVariableModel
| DashboardVariableModel; | DashboardVariableModel;
type VarValue = string | number | boolean | undefined;
export type VariableMap = Record<string, VarValue>;
export enum VariableRefresh { export enum VariableRefresh {
never, // removed from the UI never, // removed from the UI
onDashboardLoad, onDashboardLoad,

@ -1,4 +1,4 @@
import { ScopedVars, TimeRange, TypedVariableModel, VariableMap } from '@grafana/data'; import { ScopedVars, TimeRange, TypedVariableModel } from '@grafana/data';
/** /**
* Via the TemplateSrv consumers get access to all the available template variables * Via the TemplateSrv consumers get access to all the available template variables
@ -18,11 +18,6 @@ export interface TemplateSrv {
*/ */
replace(target?: string, scopedVars?: ScopedVars, format?: string | Function): string; 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. * Checks if a target contains template variables.
*/ */

@ -1,3 +1,4 @@
import { property } from 'lodash';
import React from 'react'; import React from 'react';
import { import {
@ -23,7 +24,8 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { PromQuery } from 'app/plugins/datasource/prometheus/types';
import { LokiQuery } from '../../../plugins/datasource/loki/types'; import { LokiQuery } from '../../../plugins/datasource/loki/types';
import { getFieldLinksForExplore, getVariableUsageInfo } from '../utils/links'; import { variableRegex } from '../../variables/utils';
import { getFieldLinksForExplore } from '../utils/links';
import { SpanLinkFunc, Trace, TraceSpan } from './components'; import { SpanLinkFunc, Trace, TraceSpan } from './components';
import { SpanLinks } from './components/types/links'; import { SpanLinks } from './components/types/links';
@ -190,13 +192,7 @@ function legacyCreateSpanLinkFactory(
// Check if all variables are defined and don't show if they aren't. This is usually handled by the // 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. // getQueryFor* functions but this is for case of custom query supplied by the user.
if ( if (dataLinkHasAllVariablesDefined(dataLink.internal!.query, scopedVars)) {
getVariableUsageInfo(
dataLink.internal!.query,
scopedVars,
getTemplateSrv().getAllVariablesInTarget.bind(getTemplateSrv())
).allVariablesDefined
) {
const link = mapInternalLinkToExplore({ const link = mapInternalLinkToExplore({
link: dataLink, link: dataLink,
internalLink: dataLink.internal!, internalLink: dataLink.internal!,
@ -580,3 +576,65 @@ 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<T extends DataQuery>(query: T, scopedVars: ScopedVars): boolean {
const vars = getVariablesMapInTemplate(getStringsFromObject(query), scopedVars);
return Object.values(vars).every((val) => val !== undefined);
}
function getStringsFromObject<T extends Object>(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<string, VarValue> {
const regex = new RegExp(variableRegex);
const values: Record<string, VarValue> = {};
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;
}

@ -16,578 +16,470 @@ import { initTemplateSrv } from '../../../../test/helpers/initTemplateSrv';
import { ContextSrv, setContextSrv } from '../../../core/services/context_srv'; import { ContextSrv, setContextSrv } from '../../../core/services/context_srv';
import { setLinkSrv } from '../../panel/panellinks/link_srv'; import { setLinkSrv } from '../../panel/panellinks/link_srv';
import { getFieldLinksForExplore, getVariableUsageInfo } from './links'; import { getFieldLinksForExplore } from './links';
describe('explore links utils', () => { describe('getFieldLinksForExplore', () => {
describe('getFieldLinksForExplore', () => { beforeEach(() => {
beforeEach(() => { setTemplateSrv(
setTemplateSrv( initTemplateSrv('key', [
initTemplateSrv('key', [ { type: 'custom', name: 'emptyVar', current: { value: null } },
{ type: 'custom', name: 'emptyVar', current: { value: null } }, { type: 'custom', name: 'num', current: { value: 1 } },
{ type: 'custom', name: 'num', current: { value: 1 } }, { type: 'custom', name: 'test', current: { value: 'foo' } },
{ type: 'custom', name: 'test', current: { value: 'foo' } }, ])
]) );
); });
});
it('returns correct link model for external link', () => { it('returns correct link model for external link', () => {
const { field, range } = setup({ const { field, range } = setup({
title: 'external', title: 'external',
url: 'http://regionalhost', url: 'http://regionalhost',
}); });
const links = getFieldLinksForExplore({ const links = getFieldLinksForExplore({
field, field,
rowIndex: ROW_WITH_TEXT_VALUE.index, rowIndex: ROW_WITH_TEXT_VALUE.index,
splitOpenFn: jest.fn(), splitOpenFn: jest.fn(),
range, range,
});
expect(links[0].href).toBe('http://regionalhost');
expect(links[0].title).toBe('external');
}); });
it('returns generates title for external link', () => { expect(links[0].href).toBe('http://regionalhost');
const { field, range } = setup({ expect(links[0].title).toBe('external');
title: '', });
url: 'http://regionalhost',
}); it('returns generates title for external link', () => {
const links = getFieldLinksForExplore({ const { field, range } = setup({
field, title: '',
rowIndex: ROW_WITH_TEXT_VALUE.index, url: 'http://regionalhost',
splitOpenFn: jest.fn(), });
range, const links = getFieldLinksForExplore({
}); field,
rowIndex: ROW_WITH_TEXT_VALUE.index,
expect(links[0].href).toBe('http://regionalhost'); splitOpenFn: jest.fn(),
expect(links[0].title).toBe('regionalhost'); range,
}); });
it('returns correct link model for internal link', () => { expect(links[0].href).toBe('http://regionalhost');
const { field, range } = setup({ expect(links[0].title).toBe('regionalhost');
title: '', });
url: '',
internal: { it('returns correct link model for internal link', () => {
query: { query: 'query_1' }, const { field, range } = setup({
datasourceUid: 'uid_1', title: '',
datasourceName: 'test_ds', url: '',
panelsState: { internal: {
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' }, query: { query: 'query_1' },
range, datasourceUid: 'uid_1',
datasourceName: 'test_ds',
panelsState: { panelsState: {
trace: { trace: {
spanId: 'abcdef', spanId: 'abcdef',
}, },
}, },
}); },
}); });
const splitfn = jest.fn();
it('returns correct link model for external link when user does not have access to explore', () => { const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, splitOpenFn: splitfn, range });
const { field, range } = setup(
{ expect(links[0].href).toBe(
title: 'external', `/explore?left=${encodeURIComponent(
url: 'http://regionalhost', '{"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',
}, },
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');
}); });
});
it('returns no internal links if when user does not have access to explore', () => { it('returns correct link model for external link when user does not have access to explore', () => {
const { field, range } = setup( const { field, range } = setup(
{ {
title: '', title: 'external',
url: '', url: 'http://regionalhost',
internal: { },
query: { query: 'query_1' }, false
datasourceUid: 'uid_1', );
datasourceName: 'test_ds', const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range });
},
}, expect(links[0].href).toBe('http://regionalhost');
false expect(links[0].title).toBe('external');
); });
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', () => { it('returns no internal links if when user does not have access to explore', () => {
const { field, range, dataFrame } = setup({ const { field, range } = setup(
{
title: '', title: '',
url: '', url: '',
internal: { internal: {
query: { query: 'query_1-${__data.fields.flux-dimensions}' }, query: { query: 'query_1' },
datasourceUid: 'uid_1', datasourceUid: 'uid_1',
datasourceName: 'test_ds', datasourceName: 'test_ds',
}, },
}); },
const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); false
expect(links).toHaveLength(1); );
expect(links[0].href).toBe( const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range });
`/explore?left=${encodeURIComponent( expect(links).toHaveLength(0);
'{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo"}]}' });
)}`
); 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', () => { it('returns internal links when target contains targetField template variable', () => {
const { field, range, dataFrame } = setup({ const { field, range, dataFrame } = setup({
title: '', title: '',
url: '', url: '',
internal: { internal: {
query: { query: 'query_1-${__targetField}' }, query: { query: 'query_1-${__targetField}' },
datasourceUid: 'uid_1', datasourceUid: 'uid_1',
datasourceName: 'test_ds', 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', () => { it('returns internal links when target contains field name template variable', () => {
// field cannot be hyphenated, change field name to non-hyphenated // field cannot be hyphenated, change field name to non-hyphenated
const noHyphenLink = { const noHyphenLink = {
title: '', title: '',
url: '', url: '',
internal: { internal: {
query: { query: 'query_1-${fluxDimensions}' }, query: { query: 'query_1-${fluxDimensions}' },
datasourceUid: 'uid_1', datasourceUid: 'uid_1',
datasourceName: 'test_ds', datasourceName: 'test_ds',
}, },
}; };
const { field, range, dataFrame } = setup(noHyphenLink, true, { 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: 'fluxDimensions', name: 'fluxDimensions',
type: FieldType.string, type: FieldType.string,
values: new ArrayVector([ROW_WITH_TEXT_VALUE.value, ROW_WITH_NULL_VALUE.value]), values: new ArrayVector([ROW_WITH_TEXT_VALUE.value, ROW_WITH_NULL_VALUE.value]),
config: { config: {
links: [noHyphenLink], 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: 'fluxDimensions', name: 'fluxDimension2',
type: FieldType.string, type: FieldType.string,
values: new ArrayVector([ROW_WITH_TEXT_VALUE.value, ROW_WITH_NULL_VALUE.value]), values: new ArrayVector(['foo2', ROW_WITH_NULL_VALUE.value]),
config: { config: {
links: [noHyphenLink], links: [noHyphenLink],
}, },
}, },
[ ]
{ );
name: 'fluxDimension2', const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame });
type: FieldType.string, expect(links).toHaveLength(1);
values: new ArrayVector(['foo2', ROW_WITH_NULL_VALUE.value]), expect(links[0].href).toBe(
config: { `/explore?left=${encodeURIComponent(
links: [noHyphenLink], '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}]}'
}, )}`
}, );
] });
);
const links = getFieldLinksForExplore({ field, rowIndex: ROW_WITH_TEXT_VALUE.index, range, dataFrame }); it('returns internal links with logfmt and regex transformation', () => {
expect(links).toHaveLength(1); const transformationLink: DataLink = {
expect(links[0].href).toBe( title: '',
`/explore?left=${encodeURIComponent( url: '',
'{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"query_1-foo-foo2"}]}' 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],
},
}); });
it('returns internal links with logfmt and regex transformation', () => { const links = [
const transformationLink: DataLink = { getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }),
title: '', getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }),
url: '', ];
internal: { expect(links[0]).toHaveLength(1);
query: { query: 'http_requests{app=${application} env=${environment}}' }, expect(links[0][0].href).toBe(
datasourceUid: 'uid_1', `/explore?left=${encodeURIComponent(
datasourceName: 'test_ds', '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo env=dev}"}]}'
transformations: [ )}`
{ type: SupportedTransformationTypes.Logfmt }, );
{ type: SupportedTransformationTypes.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' }, 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 { field, range, dataFrame } = setup(transformationLink, true, { it('returns internal links with 2 unnamed regex transformations and use the last transformation', () => {
name: 'msg', const transformationLink: DataLink = {
type: FieldType.string, title: '',
values: new ArrayVector(['application=foo host=dev-001', 'application=bar host=prod-003']), url: '',
config: { internal: {
links: [transformationLink], query: { query: 'http_requests{env=${msg}}' },
}, datasourceUid: 'uid_1',
}); datasourceName: 'test_ds',
transformations: [
const links = [ { type: SupportedTransformationTypes.Regex, expression: 'fieldA=(asparagus|broccoli)' },
getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), { type: SupportedTransformationTypes.Regex, expression: 'fieldB=(apple|banana)' },
getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), ],
]; },
expect(links[0]).toHaveLength(1); };
expect(links[0][0].href).toBe(
`/explore?left=${encodeURIComponent( const { field, range, dataFrame } = setup(transformationLink, true, {
'{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo env=dev}"}]}' name: 'msg',
)}` type: FieldType.string,
); values: new ArrayVector(['fieldA=asparagus fieldB=banana', 'fieldA=broccoli fieldB=apple']),
expect(links[1]).toHaveLength(1); config: {
expect(links[1][0].href).toBe( links: [transformationLink],
`/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 2 unnamed regex transformations and use the last transformation', () => { const links = [
const transformationLink: DataLink = { getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }),
title: '', getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }),
url: '', ];
internal: { expect(links[0]).toHaveLength(1);
query: { query: 'http_requests{env=${msg}}' }, expect(links[0][0].href).toBe(
datasourceUid: 'uid_1', `/explore?left=${encodeURIComponent(
datasourceName: 'test_ds', '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=banana}"}]}'
transformations: [ )}`
{ type: SupportedTransformationTypes.Regex, expression: 'fieldA=(asparagus|broccoli)' }, );
{ type: SupportedTransformationTypes.Regex, expression: 'fieldB=(apple|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 { field, range, dataFrame } = setup(transformationLink, true, { it('returns internal links with logfmt with stringified booleans', () => {
name: 'msg', const transformationLink: DataLink = {
type: FieldType.string, title: '',
values: new ArrayVector(['fieldA=asparagus fieldB=banana', 'fieldA=broccoli fieldB=apple']), url: '',
config: { internal: {
links: [transformationLink], query: { query: 'http_requests{app=${application} isOnline=${online}}' },
}, datasourceUid: 'uid_1',
}); datasourceName: 'test_ds',
transformations: [{ type: SupportedTransformationTypes.Logfmt }],
const links = [ },
getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), };
getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }),
]; const { field, range, dataFrame } = setup(transformationLink, true, {
expect(links[0]).toHaveLength(1); name: 'msg',
expect(links[0][0].href).toBe( type: FieldType.string,
`/explore?left=${encodeURIComponent( values: new ArrayVector(['application=foo online=true', 'application=bar online=false']),
'{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{env=banana}"}]}' config: {
)}` links: [transformationLink],
); },
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 logfmt with stringified booleans', () => { const links = [
const transformationLink: DataLink = { getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }),
title: '', getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }),
url: '', ];
internal: { expect(links[0]).toHaveLength(1);
query: { query: 'http_requests{app=${application} isOnline=${online}}' }, expect(links[0][0].href).toBe(
datasourceUid: 'uid_1', `/explore?left=${encodeURIComponent(
datasourceName: 'test_ds', '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=foo isOnline=true}"}]}'
transformations: [{ type: SupportedTransformationTypes.Logfmt }], )}`
}, );
}; 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}"}]}'
)}`
);
});
const { field, range, dataFrame } = setup(transformationLink, true, { it('returns internal links with logfmt with correct data on transformation-defined field', () => {
name: 'msg', 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',
type: FieldType.string, type: FieldType.string,
values: new ArrayVector(['application=foo online=true', 'application=bar online=false']), values: new ArrayVector(['application=link', 'application=link2']),
config: { config: {
links: [transformationLink], 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: 'fieldWithLink', name: 'fieldNamedInTransformation',
type: FieldType.string, type: FieldType.string,
values: new ArrayVector(['application=link', 'application=link2']), values: new ArrayVector(['application=transform', 'application=transform2']),
config: { 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: [
{ {
name: 'fieldNamedInTransformation', type: SupportedTransformationTypes.Regex,
type: FieldType.string, expression: '(?=.*(?<application>(grafana|loki)))(?=.*(?<environment>(dev|prod)))',
values: new ArrayVector(['application=transform', 'application=transform2']),
config: {},
}, },
] ],
); },
};
const links = [
getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }), const { field, range, dataFrame } = setup(transformationLink, true, {
getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }), name: 'msg',
]; type: FieldType.string,
expect(links[0]).toHaveLength(1); values: new ArrayVector(['foo loki prod', 'dev bar grafana', 'prod grafana foo']),
expect(links[0][0].href).toBe( config: {
`/explore?left=${encodeURIComponent( links: [transformationLink],
'{"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: '(?=.*(?<application>(grafana|loki)))(?=.*(?<environment>(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);
}); });
it('does not return internal links when not all query variables are matched', () => { const links = [
const transformationLink: DataLink = { getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame }),
title: '', getFieldLinksForExplore({ field, rowIndex: 1, range, dataFrame }),
url: '', getFieldLinksForExplore({ field, rowIndex: 2, range, dataFrame }),
internal: { ];
query: { query: 'http_requests{app=${application} env=${diffVar}}' }, expect(links[0]).toHaveLength(1);
datasourceUid: 'uid_1', expect(links[0][0].href).toBe(
datasourceName: 'test_ds', `/explore?left=${encodeURIComponent(
transformations: [{ type: SupportedTransformationTypes.Logfmt }], '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=loki env=prod}"}]}'
}, )}`
}; );
expect(links[1]).toHaveLength(1);
const { field, range, dataFrame } = setup(transformationLink, true, { expect(links[1][0].href).toBe(
name: 'msg', `/explore?left=${encodeURIComponent(
type: FieldType.string, '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=dev}"}]}'
values: new ArrayVector(['application=foo host=dev-001']), )}`
config: { );
links: [transformationLink],
}, expect(links[2]).toHaveLength(1);
}); expect(links[2][0].href).toBe(
`/explore?left=${encodeURIComponent(
const links = [getFieldLinksForExplore({ field, rowIndex: 0, range, dataFrame })]; '{"range":{"from":"now-1h","to":"now"},"datasource":"uid_1","queries":[{"query":"http_requests{app=grafana env=prod}"}]}'
expect(links[0]).toHaveLength(0); )}`
}); );
}); });
describe('getVariableUsageInfo', () => { it('returns no internal links when target contains empty template variables', () => {
it('returns true when query contains variables and all variables are used', () => { const { field, range, dataFrame } = setup({
const dataLink = { title: '',
url: '', url: '',
title: '', internal: {
internal: { query: { query: 'query_1-${__data.fields.flux-dimensions}' },
datasourceUid: 'uid', datasourceUid: 'uid_1',
datasourceName: 'dsName', datasourceName: 'test_ds',
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);
}); });
}); });

@ -12,7 +12,6 @@ import {
SplitOpen, SplitOpen,
DataLink, DataLink,
DisplayValue, DisplayValue,
VariableMap,
} from '@grafana/data'; } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
@ -22,26 +21,40 @@ import { getLinkSrv } from '../../panel/panellinks/link_srv';
type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean; type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean;
const dataLinkHasRequiredPermissionsFilter = (link: DataLink) => { const dataLinkHasRequiredPermissions = (link: DataLink) => {
return !link.internal || contextSrv.hasAccessToExplore(); return !link.internal || contextSrv.hasAccessToExplore();
}; };
/** /**
* Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not * Check if every variable in the link has a value. If not this returns false. If there are no variables in the link
* be passed back to the visualization. * this will return true.
* @param link
* @param scopedVars
*/ */
const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilter]; 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;
};
/** /**
* This extension of the LinkModel was done to support correlations, which need the variables' names * Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not
* and values split out for display purposes * be passed back to the visualization.
*
* 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
*/ */
export interface ExploreFieldLinkModel extends LinkModel<Field> { const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasAllVariablesDefined, dataLinkHasRequiredPermissions];
variables?: VariableMap;
}
/** /**
* Get links from the field of a dataframe and in addition check if there is associated * Get links from the field of a dataframe and in addition check if there is associated
@ -57,7 +70,7 @@ export const getFieldLinksForExplore = (options: {
range: TimeRange; range: TimeRange;
vars?: ScopedVars; vars?: ScopedVars;
dataFrame?: DataFrame; dataFrame?: DataFrame;
}): ExploreFieldLinkModel[] => { }): Array<LinkModel<Field>> => {
const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options; const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options;
const scopedVars: ScopedVars = { ...(vars || {}) }; const scopedVars: ScopedVars = { ...(vars || {}) };
scopedVars['__value'] = { scopedVars['__value'] = {
@ -104,7 +117,7 @@ export const getFieldLinksForExplore = (options: {
return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars)); return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars));
}); });
const fieldLinks = links.map((link) => { return links.map((link) => {
if (!link.internal) { if (!link.internal) {
const replace: InterpolateFunction = (value, vars) => const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars }); getTemplateSrv().replace(value, { ...vars, ...scopedVars });
@ -133,35 +146,19 @@ export const getFieldLinksForExplore = (options: {
}); });
} }
const allVars = { ...scopedVars, ...internalLinkSpecificVars }; return mapInternalLinkToExplore({
const varMapFn = getTemplateSrv().getAllVariablesInTarget.bind(getTemplateSrv()); link,
const variableData = getVariableUsageInfo(link, allVars, varMapFn); internalLink: link.internal,
let variables: VariableMap = {}; scopedVars: { ...scopedVars, ...internalLinkSpecificVars },
if (Object.keys(variableData.variables).length === 0) { range,
const fieldName = field.name.toString(); field,
variables[fieldName] = ''; onClickFn: splitOpenFn,
} else { replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
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 []; return [];
}; };
@ -207,36 +204,3 @@ export function useLinks(range: TimeRange, splitOpenFn?: SplitOpen) {
[range, splitOpenFn] [range, splitOpenFn]
); );
} }
/**
* Use variable map from templateSrv to determine if all variables have values
* @param query
* @param scopedVars
* @param getVarMap
*/
export function getVariableUsageInfo<T extends DataLink>(
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;
}

@ -8,7 +8,7 @@ import { calculateLogsLabelStats, calculateStats } from '../utils';
import { LogDetailsRow } from './LogDetailsRow'; import { LogDetailsRow } from './LogDetailsRow';
import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles'; import { getLogLevelStyles, LogRowStyles } from './getLogRowStyles';
import { getAllFields, createLogLineLinks } from './logParser'; import { getAllFields } from './logParser';
export interface Props extends Themeable2 { export interface Props extends Themeable2 {
row: LogRowModel; row: LogRowModel;
@ -51,17 +51,10 @@ class UnThemedLogDetails extends PureComponent<Props> {
const labels = row.labels ? row.labels : {}; const labels = row.labels ? row.labels : {};
const labelsAvailable = Object.keys(labels).length > 0; const labelsAvailable = Object.keys(labels).length > 0;
const fieldsAndLinks = getAllFields(row, getFieldLinks); const fieldsAndLinks = getAllFields(row, getFieldLinks);
let fieldsWithLinks = fieldsAndLinks.filter((f) => f.links?.length); const links = fieldsAndLinks.filter((f) => f.links?.length).sort();
const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== row.entryFieldIndex).sort(); const fields = fieldsAndLinks.filter((f) => f.links?.length === 0).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 fieldsAvailable = fields && fields.length > 0;
const fieldsWithLinksAvailable = const linksAvailable = links && links.length > 0;
(displayedFieldsWithLinks && displayedFieldsWithLinks.length > 0) ||
(fieldsWithLinksFromVariableMap && fieldsWithLinksFromVariableMap.length > 0);
// If logs with error, we are not showing the level color // If logs with error, we are not showing the level color
const levelClassName = hasError const levelClassName = hasError
@ -85,13 +78,13 @@ class UnThemedLogDetails extends PureComponent<Props> {
)} )}
{Object.keys(labels) {Object.keys(labels)
.sort() .sort()
.map((key, i) => { .map((key) => {
const value = labels[key]; const value = labels[key];
return ( return (
<LogDetailsRow <LogDetailsRow
key={`${key}=${value}-${i}`} key={`${key}=${value}`}
parsedKeys={[key]} parsedKey={key}
parsedValues={[value]} parsedValue={value}
isLabel={true} isLabel={true}
getStats={() => calculateLogsLabelStats(getRows(), key)} getStats={() => calculateLogsLabelStats(getRows(), key)}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
@ -102,17 +95,16 @@ class UnThemedLogDetails extends PureComponent<Props> {
app={app} app={app}
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
displayedFields={displayedFields} displayedFields={displayedFields}
disableActions={false}
/> />
); );
})} })}
{fields.map((field, i) => { {fields.map((field) => {
const { keys, values, fieldIndex } = field; const { key, value, fieldIndex } = field;
return ( return (
<LogDetailsRow <LogDetailsRow
key={`${keys[0]}=${values[0]}-${i}`} key={`${key}=${value}`}
parsedKeys={keys} parsedKey={key}
parsedValues={values} parsedValue={value}
onClickShowField={onClickShowField} onClickShowField={onClickShowField}
onClickHideField={onClickHideField} onClickHideField={onClickHideField}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
@ -122,25 +114,24 @@ class UnThemedLogDetails extends PureComponent<Props> {
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
row={row} row={row}
app={app} app={app}
disableActions={false}
/> />
); );
})} })}
{fieldsWithLinksAvailable && ( {linksAvailable && (
<tr> <tr>
<td colSpan={100} className={styles.logDetailsHeading} aria-label="Data Links"> <td colSpan={100} className={styles.logDetailsHeading} aria-label="Data Links">
Links Links
</td> </td>
</tr> </tr>
)} )}
{displayedFieldsWithLinks.map((field, i) => { {links.map((field) => {
const { keys, values, links, fieldIndex } = field; const { key, value, links, fieldIndex } = field;
return ( return (
<LogDetailsRow <LogDetailsRow
key={`${keys[0]}=${values[0]}-${i}`} key={`${key}=${value}`}
parsedKeys={keys} parsedKey={key}
parsedValues={values} parsedValue={value}
links={links} links={links}
onClickShowField={onClickShowField} onClickShowField={onClickShowField}
onClickHideField={onClickHideField} onClickHideField={onClickHideField}
@ -149,31 +140,10 @@ class UnThemedLogDetails extends PureComponent<Props> {
wrapLogMessage={wrapLogMessage} wrapLogMessage={wrapLogMessage}
row={row} row={row}
app={app} app={app}
disableActions={false}
/> />
); );
})} })}
{fieldsWithLinksFromVariableMap?.map((field, i) => { {!fieldsAvailable && !labelsAvailable && !linksAvailable && (
const { keys, values, links, fieldIndex } = field;
return (
<LogDetailsRow
key={`${keys[0]}=${values[0]}-${i}`}
parsedKeys={keys}
parsedValues={values}
links={links}
onClickShowField={onClickShowField}
onClickHideField={onClickHideField}
getStats={() => calculateStats(row.dataFrame.fields[fieldIndex].values.toArray())}
displayedFields={displayedFields}
wrapLogMessage={wrapLogMessage}
row={row}
app={app}
disableActions={true}
/>
);
})}
{!fieldsAvailable && !labelsAvailable && !fieldsWithLinksAvailable && (
<tr> <tr>
<td colSpan={100} aria-label="No details"> <td colSpan={100} aria-label="No details">
No details available No details available

@ -9,8 +9,8 @@ type Props = ComponentProps<typeof LogDetailsRow>;
const setup = (propOverrides?: Partial<Props>) => { const setup = (propOverrides?: Partial<Props>) => {
const props: Props = { const props: Props = {
parsedValues: [''], parsedValue: '',
parsedKeys: [''], parsedKey: '',
isLabel: true, isLabel: true,
wrapLogMessage: false, wrapLogMessage: false,
getStats: () => null, getStats: () => null,
@ -20,7 +20,6 @@ const setup = (propOverrides?: Partial<Props>) => {
onClickHideField: () => {}, onClickHideField: () => {},
displayedFields: [], displayedFields: [],
row: {} as LogRowModel, row: {} as LogRowModel,
disableActions: false,
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
@ -41,11 +40,11 @@ jest.mock('@grafana/runtime', () => ({
describe('LogDetailsRow', () => { describe('LogDetailsRow', () => {
it('should render parsed key', () => { it('should render parsed key', () => {
setup({ parsedKeys: ['test key'] }); setup({ parsedKey: 'test key' });
expect(screen.getByText('test key')).toBeInTheDocument(); expect(screen.getByText('test key')).toBeInTheDocument();
}); });
it('should render parsed value', () => { it('should render parsed value', () => {
setup({ parsedValues: ['test value'] }); setup({ parsedValue: 'test value' });
expect(screen.getByText('test value')).toBeInTheDocument(); expect(screen.getByText('test value')).toBeInTheDocument();
}); });
@ -74,8 +73,8 @@ describe('LogDetailsRow', () => {
it('should render stats when stats icon is clicked', () => { it('should render stats when stats icon is clicked', () => {
setup({ setup({
parsedKeys: ['key'], parsedKey: 'key',
parsedValues: ['value'], parsedValue: 'value',
getStats: () => { getStats: () => {
return [ return [
{ {

@ -13,9 +13,8 @@ import { getLogRowStyles } from './getLogRowStyles';
//Components //Components
export interface Props extends Themeable2 { export interface Props extends Themeable2 {
parsedValues: string[]; parsedValue: string;
parsedKeys: string[]; parsedKey: string;
disableActions: boolean;
wrapLogMessage?: boolean; wrapLogMessage?: boolean;
isLabel?: boolean; isLabel?: boolean;
onClickFilterLabel?: (key: string, value: string) => void; onClickFilterLabel?: (key: string, value: string) => void;
@ -61,9 +60,6 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
} }
} }
`, `,
adjoiningLinkButton: css`
margin-left: ${theme.spacing(1)};
`,
wrapLine: css` wrapLine: css`
label: wrapLine; label: wrapLine;
white-space: pre-wrap; white-space: pre-wrap;
@ -72,8 +68,8 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
padding: 0 ${theme.spacing(1)}; padding: 0 ${theme.spacing(1)};
`, `,
logDetailsValue: css` logDetailsValue: css`
display: flex; display: table-cell;
align-items: center; vertical-align: middle;
line-height: 22px; line-height: 22px;
.show-on-hover { .show-on-hover {
@ -109,9 +105,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
} }
showField = () => { showField = () => {
const { onClickShowField: onClickShowDetectedField, parsedKeys, row } = this.props; const { onClickShowField: onClickShowDetectedField, parsedKey, row } = this.props;
if (onClickShowDetectedField) { if (onClickShowDetectedField) {
onClickShowDetectedField(parsedKeys[0]); onClickShowDetectedField(parsedKey);
} }
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
@ -122,9 +118,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}; };
hideField = () => { hideField = () => {
const { onClickHideField: onClickHideDetectedField, parsedKeys, row } = this.props; const { onClickHideField: onClickHideDetectedField, parsedKey, row } = this.props;
if (onClickHideDetectedField) { if (onClickHideDetectedField) {
onClickHideDetectedField(parsedKeys[0]); onClickHideDetectedField(parsedKey);
} }
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
@ -135,9 +131,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}; };
filterLabel = () => { filterLabel = () => {
const { onClickFilterLabel, parsedKeys, parsedValues, row } = this.props; const { onClickFilterLabel, parsedKey, parsedValue, row } = this.props;
if (onClickFilterLabel) { if (onClickFilterLabel) {
onClickFilterLabel(parsedKeys[0], parsedValues[0]); onClickFilterLabel(parsedKey, parsedValue);
} }
reportInteraction('grafana_explore_logs_log_details_filter_clicked', { reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
@ -148,9 +144,9 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}; };
filterOutLabel = () => { filterOutLabel = () => {
const { onClickFilterOutLabel, parsedKeys, parsedValues, row } = this.props; const { onClickFilterOutLabel, parsedKey, parsedValue, row } = this.props;
if (onClickFilterOutLabel) { if (onClickFilterOutLabel) {
onClickFilterOutLabel(parsedKeys[0], parsedValues[0]); onClickFilterOutLabel(parsedKey, parsedValue);
} }
reportInteraction('grafana_explore_logs_log_details_filter_clicked', { reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
@ -194,68 +190,25 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
}); });
} }
generateClipboardButton(val: string) {
const { theme } = this.props;
const styles = getStyles(theme);
return (
<div className={cx('show-on-hover', styles.copyButton)}>
<ClipboardButton
getText={() => val}
title="Copy value to clipboard"
fill="text"
variant="secondary"
icon="copy"
size="md"
/>
</div>
);
}
generateMultiVal(value: string[], showCopy?: boolean) {
return (
<table>
<tbody>
{value?.map((val, i) => {
return (
<tr key={`${val}-${i}`}>
<td>
{val}
{showCopy && val !== '' && this.generateClipboardButton(val)}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
render() { render() {
const { const {
theme, theme,
parsedKeys, parsedKey,
parsedValues, parsedValue,
isLabel, isLabel,
links, links,
displayedFields, displayedFields,
wrapLogMessage, wrapLogMessage,
onClickFilterLabel, onClickFilterLabel,
onClickFilterOutLabel, onClickFilterOutLabel,
disableActions,
} = this.props; } = this.props;
const { showFieldsStats, fieldStats, fieldCount } = this.state; const { showFieldsStats, fieldStats, fieldCount } = this.state;
const styles = getStyles(theme); const styles = getStyles(theme);
const style = getLogRowStyles(theme); const style = getLogRowStyles(theme);
const singleKey = parsedKeys == null ? false : parsedKeys.length === 1; const hasFilteringFunctionality = onClickFilterLabel && onClickFilterOutLabel;
const singleVal = parsedValues == null ? false : parsedValues.length === 1;
const hasFilteringFunctionality = !disableActions && onClickFilterLabel && onClickFilterOutLabel;
const isMultiParsedValueWithNoContent =
!singleVal && parsedValues != null && !parsedValues.every((val) => val === '');
const toggleFieldButton = const toggleFieldButton =
displayedFields && parsedKeys != null && displayedFields.includes(parsedKeys[0]) ? ( displayedFields && displayedFields.includes(parsedKey) ? (
<IconButton variant="primary" tooltip="Hide this field" name="eye" onClick={this.hideField} /> <IconButton variant="primary" tooltip="Hide this field" name="eye" onClick={this.hideField} />
) : ( ) : (
<IconButton tooltip="Show this field instead of the message" name="eye" onClick={this.showField} /> <IconButton tooltip="Show this field instead of the message" name="eye" onClick={this.showField} />
@ -272,37 +225,44 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
{hasFilteringFunctionality && ( {hasFilteringFunctionality && (
<IconButton name="search-minus" tooltip="Filter out value" onClick={this.filterOutLabel} /> <IconButton name="search-minus" tooltip="Filter out value" onClick={this.filterOutLabel} />
)} )}
{!disableActions && displayedFields && toggleFieldButton} {displayedFields && toggleFieldButton}
{!disableActions && ( <IconButton
<IconButton variant={showFieldsStats ? 'primary' : 'secondary'}
variant={showFieldsStats ? 'primary' : 'secondary'} name="signal"
name="signal" tooltip="Ad-hoc statistics"
tooltip="Ad-hoc statistics" className="stats-button"
className="stats-button" onClick={this.showStats}
disabled={!singleKey} />
onClick={this.showStats}
/>
)}
</div> </div>
</td> </td>
{/* Key - value columns */} {/* Key - value columns */}
<td className={style.logDetailsLabel}>{singleKey ? parsedKeys[0] : this.generateMultiVal(parsedKeys)}</td> <td className={style.logDetailsLabel}>{parsedKey}</td>
<td className={cx(styles.wordBreakAll, wrapLogMessage && styles.wrapLine)}> <td className={cx(styles.wordBreakAll, wrapLogMessage && styles.wrapLine)}>
<div className={styles.logDetailsValue}> <div className={styles.logDetailsValue}>
{singleVal ? parsedValues[0] : this.generateMultiVal(parsedValues, true)} {parsedValue}
{singleVal && this.generateClipboardButton(parsedValues[0])}
<div className={cx((singleVal || isMultiParsedValueWithNoContent) && styles.adjoiningLinkButton)}> <div className={cx('show-on-hover', styles.copyButton)}>
{links?.map((link, i) => ( <ClipboardButton
<span key={`${link.title}-${i}`}> getText={() => parsedValue}
<DataLinkButton link={link} /> title="Copy value to clipboard"
</span> fill="text"
))} variant="secondary"
icon="copy"
size="md"
/>
</div> </div>
{links?.map((link) => (
<span key={link.title}>
&nbsp;
<DataLinkButton link={link} />
</span>
))}
</div> </div>
</td> </td>
</tr> </tr>
{showFieldsStats && singleKey && singleVal && ( {showFieldsStats && (
<tr> <tr>
<td> <td>
<IconButton <IconButton
@ -316,8 +276,8 @@ class UnThemedLogDetailsRow extends PureComponent<Props, State> {
<div className={styles.logDetailsStats}> <div className={styles.logDetailsStats}>
<LogLabelStats <LogLabelStats
stats={fieldStats!} stats={fieldStats!}
label={parsedKeys[0]} label={parsedKey}
value={parsedValues[0]} value={parsedValue}
rowCount={fieldCount} rowCount={fieldCount}
isLabel={isLabel} isLabel={isLabel}
/> />

@ -22,16 +22,16 @@ class UnThemedLogRowMessageDisplayedFields extends PureComponent<Props> {
: css` : css`
white-space: nowrap; white-space: nowrap;
`; `;
// only single key/value rows are filterable, so we only need the first field key for filtering
const line = showDetectedFields const line = showDetectedFields
.map((parsedKey) => { .map((parsedKey) => {
const field = fields.find((field) => { const field = fields.find((field) => {
const { keys } = field; const { key } = field;
return keys[0] === parsedKey; return key === parsedKey;
}); });
if (field !== undefined && field !== null) { if (field !== undefined && field !== null) {
return `${parsedKey}=${field.values}`; return `${parsedKey}=${field.value}`;
} }
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) {

@ -1,212 +1,155 @@
import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data'; import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data';
import { ExploreFieldLinkModel } from 'app/features/explore/utils/links';
import { createLogRow } from './__mocks__/logRow'; import { createLogRow } from './__mocks__/logRow';
import { getAllFields, createLogLineLinks, FieldDef } from './logParser'; import { getAllFields } from './logParser';
describe('logParser', () => { describe('getAllFields', () => {
describe('getAllFields', () => { it('should filter out field with labels name and other type', () => {
it('should filter out field with labels name and other type', () => { const logRow = createLogRow({
const logRow = createLogRow({ entryFieldIndex: 10,
entryFieldIndex: 10, dataFrame: new MutableDataFrame({
dataFrame: new MutableDataFrame({ refId: 'A',
refId: 'A', fields: [
fields: [ testStringField,
testStringField, {
{ name: 'labels',
name: 'labels', type: FieldType.other,
type: FieldType.other, config: {},
config: {}, values: new ArrayVector([{ place: 'luna', source: 'data' }]),
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);
}); });
it('should not filter out field with labels name and string type', () => { const fields = getAllFields(logRow);
const logRow = createLogRow({ expect(fields.length).toBe(1);
entryFieldIndex: 10, expect(fields.find((field) => field.key === 'labels')).toBe(undefined);
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);
});
it('should filter out field with id name', () => { it('should not filter out field with labels name and string type', () => {
const logRow = createLogRow({ const logRow = createLogRow({
entryFieldIndex: 10, entryFieldIndex: 10,
dataFrame: new MutableDataFrame({ dataFrame: new MutableDataFrame({
refId: 'A', refId: 'A',
fields: [ fields: [
testStringField, testStringField,
{ {
name: 'id', name: 'labels',
type: FieldType.string, type: FieldType.string,
config: {}, config: {},
values: new ArrayVector(['1659620138401000000_8b1f7688_']), values: new ArrayVector([{ place: 'luna', source: 'data' }]),
}, },
], ],
}), }),
});
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(2);
expect(fields.find((field) => field.key === 'labels')).not.toBe(undefined);
});
it('should filter out field with config hidden field', () => { it('should filter out field with id name', () => {
const testField = { ...testStringField }; const logRow = createLogRow({
testField.config = { entryFieldIndex: 10,
custom: { dataFrame: new MutableDataFrame({
hidden: true, refId: 'A',
}, fields: [
}; testStringField,
const logRow = createLogRow({ {
entryFieldIndex: 10, name: 'id',
dataFrame: new MutableDataFrame({ type: FieldType.string,
refId: 'A', config: {},
fields: [{ ...testField }], values: new ArrayVector(['1659620138401000000_8b1f7688_']),
}), },
}); ],
}),
const fields = getAllFields(logRow);
expect(fields.length).toBe(0);
expect(fields.find((field) => field.keys[0] === testField.name)).toBe(undefined);
}); });
it('should filter out field with null values', () => { const fields = getAllFields(logRow);
const logRow = createLogRow({ expect(fields.length).toBe(1);
entryFieldIndex: 10, expect(fields.find((field) => field.key === 'id')).toBe(undefined);
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);
});
it('should not filter out field with string values', () => { it('should filter out entry field which is shown as the log message', () => {
const logRow = createLogRow({ const logRow = createLogRow({
entryFieldIndex: 10, entryFieldIndex: 3,
dataFrame: new MutableDataFrame({ dataFrame: new MutableDataFrame({
refId: 'A', refId: 'A',
fields: [{ ...testStringField }], fields: [
}), testStringField,
}); {
name: 'labels',
const fields = getAllFields(logRow); type: FieldType.other,
expect(fields.length).toBe(1); config: {},
expect(fields.find((field) => field.keys[0] === testStringField.name)).not.toBe(undefined); 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',
]),
},
],
}),
}); });
const fields = getAllFields(logRow);
expect(fields.find((field) => field.key === 'Line')).toBe(undefined);
}); });
describe('createLogLineLinks', () => { it('should filter out field with config hidden field', () => {
it('should change FieldDef to have keys of variable keys', () => { const testField = { ...testStringField };
const variableLink: ExploreFieldLinkModel = { testField.config = {
href: 'test', custom: {
onClick: () => {}, hidden: true,
origin: { },
config: { links: [] }, };
name: 'Line', const logRow = createLogRow({
type: FieldType.string, entryFieldIndex: 10,
values: new ArrayVector(['a', 'b']), dataFrame: new MutableDataFrame({
}, refId: 'A',
title: 'test', fields: [{ ...testField }],
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');
}); });
it('should convert null value to empty string and non string to string', () => { const fields = getAllFields(logRow);
const variableLink: ExploreFieldLinkModel = { expect(fields.length).toBe(0);
href: 'test', expect(fields.find((field) => field.key === testField.name)).toBe(undefined);
onClick: () => {}, });
origin: {
config: { links: [] }, it('should filter out field with null values', () => {
name: 'Line', const logRow = createLogRow({
type: FieldType.string, entryFieldIndex: 10,
values: new ArrayVector(['a', 'b']), dataFrame: new MutableDataFrame({
}, refId: 'A',
title: 'test', fields: [{ ...testFieldWithNullValue }],
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');
}); });
it('should return empty array if no variables', () => { const fields = getAllFields(logRow);
const variableLink: ExploreFieldLinkModel = { expect(fields.length).toBe(0);
href: 'test', expect(fields.find((field) => field.key === testFieldWithNullValue.name)).toBe(undefined);
onClick: () => {}, });
origin: {
config: { links: [] }, it('should not filter out field with string values', () => {
name: 'Line', const logRow = createLogRow({
type: FieldType.string, entryFieldIndex: 10,
values: new ArrayVector(['a', 'b']), dataFrame: new MutableDataFrame({
}, refId: 'A',
title: 'test', fields: [{ ...testStringField }],
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);
}); });
const fields = getAllFields(logRow);
expect(fields.length).toBe(1);
expect(fields.find((field) => field.key === testStringField.name)).not.toBe(undefined);
}); });
}); });

@ -1,12 +1,11 @@
import memoizeOne from 'memoize-one'; import memoizeOne from 'memoize-one';
import { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data'; import { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data';
import { ExploreFieldLinkModel } from 'app/features/explore/utils/links';
export type FieldDef = { type FieldDef = {
keys: string[]; key: string;
values: string[]; value: string;
links?: Array<LinkModel<Field>> | ExploreFieldLinkModel[]; links?: Array<LinkModel<Field>>;
fieldIndex: number; fieldIndex: number;
}; };
@ -17,11 +16,7 @@ export type FieldDef = {
export const getAllFields = memoizeOne( export const getAllFields = memoizeOne(
( (
row: LogRowModel, row: LogRowModel,
getFieldLinks?: ( getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array<LinkModel<Field>>
field: Field,
rowIndex: number,
dataFrame: DataFrame
) => Array<LinkModel<Field>> | ExploreFieldLinkModel[]
) => { ) => {
const dataframeFields = getDataframeFields(row, getFieldLinks); const dataframeFields = getDataframeFields(row, getFieldLinks);
@ -29,31 +24,6 @@ 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 * creates fields from the dataframe-fields, adding data-links, when field.config.links exists
*/ */
@ -68,8 +38,8 @@ export const getDataframeFields = memoizeOne(
.map((field) => { .map((field) => {
const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : []; const links = getFieldLinks ? getFieldLinks(field, row.rowIndex, row.dataFrame) : [];
return { return {
keys: [field.name], key: field.name,
values: [field.values.get(row.rowIndex).toString()], value: field.values.get(row.rowIndex).toString(),
links: links, links: links,
fieldIndex: field.index, fieldIndex: field.index,
}; };
@ -87,6 +57,10 @@ function shouldRemoveField(field: Field, index: number, row: LogRowModel) {
if (field.name === 'id' || field.name === 'tsNs') { if (field.name === 'id' || field.name === 'tsNs') {
return true; 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); const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time);
if ( if (
field.name === firstTimeField?.name && field.name === firstTimeField?.name &&

@ -135,8 +135,7 @@ export const escapeUnescapedString = (string: string) =>
export function logRowsToReadableJson(logs: LogRowModel[]) { export function logRowsToReadableJson(logs: LogRowModel[]) {
return logs.map((log) => { return logs.map((log) => {
const fields = getDataframeFields(log).reduce<Record<string, string>>((acc, field) => { const fields = getDataframeFields(log).reduce<Record<string, string>>((acc, field) => {
const key = field.keys[0]; acc[field.key] = field.value;
acc[key] = field.values[0];
return acc; return acc;
}, {}); }, {});

@ -40,21 +40,6 @@ export class TemplateSrvMock implements TemplateSrv {
}); });
} }
getAllVariablesInTarget(target: string, scopedVars: ScopedVars): Record<string, string> {
const regexp = new RegExp(this.regex);
const values: Record<string, string> = {};
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) { getVariableName(expression: string) {
this.regex.lastIndex = 0; this.regex.lastIndex = 0;
const match = this.regex.exec(expression); const match = this.regex.exec(expression);

@ -7,7 +7,6 @@ import {
AdHocVariableFilter, AdHocVariableFilter,
AdHocVariableModel, AdHocVariableModel,
TypedVariableModel, TypedVariableModel,
VariableMap,
} from '@grafana/data'; } from '@grafana/data';
import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime'; import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime';
import { sceneGraph, FormatRegistryID, formatRegistry, VariableCustomFormatterFn } from '@grafana/scenes'; import { sceneGraph, FormatRegistryID, formatRegistry, VariableCustomFormatterFn } from '@grafana/scenes';
@ -352,51 +351,6 @@ 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) { isAllValue(value: any) {
return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE);
} }

@ -185,19 +185,17 @@ describe('ensureStringValues', () => {
describe('containsVariable', () => { describe('containsVariable', () => {
it.each` it.each`
value | expected value | expected
${''} | ${false} ${''} | ${false}
${'$var'} | ${true} ${'$var'} | ${true}
${{ thing1: '${var}' }} | ${true} ${{ thing1: '${var}' }} | ${true}
${{ thing1: '${var:fmt}' }} | ${true} ${{ thing1: '${var:fmt}' }} | ${true}
${{ thing1: '${var.fieldPath}' }} | ${true} ${{ thing1: ['1', '${var}'] }} | ${true}
${{ thing1: '${var.fieldPath:fmt}' }} | ${true} ${{ thing1: ['1', '[[var]]'] }} | ${true}
${{ thing1: ['1', '${var}'] }} | ${true} ${{ thing1: ['1', '[[var:fmt]]'] }} | ${true}
${{ thing1: ['1', '[[var]]'] }} | ${true} ${{ thing1: { thing2: '${var}' } }} | ${true}
${{ thing1: ['1', '[[var:fmt]]'] }} | ${true} ${{ params: [['param', '$var']] }} | ${true}
${{ thing1: { thing2: '${var}' } }} | ${true} ${{ params: [['param', '${var}']] }} | ${true}
${{ params: [['param', '$var']] }} | ${true}
${{ params: [['param', '${var}']] }} | ${true}
`('when called with value:$value then result should be:$expected', ({ value, expected }) => { `('when called with value:$value then result should be:$expected', ({ value, expected }) => {
expect(containsVariable(value, 'var')).toEqual(expected); expect(containsVariable(value, 'var')).toEqual(expected);
}); });

@ -16,10 +16,9 @@ import { QueryVariableModel, TransactionStatus, VariableModel, VariableRefresh,
/* /*
* This regex matches 3 types of variable reference with an optional format specifier * This regex matches 3 types of variable reference with an optional format specifier
* There are 6 capture groups that replace will return * \$(\w+) $var1
* \$(\w+) $var1 * \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
* \[\[(\w+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3}
* \${(\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; export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g;

@ -38,9 +38,6 @@ describe('DebugSection', () => {
getVariables() { getVariables() {
return []; return [];
}, },
getAllVariablesInTarget(target, scopedVars) {
return {};
},
containsTemplate() { containsTemplate() {
return false; return false;
}, },

@ -21,7 +21,6 @@ describe('ZipkinDatasource', () => {
replace: jest.fn(), replace: jest.fn(),
getVariables: jest.fn(), getVariables: jest.fn(),
containsTemplate: jest.fn(), containsTemplate: jest.fn(),
getAllVariablesInTarget: jest.fn(),
updateTimeRange: jest.fn(), updateTimeRange: jest.fn(),
}; };

Loading…
Cancel
Save