Tempo / Trace Viewer: Implement deep linking to spans

This commit is contained in:
Erin Noe-Payne 2022-01-24 10:49:35 -05:00 committed by GitHub
parent fdeaf7a5c4
commit ac945fb6e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 472 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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":{}}'
)}`
);
});

View File

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

View File

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

View File

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

View File

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

View File

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