mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
58a2b5d64d
commit
2ae226de89
@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 {
|
||||
|
@ -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],
|
||||
|
@ -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>({
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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]);
|
||||
};
|
@ -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,
|
||||
|
@ -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));
|
||||
});
|
||||
|
||||
|
@ -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
|
||||
|
@ -179,6 +179,16 @@ export class PanelQueryRunner {
|
||||
...fieldConfig!,
|
||||
}),
|
||||
};
|
||||
if (processedData.annotations) {
|
||||
processedData.annotations = applyFieldOverrides({
|
||||
data: processedData.annotations,
|
||||
...fieldConfig!,
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
isFirstPacket = false;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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 (
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
|
Loading…
Reference in New Issue
Block a user