diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 3aa26b74b28..ca67d49af23 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -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, }) ); diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 2d69aedfc7a..a0c6ac312e2 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -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 { query: T; datasourceUid: string; datasourceName: string; + panelsState?: ExplorePanelsState; } export type LinkTarget = '_blank' | '_self' | undefined; diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 335c65e60e2..cd115a42a74 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -1,3 +1,4 @@ +import { PreferredVisualisationType } from './data'; import { DataQuery } from './query'; import { RawTimeRange, TimeRange } from './time'; @@ -10,11 +11,20 @@ export interface ExploreUrlState { range: RawTimeRange; originPanelId?: number; context?: string; + panelsState?: ExplorePanelsState; +} + +export interface ExplorePanelsState extends Partial> { + trace?: ExploreTracePanelState; +} + +export interface ExploreTracePanelState { + spanId?: string; } /** * SplitOpen type is used in Explore and related components. */ export type SplitOpen = ( - options?: { datasourceUid: string; query: T; range?: TimeRange } | undefined + options?: { datasourceUid: string; query: T; range?: TimeRange; panelsState?: ExplorePanelsState } | undefined ) => void; diff --git a/packages/grafana-data/src/utils/dataLinks.test.ts b/packages/grafana-data/src/utils/dataLinks.test.ts index daab192e387..b609ed2827b 100644 --- a/packages/grafana-data/src/utils/dataLinks.test.ts +++ b/packages/grafana-data/src/utils/dataLinks.test.ts @@ -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, }) ); diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 593799d293d..ce279fe5421 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -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 { 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(datasourceName: string, query: T, range: TimeRange): string { +function generateInternalHref( + 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( - link: DataLink, +function interpolateObject( + 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); diff --git a/packages/grafana-data/src/utils/url.ts b/packages/grafana-data/src/utils/url.ts index 3e53a71578d..b034c0e03af 100644 --- a/packages/grafana-data/src/utils/url.ts +++ b/packages/grafana-data/src/utils/url.ts @@ -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); } diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx index 22bc0aeb421..810a6218784 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.tsx @@ -246,6 +246,20 @@ export default class ListView extends React.Component { 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. diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx index 19a517749b1..0a5caf1dce4 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx @@ -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 { isChildrenExpanded, isDetailExpanded, isMatchingFilter, + isFocused, numTicks, rpc, noInstrumentedServer, @@ -377,6 +410,7 @@ export class UnthemedSpanBarRow extends React.PureComponent { [styles.rowExpanded]: isDetailExpanded, [styles.rowMatchingFilter]: isMatchingFilter, [styles.rowExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded, + [styles.rowFocused]: isFocused, [styles.rowClippingLeft]: clippingLeft, [styles.rowClippingRight]: clippingRight, }, diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js index 273956ef4ae..b63b2bfcad5 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js @@ -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('', () => { tagsToggle: jest.fn(), warningsToggle: jest.fn(), referencesToggle: jest.fn(), + createFocusSpanLink: jest.fn(), }; span.logs = [ { @@ -180,7 +180,7 @@ describe('', () => { 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(); }); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx index a1ea131a29d..9b3add1d7a6 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx @@ -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 ( diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx index d6cdadac5f7..d254f34d554 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.tsx @@ -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 { @@ -121,6 +123,8 @@ export class UnthemedSpanDetailRow extends React.PureComponent diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx index 01eed9b8b6f..bf34107f896 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -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; @@ -200,6 +206,7 @@ export class UnthemedVirtualizedTraceView extends React.Component { + 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 ); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx index b8e560a40d5..4d09b2ba79e 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx @@ -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 = { diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index c9dcadbca6e..79bed918a2e 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -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); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 1a62f12ee84..77a29040e18 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -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 { diff --git a/public/app/features/explore/ExplorePaneContainer.tsx b/public/app/features/explore/ExplorePaneContainer.tsx index 47b0bd579c1..60026ab49ac 100644 --- a/public/app/features/explore/ExplorePaneContainer.tsx +++ b/public/app/features/explore/ExplorePaneContainer.tsx @@ -44,7 +44,15 @@ class ExplorePaneContainerUnconnected extends React.PureComponent { } 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 { 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, }; } diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx index 29f7c85b6ad..ccdf5e3a75f 100644 --- a/public/app/features/explore/TableContainer.tsx +++ b/public/app/features/explore/TableContainer.tsx @@ -71,7 +71,13 @@ export class TableContainer extends PureComponent { // 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!, + }); }; } } diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 4b5b1239304..03210e667c9 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -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} /> ); @@ -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] { + 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]; +} diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index 253f16a7b6d..9202a150b47 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -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":{}}' )}` ); }); diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index f98cdc3a936..32b93b04ff2 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -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('explore/changeSize'); +/** + * Tracks the state of explore panels that gets synced with the url. + */ +interface ChangePanelsState { + exploreId: ExploreId; + panelsState: ExplorePanelsState; +} +const changePanelsStateAction = createAction('explore/changePanels'); +export function changePanelState( + exploreId: ExploreId, + panel: PreferredVisualisationType, + panelState: ExplorePanelsState[PreferredVisualisationType] +): ThunkResult { + 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 { 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, }; }; diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 96de72611ea..fd0659efe05 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -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(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 { +export const splitOpen: SplitOpen = (options): ThunkResult => { return async (dispatch, getState) => { const leftState: ExploreItemState = getState().explore[ExploreId.left]; const leftUrlState = getUrlStateFromPaneState(leftState); @@ -104,13 +99,14 @@ export function splitOpen(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 diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 72922bcccd0..3edbbb17eea 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -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(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), }; } diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 8982ad91c6b..7d049b48197 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -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', + }, + }, }); }); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 6229478064c..9540c2a5ce0 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -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 {