mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Tempo / Trace Viewer: Implement deep linking to spans
This commit is contained in:
parent
fdeaf7a5c4
commit
ac945fb6e1
@ -644,7 +644,7 @@ describe('getLinksSupplier', () => {
|
||||
expect(links[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'testDS',
|
||||
href: `/explore?left=${encodeURIComponent('{"datasource":"testDS","queries":["12345"]}')}`,
|
||||
href: `/explore?left=${encodeURIComponent('{"datasource":"testDS","queries":["12345"],"panelsState":{}}')}`,
|
||||
onClick: undefined,
|
||||
})
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DataQuery } from './query';
|
||||
import { InterpolateFunction } from './panel';
|
||||
import { ExplorePanelsState } from './explore';
|
||||
|
||||
/**
|
||||
* Callback info for DataLink click events
|
||||
@ -44,6 +45,7 @@ export interface InternalDataLink<T extends DataQuery = any> {
|
||||
query: T;
|
||||
datasourceUid: string;
|
||||
datasourceName: string;
|
||||
panelsState?: ExplorePanelsState;
|
||||
}
|
||||
|
||||
export type LinkTarget = '_blank' | '_self' | undefined;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { PreferredVisualisationType } from './data';
|
||||
import { DataQuery } from './query';
|
||||
import { RawTimeRange, TimeRange } from './time';
|
||||
|
||||
@ -10,11 +11,20 @@ export interface ExploreUrlState<T extends DataQuery = AnyQuery> {
|
||||
range: RawTimeRange;
|
||||
originPanelId?: number;
|
||||
context?: string;
|
||||
panelsState?: ExplorePanelsState;
|
||||
}
|
||||
|
||||
export interface ExplorePanelsState extends Partial<Record<PreferredVisualisationType, {}>> {
|
||||
trace?: ExploreTracePanelState;
|
||||
}
|
||||
|
||||
export interface ExploreTracePanelState {
|
||||
spanId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SplitOpen type is used in Explore and related components.
|
||||
*/
|
||||
export type SplitOpen = <T extends DataQuery = any>(
|
||||
options?: { datasourceUid: string; query: T; range?: TimeRange } | undefined
|
||||
options?: { datasourceUid: string; query: T; range?: TimeRange; panelsState?: ExplorePanelsState } | undefined
|
||||
) => void;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { mapInternalLinkToExplore } from './dataLinks';
|
||||
import { FieldType } from '../types';
|
||||
import { DataLink, FieldType } from '../types';
|
||||
import { ArrayVector } from '../vector';
|
||||
|
||||
describe('mapInternalLinkToExplore', () => {
|
||||
@ -31,7 +31,52 @@ describe('mapInternalLinkToExplore', () => {
|
||||
expect(link).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'dsName',
|
||||
href: `/explore?left=${encodeURIComponent('{"datasource":"dsName","queries":[{"query":"12344"}]}')}`,
|
||||
href: `/explore?left=${encodeURIComponent(
|
||||
'{"datasource":"dsName","queries":[{"query":"12344"}],"panelsState":{}}'
|
||||
)}`,
|
||||
onClick: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes panels state', () => {
|
||||
const panelsState = {
|
||||
trace: {
|
||||
spanId: 'abcdef',
|
||||
},
|
||||
};
|
||||
|
||||
const dataLink: DataLink = {
|
||||
url: '',
|
||||
title: '',
|
||||
internal: {
|
||||
datasourceUid: 'uid',
|
||||
datasourceName: 'dsName',
|
||||
query: { query: '12344' },
|
||||
panelsState,
|
||||
},
|
||||
};
|
||||
|
||||
const link = mapInternalLinkToExplore({
|
||||
link: dataLink,
|
||||
internalLink: dataLink.internal!,
|
||||
scopedVars: {},
|
||||
range: {} as any,
|
||||
field: {
|
||||
name: 'test',
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
values: new ArrayVector([2]),
|
||||
},
|
||||
replaceVariables: (val) => val,
|
||||
});
|
||||
|
||||
expect(link).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'dsName',
|
||||
href: `/explore?left=${encodeURIComponent(
|
||||
'{"datasource":"dsName","queries":[{"query":"12344"}],"panelsState":{"trace":{"spanId":"abcdef"}}}'
|
||||
)}`,
|
||||
onClick: undefined,
|
||||
})
|
||||
);
|
||||
|
@ -1,11 +1,13 @@
|
||||
import {
|
||||
DataLink,
|
||||
DataQuery,
|
||||
ExplorePanelsState,
|
||||
Field,
|
||||
InternalDataLink,
|
||||
InterpolateFunction,
|
||||
LinkModel,
|
||||
ScopedVars,
|
||||
SplitOpen,
|
||||
TimeRange,
|
||||
} from '../types';
|
||||
import { locationUtil } from './location';
|
||||
@ -33,26 +35,28 @@ export type LinkToExploreOptions = {
|
||||
range: TimeRange;
|
||||
field: Field;
|
||||
internalLink: InternalDataLink;
|
||||
onClickFn?: (options: { datasourceUid: string; query: any; range?: TimeRange }) => void;
|
||||
onClickFn?: SplitOpen;
|
||||
replaceVariables: InterpolateFunction;
|
||||
};
|
||||
|
||||
export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkModel<Field> {
|
||||
const { onClickFn, replaceVariables, link, scopedVars, range, field, internalLink } = options;
|
||||
|
||||
const interpolatedQuery = interpolateQuery(link, scopedVars, replaceVariables);
|
||||
const interpolatedQuery = interpolateObject(link.internal?.query, scopedVars, replaceVariables);
|
||||
const interpolatedPanelsState = interpolateObject(link.internal?.panelsState, scopedVars, replaceVariables);
|
||||
const title = link.title ? link.title : internalLink.datasourceName;
|
||||
|
||||
return {
|
||||
title: replaceVariables(title, scopedVars),
|
||||
// In this case this is meant to be internal link (opens split view by default) the href will also points
|
||||
// to explore but this way you can open it in new tab.
|
||||
href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range),
|
||||
href: generateInternalHref(internalLink.datasourceName, interpolatedQuery, range, interpolatedPanelsState),
|
||||
onClick: onClickFn
|
||||
? () => {
|
||||
onClickFn({
|
||||
datasourceUid: internalLink.datasourceUid,
|
||||
query: interpolatedQuery,
|
||||
panelsState: interpolatedPanelsState,
|
||||
range,
|
||||
});
|
||||
}
|
||||
@ -65,26 +69,32 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod
|
||||
/**
|
||||
* Generates href for internal derived field link.
|
||||
*/
|
||||
function generateInternalHref<T extends DataQuery = any>(datasourceName: string, query: T, range: TimeRange): string {
|
||||
function generateInternalHref<T extends DataQuery = any>(
|
||||
datasourceName: string,
|
||||
query: T,
|
||||
range: TimeRange,
|
||||
panelsState?: ExplorePanelsState
|
||||
): string {
|
||||
return locationUtil.assureBaseUrl(
|
||||
`/explore?left=${encodeURIComponent(
|
||||
serializeStateToUrlParam({
|
||||
range: range.raw,
|
||||
datasource: datasourceName,
|
||||
queries: [query],
|
||||
panelsState: panelsState,
|
||||
})
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
function interpolateQuery<T extends DataQuery = any>(
|
||||
link: DataLink,
|
||||
function interpolateObject<T extends object>(
|
||||
object: T | undefined,
|
||||
scopedVars: ScopedVars,
|
||||
replaceVariables: InterpolateFunction
|
||||
): T {
|
||||
let stringifiedQuery = '';
|
||||
try {
|
||||
stringifiedQuery = JSON.stringify(link.internal?.query || '');
|
||||
stringifiedQuery = JSON.stringify(object || {});
|
||||
} catch (err) {
|
||||
// should not happen and not much to do about this, possibly something non stringifiable in the query
|
||||
console.error(err);
|
||||
|
@ -195,9 +195,23 @@ export const urlUtil = {
|
||||
parseKeyValue,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an string that is used in URL to represent the Explore state. This is basically just a stringified json
|
||||
* that is that used as a state of a single Explore pane so it does not represent full Explore URL.
|
||||
*
|
||||
* There are 2 versions of this, normal and compact. Normal is just the same object stringified while compact turns
|
||||
* properties of the object into array where the order is significant.
|
||||
* @param urlState
|
||||
* @param compact
|
||||
*/
|
||||
export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
|
||||
if (compact) {
|
||||
return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
|
||||
const compactState: unknown[] = [urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries];
|
||||
// only serialize panel state if we have at least one non-default panel configuration
|
||||
if (urlState.panelsState !== undefined) {
|
||||
compactState.push({ __panelsState: urlState.panelsState });
|
||||
}
|
||||
return JSON.stringify(compactState);
|
||||
}
|
||||
return JSON.stringify(urlState);
|
||||
}
|
||||
|
@ -246,6 +246,20 @@ export default class ListView extends React.Component<TListViewProps> {
|
||||
getRowPosition = (index: number): { height: number; y: number } =>
|
||||
this._yPositions.getRowPosition(index, this._getHeight);
|
||||
|
||||
scrollToIndex = (index: number) => {
|
||||
// calculate the position of the list view relative to the scroll parent
|
||||
const { scrollElement } = this.props;
|
||||
const scrollElementTop = scrollElement?.getBoundingClientRect().top || 0;
|
||||
const listViewTop = (scrollElement?.scrollTop || 0) + (this._itemHolderElm?.getBoundingClientRect().top || 0);
|
||||
const listViewOffset = listViewTop - scrollElementTop;
|
||||
|
||||
const itemOffset = this.getRowPosition(index).y;
|
||||
|
||||
// hard code a small offset to leave a little bit of space above the focused span, so it is visually clear
|
||||
// that there is content above
|
||||
this.props.scrollElement?.scrollTo({ top: itemOffset + listViewOffset - 80 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll event listener that schedules a remeasuring of which items should be
|
||||
* rendered.
|
||||
|
@ -17,7 +17,7 @@ import IoAlert from 'react-icons/lib/io/alert';
|
||||
import IoArrowRightA from 'react-icons/lib/io/arrow-right-a';
|
||||
import IoNetwork from 'react-icons/lib/io/network';
|
||||
import MdFileUpload from 'react-icons/lib/md/file-upload';
|
||||
import { css } from '@emotion/css';
|
||||
import { css, keyframes } from '@emotion/css';
|
||||
import cx from 'classnames';
|
||||
import { stylesFactory, withTheme2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@ -41,6 +41,18 @@ const viewClassName = 'jaegerView';
|
||||
const nameColumnClassName = 'nameColumn';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
const animations = {
|
||||
flash: keyframes`
|
||||
label: flash;
|
||||
from {
|
||||
background-color: ${autoColor(theme, '#68b9ff')};
|
||||
}
|
||||
to {
|
||||
background-color: default;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return {
|
||||
nameWrapper: css`
|
||||
label: nameWrapper;
|
||||
@ -168,6 +180,25 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
outline: 1px solid ${autoColor(theme, '#ddd')};
|
||||
}
|
||||
`,
|
||||
rowFocused: css`
|
||||
label: rowFocused;
|
||||
background-color: ${autoColor(theme, '#cbe7ff')};
|
||||
animation: ${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0);
|
||||
& .${nameWrapperClassName}, .${viewClassName}, .${nameWrapperMatchingFilterClassName} {
|
||||
background-color: ${autoColor(theme, '#cbe7ff')};
|
||||
animation: ${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0);
|
||||
}
|
||||
& .${spanBarClassName} {
|
||||
opacity: 1;
|
||||
}
|
||||
& .${spanBarLabelClassName} {
|
||||
color: ${autoColor(theme, '#000')};
|
||||
}
|
||||
&:hover .${nameWrapperClassName}, :hover .${viewClassName} {
|
||||
background: ${autoColor(theme, '#d5ebff')};
|
||||
box-shadow: 0 1px 0 ${autoColor(theme, '#ddd')};
|
||||
}
|
||||
`,
|
||||
|
||||
rowExpandedAndMatchingFilter: css`
|
||||
label: rowExpandedAndMatchingFilter;
|
||||
@ -268,6 +299,7 @@ type SpanBarRowProps = {
|
||||
isChildrenExpanded: boolean;
|
||||
isDetailExpanded: boolean;
|
||||
isMatchingFilter: boolean;
|
||||
isFocused: boolean;
|
||||
onDetailToggled: (spanID: string) => void;
|
||||
onChildrenToggled: (spanID: string) => void;
|
||||
numTicks: number;
|
||||
@ -330,6 +362,7 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
|
||||
isChildrenExpanded,
|
||||
isDetailExpanded,
|
||||
isMatchingFilter,
|
||||
isFocused,
|
||||
numTicks,
|
||||
rpc,
|
||||
noInstrumentedServer,
|
||||
@ -377,6 +410,7 @@ export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
|
||||
[styles.rowExpanded]: isDetailExpanded,
|
||||
[styles.rowMatchingFilter]: isMatchingFilter,
|
||||
[styles.rowExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded,
|
||||
[styles.rowFocused]: isFocused,
|
||||
[styles.rowClippingLeft]: clippingLeft,
|
||||
[styles.rowClippingRight]: clippingRight,
|
||||
},
|
||||
|
@ -22,7 +22,6 @@ import AccordianLogs from './AccordianLogs';
|
||||
import DetailState from './DetailState';
|
||||
import SpanDetail from './index';
|
||||
import { formatDuration } from '../utils';
|
||||
import CopyIcon from '../../common/CopyIcon';
|
||||
import LabeledList from '../../common/LabeledList';
|
||||
import traceGenerator from '../../demo/trace-generators';
|
||||
import transformTraceData from '../../model/transform-trace-data';
|
||||
@ -44,6 +43,7 @@ describe('<SpanDetail>', () => {
|
||||
tagsToggle: jest.fn(),
|
||||
warningsToggle: jest.fn(),
|
||||
referencesToggle: jest.fn(),
|
||||
createFocusSpanLink: jest.fn(),
|
||||
};
|
||||
span.logs = [
|
||||
{
|
||||
@ -180,7 +180,7 @@ describe('<SpanDetail>', () => {
|
||||
expect(props.referencesToggle).toHaveBeenLastCalledWith(span.spanID);
|
||||
});
|
||||
|
||||
it('renders CopyIcon with deep link URL', () => {
|
||||
expect(wrapper.find(CopyIcon).prop('copyText').includes(`?uiFind=${props.span.spanID}`)).toBe(true);
|
||||
it('renders deep link URL', () => {
|
||||
expect(wrapper.find('a').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
@ -16,21 +16,30 @@ import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import cx from 'classnames';
|
||||
import { DataLinkButton, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import IoLink from 'react-icons/lib/io/link';
|
||||
|
||||
import AccordianKeyValues from './AccordianKeyValues';
|
||||
import AccordianLogs from './AccordianLogs';
|
||||
import AccordianText from './AccordianText';
|
||||
import DetailState from './DetailState';
|
||||
import { formatDuration } from '../utils';
|
||||
import CopyIcon from '../../common/CopyIcon';
|
||||
import LabeledList from '../../common/LabeledList';
|
||||
import { SpanLinkFunc, TNil } from '../../types';
|
||||
import { TraceKeyValuePair, TraceLink, TraceLog, TraceSpan } from '../../types/trace';
|
||||
import AccordianReferences from './AccordianReferences';
|
||||
import { autoColor } from '../../Theme';
|
||||
import { UIDivider } from '../../uiElementsContext';
|
||||
import { ubFlex, ubFlexAuto, ubItemsCenter, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles';
|
||||
import {
|
||||
uAlignIcon,
|
||||
ubFlex,
|
||||
ubFlexAuto,
|
||||
ubItemsCenter,
|
||||
ubM0,
|
||||
ubMb1,
|
||||
ubMy1,
|
||||
ubTxRightAlign,
|
||||
} from '../../uberUtilityStyles';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
@ -99,6 +108,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
word-break: break-all;
|
||||
white-space: pre;
|
||||
`,
|
||||
LinkIcon: css`
|
||||
font-size: 1.5em;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
@ -116,6 +128,8 @@ type SpanDetailProps = {
|
||||
referencesToggle: (spanID: string) => void;
|
||||
focusSpan: (uiFind: string) => void;
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
};
|
||||
|
||||
export default function SpanDetail(props: SpanDetailProps) {
|
||||
@ -133,6 +147,7 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
referencesToggle,
|
||||
focusSpan,
|
||||
createSpanLink,
|
||||
createFocusSpanLink,
|
||||
} = props;
|
||||
const {
|
||||
isTagsOpen,
|
||||
@ -147,6 +162,7 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
process,
|
||||
duration,
|
||||
relativeStartTime,
|
||||
traceID,
|
||||
spanID,
|
||||
logs,
|
||||
tags,
|
||||
@ -171,9 +187,9 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
value: formatDuration(relativeStartTime),
|
||||
},
|
||||
];
|
||||
const deepLinkCopyText = `${window.location.origin}${window.location.pathname}?uiFind=${spanID}`;
|
||||
const styles = useStyles2(getStyles);
|
||||
const link = createSpanLink?.(span);
|
||||
const focusSpanLink = createFocusSpanLink(traceID, spanID);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -263,13 +279,25 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
/>
|
||||
)}
|
||||
<small className={styles.debugInfo}>
|
||||
<a
|
||||
{...focusSpanLink}
|
||||
onClick={(e) => {
|
||||
// click handling logic copied from react router:
|
||||
// https://github.com/remix-run/react-router/blob/997b4d67e506d39ac6571cb369d6d2d6b3dda557/packages/react-router-dom/index.tsx#L392-L394s
|
||||
if (
|
||||
focusSpanLink.onClick &&
|
||||
e.button === 0 && // Ignore everything but left clicks
|
||||
(!e.currentTarget.target || e.currentTarget.target === '_self') && // Let browser handle "target=_blank" etc.
|
||||
!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) // Ignore clicks with modifier keys
|
||||
) {
|
||||
e.preventDefault();
|
||||
focusSpanLink.onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IoLink className={cx(uAlignIcon, styles.LinkIcon)}></IoLink>
|
||||
</a>
|
||||
<span className={styles.debugLabel} data-label="SpanID:" /> {spanID}
|
||||
<CopyIcon
|
||||
copyText={deepLinkCopyText}
|
||||
icon="link"
|
||||
placement="topRight"
|
||||
tooltipTitle="Copy deep link to this span"
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,7 +21,7 @@ import SpanTreeOffset from './SpanTreeOffset';
|
||||
import TimelineRow from './TimelineRow';
|
||||
import { autoColor } from '../Theme';
|
||||
import { stylesFactory, withTheme2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
|
||||
import { TraceLog, TraceSpan, TraceKeyValuePair, TraceLink } from '../types/trace';
|
||||
import { SpanLinkFunc } from '../types';
|
||||
@ -89,6 +89,8 @@ type SpanDetailRowProps = {
|
||||
removeHoverIndentGuideId: (spanID: string) => void;
|
||||
theme: GrafanaTheme2;
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
};
|
||||
|
||||
export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProps> {
|
||||
@ -121,6 +123,8 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp
|
||||
removeHoverIndentGuideId,
|
||||
theme,
|
||||
createSpanLink,
|
||||
focusedSpanId,
|
||||
createFocusSpanLink,
|
||||
} = this.props;
|
||||
const styles = getStyles(theme);
|
||||
return (
|
||||
@ -160,6 +164,8 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp
|
||||
traceStartTime={traceStartTime}
|
||||
focusSpan={focusSpan}
|
||||
createSpanLink={createSpanLink}
|
||||
focusedSpanId={focusedSpanId}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRow.Cell>
|
||||
|
@ -18,7 +18,7 @@ import { css } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { stylesFactory, withTheme2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
|
||||
import ListView from './ListView';
|
||||
import SpanBarRow from './SpanBarRow';
|
||||
@ -86,6 +86,8 @@ type TVirtualizedTraceViewOwnProps = {
|
||||
theme: GrafanaTheme2;
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
scrollElement?: Element;
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
};
|
||||
|
||||
type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
|
||||
@ -172,6 +174,10 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
setTrace(trace, uiFind);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.scrollToSpan(this.props.focusedSpanId);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: VirtualizedTraceViewProps) {
|
||||
// If any prop updates, VirtualizedTraceViewImpl should update.
|
||||
const nextPropKeys = Object.keys(nextProps) as Array<keyof VirtualizedTraceViewProps>;
|
||||
@ -200,6 +206,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
setTrace,
|
||||
trace: nextTrace,
|
||||
uiFind,
|
||||
focusedSpanId,
|
||||
} = this.props;
|
||||
|
||||
if (trace !== nextTrace) {
|
||||
@ -214,6 +221,10 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
scrollToFirstVisibleSpan();
|
||||
clearShouldScrollToFirstUiFindMatch();
|
||||
}
|
||||
|
||||
if (focusedSpanId !== prevProps.focusedSpanId) {
|
||||
this.scrollToSpan(focusedSpanId);
|
||||
}
|
||||
}
|
||||
|
||||
getRowStates(): RowState[] {
|
||||
@ -323,6 +334,16 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
: this.renderSpanBarRow(span, spanIndex, key, style, attrs);
|
||||
};
|
||||
|
||||
scrollToSpan = (spanID?: string) => {
|
||||
if (spanID == null) {
|
||||
return;
|
||||
}
|
||||
const i = this.getRowStates().findIndex((row) => row.span.spanID === spanID);
|
||||
if (i >= 0) {
|
||||
this.listView?.scrollToIndex(i);
|
||||
}
|
||||
};
|
||||
|
||||
renderSpanBarRow(span: TraceSpan, spanIndex: number, key: string, style: React.CSSProperties, attrs: {}) {
|
||||
const { spanID } = span;
|
||||
const { serviceName } = span.process;
|
||||
@ -340,6 +361,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
removeHoverIndentGuideId,
|
||||
theme,
|
||||
createSpanLink,
|
||||
focusedSpanId,
|
||||
} = this.props;
|
||||
// to avert flow error
|
||||
if (!trace) {
|
||||
@ -349,6 +371,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
const isCollapsed = childrenHiddenIDs.has(spanID);
|
||||
const isDetailExpanded = detailStates.has(spanID);
|
||||
const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false;
|
||||
const isFocused = spanID === focusedSpanId;
|
||||
const showErrorIcon = isErrorSpan(span) || (isCollapsed && spanContainsErredSpan(trace.spans, spanIndex));
|
||||
|
||||
// Check for direct child "server" span if the span is a "client" span.
|
||||
@ -389,6 +412,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
isChildrenExpanded={!isCollapsed}
|
||||
isDetailExpanded={isDetailExpanded}
|
||||
isMatchingFilter={isMatchingFilter}
|
||||
isFocused={isFocused}
|
||||
numTicks={NUM_TICKS}
|
||||
onDetailToggled={detailToggle}
|
||||
onChildrenToggled={childrenToggle}
|
||||
@ -430,6 +454,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
linksGetter,
|
||||
theme,
|
||||
createSpanLink,
|
||||
focusedSpanId,
|
||||
createFocusSpanLink,
|
||||
} = this.props;
|
||||
const detailState = detailStates.get(spanID);
|
||||
if (!trace || !detailState) {
|
||||
@ -459,6 +485,8 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||
createSpanLink={createSpanLink}
|
||||
focusedSpanId={focusedSpanId}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -14,7 +14,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { stylesFactory, withTheme2 } from '@grafana/ui';
|
||||
|
||||
import TimelineHeaderRow from './TimelineHeaderRow';
|
||||
@ -103,6 +103,8 @@ type TProps = TExtractUiFindFromStateReturn & {
|
||||
theme: GrafanaTheme2;
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
scrollElement?: Element;
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
};
|
||||
|
||||
type State = {
|
||||
|
@ -182,6 +182,36 @@ describe('state functions', () => {
|
||||
from: 'now - 5h',
|
||||
to: 'now',
|
||||
},
|
||||
panelsState: undefined,
|
||||
};
|
||||
const serialized = serializeStateToUrlParam(state, true);
|
||||
const parsed = parseUrlState(serialized);
|
||||
expect(state).toMatchObject(parsed);
|
||||
});
|
||||
|
||||
it('can parse serialized panelsState into the original state', () => {
|
||||
const state = {
|
||||
...DEFAULT_EXPLORE_STATE,
|
||||
datasource: 'foo',
|
||||
queries: [
|
||||
{
|
||||
expr: 'metric{test="a/b"}',
|
||||
refId: 'A',
|
||||
},
|
||||
{
|
||||
expr: 'super{foo="x/z"}',
|
||||
refId: 'B',
|
||||
},
|
||||
],
|
||||
range: {
|
||||
from: 'now - 5h',
|
||||
to: 'now',
|
||||
},
|
||||
panelsState: {
|
||||
trace: {
|
||||
spanId: 'abcdef',
|
||||
},
|
||||
},
|
||||
};
|
||||
const serialized = serializeStateToUrlParam(state, true);
|
||||
const parsed = parseUrlState(serialized);
|
||||
|
@ -245,10 +245,13 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
|
||||
};
|
||||
const datasource = parsed[ParseUrlStateIndex.Datasource];
|
||||
const parsedSegments = parsed.slice(ParseUrlStateIndex.SegmentsStart);
|
||||
const queries = parsedSegments.filter((segment) => !isSegment(segment, 'ui', 'originPanelId', 'mode'));
|
||||
const queries = parsedSegments.filter(
|
||||
(segment) => !isSegment(segment, 'ui', 'originPanelId', 'mode', '__panelsState')
|
||||
);
|
||||
|
||||
const originPanelId = parsedSegments.filter((segment) => isSegment(segment, 'originPanelId'))[0];
|
||||
return { datasource, queries, range, originPanelId };
|
||||
const originPanelId = parsedSegments.find((segment) => isSegment(segment, 'originPanelId'));
|
||||
const panelsState = parsedSegments.find((segment) => isSegment(segment, '__panelsState'))?.__panelsState;
|
||||
return { datasource, queries, range, originPanelId, panelsState };
|
||||
}
|
||||
|
||||
export function generateKey(index = 0): string {
|
||||
|
@ -44,7 +44,15 @@ class ExplorePaneContainerUnconnected extends React.PureComponent<Props> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { initialized, exploreId, initialDatasource, initialQueries, initialRange, originPanelId } = this.props;
|
||||
const {
|
||||
initialized,
|
||||
exploreId,
|
||||
initialDatasource,
|
||||
initialQueries,
|
||||
initialRange,
|
||||
originPanelId,
|
||||
panelsState,
|
||||
} = this.props;
|
||||
const width = this.el?.offsetWidth ?? 0;
|
||||
|
||||
// initialize the whole explore first time we mount and if browser history contains a change in datasource
|
||||
@ -56,6 +64,7 @@ class ExplorePaneContainerUnconnected extends React.PureComponent<Props> {
|
||||
initialRange,
|
||||
width,
|
||||
this.exploreEvents,
|
||||
panelsState,
|
||||
originPanelId
|
||||
);
|
||||
}
|
||||
@ -101,7 +110,7 @@ function mapStateToProps(state: StoreState, props: OwnProps) {
|
||||
const timeZone = getTimeZone(state.user);
|
||||
const fiscalYearStartMonth = getFiscalYearStartMonth(state.user);
|
||||
|
||||
const { datasource, queries, range: urlRange, originPanelId } = (urlState || {}) as ExploreUrlState;
|
||||
const { datasource, queries, range: urlRange, originPanelId, panelsState } = (urlState || {}) as ExploreUrlState;
|
||||
const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId));
|
||||
const initialQueries: DataQuery[] = ensureQueriesMemoized(queries);
|
||||
const initialRange = urlRange
|
||||
@ -114,6 +123,7 @@ function mapStateToProps(state: StoreState, props: OwnProps) {
|
||||
initialQueries,
|
||||
initialRange,
|
||||
originPanelId,
|
||||
panelsState,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -71,7 +71,13 @@ export class TableContainer extends PureComponent<Props> {
|
||||
// differently and sidestep this getLinks API on a dataframe
|
||||
for (const field of dataFrame.fields) {
|
||||
field.getLinks = (config: ValueLinkConfig) => {
|
||||
return getFieldLinksForExplore({ field, rowIndex: config.valueRowIndex!, splitOpenFn: splitOpen, range });
|
||||
return getFieldLinksForExplore({
|
||||
field,
|
||||
rowIndex: config.valueRowIndex!,
|
||||
splitOpenFn: splitOpen,
|
||||
range,
|
||||
dataFrame: dataFrame!,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,15 @@
|
||||
import { DataFrame, DataFrameView, SplitOpen, TraceSpanRow } from '@grafana/data';
|
||||
import {
|
||||
DataFrame,
|
||||
DataFrameView,
|
||||
DataLink,
|
||||
DataSourceApi,
|
||||
Field,
|
||||
LinkModel,
|
||||
mapInternalLinkToExplore,
|
||||
SplitOpen,
|
||||
TraceSpanRow,
|
||||
} from '@grafana/data';
|
||||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
import {
|
||||
Trace,
|
||||
TracePageHeader,
|
||||
@ -15,7 +26,8 @@ import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||
import { StoreState } from 'app/types';
|
||||
import { ExploreId } from 'app/types/explore';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { changePanelState } from '../state/explorePane';
|
||||
import { createSpanLinkFactory } from './createSpanLink';
|
||||
import { UIElements } from './uiElements';
|
||||
import { useChildrenState } from './useChildrenState';
|
||||
@ -66,10 +78,16 @@ export function TraceView(props: Props) {
|
||||
|
||||
const traceProp = useMemo(() => transformDataFrames(frame), [frame]);
|
||||
const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans);
|
||||
const dataSourceName = useSelector((state: StoreState) => state.explore[props.exploreId]?.datasourceInstance?.name);
|
||||
const traceToLogsOptions = (getDatasourceSrv().getInstanceSettings(dataSourceName)?.jsonData as TraceToLogsData)
|
||||
?.tracesToLogs;
|
||||
const timeZone = useSelector((state: StoreState) => getTimeZone(state.user));
|
||||
|
||||
const datasource = useSelector(
|
||||
(state: StoreState) => state.explore[props.exploreId]?.datasourceInstance ?? undefined
|
||||
);
|
||||
|
||||
const [focusedSpanId, createFocusSpanLink] = useFocusSpanLink({
|
||||
refId: frame?.refId,
|
||||
exploreId: props.exploreId,
|
||||
datasource,
|
||||
});
|
||||
|
||||
const traceTimeline: TTraceTimeline = useMemo(
|
||||
() => ({
|
||||
@ -83,11 +101,14 @@ export function TraceView(props: Props) {
|
||||
[childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, traceProp?.traceID]
|
||||
);
|
||||
|
||||
const traceToLogsOptions = (getDatasourceSrv().getInstanceSettings(datasource?.name)?.jsonData as TraceToLogsData)
|
||||
?.tracesToLogs;
|
||||
const createSpanLink = useMemo(
|
||||
() => createSpanLinkFactory({ splitOpenFn: props.splitOpenFn, traceToLogsOptions, dataFrame: frame }),
|
||||
[props.splitOpenFn, traceToLogsOptions, frame]
|
||||
);
|
||||
const onSlimViewClicked = useCallback(() => setSlim(!slim), [slim]);
|
||||
const timeZone = useSelector((state: StoreState) => getTimeZone(state.user));
|
||||
|
||||
if (!props.dataFrames?.length || !traceProp) {
|
||||
return null;
|
||||
@ -151,6 +172,8 @@ export function TraceView(props: Props) {
|
||||
uiFind={search}
|
||||
createSpanLink={createSpanLink}
|
||||
scrollElement={props.scrollElement}
|
||||
focusedSpanId={focusedSpanId}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
/>
|
||||
</UIElementsContext.Provider>
|
||||
);
|
||||
@ -198,3 +221,59 @@ function transformTraceDataFrame(frame: DataFrame): TraceResponse {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles focusing a span. Returns the span id to focus to based on what is in current explore state and also a
|
||||
* function to change the focused span id.
|
||||
* @param options
|
||||
*/
|
||||
function useFocusSpanLink(options: {
|
||||
exploreId: ExploreId;
|
||||
refId?: string;
|
||||
datasource?: DataSourceApi;
|
||||
}): [string | undefined, (traceId: string, spanId: string) => LinkModel<Field>] {
|
||||
const panelState = useSelector((state: StoreState) => state.explore[options.exploreId]?.panelsState.trace);
|
||||
const focusedSpanId = panelState?.spanId;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const setFocusedSpanId = (spanId?: string) =>
|
||||
dispatch(
|
||||
changePanelState(options.exploreId, 'trace', {
|
||||
...panelState,
|
||||
spanId,
|
||||
})
|
||||
);
|
||||
|
||||
const query = useSelector((state: StoreState) =>
|
||||
state.explore[options.exploreId]?.queries.find((query) => query.refId === options.refId)
|
||||
);
|
||||
|
||||
const createFocusSpanLink = (traceId: string, spanId: string) => {
|
||||
const link: DataLink = {
|
||||
title: 'Deep link to this span',
|
||||
url: '',
|
||||
internal: {
|
||||
datasourceUid: options.datasource?.uid!,
|
||||
datasourceName: options.datasource?.name!,
|
||||
query: query,
|
||||
panelsState: {
|
||||
trace: {
|
||||
spanId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return mapInternalLinkToExplore({
|
||||
link,
|
||||
internalLink: link.internal!,
|
||||
scopedVars: {},
|
||||
range: {} as any,
|
||||
field: {} as Field,
|
||||
onClickFn: () => setFocusedSpanId(focusedSpanId === spanId ? undefined : spanId),
|
||||
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
|
||||
});
|
||||
};
|
||||
|
||||
return [focusedSpanId, createFocusSpanLink];
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ describe('createSpanLinkFactory', () => {
|
||||
const linkDef = createLink!(createTraceSpan());
|
||||
expect(linkDef!.href).toBe(
|
||||
`/explore?left=${encodeURIComponent(
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}]}'
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"}","refId":""}],"panelsState":{}}'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
@ -54,7 +54,7 @@ describe('createSpanLinkFactory', () => {
|
||||
);
|
||||
expect(linkDef!.href).toBe(
|
||||
`/explore?left=${encodeURIComponent(
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}]}'
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{ip=\\"192.168.0.1\\"}","refId":""}],"panelsState":{}}'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
@ -77,7 +77,7 @@ describe('createSpanLinkFactory', () => {
|
||||
);
|
||||
expect(linkDef!.href).toBe(
|
||||
`/explore?left=${encodeURIComponent(
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}]}'
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{ip=\\"192.168.0.1\\", host=\\"host\\"}","refId":""}],"panelsState":{}}'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
@ -101,7 +101,7 @@ describe('createSpanLinkFactory', () => {
|
||||
);
|
||||
expect(linkDef!.href).toBe(
|
||||
`/explore?left=${encodeURIComponent(
|
||||
'{"range":{"from":"2020-10-14T01:01:00.000Z","to":"2020-10-14T01:01:01.000Z"},"datasource":"loki1","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}]}'
|
||||
'{"range":{"from":"2020-10-14T01:01:00.000Z","to":"2020-10-14T01:01:01.000Z"},"datasource":"loki1","queries":[{"expr":"{hostname=\\"hostname1\\"}","refId":""}],"panelsState":{}}'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
@ -116,7 +116,7 @@ describe('createSpanLinkFactory', () => {
|
||||
|
||||
expect(linkDef!.href).toBe(
|
||||
`/explore?left=${encodeURIComponent(
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"} |=\\"7946b05c2e2e4e5a\\" |=\\"6605c7b08e715d6c\\"","refId":""}]}'
|
||||
'{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"loki1","queries":[{"expr":"{cluster=\\"cluster1\\", hostname=\\"hostname1\\"} |=\\"7946b05c2e2e4e5a\\" |=\\"6605c7b08e715d6c\\"","refId":""}],"panelsState":{}}'
|
||||
)}`
|
||||
);
|
||||
});
|
||||
|
@ -22,13 +22,22 @@ import {
|
||||
storeGraphStyle,
|
||||
} from './utils';
|
||||
import { createAction, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { EventBusExtended, DataQuery, ExploreUrlState, TimeRange, HistoryItem, DataSourceApi } from '@grafana/data';
|
||||
import {
|
||||
EventBusExtended,
|
||||
DataQuery,
|
||||
ExploreUrlState,
|
||||
TimeRange,
|
||||
HistoryItem,
|
||||
DataSourceApi,
|
||||
ExplorePanelsState,
|
||||
PreferredVisualisationType,
|
||||
} from '@grafana/data';
|
||||
// Types
|
||||
import { ThunkResult } from 'app/types';
|
||||
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { getRichHistory } from '../../../core/utils/richHistory';
|
||||
import { richHistoryUpdatedAction } from './main';
|
||||
import { richHistoryUpdatedAction, stateSave } from './main';
|
||||
|
||||
//
|
||||
// Actions and Payloads
|
||||
@ -45,6 +54,38 @@ export interface ChangeSizePayload {
|
||||
}
|
||||
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
|
||||
|
||||
/**
|
||||
* Tracks the state of explore panels that gets synced with the url.
|
||||
*/
|
||||
interface ChangePanelsState {
|
||||
exploreId: ExploreId;
|
||||
panelsState: ExplorePanelsState;
|
||||
}
|
||||
const changePanelsStateAction = createAction<ChangePanelsState>('explore/changePanels');
|
||||
export function changePanelState(
|
||||
exploreId: ExploreId,
|
||||
panel: PreferredVisualisationType,
|
||||
panelState: ExplorePanelsState[PreferredVisualisationType]
|
||||
): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const exploreItem = getState().explore[exploreId];
|
||||
if (exploreItem === undefined) {
|
||||
return;
|
||||
}
|
||||
const { panelsState } = exploreItem;
|
||||
dispatch(
|
||||
changePanelsStateAction({
|
||||
exploreId,
|
||||
panelsState: {
|
||||
...panelsState,
|
||||
[panel]: panelState,
|
||||
},
|
||||
})
|
||||
);
|
||||
dispatch(stateSave());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Explore state with state from the URL and the React component.
|
||||
* Call this only on components for with the Explore state has not been initialized.
|
||||
@ -102,6 +143,8 @@ export function initializeExplore(
|
||||
range: TimeRange,
|
||||
containerWidth: number,
|
||||
eventBridge: EventBusExtended,
|
||||
panelsState?: ExplorePanelsState,
|
||||
|
||||
originPanelId?: number | null
|
||||
): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
@ -128,6 +171,9 @@ export function initializeExplore(
|
||||
history,
|
||||
})
|
||||
);
|
||||
if (panelsState !== undefined) {
|
||||
dispatch(changePanelsStateAction({ exploreId, panelsState }));
|
||||
}
|
||||
dispatch(updateTime({ exploreId }));
|
||||
|
||||
if (instance) {
|
||||
@ -159,7 +205,7 @@ export function refreshExplore(exploreId: ExploreId, newUrlQuery: string): Thunk
|
||||
|
||||
const { containerWidth, eventBridge } = itemState;
|
||||
|
||||
const { datasource, queries, range: urlRange, originPanelId } = newUrlState;
|
||||
const { datasource, queries, range: urlRange, originPanelId, panelsState } = newUrlState;
|
||||
const refreshQueries: DataQuery[] = [];
|
||||
|
||||
for (let index = 0; index < queries.length; index++) {
|
||||
@ -176,7 +222,16 @@ export function refreshExplore(exploreId: ExploreId, newUrlQuery: string): Thunk
|
||||
if (update.datasource) {
|
||||
const initialQueries = ensureQueries(queries);
|
||||
await dispatch(
|
||||
initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, originPanelId)
|
||||
initializeExplore(
|
||||
exploreId,
|
||||
datasource,
|
||||
initialQueries,
|
||||
range,
|
||||
containerWidth,
|
||||
eventBridge,
|
||||
panelsState,
|
||||
originPanelId
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -189,6 +244,10 @@ export function refreshExplore(exploreId: ExploreId, newUrlQuery: string): Thunk
|
||||
dispatch(setQueriesAction({ exploreId, queries: refreshQueries }));
|
||||
}
|
||||
|
||||
if (update.panelsState && panelsState !== undefined) {
|
||||
dispatch(changePanelsStateAction({ exploreId, panelsState }));
|
||||
}
|
||||
|
||||
// always run queries when refresh is needed
|
||||
if (update.queries || update.range) {
|
||||
dispatch(runQueries(exploreId));
|
||||
@ -220,6 +279,11 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
|
||||
return { ...state, graphStyle };
|
||||
}
|
||||
|
||||
if (changePanelsStateAction.match(action)) {
|
||||
const { panelsState } = action.payload;
|
||||
return { ...state, panelsState };
|
||||
}
|
||||
|
||||
if (initializeExploreAction.match(action)) {
|
||||
const { containerWidth, eventBridge, queries, range, originPanelId, datasourceInstance, history } = action.payload;
|
||||
|
||||
@ -254,14 +318,17 @@ export const urlDiff = (
|
||||
datasource: boolean;
|
||||
queries: boolean;
|
||||
range: boolean;
|
||||
panelsState: boolean;
|
||||
} => {
|
||||
const datasource = !isEqual(currentUrlState?.datasource, oldUrlState?.datasource);
|
||||
const queries = !isEqual(currentUrlState?.queries, oldUrlState?.queries);
|
||||
const range = !isEqual(currentUrlState?.range || DEFAULT_RANGE, oldUrlState?.range || DEFAULT_RANGE);
|
||||
const panelsState = !isEqual(currentUrlState?.panelsState, oldUrlState?.panelsState);
|
||||
|
||||
return {
|
||||
datasource,
|
||||
queries,
|
||||
range,
|
||||
panelsState,
|
||||
};
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { AnyAction } from 'redux';
|
||||
import { DataSourceSrv, getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { DataQuery, ExploreUrlState, serializeStateToUrlParam, TimeRange, UrlQueryMap } from '@grafana/data';
|
||||
import { ExploreUrlState, serializeStateToUrlParam, SplitOpen, UrlQueryMap } from '@grafana/data';
|
||||
import { GetExploreUrlArguments, stopQueryState } from 'app/core/utils/explore';
|
||||
import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore';
|
||||
import { paneReducer } from './explorePane';
|
||||
@ -87,12 +87,7 @@ export const lastSavedUrl: UrlQueryMap = {};
|
||||
* or uses values from options arg. This does only navigation each pane is then responsible for initialization from
|
||||
* the URL.
|
||||
*/
|
||||
export function splitOpen<T extends DataQuery = any>(options?: {
|
||||
datasourceUid: string;
|
||||
query: T;
|
||||
// Don't use right now. It's used for Traces to Logs interaction but is hacky in how the range is actually handled.
|
||||
range?: TimeRange;
|
||||
}): ThunkResult<void> {
|
||||
export const splitOpen: SplitOpen = (options): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const leftState: ExploreItemState = getState().explore[ExploreId.left];
|
||||
const leftUrlState = getUrlStateFromPaneState(leftState);
|
||||
@ -104,13 +99,14 @@ export function splitOpen<T extends DataQuery = any>(options?: {
|
||||
datasource: datasourceName,
|
||||
queries: [options.query],
|
||||
range: options.range || leftState.range,
|
||||
panelsState: options.panelsState,
|
||||
};
|
||||
}
|
||||
|
||||
const urlState = serializeStateToUrlParam(rightUrlState, true);
|
||||
locationService.partial({ right: urlState }, true);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the split view and save URL state. We need to update the state here because when closing we cannot just
|
||||
|
@ -14,6 +14,7 @@ import { getDatasourceSrv } from '../../plugins/datasource_srv';
|
||||
import store from '../../../core/store';
|
||||
import { clearQueryKeys, lastUsedDatasourceKeyForOrgId, toGraphStyle } from '../../../core/utils/explore';
|
||||
import { toRawTimeRange } from '../utils/time';
|
||||
import { isEmpty, isObject, mapValues, omitBy } from 'lodash';
|
||||
|
||||
export const DEFAULT_RANGE = {
|
||||
from: 'now-6h',
|
||||
@ -63,6 +64,7 @@ export const makeExplorePaneState = (): ExploreItemState => ({
|
||||
logsVolumeDataProvider: undefined,
|
||||
logsVolumeData: undefined,
|
||||
graphStyle: loadGraphStyle(),
|
||||
panelsState: {},
|
||||
});
|
||||
|
||||
export const createEmptyQueryResponse = (): PanelData => ({
|
||||
@ -101,6 +103,17 @@ export async function loadAndInitDatasource(
|
||||
return { history, instance };
|
||||
}
|
||||
|
||||
// recursively walks an object, removing keys where the value is undefined
|
||||
// if the resulting object is empty, returns undefined
|
||||
function pruneObject(obj: object): object | undefined {
|
||||
let pruned = mapValues(obj, (value) => (isObject(value) ? pruneObject(value) : value));
|
||||
pruned = omitBy<typeof pruned>(pruned, isEmpty);
|
||||
if (isEmpty(pruned)) {
|
||||
return undefined;
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlState {
|
||||
return {
|
||||
// datasourceInstance should not be undefined anymore here but in case there is some path for it to be undefined
|
||||
@ -108,6 +121,8 @@ export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlStat
|
||||
datasource: pane.datasourceInstance?.name || '',
|
||||
queries: pane.queries.map(clearQueryKeys),
|
||||
range: toRawTimeRange(pane.range),
|
||||
// don't include panelsState in the url unless a piece of state is actually set
|
||||
panelsState: pruneObject(pane.panelsState),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -55,6 +55,11 @@ describe('getFieldLinksForExplore', () => {
|
||||
query: { query: 'query_1' },
|
||||
datasourceUid: 'uid_1',
|
||||
datasourceName: 'test_ds',
|
||||
panelsState: {
|
||||
trace: {
|
||||
spanId: 'abcdef',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const splitfn = jest.fn();
|
||||
@ -62,7 +67,7 @@ describe('getFieldLinksForExplore', () => {
|
||||
|
||||
expect(links[0].href).toBe(
|
||||
`/explore?left=${encodeURIComponent(
|
||||
'{"range":{"from":"now-1h","to":"now"},"datasource":"test_ds","queries":[{"query":"query_1"}]}'
|
||||
'{"range":{"from":"now-1h","to":"now"},"datasource":"test_ds","queries":[{"query":"query_1"}],"panelsState":{"trace":{"spanId":"abcdef"}}}'
|
||||
)}`
|
||||
);
|
||||
expect(links[0].title).toBe('test_ds');
|
||||
@ -75,6 +80,11 @@ describe('getFieldLinksForExplore', () => {
|
||||
datasourceUid: 'uid_1',
|
||||
query: { query: 'query_1' },
|
||||
range,
|
||||
panelsState: {
|
||||
trace: {
|
||||
spanId: 'abcdef',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -4,15 +4,16 @@ import {
|
||||
DataFrame,
|
||||
DataQuery,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataSourceApi,
|
||||
EventBusExtended,
|
||||
HistoryItem,
|
||||
LogsModel,
|
||||
PanelData,
|
||||
QueryHint,
|
||||
RawTimeRange,
|
||||
TimeRange,
|
||||
EventBusExtended,
|
||||
DataQueryResponse,
|
||||
ExplorePanelsState,
|
||||
} from '@grafana/data';
|
||||
|
||||
export enum ExploreId {
|
||||
@ -173,6 +174,7 @@ export interface ExploreItemState {
|
||||
|
||||
/* explore graph style */
|
||||
graphStyle: ExploreGraphStyle;
|
||||
panelsState: ExplorePanelsState;
|
||||
}
|
||||
|
||||
export interface ExploreUpdateState {
|
||||
|
Loading…
Reference in New Issue
Block a user