From 42b55aedbc3e35ed426ff684347492cbdbea3314 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 29 Feb 2024 13:46:53 -0600 Subject: [PATCH] DataLinks: Handle getLinks() regen during data updates and frame joining (#83654) --- .../app/core/components/GraphNG/GraphNG.tsx | 66 +++++++++++++++---- .../TimelineChart/TimelineChart.tsx | 1 + .../rules/state-history/LogTimelineViewer.tsx | 6 +- .../plugins/panel/barchart/BarChartPanel.tsx | 6 +- .../panel/candlestick/CandlestickPanel.tsx | 5 +- .../state-timeline/StateTimelinePanel.tsx | 4 +- .../status-history/StatusHistoryPanel.tsx | 5 +- .../app/plugins/panel/status-history/utils.ts | 2 +- .../panel/timeseries/TimeSeriesPanel.tsx | 14 +--- public/app/plugins/panel/timeseries/utils.ts | 40 ----------- public/app/plugins/panel/trend/TrendPanel.tsx | 14 +--- .../panel/xychart/XYChartTooltip.test.tsx | 10 ++- 12 files changed, 91 insertions(+), 82 deletions(-) diff --git a/public/app/core/components/GraphNG/GraphNG.tsx b/public/app/core/components/GraphNG/GraphNG.tsx index b4b5974d55d..9c53722f21f 100644 --- a/public/app/core/components/GraphNG/GraphNG.tsx +++ b/public/app/core/components/GraphNG/GraphNG.tsx @@ -7,10 +7,13 @@ import { DataFrame, DataHoverClearEvent, DataHoverEvent, + DataLinkPostProcessor, Field, FieldMatcherID, fieldMatchers, FieldType, + getLinksSupplier, + InterpolateFunction, LegacyGraphHoverEvent, TimeRange, TimeZone, @@ -49,6 +52,8 @@ export interface GraphNGProps extends Themeable2 { propsToDiff?: Array; preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null; renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null; + replaceVariables: InterpolateFunction; + dataLinkPostProcessor?: DataLinkPostProcessor; /** * needed for propsToDiff to re-init the plot & config @@ -105,30 +110,67 @@ export class GraphNG extends Component { prepState(props: GraphNGProps, withConfig = true) { let state: GraphNGState = null as any; - const { frames, fields, preparePlotFrame } = props; + const { frames, fields, preparePlotFrame, replaceVariables, dataLinkPostProcessor } = props; - const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame; + const preparePlotFrameFn = preparePlotFrame ?? defaultPreparePlotFrame; + + const matchY = fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])); + + // if there are data links, we have to keep all fields so they're index-matched, then filter out dimFields.y + const withLinks = frames.some((frame) => frame.fields.some((field) => (field.config.links?.length ?? 0) > 0)); + + const dimFields = fields ?? { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: withLinks ? () => true : matchY, + }; + + const alignedFrame = preparePlotFrameFn(frames, dimFields, props.timeRange); - const alignedFrame = preparePlotFrameFn( - frames, - fields || { - x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), - y: fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])), - }, - props.timeRange - ); pluginLog('GraphNG', false, 'data aligned', alignedFrame); if (alignedFrame) { + let alignedFrameFinal = alignedFrame; + + if (withLinks) { + const timeZone = Array.isArray(this.props.timeZone) ? this.props.timeZone[0] : this.props.timeZone; + + alignedFrame.fields.forEach((field) => { + field.getLinks = getLinksSupplier( + alignedFrame, + field, + { + ...field.state?.scopedVars, + __dataContext: { + value: { + data: [alignedFrame], + field: field, + frame: alignedFrame, + frameIndex: 0, + }, + }, + }, + replaceVariables, + timeZone, + dataLinkPostProcessor + ); + }); + + // filter join field and dimFields.y + alignedFrameFinal = { + ...alignedFrame, + fields: alignedFrame.fields.filter((field, i) => i === 0 || matchY(field, alignedFrame, [alignedFrame])), + }; + } + let config = this.state?.config; if (withConfig) { - config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); + config = props.prepConfig(alignedFrameFinal, this.props.frames, this.getTimeRange); pluginLog('GraphNG', false, 'config prepared', config); } state = { - alignedFrame, + alignedFrame: alignedFrameFinal, config, }; diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index c22e3230dac..9d2474a4611 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -93,6 +93,7 @@ export class TimelineChart extends React.Component { prepConfig={this.prepConfig} propsToDiff={propsToDiff} renderLegend={this.renderLegend} + dataLinkPostProcessor={this.panelContext?.dataLinkPostProcessor} /> ); } diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx index b0d2d286b50..f0e2d72fc3b 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogTimelineViewer.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { BehaviorSubject } from 'rxjs'; -import { DataFrame, TimeRange } from '@grafana/data'; +import { DataFrame, InterpolateFunction, TimeRange } from '@grafana/data'; import { VisibilityMode } from '@grafana/schema'; import { LegendDisplayMode, UPlotConfigBuilder, useTheme2 } from '@grafana/ui'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; @@ -15,6 +15,9 @@ interface LogTimelineViewerProps { onPointerMove?: (seriesIdx: number, pointerIdx: number) => void; } +// noop +const replaceVariables: InterpolateFunction = (v) => v; + export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove = noop }: LogTimelineViewerProps) => { const theme = useTheme2(); const { setupCursorTracking } = useCursorTimelinePosition(onPointerMove); @@ -45,6 +48,7 @@ export const LogTimelineViewer = React.memo(({ frames, timeRange, onPointerMove { label: 'NoData', color: theme.colors.info.main, yAxis: 1 }, { label: 'Mixed', color: theme.colors.text.secondary, yAxis: 1 }, ]} + replaceVariables={replaceVariables} > {(builder) => { setupCursorTracking(builder); diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index b5d2b1ad4b7..abdeb0c0822 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -69,9 +69,9 @@ const propsToDiff: Array = [ interface Props extends PanelProps {} -export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZone, id }: Props) => { +export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZone, id, replaceVariables }: Props) => { const theme = useTheme2(); - const { eventBus } = usePanelContext(); + const { eventBus, dataLinkPostProcessor } = usePanelContext(); const oldConfig = useRef(undefined); const isToolTipOpen = useRef(false); @@ -326,6 +326,8 @@ export const BarChartPanel = ({ data, options, fieldConfig, width, height, timeZ structureRev={structureRev} width={width} height={height} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(config) => { if (showNewVizTooltips && options.tooltip.mode !== TooltipDisplayMode.None) { diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index fa71996e3b6..7008948b7ed 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -42,7 +42,8 @@ export const CandlestickPanel = ({ onChangeTimeRange, replaceVariables, }: CandlestickPanelProps) => { - const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds } = usePanelContext(); + const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds, dataLinkPostProcessor } = + usePanelContext(); const theme = useTheme2(); @@ -256,6 +257,8 @@ export const CandlestickPanel = ({ tweakAxis={tweakAxis} tweakScale={tweakScale} options={options} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(uplotConfig, alignedDataFrame) => { alignedDataFrame.fields.forEach((field) => { diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index 0cdc5c0ec96..f613e935d49 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -70,7 +70,7 @@ export const StateTimelinePanel = ({ const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 const [newAnnotationRange, setNewAnnotationRange] = useState(null); - const { sync, canAddAnnotations } = usePanelContext(); + const { sync, canAddAnnotations, dataLinkPostProcessor } = usePanelContext(); const onCloseToolTip = () => { isToolTipOpen.current = false; @@ -184,6 +184,8 @@ export const StateTimelinePanel = ({ legendItems={legendItems} {...options} mode={TimelineMode.Changes} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(builder, alignedFrame) => { if (oldConfig.current !== builder && !showNewVizTooltips) { diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 29192589c10..560c80e8714 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -45,6 +45,7 @@ export const StatusHistoryPanel = ({ options, width, height, + replaceVariables, onChangeTimeRange, }: TimelinePanelProps) => { const theme = useTheme2(); @@ -67,7 +68,7 @@ export const StatusHistoryPanel = ({ const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); // temp range set for adding new annotation set by TooltipPlugin2, consumed by AnnotationPlugin2 const [newAnnotationRange, setNewAnnotationRange] = useState(null); - const { sync, canAddAnnotations } = usePanelContext(); + const { sync, canAddAnnotations, dataLinkPostProcessor } = usePanelContext(); const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); @@ -213,6 +214,8 @@ export const StatusHistoryPanel = ({ legendItems={legendItems} {...options} mode={TimelineMode.Samples} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(builder, alignedFrame) => { if (oldConfig.current !== builder && !showNewVizTooltips) { diff --git a/public/app/plugins/panel/status-history/utils.ts b/public/app/plugins/panel/status-history/utils.ts index 735ef03a5ef..946424eb2ec 100644 --- a/public/app/plugins/panel/status-history/utils.ts +++ b/public/app/plugins/panel/status-history/utils.ts @@ -4,7 +4,7 @@ export const getDataLinks = (field: Field, rowIdx: number) => { const links: Array> = []; const linkLookup = new Set(); - if (field.getLinks) { + if ((field.config.links?.length ?? 0) > 0 && field.getLinks != null) { const v = field.values[rowIdx]; const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; field.getLinks({ calculatedValue: disp, valueRowIndex: rowIdx }).forEach((link) => { diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 36480a53d57..1a4ed79cdd9 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -18,7 +18,7 @@ import { ExemplarsPlugin, getVisibleLabels } from './plugins/ExemplarsPlugin'; import { OutsideRangePlugin } from './plugins/OutsideRangePlugin'; import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin'; import { getPrepareTimeseriesSuggestion } from './suggestions'; -import { getTimezones, isTooltipScrollable, prepareGraphableFields, regenerateLinksSupplier } from './utils'; +import { getTimezones, isTooltipScrollable, prepareGraphableFields } from './utils'; interface TimeSeriesPanelProps extends PanelProps {} @@ -96,18 +96,10 @@ export const TimeSeriesPanel = ({ height={height} legend={options.legend} options={options} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(uplotConfig, alignedDataFrame) => { - if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) { - alignedDataFrame = regenerateLinksSupplier( - alignedDataFrame, - frames, - replaceVariables, - timeZone, - dataLinkPostProcessor - ); - } - return ( <> diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index 8889a49412e..3f9212ee4d7 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -3,10 +3,7 @@ import { Field, FieldType, getDisplayProcessor, - getLinksSupplier, GrafanaTheme2, - DataLinkPostProcessor, - InterpolateFunction, isBooleanUnit, TimeRange, cacheFieldDisplayNames, @@ -266,43 +263,6 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s return timezones.map((v) => (v?.length ? v : defaultTimezone)); } -export function regenerateLinksSupplier( - alignedDataFrame: DataFrame, - frames: DataFrame[], - replaceVariables: InterpolateFunction, - timeZone: string, - dataLinkPostProcessor?: DataLinkPostProcessor -): DataFrame { - alignedDataFrame.fields.forEach((field) => { - if (field.state?.origin?.frameIndex === undefined || frames[field.state?.origin?.frameIndex] === undefined) { - return; - } - - const tempFields: Field[] = []; - for (const frameField of frames[field.state?.origin?.frameIndex].fields) { - if (frameField.type === FieldType.string) { - tempFields.push(frameField); - } - } - - const tempFrame: DataFrame = { - fields: [...alignedDataFrame.fields, ...tempFields], - length: alignedDataFrame.fields.length + tempFields.length, - }; - - field.getLinks = getLinksSupplier( - tempFrame, - field, - field.state!.scopedVars!, - replaceVariables, - timeZone, - dataLinkPostProcessor - ); - }); - - return alignedDataFrame; -} - export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions) => { return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null; }; diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx index 4d8d20db97f..cb90f861f3e 100644 --- a/public/app/plugins/panel/trend/TrendPanel.tsx +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -11,7 +11,7 @@ import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { findFieldIndex } from 'app/features/dimensions'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; -import { isTooltipScrollable, prepareGraphableFields, regenerateLinksSupplier } from '../timeseries/utils'; +import { isTooltipScrollable, prepareGraphableFields } from '../timeseries/utils'; import { Options } from './panelcfg.gen'; @@ -109,18 +109,10 @@ export const TrendPanel = ({ legend={options.legend} options={options} preparePlotFrame={preparePlotFrameTimeless} + replaceVariables={replaceVariables} + dataLinkPostProcessor={dataLinkPostProcessor} > {(uPlotConfig, alignedDataFrame) => { - if (alignedDataFrame.fields.some((f) => Boolean(f.config.links?.length))) { - alignedDataFrame = regenerateLinksSupplier( - alignedDataFrame, - info.frames!, - replaceVariables, - timeZone, - dataLinkPostProcessor - ); - } - return ( <> diff --git a/public/app/plugins/panel/xychart/XYChartTooltip.test.tsx b/public/app/plugins/panel/xychart/XYChartTooltip.test.tsx index f9b301b064e..b8999620597 100644 --- a/public/app/plugins/panel/xychart/XYChartTooltip.test.tsx +++ b/public/app/plugins/panel/xychart/XYChartTooltip.test.tsx @@ -154,7 +154,15 @@ function buildData({ dataLinkTitle = 'Grafana', field1Name = 'field_1', field2Na { name: field2Name, type: FieldType.number, - config: {}, + config: { + links: [ + { + title: dataLinkTitle, + targetBlank: true, + url: 'http://www.someWebsite.com', + }, + ], + }, values: [500, 300, 150, 250, 600, 500, 700, 400, 540, 630, 460, 250, 500, 400, 800, 930, 360], getLinks: (_config: ValueLinkConfig) => [ {