From 2ae226de891914000afa561cb9a189fbf49bbf66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Mon, 31 Jul 2023 14:10:03 +0200 Subject: [PATCH] Explore: Decouple SplitOpen and getFieldLinksForExplore from Panel visualizations (#71811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Co-authored-by: Torkel Ödegaard --- .../grafana-data/src/field/fieldOverrides.ts | 117 ++++++++++++------ .../grafana-data/src/types/fieldOverrides.ts | 26 +++- packages/grafana-data/src/utils/dataLinks.ts | 6 +- .../components/PanelChrome/PanelContext.ts | 8 ++ .../app/features/explore/CustomContainer.tsx | 71 +++++++---- public/app/features/explore/Explore.tsx | 4 +- .../features/explore/Graph/ExploreGraph.tsx | 45 +++++-- .../RawPrometheus/RawPrometheusContainer.tsx | 23 +--- .../features/explore/Table/TableContainer.tsx | 23 +--- .../hooks/useExploreDataLinkPostProcessor.ts | 11 ++ .../app/features/explore/utils/links.test.ts | 8 +- public/app/features/explore/utils/links.ts | 47 ++++++- .../panel/components/PanelRenderer.tsx | 13 +- .../features/query/state/PanelQueryRunner.ts | 10 ++ .../panel/candlestick/CandlestickPanel.tsx | 17 +-- .../panel/timeseries/TimeSeriesPanel.tsx | 18 +-- .../timeseries/plugins/ExemplarMarker.tsx | 8 +- .../timeseries/plugins/ExemplarsPlugin.tsx | 14 +-- public/app/plugins/panel/timeseries/utils.ts | 13 +- public/app/plugins/panel/trend/TrendPanel.tsx | 10 +- 20 files changed, 331 insertions(+), 161 deletions(-) create mode 100644 public/app/features/explore/hooks/useExploreDataLinkPostProcessor.ts diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 73838bac94a..5abdb0943aa 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -25,6 +25,7 @@ import { FieldConfigSource, FieldOverrideContext, FieldType, + DataLinkPostProcessor, InterpolateFunction, LinkModel, NumericRange, @@ -203,7 +204,8 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra field, field.state!.scopedVars, context.replaceVariables, - options.timeZone + options.timeZone, + options.dataLinkPostProcessor ); } @@ -358,20 +360,39 @@ export function validateFieldConfig(config: FieldConfig) { } } +const defaultInternalLinkPostProcessor: DataLinkPostProcessor = (options) => { + // For internal links at the moment only destination is Explore. + const { link, linkModel, dataLinkScopedVars, field, replaceVariables } = options; + + if (link.internal) { + return mapInternalLinkToExplore({ + link, + internalLink: link.internal, + scopedVars: dataLinkScopedVars, + field, + range: link.internal.range ?? ({} as any), + replaceVariables, + }); + } else { + return linkModel; + } +}; + export const getLinksSupplier = ( frame: DataFrame, field: Field, fieldScopedVars: ScopedVars, replaceVariables: InterpolateFunction, - timeZone?: TimeZone + timeZone?: TimeZone, + dataLinkPostProcessor?: DataLinkPostProcessor ) => (config: ValueLinkConfig): Array> => { if (!field.config.links || field.config.links.length === 0) { return []; } - return field.config.links.map((link: DataLink) => { + const linkModels = field.config.links.map((link: DataLink) => { const dataContext: DataContextScopedVar = getFieldDataContextClone(frame, field, fieldScopedVars); const dataLinkScopedVars = { ...fieldScopedVars, @@ -388,12 +409,14 @@ export const getLinksSupplier = dataContext.value.calculatedValue = config.calculatedValue; } + let linkModel: LinkModel; + if (link.onClick) { - return { + linkModel = { href: link.url, title: replaceVariables(link.title || '', dataLinkScopedVars), target: link.targetBlank ? '_blank' : undefined, - onClick: (evt, origin) => { + onClick: (evt: MouseEvent, origin: Field) => { link.onClick!({ origin: origin ?? field, e: evt, @@ -402,40 +425,40 @@ export const getLinksSupplier = }, origin: field, }; + } else { + let href = link.onBuildUrl + ? link.onBuildUrl({ + origin: field, + replaceVariables: boundReplaceVariables, + }) + : link.url; + + if (href) { + href = locationUtil.assureBaseUrl(href.replace(/\n/g, '')); + href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.UriEncode); + href = locationUtil.processUrl(href); + } + + linkModel = { + href, + title: replaceVariables(link.title || '', dataLinkScopedVars), + target: link.targetBlank ? '_blank' : undefined, + origin: field, + }; } - if (link.internal) { - // For internal links at the moment only destination is Explore. - return mapInternalLinkToExplore({ - link, - internalLink: link.internal, - scopedVars: dataLinkScopedVars, - field, - range: link.internal.range ?? ({} as any), - replaceVariables, - }); - } - let href = link.onBuildUrl - ? link.onBuildUrl({ - origin: field, - replaceVariables: boundReplaceVariables, - }) - : link.url; - - if (href) { - href = locationUtil.assureBaseUrl(href.replace(/\n/g, '')); - href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.UriEncode); - href = locationUtil.processUrl(href); - } - - const info: LinkModel = { - href, - title: replaceVariables(link.title || '', dataLinkScopedVars), - target: link.targetBlank ? '_blank' : undefined, - origin: field, - }; - return info; + return (dataLinkPostProcessor || defaultInternalLinkPostProcessor)({ + frame, + field, + dataLinkScopedVars, + replaceVariables, + config, + link, + linkModel, + }); }); + + return linkModels.filter((link): link is LinkModel => !!link); }; /** @@ -478,7 +501,8 @@ export function useFieldOverrides( data: PanelData | undefined, timeZone: string, theme: GrafanaTheme2, - replace: InterpolateFunction + replace: InterpolateFunction, + dataLinkPostProcessor?: DataLinkPostProcessor ): PanelData | undefined { const fieldConfigRegistry = plugin?.fieldConfigRegistry; const structureRev = useRef(0); @@ -500,7 +524,7 @@ export function useFieldOverrides( structureRev.current++; } - return { + const panelData: PanelData = { structureRev: structureRev.current, ...data, series: applyFieldOverrides({ @@ -510,9 +534,24 @@ export function useFieldOverrides( replaceVariables: replace, theme, timeZone, + dataLinkPostProcessor, }), }; - }, [fieldConfigRegistry, fieldConfig, data, prevSeries, timeZone, theme, replace]); + if (data.annotations && data.annotations.length > 0) { + panelData.annotations = applyFieldOverrides({ + data: data.annotations, + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: replace, + theme, + timeZone, + dataLinkPostProcessor, + }); + } + return panelData; + }, [fieldConfigRegistry, fieldConfig, data, prevSeries, timeZone, theme, replace, dataLinkPostProcessor]); } /** diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index 090d37bfd01..8a3a9bf15d0 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -2,7 +2,17 @@ import { ComponentType } from 'react'; import { StandardEditorProps, FieldConfigOptionsRegistry, StandardEditorContext } from '../field'; import { GrafanaTheme2 } from '../themes'; -import { MatcherConfig, FieldConfig, Field, DataFrame, TimeZone } from '../types'; +import { + MatcherConfig, + FieldConfig, + Field, + DataFrame, + TimeZone, + ScopedVars, + ValueLinkConfig, + LinkModel, + DataLink, +} from '../types'; import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; import { OptionEditorConfig } from './options'; @@ -112,6 +122,19 @@ export interface FieldConfigPropertyItem boolean; } +export type DataLinkPostProcessorOptions = { + frame: DataFrame; + field: Field; + dataLinkScopedVars: ScopedVars; + replaceVariables: InterpolateFunction; + timeZone?: TimeZone; + config: ValueLinkConfig; + link: DataLink; + linkModel: LinkModel; +}; + +export type DataLinkPostProcessor = (options: DataLinkPostProcessorOptions) => LinkModel | undefined; + export interface ApplyFieldOverrideOptions { data?: DataFrame[]; fieldConfig: FieldConfigSource; @@ -119,6 +142,7 @@ export interface ApplyFieldOverrideOptions { replaceVariables: InterpolateFunction; theme: GrafanaTheme2; timeZone?: TimeZone; + dataLinkPostProcessor?: DataLinkPostProcessor; } export enum FieldConfigProperty { diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 14b27ade9bf..e483ab427d8 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -53,7 +53,11 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod // to explore but this way you can open it in new tab. href: generateInternalHref(internalLink.datasourceUid, interpolatedQuery, range, interpolatedPanelsState), onClick: onClickFn - ? () => { + ? (event) => { + // Explore data links can be displayed not only in DataLinkButton but it can be used by the consumer in + // other way, for example MenuItem. We want to provide the URL (for opening in the new tab as well as + // the onClick to open the split view). + event.preventDefault(); onClickFn({ datasourceUid: internalLink.datasourceUid, queries: [interpolatedQuery], diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index bd9512d1514..a09418d720c 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -9,6 +9,7 @@ import { SplitOpen, CoreApp, DataFrame, + DataLinkPostProcessor, } from '@grafana/data'; import { AdHocFilterItem } from '../Table/types'; @@ -72,6 +73,7 @@ export interface PanelContext { /** * onSplitOpen is used in Explore to open the split view. It can be used in panels which has intercations and used in Explore as well. * For example TimeSeries panel. + * @deprecated will be removed in the future. It's not needed as visualization can just field.getLinks now */ onSplitOpen?: SplitOpen; @@ -91,6 +93,12 @@ export interface PanelContext { * in a the Promise resolving to a false value. */ onUpdateData?: (frames: DataFrame[]) => Promise; + + /** + * Optional supplier for internal data links. If not provided a link pointing to Explore will be generated. + * @internal + */ + dataLinkPostProcessor?: DataLinkPostProcessor; } export const PanelContextRoot = React.createContext({ diff --git a/public/app/features/explore/CustomContainer.tsx b/public/app/features/explore/CustomContainer.tsx index 42629d97e15..dbbba6c798b 100644 --- a/public/app/features/explore/CustomContainer.tsx +++ b/public/app/features/explore/CustomContainer.tsx @@ -1,11 +1,13 @@ -import React from 'react'; +import React, { useMemo } from 'react'; -import { AbsoluteTimeRange, DataFrame, dateTime, LoadingState } from '@grafana/data'; +import { AbsoluteTimeRange, DataFrame, dateTime, EventBus, LoadingState, SplitOpen } from '@grafana/data'; import { PanelRenderer } from '@grafana/runtime'; -import { PanelChrome } from '@grafana/ui'; +import { PanelChrome, PanelContext, PanelContextProvider } from '@grafana/ui'; import { getPanelPluginMeta } from '../plugins/importPanelPlugin'; +import { useExploreDataLinkPostProcessor } from './hooks/useExploreDataLinkPostProcessor'; + export interface Props { width: number; height: number; @@ -14,32 +16,57 @@ export interface Props { frames: DataFrame[]; absoluteRange: AbsoluteTimeRange; state: LoadingState; + splitOpenFn: SplitOpen; + eventBus: EventBus; } -export function CustomContainer({ width, height, timeZone, state, pluginId, frames, absoluteRange }: Props) { - const timeRange = { - from: dateTime(absoluteRange.from), - to: dateTime(absoluteRange.to), - raw: { +export function CustomContainer({ + width, + height, + timeZone, + state, + pluginId, + frames, + absoluteRange, + splitOpenFn, + eventBus, +}: Props) { + const timeRange = useMemo( + () => ({ from: dateTime(absoluteRange.from), to: dateTime(absoluteRange.to), - }, - }; + raw: { + from: dateTime(absoluteRange.from), + to: dateTime(absoluteRange.to), + }, + }), + [absoluteRange.from, absoluteRange.to] + ); const plugin = getPanelPluginMeta(pluginId); + const dataLinkPostProcessor = useExploreDataLinkPostProcessor(splitOpenFn, timeRange); + + const panelContext: PanelContext = { + dataLinkPostProcessor, + eventBus, + eventsScope: 'explore', + }; + return ( - - {(innerWidth, innerHeight) => ( - - )} - + + + {(innerWidth, innerHeight) => ( + + )} + + ); } diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index db0924b37fe..4df94b31c15 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -326,7 +326,7 @@ export class Explore extends React.PureComponent { } renderCustom(width: number) { - const { timeZone, queryResponse, absoluteRange } = this.props; + const { timeZone, queryResponse, absoluteRange, eventBus } = this.props; const groupedByPlugin = groupBy(queryResponse?.customFrames, 'meta.preferredVisualisationPluginId'); @@ -341,6 +341,8 @@ export class Explore extends React.PureComponent { absoluteRange={absoluteRange} height={400} width={width} + splitOpenFn={this.onSplitOpen(pluginId)} + eventBus={eventBus} /> ); }); diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index 792344b37b8..b9364bb77f9 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -42,6 +42,7 @@ import { Options as TimeSeriesOptions } from 'app/plugins/panel/timeseries/panel import { ExploreGraphStyle } from 'app/types'; import { seriesVisibilityConfigFactory } from '../../dashboard/dashgrid/SeriesVisibilityConfigFactory'; +import { useExploreDataLinkPostProcessor } from '../hooks/useExploreDataLinkPostProcessor'; import { applyGraphStyle, applyThresholdsConfig } from './exploreGraphStyleUtils'; import { useStructureRev } from './useStructureRev'; @@ -91,14 +92,17 @@ export function ExploreGraph({ const style = useStyles2(getStyles); const [showAllTimeSeries, setShowAllTimeSeries] = useState(false); - const timeRange = { - from: dateTime(absoluteRange.from), - to: dateTime(absoluteRange.to), - raw: { + const timeRange = useMemo( + () => ({ from: dateTime(absoluteRange.from), to: dateTime(absoluteRange.to), - }, - }; + raw: { + from: dateTime(absoluteRange.from), + to: dateTime(absoluteRange.to), + }, + }), + [absoluteRange.from, absoluteRange.to] + ); const fieldConfigRegistry = useMemo( () => createFieldConfigRegistry(getGraphFieldConfig(defaultGraphConfig), 'Explore'), @@ -126,6 +130,8 @@ export function ExploreGraph({ return applyThresholdsConfig(withGraphStyle, thresholdsStyle, thresholdsConfig); }, [fieldConfig, graphStyle, yAxisMaximum, thresholdsConfig, thresholdsStyle]); + const dataLinkPostProcessor = useExploreDataLinkPostProcessor(splitOpenFn, timeRange); + const dataWithConfig = useMemo(() => { return applyFieldOverrides({ fieldConfig: styledFieldConfig, @@ -134,8 +140,23 @@ export function ExploreGraph({ replaceVariables: (value) => value, // We don't need proper replace here as it is only used in getLinks and we use getFieldLinks theme, fieldConfigRegistry, + dataLinkPostProcessor, }); - }, [fieldConfigRegistry, data, timeZone, theme, styledFieldConfig, showAllTimeSeries]); + }, [fieldConfigRegistry, data, timeZone, theme, styledFieldConfig, showAllTimeSeries, dataLinkPostProcessor]); + + const annotationsWithConfig = useMemo(() => { + return applyFieldOverrides({ + fieldConfig: { + defaults: {}, + overrides: [], + }, + data: annotations, + timeZone, + replaceVariables: (value) => value, + theme, + dataLinkPostProcessor, + }); + }, [annotations, timeZone, theme, dataLinkPostProcessor]); const structureRev = useStructureRev(dataWithConfig); @@ -156,10 +177,10 @@ export function ExploreGraph({ eventsScope: 'explore', eventBus, sync: () => DashboardCursorSync.Crosshair, - onSplitOpen: splitOpenFn, onToggleSeriesVisibility(label: string, mode: SeriesVisibilityChangeMode) { setFieldConfig(seriesVisibilityConfigFactory(label, mode, fieldConfig, data)); }, + dataLinkPostProcessor, }; const panelOptions: TimeSeriesOptions = useMemo( @@ -192,7 +213,13 @@ export function ExploreGraph({ )} { - return getFieldLinksForExplore({ - field, - rowIndex: config.valueRowIndex!, - splitOpenFn, - range, - dataFrame: frame!, - }); - }; - } - } } const mainFrame = this.getMainFrame(dataFrames); diff --git a/public/app/features/explore/Table/TableContainer.tsx b/public/app/features/explore/Table/TableContainer.tsx index 8c1bb2afc31..0be3439a309 100644 --- a/public/app/features/explore/Table/TableContainer.tsx +++ b/public/app/features/explore/Table/TableContainer.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { ValueLinkConfig, applyFieldOverrides, TimeZone, SplitOpen, DataFrame, LoadingState } from '@grafana/data'; +import { applyFieldOverrides, TimeZone, SplitOpen, DataFrame, LoadingState } from '@grafana/data'; import { Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; import { config } from 'app/core/config'; import { StoreState } from 'app/types'; @@ -9,7 +9,7 @@ import { ExploreItemState } from 'app/types/explore'; import { MetaInfoText } from '../MetaInfoText'; import { selectIsWaitingForData } from '../state/query'; -import { getFieldLinksForExplore } from '../utils/links'; +import { exploreDataLinkPostProcessorFactory } from '../utils/links'; interface TableContainerProps { ariaLabel?: string; @@ -52,6 +52,8 @@ export class TableContainer extends PureComponent { let dataFrames = tableResult; + const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range); + if (dataFrames?.length) { dataFrames = applyFieldOverrides({ data: dataFrames, @@ -62,23 +64,8 @@ export class TableContainer extends PureComponent { defaults: {}, overrides: [], }, + dataLinkPostProcessor, }); - // Bit of code smell here. We need to add links here to the frame modifying the frame on every render. - // Should work fine in essence but still not the ideal way to pass props. In logs container we do this - // differently and sidestep this getLinks API on a dataframe - for (const frame of dataFrames) { - for (const field of frame.fields) { - field.getLinks = (config: ValueLinkConfig) => { - return getFieldLinksForExplore({ - field, - rowIndex: config.valueRowIndex!, - splitOpenFn, - range, - dataFrame: frame!, - }); - }; - } - } } // move dataframes to be grouped by table, with optional sub-tables for a row diff --git a/public/app/features/explore/hooks/useExploreDataLinkPostProcessor.ts b/public/app/features/explore/hooks/useExploreDataLinkPostProcessor.ts new file mode 100644 index 00000000000..fcca52c1b10 --- /dev/null +++ b/public/app/features/explore/hooks/useExploreDataLinkPostProcessor.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; + +import { SplitOpen, TimeRange } from '@grafana/data'; + +import { exploreDataLinkPostProcessorFactory } from '../utils/links'; + +export const useExploreDataLinkPostProcessor = (splitOpenFn: SplitOpen, timeRange: TimeRange) => { + return useMemo(() => { + return exploreDataLinkPostProcessorFactory(splitOpenFn, timeRange); + }, [splitOpenFn, timeRange]); +}; diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 64d0e54d460..a2aecbe8f0a 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -105,8 +105,12 @@ describe('explore links utils', () => { ); expect(links[0].title).toBe('test_ds'); + const preventDefault = jest.fn(); + if (links[0].onClick) { - links[0].onClick({}); + links[0].onClick({ + preventDefault, + }); } expect(splitfn).toBeCalledWith({ @@ -120,6 +124,8 @@ describe('explore links utils', () => { }, }); + expect(preventDefault).toBeCalled(); + expect(reportInteraction).toBeCalledWith('grafana_data_link_clicked', { app: CoreApp.Explore, internal: true, diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index c308b882484..cf3ed748e95 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -1,4 +1,4 @@ -import { uniqBy } from 'lodash'; +import { first, uniqBy } from 'lodash'; import { useCallback } from 'react'; import { @@ -16,6 +16,7 @@ import { DataLinkConfigOrigin, CoreApp, SplitOpenOptions, + DataLinkPostProcessor, } from '@grafana/data'; import { getTemplateSrv, reportInteraction, VariableInterpolation } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; @@ -49,6 +50,41 @@ export interface ExploreFieldLinkModel extends LinkModel { 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 @@ -58,6 +94,7 @@ const DATA_LINK_USAGE_KEY = 'grafana_data_link_clicked'; * * 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; @@ -66,6 +103,8 @@ export const getFieldLinksForExplore = (options: { 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 || {}) }; @@ -108,8 +147,10 @@ export const getFieldLinksForExplore = (options: { }; } - if (field.config.links) { - const links = field.config.links.filter((link) => { + const linksToProcess = options.linksToProcess || field.config.links; + + if (linksToProcess) { + const links = linksToProcess.filter((link) => { return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars)); }); diff --git a/public/app/features/panel/components/PanelRenderer.tsx b/public/app/features/panel/components/PanelRenderer.tsx index dc2c43decd0..01217f1b5e1 100644 --- a/public/app/features/panel/components/PanelRenderer.tsx +++ b/public/app/features/panel/components/PanelRenderer.tsx @@ -10,7 +10,7 @@ import { useFieldOverrides, } from '@grafana/data'; import { getTemplateSrv, PanelRendererProps } from '@grafana/runtime'; -import { ErrorBoundaryAlert, useTheme2 } from '@grafana/ui'; +import { ErrorBoundaryAlert, usePanelContext, useTheme2 } from '@grafana/ui'; import { appEvents } from 'app/core/core'; import { importPanelPlugin, syncGetPanelPlugin } from '../../plugins/importPanelPlugin'; @@ -38,7 +38,16 @@ export function PanelRenderer

(pr const [plugin, setPlugin] = useState(syncGetPanelPlugin(pluginId)); const [error, setError] = useState(); const optionsWithDefaults = useOptionDefaults(plugin, options, fieldConfig); - const dataWithOverrides = useFieldOverrides(plugin, optionsWithDefaults?.fieldConfig, data, timeZone, theme, replace); + const { dataLinkPostProcessor } = usePanelContext(); + const dataWithOverrides = useFieldOverrides( + plugin, + optionsWithDefaults?.fieldConfig, + data, + timeZone, + theme, + replace, + dataLinkPostProcessor + ); useEffect(() => { // If we already have a plugin and it's correct one do nothing diff --git a/public/app/features/query/state/PanelQueryRunner.ts b/public/app/features/query/state/PanelQueryRunner.ts index 5941735cd56..3a2eb61698a 100644 --- a/public/app/features/query/state/PanelQueryRunner.ts +++ b/public/app/features/query/state/PanelQueryRunner.ts @@ -179,6 +179,16 @@ export class PanelQueryRunner { ...fieldConfig!, }), }; + if (processedData.annotations) { + processedData.annotations = applyFieldOverrides({ + data: processedData.annotations, + ...fieldConfig!, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }); + } isFirstPacket = false; } } diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index 076f9d9a515..0a78079f6f4 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -11,7 +11,6 @@ import { TimeSeries, TooltipPlugin, UPlotConfigBuilder, usePanelContext, useThem import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; import { config } from 'app/core/config'; -import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; @@ -38,12 +37,7 @@ export const CandlestickPanel = ({ onChangeTimeRange, replaceVariables, }: CandlestickPanelProps) => { - const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds, onSplitOpen } = - usePanelContext(); - - const getFieldLinks = (field: Field, rowIndex: number) => { - return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); - }; + const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds } = usePanelContext(); const theme = useTheme2(); @@ -316,14 +310,7 @@ export const CandlestickPanel = ({ defaultItems={[]} /> )} - {data.annotations && ( - - )} + {data.annotations && } {((canEditThresholds && onThresholdsChange) || showThresholds) && ( { - const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds, onSplitOpen } = + const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds, dataLinkPostProcessor } = usePanelContext(); - const getFieldLinks = (field: Field, rowIndex: number) => { - return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); - }; - const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data.series, timeRange]); const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); const suggestions = useMemo(() => { @@ -80,7 +75,13 @@ export const TimeSeriesPanel = ({ > {(config, alignedDataFrame) => { if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) { - alignedDataFrame = regenerateLinksSupplier(alignedDataFrame, frames, replaceVariables, timeZone); + alignedDataFrame = regenerateLinksSupplier( + alignedDataFrame, + frames, + replaceVariables, + timeZone, + dataLinkPostProcessor + ); } return ( @@ -149,7 +150,6 @@ export const TimeSeriesPanel = ({ config={config} exemplars={data.annotations} timeZone={timeZone} - getFieldLinks={getFieldLinks} /> )} diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx index 80826e0d7e4..cda2b2b5f9b 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx @@ -9,7 +9,6 @@ import { Field, FieldType, GrafanaTheme2, - LinkModel, systemDateFormats, TimeZone, } from '@grafana/data'; @@ -23,7 +22,6 @@ interface ExemplarMarkerProps { dataFrame: DataFrame; dataFrameFieldIndex: DataFrameFieldIndex; config: UPlotConfigBuilder; - getFieldLinks: (field: Field, rowIndex: number) => Array>; exemplarColor?: string; clickedExemplarFieldIndex: DataFrameFieldIndex | undefined; setClickedExemplarFieldIndex: React.Dispatch; @@ -34,7 +32,6 @@ export const ExemplarMarker = ({ dataFrame, dataFrameFieldIndex, config, - getFieldLinks, exemplarColor, clickedExemplarFieldIndex, setClickedExemplarFieldIndex, @@ -160,10 +157,10 @@ export const ExemplarMarker = ({

- {orderedDataFrameFields.map((field, i) => { + {orderedDataFrameFields.map((field: Field, i) => { const value = field.values[dataFrameFieldIndex.fieldIndex]; const links = field.config.links?.length - ? getFieldLinks(field, dataFrameFieldIndex.fieldIndex) + ? field.getLinks?.({ valueRowIndex: dataFrameFieldIndex.fieldIndex }) : undefined; return ( @@ -187,7 +184,6 @@ export const ExemplarMarker = ({ }, [ attributes.popper, dataFrame.fields, - getFieldLinks, dataFrameFieldIndex, onMouseEnter, onMouseLeave, diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx index aa787148c96..321dbfb1cec 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx @@ -4,9 +4,7 @@ import uPlot from 'uplot'; import { DataFrame, DataFrameFieldIndex, - Field, Labels, - LinkModel, TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME, TimeZone, @@ -19,17 +17,10 @@ interface ExemplarsPluginProps { config: UPlotConfigBuilder; exemplars: DataFrame[]; timeZone: TimeZone; - getFieldLinks: (field: Field, rowIndex: number) => Array>; visibleSeries?: VisibleExemplarLabels; } -export const ExemplarsPlugin = ({ - exemplars, - timeZone, - getFieldLinks, - config, - visibleSeries, -}: ExemplarsPluginProps) => { +export const ExemplarsPlugin = ({ exemplars, timeZone, config, visibleSeries }: ExemplarsPluginProps) => { const plotInstance = useRef(); const [lockedExemplarFieldIndex, setLockedExemplarFieldIndex] = useState(); @@ -88,7 +79,6 @@ export const ExemplarsPlugin = ({ setClickedExemplarFieldIndex={setLockedExemplarFieldIndex} clickedExemplarFieldIndex={lockedExemplarFieldIndex} timeZone={timeZone} - getFieldLinks={getFieldLinks} dataFrame={dataFrame} dataFrameFieldIndex={dataFrameFieldIndex} config={config} @@ -96,7 +86,7 @@ export const ExemplarsPlugin = ({ /> ); }, - [config, timeZone, getFieldLinks, visibleSeries, setLockedExemplarFieldIndex, lockedExemplarFieldIndex] + [config, timeZone, visibleSeries, setLockedExemplarFieldIndex, lockedExemplarFieldIndex] ); return ( diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index 83baf64b0da..27cb4845a6a 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -6,6 +6,7 @@ import { getDisplayProcessor, getLinksSupplier, GrafanaTheme2, + DataLinkPostProcessor, InterpolateFunction, isBooleanUnit, SortedVector, @@ -268,7 +269,8 @@ export function regenerateLinksSupplier( alignedDataFrame: DataFrame, frames: DataFrame[], replaceVariables: InterpolateFunction, - timeZone: string + timeZone: string, + dataLinkPostProcessor?: DataLinkPostProcessor ): DataFrame { alignedDataFrame.fields.forEach((field) => { if (field.state?.origin?.frameIndex === undefined || frames[field.state?.origin?.frameIndex] === undefined) { @@ -297,7 +299,14 @@ export function regenerateLinksSupplier( length: alignedDataFrame.fields.length + tempFields.length, }; - field.getLinks = getLinksSupplier(tempFrame, field, field.state!.scopedVars!, replaceVariables, timeZone); + field.getLinks = getLinksSupplier( + tempFrame, + field, + field.state!.scopedVars!, + replaceVariables, + timeZone, + dataLinkPostProcessor + ); }); return alignedDataFrame; diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx index ccddf9b953b..541c72a0cb0 100644 --- a/public/app/plugins/panel/trend/TrendPanel.tsx +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -30,7 +30,7 @@ export const TrendPanel = ({ replaceVariables, id, }: PanelProps) => { - const { sync } = usePanelContext(); + const { sync, dataLinkPostProcessor } = usePanelContext(); // Need to fallback to first number field if no xField is set in options otherwise panel crashes 😬 const trendXFieldName = options.xField ?? data.series[0].fields.find((field) => field.type === FieldType.number)?.name; @@ -116,7 +116,13 @@ export const TrendPanel = ({ > {(config, alignedDataFrame) => { if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) { - alignedDataFrame = regenerateLinksSupplier(alignedDataFrame, info.frames!, replaceVariables, timeZone); + alignedDataFrame = regenerateLinksSupplier( + alignedDataFrame, + info.frames!, + replaceVariables, + timeZone, + dataLinkPostProcessor + ); } return (