DataLinks: Add internal links in table and allow custom query (#25613)

* Add internal links in table and with custom query

* Add specific types for internal and external link

* Change the datalink types to be more backward compatible

* Refactor the link utils for explore

* Add internal linking to table panels

* Fix derived field condition

* Prettify

* Add and fix tests

* Prettify

* Fix imports and tests

* Remove unused type

* Update packages/grafana-data/src/types/explore.ts

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* Update packages/grafana-data/src/types/explore.ts

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Andrej Ocenas
2020-06-30 14:51:04 +02:00
committed by GitHub
parent 463e8ffd92
commit 81d7cb1773
40 changed files with 523 additions and 271 deletions

View File

@@ -27,6 +27,7 @@ import { fieldIndexComparer } from '../field/fieldComparers';
function convertTableToDataFrame(table: TableData): DataFrame {
const fields = table.columns.map(c => {
// TODO: should be Column but type does not exists there so not sure whats up here.
const { text, type, ...disp } = c as any;
return {
name: text, // rename 'text' to the 'name' field

View File

@@ -118,6 +118,7 @@ describe('applyFieldOverrides', () => {
overrides: [],
},
replaceVariables: (value: any) => value,
getDataSourceSettingsByUid: undefined as any,
theme: {} as GrafanaTheme,
fieldConfigRegistry: new FieldConfigOptionsRegistry(),
});
@@ -187,6 +188,7 @@ describe('applyFieldOverrides', () => {
overrides: [],
},
fieldConfigRegistry: customFieldRegistry,
getDataSourceSettingsByUid: undefined as any,
replaceVariables: v => v,
theme: {} as GrafanaTheme,
})[0];
@@ -204,6 +206,7 @@ describe('applyFieldOverrides', () => {
data: [f0], // the frame
fieldConfig: src as FieldConfigSource, // defaults + overrides
replaceVariables: (undefined as any) as InterpolateFunction,
getDataSourceSettingsByUid: undefined as any,
theme: (undefined as any) as GrafanaTheme,
fieldConfigRegistry: customFieldRegistry,
})[0];
@@ -231,6 +234,7 @@ describe('applyFieldOverrides', () => {
data: [f0], // the frame
fieldConfig: src as FieldConfigSource, // defaults + overrides
replaceVariables: (undefined as any) as InterpolateFunction,
getDataSourceSettingsByUid: undefined as any,
theme: (undefined as any) as GrafanaTheme,
autoMinMax: true,
})[0];
@@ -478,11 +482,72 @@ describe('getLinksSupplier', () => {
});
const replaceSpy = jest.fn();
const supplier = getLinksSupplier(f0, f0.fields[0], {}, replaceSpy, { theme: {} as GrafanaTheme });
const supplier = getLinksSupplier(
f0,
f0.fields[0],
{},
replaceSpy,
// this is used only for internal links so isn't needed here
() => ({} as any),
{
theme: {} as GrafanaTheme,
}
);
supplier({});
expect(replaceSpy).toBeCalledTimes(2);
expect(replaceSpy.mock.calls[0][0]).toEqual('url to be interpolated');
expect(replaceSpy.mock.calls[1][0]).toEqual('title to be interpolated');
});
it('handles internal links', () => {
locationUtil.initialize({
getConfig: () => ({ appSubUrl: '' } as any),
buildParamsFromVariables: (() => {}) as any,
getTimeRangeForUrl: (() => {}) as any,
});
const f0 = new MutableDataFrame({
name: 'A',
fields: [
{
name: 'message',
type: FieldType.string,
values: [10, 20],
config: {
links: [
{
url: '',
title: '',
internal: {
datasourceUid: '0',
query: '12345',
},
},
],
},
},
],
});
const supplier = getLinksSupplier(
f0,
f0.fields[0],
{},
// We do not need to interpolate anything for this test
(value, vars, format) => value,
uid => ({ name: 'testDS' } as any),
{ theme: {} as GrafanaTheme }
);
const links = supplier({ valueRowIndex: 0 });
expect(links.length).toBe(1);
expect(links[0]).toEqual(
expect.objectContaining({
title: 'testDS',
href:
'/explore?left={"datasource":"testDS","queries":["12345"],"mode":"Metrics","ui":{"showingGraph":true,"showingTable":true,"showingLogs":true}}',
onClick: undefined,
})
);
});
});

View File

@@ -16,6 +16,8 @@ import {
ValueLinkConfig,
GrafanaTheme,
TimeZone,
DataLink,
DataSourceInstanceSettings,
} from '../types';
import { fieldMatchers, ReducerID, reduceField } from '../transformations';
import { FieldMatcher } from '../types/transformations';
@@ -33,6 +35,7 @@ import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
import { formatLabels } from '../utils/labels';
import { getFrameDisplayName, getFieldDisplayName } from './fieldState';
import { getTimeField } from '../dataframe/processDataFrame';
import { mapInternalLinkToExplore } from '../utils/dataLinks';
interface OverrideProps {
match: FieldMatcher;
@@ -129,6 +132,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
data: options.data!,
dataFrameIndex: index,
replaceVariables: options.replaceVariables,
getDataSourceSettingsByUid: options.getDataSourceSettingsByUid,
fieldConfigRegistry: fieldConfigRegistry,
};
@@ -206,10 +210,17 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
});
// Attach data links supplier
f.getLinks = getLinksSupplier(frame, f, fieldScopedVars, context.replaceVariables, {
theme: options.theme,
timeZone: options.timeZone,
});
f.getLinks = getLinksSupplier(
frame,
f,
fieldScopedVars,
context.replaceVariables,
context.getDataSourceSettingsByUid,
{
theme: options.theme,
timeZone: options.timeZone,
}
);
return f;
});
@@ -348,6 +359,7 @@ export const getLinksSupplier = (
field: Field,
fieldScopedVars: ScopedVars,
replaceVariables: InterpolateFunction,
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined,
options: {
theme: GrafanaTheme;
timeZone?: TimeZone;
@@ -359,20 +371,11 @@ export const getLinksSupplier = (
const timeRangeUrl = locationUtil.getTimeRangeUrlParams();
const { timeField } = getTimeField(frame);
return field.config.links.map(link => {
let href = link.url;
return field.config.links.map((link: DataLink) => {
const variablesQuery = locationUtil.getVariablesUrlParams();
let dataFrameVars = {};
let valueVars = {};
const info: LinkModel<Field> = {
href: locationUtil.assureBaseUrl(href.replace(/\n/g, '')),
title: link.title || '',
target: link.targetBlank ? '_blank' : undefined,
origin: field,
};
const variablesQuery = locationUtil.getVariablesUrlParams();
// We are not displaying reduction result
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) {
const fieldsProxy = getFieldDisplayValuesProxy(frame, config.valueRowIndex, options);
@@ -419,10 +422,25 @@ export const getLinksSupplier = (
},
};
info.href = replaceVariables(info.href, variables);
info.title = replaceVariables(info.title, variables);
info.href = locationUtil.processUrl(info.href);
if (link.internal) {
// For internal links at the moment only destination is Explore.
return mapInternalLinkToExplore(link, variables, {} as any, field, {
replaceVariables,
getDataSourceSettingsByUid,
});
} else {
let href = locationUtil.assureBaseUrl(link.url.replace(/\n/g, ''));
href = replaceVariables(href, variables);
href = locationUtil.processUrl(href);
return info;
const info: LinkModel<Field> = {
href,
title: replaceVariables(link.title || '', variables),
target: link.targetBlank ? '_blank' : undefined,
origin: field,
};
return info;
}
});
};

View File

@@ -28,6 +28,7 @@ describe('getFieldDisplayValuesProxy', () => {
overrides: [],
},
replaceVariables: (val: string) => val,
getDataSourceSettingsByUid: (val: string) => ({} as any),
timeZone: 'utc',
theme: {} as GrafanaTheme,
autoMinMax: true,

View File

@@ -1,4 +1,5 @@
import { ScopedVars } from './ScopedVars';
import { DataQuery } from './datasource';
/**
* Callback info for DataLink click events
@@ -10,10 +11,13 @@ export interface DataLinkClickEvent<T = any> {
}
/**
* Link configuration. The values may contain variables that need to be
* processed before running
* Link configuration. The values may contain variables that need to be
* processed before showing the link to user.
*
* TODO: <T extends DataQuery> is not strictly true for internal links as we do not need refId for example but all
* data source defined queries extend this so this is more for documentation.
*/
export interface DataLink {
export interface DataLink<T extends DataQuery = any> {
title: string;
targetBlank?: boolean;
@@ -28,16 +32,19 @@ export interface DataLink {
// Not saved in JSON/DTO
onClick?: (event: DataLinkClickEvent) => void;
// At the moment this is used for derived fields for metadata about internal linking.
meta?: {
datasourceUid?: string;
// If dataLink represents internal link this has to be filled. Internal link is defined as a query in a particular
// datas ource that we want to show to the user. Usually this results in a link to explore but can also lead to
// more custom onClick behaviour if needed.
internal?: {
query: T;
datasourceUid: string;
};
}
export type LinkTarget = '_blank' | '_self' | undefined;
/**
* Processed Link Model. The values are ready to use
* Processed Link Model. The values are ready to use
*/
export interface LinkModel<T = any> {
href: string;

View File

@@ -0,0 +1,22 @@
import { ExploreMode } from './datasource';
import { RawTimeRange } from './time';
import { LogsDedupStrategy } from './logs';
/** @internal */
export interface ExploreUrlState {
datasource: string;
queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
mode: ExploreMode;
range: RawTimeRange;
ui: ExploreUIState;
originPanelId?: number;
context?: string;
}
/** @internal */
export interface ExploreUIState {
showingTable: boolean;
showingGraph: boolean;
showingLogs: boolean;
dedupStrategy?: LogsDedupStrategy;
}

View File

@@ -1,5 +1,13 @@
import { ComponentType } from 'react';
import { MatcherConfig, FieldConfig, Field, DataFrame, GrafanaTheme, TimeZone } from '../types';
import {
MatcherConfig,
FieldConfig,
Field,
DataFrame,
GrafanaTheme,
TimeZone,
DataSourceInstanceSettings,
} from '../types';
import { InterpolateFunction } from './panel';
import { StandardEditorProps, FieldConfigOptionsRegistry, StandardEditorContext } from '../field';
import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
@@ -106,6 +114,7 @@ export interface ApplyFieldOverrideOptions {
data?: DataFrame[];
fieldConfig: FieldConfigSource;
replaceVariables: InterpolateFunction;
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined;
theme: GrafanaTheme;
timeZone?: TimeZone;
autoMinMax?: boolean;

View File

@@ -25,6 +25,7 @@ export * from './theme';
export * from './orgs';
export * from './flot';
export * from './trace';
export * from './explore';
import * as AppEvents from './appEvents';
import { AppEvent } from './appEvents';

View File

@@ -0,0 +1,39 @@
import { mapInternalLinkToExplore } from './dataLinks';
import { FieldType } from '../types';
import { ArrayVector } from '../vector';
describe('mapInternalLinkToExplore', () => {
it('creates internal link', () => {
const link = mapInternalLinkToExplore(
{
url: '',
title: '',
internal: {
datasourceUid: 'uid',
query: { query: '12344' },
},
},
{},
{} as any,
{
name: 'test',
type: FieldType.number,
config: {},
values: new ArrayVector([2]),
},
{
replaceVariables: val => val,
getDataSourceSettingsByUid: uid => ({ name: 'testDS' } as any),
}
);
expect(link).toEqual(
expect.objectContaining({
title: 'testDS',
href:
'/explore?left={"datasource":"testDS","queries":[{"query":"12344"}],"mode":"Metrics","ui":{"showingGraph":true,"showingTable":true,"showingLogs":true}}',
onClick: undefined,
})
);
});
});

View File

@@ -1,3 +1,17 @@
import {
DataLink,
DataQuery,
DataSourceInstanceSettings,
ExploreMode,
Field,
InterpolateFunction,
LinkModel,
ScopedVars,
TimeRange,
} from '../types';
import { locationUtil } from './location';
import { serializeStateToUrlParam } from './url';
export const DataLinkBuiltInVars = {
keepTime: '__url_time_range',
timeRangeFrom: '__from',
@@ -12,3 +26,96 @@ export const DataLinkBuiltInVars = {
// name of the calculation represented by the value
valueCalc: '__value.calc',
};
type Options = {
onClickFn?: (options: { datasourceUid: string; query: any }) => void;
replaceVariables: InterpolateFunction;
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined;
};
export function mapInternalLinkToExplore(
link: DataLink,
scopedVars: ScopedVars,
range: TimeRange,
field: Field,
options: Options
): LinkModel<Field> {
if (!link.internal) {
throw new Error('Trying to map external link as internal');
}
const { onClickFn, replaceVariables, getDataSourceSettingsByUid } = options;
const interpolatedQuery = interpolateQuery(link, scopedVars, replaceVariables);
return {
title: link.title
? replaceVariables(link.title || '', scopedVars)
: getDataSourceSettingsByUid(link.internal.datasourceUid)?.name || 'Unknown datasource',
// In this case this is meant to be internal link (opens split view by default) the href will also points
// to explore but this way you can open it in new tab.
href: generateInternalHref(
getDataSourceSettingsByUid(link.internal.datasourceUid)?.name || 'unknown',
interpolatedQuery,
range
),
onClick: onClickFn
? () => {
onClickFn?.({
datasourceUid: link.internal!.datasourceUid,
query: interpolatedQuery,
});
}
: undefined,
target: '_self',
origin: field,
};
}
/**
* Generates href for internal derived field link.
*/
function generateInternalHref<T extends DataQuery = any>(datasourceName: string, query: T, range: TimeRange): string {
return locationUtil.assureBaseUrl(
`/explore?left=${serializeStateToUrlParam({
range: range.raw,
datasource: datasourceName,
queries: [query],
// This should get overwritten if datasource does not support that mode and we do not know what mode is
// preferred anyway.
mode: ExploreMode.Metrics,
ui: {
showingGraph: true,
showingTable: true,
showingLogs: true,
},
})}`
);
}
function interpolateQuery<T extends DataQuery = any>(
link: DataLink,
scopedVars: ScopedVars,
replaceVariables: InterpolateFunction
): T {
let stringifiedQuery = '';
try {
stringifiedQuery = JSON.stringify(link.internal?.query || '');
} catch (err) {
// should not happen and not much to do about this, possibly something non stringifiable in the query
console.error(err);
}
// Replace any variables inside the query. This may not be the safest as it can also replace keys etc so may not
// actually work with every datasource query right now.
stringifiedQuery = replaceVariables(stringifiedQuery, scopedVars);
let replacedQuery = {} as T;
try {
replacedQuery = JSON.parse(stringifiedQuery);
} catch (err) {
// again should not happen and not much to do about this, probably some issue with how we replaced the variables.
console.error(stringifiedQuery, err);
}
return replacedQuery;
}

View File

@@ -2,6 +2,8 @@
* @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT
*/
import { ExploreUrlState } from '../types/explore';
/**
* Type to represent the value of a single query variable.
*
@@ -129,3 +131,24 @@ export const urlUtil = {
appendQueryToUrl,
getUrlSearchParams,
};
export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
if (compact) {
return JSON.stringify([
urlState.range.from,
urlState.range.to,
urlState.datasource,
...urlState.queries,
{ mode: urlState.mode },
{
ui: [
!!urlState.ui.showingGraph,
!!urlState.ui.showingLogs,
!!urlState.ui.showingTable,
urlState.ui.dedupStrategy,
],
},
]);
}
return JSON.stringify(urlState);
}

View File

@@ -1,5 +1,5 @@
import React, { ChangeEvent, useContext } from 'react';
import { DataLink, VariableSuggestion, GrafanaTheme } from '@grafana/data';
import { VariableSuggestion, GrafanaTheme, DataLink } from '@grafana/data';
import { Switch } from '../Switch/Switch';
import { css } from 'emotion';
import { ThemeContext, stylesFactory } from '../../themes/index';

View File

@@ -20,7 +20,23 @@ export const DefaultCell: FC<TableCellProps> = props => {
<div className={tableStyles.tableCell}>
{link ? (
<Tooltip content={link.title}>
<a href={link.href} target={link.target} title={link.title} className={tableStyles.tableCellLink}>
<a
href={link.href}
onClick={
link.onClick
? event => {
// Allow opening in new tab
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link!.onClick) {
event.preventDefault();
link!.onClick(event);
}
}
: undefined
}
target={link.target}
title={link.title}
className={tableStyles.tableCellLink}
>
{value}
</a>
</Tooltip>

View File

@@ -90,6 +90,7 @@ function buildData(theme: GrafanaTheme, config: Record<string, FieldConfig>): Da
},
theme,
replaceVariables: (value: string) => value,
getDataSourceSettingsByUid: (value: string) => ({} as any),
})[0];
}