Files
grafana/public/app/features/explore/utils/links.ts
Piotr Jamróz 2ae226de89 Explore: Decouple SplitOpen and getFieldLinksForExplore from Panel visualizations (#71811)
* Allow overriding internal data link supplier

* Remove SplitOpen and getFieldLinksForExplore dependencies

* Fix checking if row index is provided

* Fix unit test

* Add a comment

* Mark SplitOpen as deprecated

* Use Panel Context to provide internal data link supplier

* Update packages/grafana-ui/src/components/PanelChrome/PanelContext.ts

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>

* Update packages/grafana-data/src/utils/dataLinks.ts

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>

* Add missing eventsScope

* Fix infinite render loops

* Rename internal data link supplier to data link post processor

* Update packages/grafana-data/src/field/fieldOverrides.ts

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>

---------

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
2023-07-31 14:10:03 +02:00

332 lines
11 KiB
TypeScript

import { first, uniqBy } from 'lodash';
import { useCallback } from 'react';
import {
Field,
LinkModel,
TimeRange,
mapInternalLinkToExplore,
InterpolateFunction,
ScopedVars,
DataFrame,
getFieldDisplayValuesProxy,
SplitOpen,
DataLink,
DisplayValue,
DataLinkConfigOrigin,
CoreApp,
SplitOpenOptions,
DataLinkPostProcessor,
} from '@grafana/data';
import { getTemplateSrv, reportInteraction, VariableInterpolation } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
import { getTransformationVars } from 'app/features/correlations/transformations';
import { getLinkSrv } from '../../panel/panellinks/link_srv';
type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean;
const dataLinkHasRequiredPermissionsFilter = (link: DataLink) => {
return !link.internal || contextSrv.hasAccessToExplore();
};
/**
* Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not
* be passed back to the visualization.
*/
const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilter];
/**
* This extension of the LinkModel was done to support correlations, which need the variables' names
* and values split out for display purposes
*
* Correlations are internal links only so the variables property will always be defined (but possibly empty)
* for internal links and undefined for non-internal links
*/
export interface ExploreFieldLinkModel extends LinkModel<Field> {
variables?: VariableInterpolation[];
}
const DATA_LINK_USAGE_KEY = 'grafana_data_link_clicked';
/**
* Creates an internal link supplier specific to Explore
*/
export const exploreDataLinkPostProcessorFactory = (
splitOpenFn: SplitOpen | undefined,
range: TimeRange
): DataLinkPostProcessor => {
const exploreDataLinkPostProcessor: DataLinkPostProcessor = (options) => {
const { field, dataLinkScopedVars: vars, frame: dataFrame, link, linkModel } = options;
const { valueRowIndex: rowIndex } = options.config;
if (!link.internal || rowIndex === undefined) {
return linkModel;
}
/**
* Even though getFieldLinksForExplore can produce internal and external links we re-use the logic for creating
* internal links only. Eventually code from getFieldLinksForExplore can be moved here and getFieldLinksForExplore
* can be removed (once all Explore panels start using field.getLinks).
*/
const links = getFieldLinksForExplore({
field,
rowIndex,
splitOpenFn,
range,
vars,
dataFrame,
linksToProcess: [link],
});
return links.length ? first(links) : undefined;
};
return exploreDataLinkPostProcessor;
};
/**
* Get links from the field of a dataframe and in addition check if there is associated
* metadata with datasource in which case we will add onClick to open the link in new split window. This assumes
* that we just supply datasource name and field value and Explore split window will know how to render that
* appropriately. This is for example used for transition from log with traceId to trace datasource to show that
* trace.
*
* Note: accessing a field via ${__data.fields.variable} will stay consistent with dashboards and return as existing but with an empty string
* Accessing a field with ${variable} will return undefined as this is unique to explore.
* @deprecated Use field.getLinks directly
*/
export const getFieldLinksForExplore = (options: {
field: Field;
rowIndex: number;
splitOpenFn?: SplitOpen;
range: TimeRange;
vars?: ScopedVars;
dataFrame?: DataFrame;
// if not provided, field.config.links are used
linksToProcess?: DataLink[];
}): ExploreFieldLinkModel[] => {
const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options;
const scopedVars: ScopedVars = { ...(vars || {}) };
scopedVars['__value'] = {
value: {
raw: field.values[rowIndex],
},
text: 'Raw value',
};
let fieldDisplayValuesProxy: Record<string, DisplayValue> | undefined = undefined;
// If we have a dataFrame we can allow referencing other columns and their values in the interpolation.
if (dataFrame) {
fieldDisplayValuesProxy = getFieldDisplayValuesProxy({
frame: dataFrame,
rowIndex,
});
scopedVars['__data'] = {
value: {
name: dataFrame.name,
refId: dataFrame.refId,
fields: fieldDisplayValuesProxy,
},
text: 'Data',
};
dataFrame.fields.forEach((f) => {
if (fieldDisplayValuesProxy && fieldDisplayValuesProxy[f.name]) {
scopedVars[f.name] = {
value: fieldDisplayValuesProxy[f.name],
};
}
});
// add this for convenience
scopedVars['__targetField'] = {
value: fieldDisplayValuesProxy[field.name],
};
}
const linksToProcess = options.linksToProcess || field.config.links;
if (linksToProcess) {
const links = linksToProcess.filter((link) => {
return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars));
});
const fieldLinks = links.map((link) => {
if (!link.internal) {
const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars });
const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field);
if (!linkModel.title) {
linkModel.title = getTitleFromHref(linkModel.href);
}
return linkModel;
} else {
let internalLinkSpecificVars: ScopedVars = {};
if (link.internal?.transformations) {
link.internal?.transformations.forEach((transformation) => {
let fieldValue;
if (transformation.field) {
const transformField = dataFrame?.fields.find((field) => field.name === transformation.field);
fieldValue = transformField?.values[rowIndex];
} else {
fieldValue = field.values[rowIndex];
}
internalLinkSpecificVars = {
...internalLinkSpecificVars,
...getTransformationVars(transformation, fieldValue, field.name),
};
});
}
const allVars = { ...scopedVars, ...internalLinkSpecificVars };
const variableData = getVariableUsageInfo(link, allVars);
let variables: VariableInterpolation[] = [];
// if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with
if (variableData.variables.length === 0) {
const fieldName = field.name.toString();
variables.push({ variableName: fieldName, value: '', match: '' });
} else {
variables = variableData.variables;
}
const splitFnWithTracking = (options?: SplitOpenOptions<DataQuery>) => {
reportInteraction(DATA_LINK_USAGE_KEY, {
origin: link.origin || DataLinkConfigOrigin.Datasource,
app: CoreApp.Explore,
internal: true,
});
splitOpenFn?.(options);
};
if (variableData.allVariablesDefined) {
const internalLink = mapInternalLinkToExplore({
link,
internalLink: link.internal,
scopedVars: allVars,
range,
field,
// Don't track internal links without split view as they are used only in Dashboards
// TODO: It should be revisited in #66570
onClickFn: options.splitOpenFn ? (options) => splitFnWithTracking(options) : undefined,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
});
return { ...internalLink, variables: variables };
} else {
return undefined;
}
}
});
return fieldLinks.filter((link): link is ExploreFieldLinkModel => !!link);
}
return [];
};
/**
* @internal
*/
export function getTitleFromHref(href: string): string {
// The URL constructor needs the url to have protocol
if (href.indexOf('://') < 0) {
// Doesn't really matter what protocol we use.
href = `http://${href}`;
}
let title;
try {
const parsedUrl = new URL(href);
title = parsedUrl.hostname;
} catch (_e) {
// Should be good enough fallback, user probably did not input valid url.
title = href;
}
return title;
}
/**
* Hook that returns a function that can be used to retrieve all the links for a row. This returns all the links from
* all the fields so is useful for visualisation where the whole row is represented as single clickable item like a
* service map.
*/
export function useLinks(range: TimeRange, splitOpenFn?: SplitOpen) {
return useCallback(
(dataFrame: DataFrame, rowIndex: number) => {
return dataFrame.fields.flatMap((f) => {
if (f.config?.links && f.config?.links.length) {
return getFieldLinksForExplore({
field: f,
rowIndex: rowIndex,
range,
dataFrame,
splitOpenFn,
});
} else {
return [];
}
});
},
[range, splitOpenFn]
);
}
// See https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables
const builtInVariables = [
'__from',
'__to',
'__interval',
'__interval_ms',
'__org',
'__user',
'__range',
'__rate_interval',
'__timeFilter',
'timeFilter',
// These are only applicable in dashboards so should not affect this for Explore
// '__dashboard',
//'__name',
];
/**
* Use variable map from templateSrv to determine if all variables have values
* @param query
* @param scopedVars
*/
export function getVariableUsageInfo<T extends DataLink>(
query: T,
scopedVars: ScopedVars
): { variables: VariableInterpolation[]; allVariablesDefined: boolean } {
let variables: VariableInterpolation[] = [];
const replaceFn = getTemplateSrv().replace.bind(getTemplateSrv());
// This adds info to the variables array while interpolating
replaceFn(getStringsFromObject(query), scopedVars, undefined, variables);
variables = uniqBy(variables, 'variableName');
return {
variables: variables,
allVariablesDefined: variables
// We filter out builtin variables as they should be always defined but sometimes only later, like
// __range_interval which is defined in prometheus at query time.
.filter((v) => !builtInVariables.includes(v.variableName))
.every((variable) => variable.found),
};
}
// Recursively get all strings from an object into a simple list with space as separator.
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;
}