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>
This commit is contained in:
Piotr Jamróz 2023-07-31 14:10:03 +02:00 committed by GitHub
parent 58a2b5d64d
commit 2ae226de89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 331 additions and 161 deletions

View File

@ -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<LinkModel<Field>> => {
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<Field>;
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<Field> = {
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]);
}
/**

View File

@ -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<TOptions = any, TValue = any, TSettings
shouldApply: (field: Field) => 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<Field> | 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 {

View File

@ -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],

View File

@ -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<boolean>;
/**
* 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<PanelContext>({

View File

@ -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 (
<PanelChrome title={plugin.name} width={width} height={height} loadingState={state}>
{(innerWidth, innerHeight) => (
<PanelRenderer
data={{ series: frames, state: state, timeRange }}
pluginId={pluginId}
title=""
width={innerWidth}
height={innerHeight}
timeZone={timeZone}
/>
)}
</PanelChrome>
<PanelContextProvider value={panelContext}>
<PanelChrome title={plugin.name} width={width} height={height} loadingState={state}>
{(innerWidth, innerHeight) => (
<PanelRenderer
data={{ series: frames, state: state, timeRange }}
pluginId={pluginId}
title=""
width={innerWidth}
height={innerHeight}
timeZone={timeZone}
/>
)}
</PanelChrome>
</PanelContextProvider>
);
}

View File

@ -326,7 +326,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}
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<Props, ExploreState> {
absoluteRange={absoluteRange}
height={400}
width={width}
splitOpenFn={this.onSplitOpen(pluginId)}
eventBus={eventBus}
/>
);
});

View File

@ -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({
</div>
)}
<PanelRenderer
data={{ series: dataWithConfig, timeRange, state: loadingState, annotations, structureRev }}
data={{
series: dataWithConfig,
timeRange,
state: loadingState,
annotations: annotationsWithConfig,
structureRev,
}}
pluginId="timeseries"
title=""
width={width}

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen, TimeZone, ValueLinkConfig } from '@grafana/data';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen, TimeZone } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime/src';
import { Collapse, RadioButtonGroup, Table, AdHocFilterItem } from '@grafana/ui';
import { config } from 'app/core/config';
@ -13,7 +13,7 @@ import { ExploreItemState, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/t
import { MetaInfoText } from '../MetaInfoText';
import RawListContainer from '../PrometheusListView/RawListContainer';
import { selectIsWaitingForData } from '../state/query';
import { getFieldLinksForExplore } from '../utils/links';
import { exploreDataLinkPostProcessorFactory } from '../utils/links';
interface RawPrometheusContainerProps {
ariaLabel?: string;
@ -118,6 +118,8 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
let dataFrames = tableResult;
const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range);
if (dataFrames?.length) {
dataFrames = applyFieldOverrides({
data: dataFrames,
@ -128,23 +130,8 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
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!,
});
};
}
}
}
const mainFrame = this.getMainFrame(dataFrames);

View File

@ -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<Props> {
let dataFrames = tableResult;
const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range);
if (dataFrames?.length) {
dataFrames = applyFieldOverrides({
data: dataFrames,
@ -62,23 +64,8 @@ export class TableContainer extends PureComponent<Props> {
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

View File

@ -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]);
};

View File

@ -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,

View File

@ -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<Field> {
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));
});

View File

@ -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<P extends object = any, F extends object = any>(pr
const [plugin, setPlugin] = useState(syncGetPanelPlugin(pluginId));
const [error, setError] = useState<string | undefined>();
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

View File

@ -179,6 +179,16 @@ export class PanelQueryRunner {
...fieldConfig!,
}),
};
if (processedData.annotations) {
processedData.annotations = applyFieldOverrides({
data: processedData.annotations,
...fieldConfig!,
fieldConfig: {
defaults: {},
overrides: [],
},
});
}
isFirstPacket = false;
}
}

View File

@ -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 && (
<ExemplarsPlugin
config={config}
exemplars={data.annotations}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
/>
)}
{data.annotations && <ExemplarsPlugin config={config} exemplars={data.annotations} timeZone={timeZone} />}
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
<ThresholdControlsPlugin

View File

@ -1,11 +1,10 @@
import React, { useMemo } from 'react';
import { Field, PanelProps, DataFrameType } from '@grafana/data';
import { PanelProps, DataFrameType } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { KeyboardPlugin, TimeSeries, TooltipPlugin, usePanelContext, ZoomPlugin } from '@grafana/ui';
import { config } from 'app/core/config';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { Options } from './panelcfg.gen';
import { AnnotationEditorPlugin } from './plugins/AnnotationEditorPlugin';
@ -31,13 +30,9 @@ export const TimeSeriesPanel = ({
replaceVariables,
id,
}: TimeSeriesPanelProps) => {
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}
/>
)}

View File

@ -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<LinkModel<Field>>;
exemplarColor?: string;
clickedExemplarFieldIndex: DataFrameFieldIndex | undefined;
setClickedExemplarFieldIndex: React.Dispatch<DataFrameFieldIndex | undefined>;
@ -34,7 +32,6 @@ export const ExemplarMarker = ({
dataFrame,
dataFrameFieldIndex,
config,
getFieldLinks,
exemplarColor,
clickedExemplarFieldIndex,
setClickedExemplarFieldIndex,
@ -160,10 +157,10 @@ export const ExemplarMarker = ({
<div>
<table className={styles.exemplarsTable}>
<tbody>
{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 (
<tr key={i}>
@ -187,7 +184,6 @@ export const ExemplarMarker = ({
}, [
attributes.popper,
dataFrame.fields,
getFieldLinks,
dataFrameFieldIndex,
onMouseEnter,
onMouseLeave,

View File

@ -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<LinkModel<Field>>;
visibleSeries?: VisibleExemplarLabels;
}
export const ExemplarsPlugin = ({
exemplars,
timeZone,
getFieldLinks,
config,
visibleSeries,
}: ExemplarsPluginProps) => {
export const ExemplarsPlugin = ({ exemplars, timeZone, config, visibleSeries }: ExemplarsPluginProps) => {
const plotInstance = useRef<uPlot>();
const [lockedExemplarFieldIndex, setLockedExemplarFieldIndex] = useState<DataFrameFieldIndex | undefined>();
@ -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 (

View File

@ -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;

View File

@ -30,7 +30,7 @@ export const TrendPanel = ({
replaceVariables,
id,
}: PanelProps<Options>) => {
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 (