mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Traces Panel: Add new Traces Panel visualization (#47534)
* Panel * Support tempo dash variables * Support tempo explore variables * Only show span links for explore * Cleanup * Added tests * apply variables to search * Tests for search variables * Handling no data * Interpolation and tests * TracesPanel tests * More tests * Fix for backend test * Manager integration test fix * Traces doc and updated visualizations index doc * Logs for this span * Search, scrollToTop, other improvements * Refactor to extract common code * Removed TopOfViewRefType optional * Remove topOfViewRef optional * Removed another optional and fixed tests * Test * Only show search bar if trace * Support traces panel in add to dashboard * Self review * Update betterer * Linter fixes * Updated traces doc * Ahh, moved the for more info too * Updated betterer.results * Added new icon * Updated expectedListResp.json
This commit is contained in:
@@ -80,7 +80,7 @@ exports[`no enzyme tests`] = {
|
||||
"packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js:3242042907": [
|
||||
[14, 26, 13, "RegExp match", "2409514259"]
|
||||
],
|
||||
"packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js:2807329716": [
|
||||
"packages/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar.test.js:1062402339": [
|
||||
[14, 19, 13, "RegExp match", "2409514259"]
|
||||
],
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js:1734982398": [
|
||||
@@ -113,7 +113,7 @@ exports[`no enzyme tests`] = {
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/TextList.test.js:3006381933": [
|
||||
[14, 19, 13, "RegExp match", "2409514259"]
|
||||
],
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js:3097530078": [
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js:2816619357": [
|
||||
[16, 19, 13, "RegExp match", "2409514259"]
|
||||
],
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetailRow.test.js:2623922632": [
|
||||
@@ -137,7 +137,7 @@ exports[`no enzyme tests`] = {
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.test.js:1423129438": [
|
||||
[15, 17, 13, "RegExp match", "2409514259"]
|
||||
],
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js:2326471104": [
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js:551014442": [
|
||||
[13, 26, 13, "RegExp match", "2409514259"]
|
||||
],
|
||||
"packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js:381298544": [
|
||||
|
||||
@@ -27,6 +27,7 @@ Grafana offers a variety of visualizations to support different use cases. This
|
||||
- [Table]({{< relref "./table/_index.md" >}}) is the main and only table visualization.
|
||||
- [Logs]({{< relref "./logs-panel.md" >}}) is the main visualization for logs.
|
||||
- [Node Graph]({{< relref "./node-graph.md" >}}) for directed graphs or networks.
|
||||
- [Traces]({{< relref "./traces.md" >}}) is the main visualization for traces.
|
||||
- Widgets
|
||||
- [Dashboard list]({{< relref "./dashboard-list-panel.md" >}}) can list dashboards.
|
||||
- [Alert list]({{< relref "./alert-list-panel.md" >}}) can list alerts.
|
||||
|
||||
19
docs/sources/visualizations/traces.md
Normal file
19
docs/sources/visualizations/traces.md
Normal file
@@ -0,0 +1,19 @@
|
||||
+++
|
||||
title = "Traces"
|
||||
keywords = ["grafana", "dashboard", "documentation", "panels", "traces"]
|
||||
weight = 850
|
||||
+++
|
||||
|
||||
# Traces panel
|
||||
|
||||
> **Note:** This panel is currently in beta. Expect changes in future releases.
|
||||
|
||||
_Traces_ are a visualization that enables you to track and log a request as it traverses the services in your infrastructure.
|
||||
|
||||
For more information about traces and how to use them, refer to the following documentation:
|
||||
|
||||
- [What are traces](https://grafana.com/docs/grafana-cloud/traces)
|
||||
- [Tracing in expliore]({{< relref "../explore/trace-integration.md" >}})
|
||||
- [Getting started with Tempo](https://grafana.com/docs/tempo/latest/getting-started)
|
||||
|
||||
{{< figure src="/static/img/docs/explore/explore-trace-view-full-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the trace view" >}}
|
||||
@@ -25,8 +25,6 @@ import * as markers from './TracePageSearchBar.markers';
|
||||
const defaultProps = {
|
||||
forwardedRef: React.createRef(),
|
||||
navigable: true,
|
||||
nextResult: () => {},
|
||||
prevResult: () => {},
|
||||
suffix: '',
|
||||
searchValue: 'something',
|
||||
};
|
||||
@@ -59,8 +57,6 @@ describe('<TracePageSearchBar>', () => {
|
||||
buttons.forEach((button) => {
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
});
|
||||
expect(wrapper.find('Button[icon="arrow-up"]').prop('onClick')).toBe(defaultProps.prevResult);
|
||||
expect(wrapper.find('Button[icon="arrow-down"]').prop('onClick')).toBe(defaultProps.nextResult);
|
||||
});
|
||||
|
||||
it('only shows navigable buttons when navigable is true', () => {
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import cx from 'classnames';
|
||||
import * as React from 'react';
|
||||
import { memo } from 'react';
|
||||
import React, { memo, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
@@ -72,16 +71,27 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
};
|
||||
|
||||
type TracePageSearchBarProps = {
|
||||
prevResult: () => void;
|
||||
nextResult: () => void;
|
||||
navigable: boolean;
|
||||
searchValue: string;
|
||||
onSearchValueChange: (value: string) => void;
|
||||
setSearch: (value: string) => void;
|
||||
searchBarSuffix: string;
|
||||
spanFindMatches: Set<string> | undefined;
|
||||
focusedSpanIdForSearch: string;
|
||||
setSearchBarSuffix: Dispatch<SetStateAction<string>>;
|
||||
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export default memo(function TracePageSearchBar(props: TracePageSearchBarProps) {
|
||||
const { navigable, nextResult, prevResult, onSearchValueChange, searchValue, searchBarSuffix } = props;
|
||||
const {
|
||||
navigable,
|
||||
setSearch,
|
||||
searchValue,
|
||||
searchBarSuffix,
|
||||
spanFindMatches,
|
||||
focusedSpanIdForSearch,
|
||||
setSearchBarSuffix,
|
||||
setFocusedSpanIdForSearch,
|
||||
} = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const suffix = searchValue ? (
|
||||
@@ -98,11 +108,60 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
|
||||
suffix,
|
||||
};
|
||||
|
||||
const setTraceSearch = (value: string) => {
|
||||
setFocusedSpanIdForSearch('');
|
||||
setSearchBarSuffix('');
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
const nextResult = () => {
|
||||
const spanMatches = Array.from(spanFindMatches!);
|
||||
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
? spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
: 0;
|
||||
|
||||
// new query || at end, go to start
|
||||
if (prevMatchedIndex === -1 || prevMatchedIndex === spanMatches.length - 1) {
|
||||
setFocusedSpanIdForSearch(spanMatches[0]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// get next
|
||||
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex + 1]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex + 2));
|
||||
};
|
||||
|
||||
const prevResult = () => {
|
||||
const spanMatches = Array.from(spanFindMatches!);
|
||||
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
? spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
: 0;
|
||||
|
||||
// new query || at start, go to end
|
||||
if (prevMatchedIndex === -1 || prevMatchedIndex === 0) {
|
||||
setFocusedSpanIdForSearch(spanMatches[spanMatches.length - 1]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(spanMatches.length));
|
||||
return;
|
||||
}
|
||||
|
||||
// get prev
|
||||
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex - 1]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex));
|
||||
};
|
||||
|
||||
const getSearchBarSuffix = (index: number): string => {
|
||||
if (spanFindMatches?.size && spanFindMatches?.size > 0) {
|
||||
return index + ' of ' + spanFindMatches?.size;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.TracePageSearchBar}>
|
||||
<span className={ubJustifyEnd} style={{ display: 'flex' }}>
|
||||
<UiFindInput
|
||||
onChange={onSearchValueChange}
|
||||
onChange={setTraceSearch}
|
||||
value={searchValue}
|
||||
inputProps={uiFindInputInputProps}
|
||||
allowClear={true}
|
||||
|
||||
@@ -35,10 +35,12 @@ describe('<SpanDetail>', () => {
|
||||
const span = transformTraceData(traceGenerator.trace({ numberOfSpans: 1 })).spans[0];
|
||||
const detailState = new DetailState().toggleLogs().toggleProcess().toggleReferences().toggleTags();
|
||||
const traceStartTime = 5;
|
||||
const topOfExploreViewRef = jest.fn();
|
||||
const props = {
|
||||
detailState,
|
||||
span,
|
||||
traceStartTime,
|
||||
topOfExploreViewRef,
|
||||
logItemToggle: jest.fn(),
|
||||
logsToggle: jest.fn(),
|
||||
processToggle: jest.fn(),
|
||||
@@ -46,6 +48,7 @@ describe('<SpanDetail>', () => {
|
||||
warningsToggle: jest.fn(),
|
||||
referencesToggle: jest.fn(),
|
||||
createFocusSpanLink: jest.fn(),
|
||||
topOfViewRefType: 'Explore',
|
||||
};
|
||||
span.logs = [
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ import LabeledList from '../../common/LabeledList';
|
||||
import { SpanLinkFunc, TNil } from '../../types';
|
||||
import { TraceKeyValuePair, TraceLink, TraceLog, TraceSpan, TraceSpanReference } from '../../types/trace';
|
||||
import { uAlignIcon, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles';
|
||||
import { TopOfViewRefType } from '../VirtualizedTraceView';
|
||||
import { formatDuration } from '../utils';
|
||||
|
||||
import AccordianKeyValues from './AccordianKeyValues';
|
||||
@@ -119,6 +120,7 @@ type SpanDetailProps = {
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
topOfViewRefType?: TopOfViewRefType;
|
||||
};
|
||||
|
||||
export default function SpanDetail(props: SpanDetailProps) {
|
||||
@@ -138,6 +140,7 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
focusSpan,
|
||||
createSpanLink,
|
||||
createFocusSpanLink,
|
||||
topOfViewRefType,
|
||||
} = props;
|
||||
const {
|
||||
isTagsOpen,
|
||||
@@ -281,27 +284,29 @@ export default function SpanDetail(props: SpanDetailProps) {
|
||||
focusSpan={focusSpan}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
</small>
|
||||
{topOfViewRefType === TopOfViewRefType.Explore && (
|
||||
<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}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import SpanDetail from './SpanDetail';
|
||||
import DetailState from './SpanDetail/DetailState';
|
||||
import SpanTreeOffset from './SpanTreeOffset';
|
||||
import TimelineRow from './TimelineRow';
|
||||
import { TopOfViewRefType } from './VirtualizedTraceView';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
@@ -93,6 +94,7 @@ type SpanDetailRowProps = {
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
topOfViewRefType?: TopOfViewRefType;
|
||||
};
|
||||
|
||||
export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProps> {
|
||||
@@ -128,6 +130,7 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp
|
||||
createSpanLink,
|
||||
focusedSpanId,
|
||||
createFocusSpanLink,
|
||||
topOfViewRefType,
|
||||
} = this.props;
|
||||
const styles = getStyles(theme);
|
||||
return (
|
||||
@@ -170,6 +173,7 @@ export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProp
|
||||
createSpanLink={createSpanLink}
|
||||
focusedSpanId={focusedSpanId}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
topOfViewRefType={topOfViewRefType}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRow.Cell>
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('<VirtualizedTraceViewImpl>', () => {
|
||||
let instance;
|
||||
|
||||
const trace = transformTraceData(traceGenerator.trace({ numberOfSpans: 10 }));
|
||||
const topOfExploreViewRef = jest.fn();
|
||||
const props = {
|
||||
childrenHiddenIDs: new Set(),
|
||||
childrenToggle: jest.fn(),
|
||||
@@ -51,6 +52,7 @@ describe('<VirtualizedTraceViewImpl>', () => {
|
||||
spanNameColumnWidth: 0.5,
|
||||
trace,
|
||||
uiFind: 'uiFind',
|
||||
topOfExploreViewRef,
|
||||
};
|
||||
|
||||
function expandRow(rowIndex) {
|
||||
@@ -109,6 +111,10 @@ describe('<VirtualizedTraceViewImpl>', () => {
|
||||
expect(wrapper.find(ListView)).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders scrollToTopButton', () => {
|
||||
expect(wrapper.find({ title: 'Scroll to top' }).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets the trace for global state.traceTimeline', () => {
|
||||
expect(props.setTrace.mock.calls).toEqual([[trace, props.uiFind]]);
|
||||
props.setTrace.mockReset();
|
||||
|
||||
@@ -45,7 +45,10 @@ type TExtractUiFindFromStateReturn = {
|
||||
uiFind: string | undefined;
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory(() => {
|
||||
const getStyles = stylesFactory((props: TVirtualizedTraceViewOwnProps) => {
|
||||
const { topOfViewRefType } = props;
|
||||
const position = topOfViewRefType === TopOfViewRefType.Explore ? 'fixed' : 'absolute';
|
||||
|
||||
return {
|
||||
rowsWrapper: css`
|
||||
width: 100%;
|
||||
@@ -60,7 +63,7 @@ const getStyles = stylesFactory(() => {
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: fixed;
|
||||
position: ${position};
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
z-index: 1;
|
||||
@@ -74,6 +77,11 @@ type RowState = {
|
||||
spanIndex: number;
|
||||
};
|
||||
|
||||
export enum TopOfViewRefType {
|
||||
Explore = 'Explore',
|
||||
Panel = 'Panel',
|
||||
}
|
||||
|
||||
type TVirtualizedTraceViewOwnProps = {
|
||||
currentViewRangeTime: [number, number];
|
||||
findMatchesIDs: Set<string> | TNil;
|
||||
@@ -104,7 +112,8 @@ type TVirtualizedTraceViewOwnProps = {
|
||||
focusedSpanId?: string;
|
||||
focusedSpanIdForSearch: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
topOfExploreViewRef?: RefObject<HTMLDivElement>;
|
||||
topOfViewRef?: RefObject<HTMLDivElement>;
|
||||
topOfViewRefType?: TopOfViewRefType;
|
||||
};
|
||||
|
||||
type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
|
||||
@@ -425,7 +434,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
};
|
||||
}
|
||||
|
||||
const styles = getStyles();
|
||||
const styles = getStyles(this.props);
|
||||
return (
|
||||
<div className={styles.row} key={key} style={style} {...attrs}>
|
||||
<SpanBarRow
|
||||
@@ -481,13 +490,14 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
createSpanLink,
|
||||
focusedSpanId,
|
||||
createFocusSpanLink,
|
||||
topOfViewRefType,
|
||||
} = this.props;
|
||||
const detailState = detailStates.get(spanID);
|
||||
if (!trace || !detailState) {
|
||||
return null;
|
||||
}
|
||||
const color = getColorByKey(serviceName, theme);
|
||||
const styles = getStyles();
|
||||
const styles = getStyles(this.props);
|
||||
return (
|
||||
<div className={styles.row} key={key} style={{ ...style, zIndex: 1 }} {...attrs}>
|
||||
<SpanDetailRow
|
||||
@@ -513,18 +523,19 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
createSpanLink={createSpanLink}
|
||||
focusedSpanId={focusedSpanId}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
topOfViewRefType={topOfViewRefType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
scrollToTop = () => {
|
||||
const { topOfExploreViewRef } = this.props;
|
||||
topOfExploreViewRef?.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
const { topOfViewRef } = this.props;
|
||||
topOfViewRef?.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
render() {
|
||||
const styles = getStyles();
|
||||
const styles = getStyles(this.props);
|
||||
const { scrollElement } = this.props;
|
||||
return (
|
||||
<>
|
||||
@@ -541,7 +552,6 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
windowScroller={false}
|
||||
scrollElement={scrollElement}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
className={styles.scrollToTopButton}
|
||||
onClick={this.scrollToTop}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { TraceSpan, Trace, TraceLog, TraceKeyValuePair, TraceLink, TraceSpanRefe
|
||||
import ExternalLinkContext from '../url/externalLinkContext';
|
||||
|
||||
import TimelineHeaderRow from './TimelineHeaderRow';
|
||||
import VirtualizedTraceView from './VirtualizedTraceView';
|
||||
import VirtualizedTraceView, { TopOfViewRefType } from './VirtualizedTraceView';
|
||||
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from './types';
|
||||
|
||||
type TExtractUiFindFromStateReturn = {
|
||||
@@ -109,7 +109,8 @@ type TProps = TExtractUiFindFromStateReturn & {
|
||||
focusedSpanId?: string;
|
||||
focusedSpanIdForSearch: string;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
topOfExploreViewRef?: RefObject<HTMLDivElement>;
|
||||
topOfViewRef?: RefObject<HTMLDivElement>;
|
||||
topOfViewRefType?: TopOfViewRefType;
|
||||
};
|
||||
|
||||
type State = {
|
||||
@@ -165,7 +166,7 @@ export class UnthemedTraceTimelineViewer extends React.PureComponent<TProps, Sta
|
||||
createLinkToExternalSpan,
|
||||
traceTimeline,
|
||||
theme,
|
||||
topOfExploreViewRef,
|
||||
topOfViewRef,
|
||||
focusedSpanIdForSearch,
|
||||
...rest
|
||||
} = this.props;
|
||||
@@ -197,7 +198,7 @@ export class UnthemedTraceTimelineViewer extends React.PureComponent<TProps, Sta
|
||||
{...traceTimeline}
|
||||
setSpanNameColumnWidth={setSpanNameColumnWidth}
|
||||
currentViewRangeTime={viewRange.time.current}
|
||||
topOfExploreViewRef={topOfExploreViewRef}
|
||||
topOfViewRef={topOfViewRef}
|
||||
focusedSpanIdForSearch={focusedSpanIdForSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -126,6 +126,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
|
||||
"candlestick": {},
|
||||
"news": {},
|
||||
"nodeGraph": {},
|
||||
"traces": {},
|
||||
"piechart": {},
|
||||
"stat": {},
|
||||
"state-timeline": {},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -90,12 +90,6 @@ describe('addPanelToDashboard', () => {
|
||||
[{ refId: 'A', hide: true }],
|
||||
{ ...createEmptyQueryResponse(), logsFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] },
|
||||
],
|
||||
[
|
||||
// trace view is not supported in dashboards, we expect to fallback to table panel
|
||||
'If there are trace frames',
|
||||
[{ refId: 'A' }],
|
||||
{ ...createEmptyQueryResponse(), traceFrames: [new MutableDataFrame({ refId: 'A', fields: [] })] },
|
||||
],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', async (_, queries, queryResponse) => {
|
||||
@@ -115,15 +109,12 @@ describe('addPanelToDashboard', () => {
|
||||
framesType: string;
|
||||
expectedPanel: string;
|
||||
};
|
||||
// Note: traceFrames test is "duplicated" in "Defaults to table" tests.
|
||||
// This is intentional as a way to enforce explicit tests for that case whenever in the future we'll
|
||||
// add support for creating traceview panels
|
||||
it.each`
|
||||
framesType | expectedPanel
|
||||
${'logsFrames'} | ${'logs'}
|
||||
${'graphFrames'} | ${'timeseries'}
|
||||
${'nodeGraphFrames'} | ${'nodeGraph'}
|
||||
${'traceFrames'} | ${'table'}
|
||||
${'traceFrames'} | ${'traces'}
|
||||
`(
|
||||
'Sets visualization to $expectedPanel if there are $frameType frames',
|
||||
async ({ framesType, expectedPanel }: TestArgs) => {
|
||||
|
||||
@@ -74,6 +74,9 @@ function getPanelType(queries: DataQuery[], queryResponse: ExplorePanelData) {
|
||||
if (queryResponse.nodeGraphFrames.some(hasQueryRefId)) {
|
||||
return 'nodeGraph';
|
||||
}
|
||||
if (queryResponse.traceFrames.some(hasQueryRefId)) {
|
||||
return 'traces';
|
||||
}
|
||||
}
|
||||
|
||||
// falling back to table
|
||||
|
||||
@@ -104,7 +104,7 @@ export type Props = ExploreProps & ConnectedProps<typeof connector>;
|
||||
export class Explore extends React.PureComponent<Props, ExploreState> {
|
||||
scrollElement: HTMLDivElement | undefined;
|
||||
absoluteTimeUnsubsciber: Unsubscribable | undefined;
|
||||
topOfExploreViewRef = createRef<HTMLDivElement>();
|
||||
topOfViewRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -313,8 +313,8 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
||||
dataFrames={dataFrames}
|
||||
splitOpenFn={splitOpen}
|
||||
scrollElement={this.scrollElement}
|
||||
topOfExploreViewRef={this.topOfExploreViewRef}
|
||||
queryResponse={queryResponse}
|
||||
topOfViewRef={this.topOfViewRef}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@@ -357,11 +357,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
||||
autoHeightMin={'100%'}
|
||||
scrollRefCallback={(scrollElement) => (this.scrollElement = scrollElement || undefined)}
|
||||
>
|
||||
<ExploreToolbar
|
||||
exploreId={exploreId}
|
||||
onChangeTime={this.onChangeTime}
|
||||
topOfExploreViewRef={this.topOfExploreViewRef}
|
||||
/>
|
||||
<ExploreToolbar exploreId={exploreId} onChangeTime={this.onChangeTime} topOfViewRef={this.topOfViewRef} />
|
||||
{datasourceMissing ? this.renderEmptyState() : null}
|
||||
{datasourceInstance && (
|
||||
<div className="explore-container">
|
||||
|
||||
@@ -36,7 +36,7 @@ const AddToDashboard = lazy(() =>
|
||||
interface OwnProps {
|
||||
exploreId: ExploreId;
|
||||
onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
|
||||
topOfExploreViewRef?: RefObject<HTMLDivElement>;
|
||||
topOfViewRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
@@ -114,14 +114,14 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
|
||||
containerWidth,
|
||||
onChangeTimeZone,
|
||||
onChangeFiscalYearStartMonth,
|
||||
topOfExploreViewRef,
|
||||
topOfViewRef,
|
||||
} = this.props;
|
||||
|
||||
const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false;
|
||||
const showSmallTimePicker = splitted || containerWidth < 1210;
|
||||
|
||||
return (
|
||||
<div ref={topOfExploreViewRef}>
|
||||
<div ref={topOfViewRef}>
|
||||
<PageToolbar
|
||||
aria-label="Explore toolbar"
|
||||
title={exploreId === ExploreId.left ? 'Explore' : undefined}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TopOfViewRefType } from '@jaegertracing/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView';
|
||||
import { TraceData, TraceSpanData } from '@jaegertracing/jaeger-ui-components/src/types/trace';
|
||||
import { render, prettyDOM, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { DataFrame, MutableDataFrame, getDefaultTimeRange, LoadingState } from '@grafana/data';
|
||||
@@ -20,6 +21,7 @@ function getTraceView(frames: DataFrame[]) {
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
};
|
||||
const topOfViewRef = createRef<HTMLDivElement>();
|
||||
|
||||
const traceView = (
|
||||
<Provider store={store}>
|
||||
@@ -30,13 +32,10 @@ function getTraceView(frames: DataFrame[]) {
|
||||
traceProp={transformDataFrames(frames[0])!}
|
||||
search=""
|
||||
focusedSpanIdForSearch=""
|
||||
expandOne={() => {}}
|
||||
expandAll={() => {}}
|
||||
collapseOne={() => {}}
|
||||
collapseAll={() => {}}
|
||||
childrenToggle={() => {}}
|
||||
childrenHiddenIDs={new Set()}
|
||||
queryResponse={mockPanelData}
|
||||
datasource={undefined}
|
||||
topOfViewRef={topOfViewRef}
|
||||
topOfViewRefType={TopOfViewRefType.Explore}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
@@ -85,10 +84,10 @@ describe('TraceView', () => {
|
||||
expect(prettyDOM(baseElement)).toEqual(prettyDOM(baseElementOld));
|
||||
});
|
||||
|
||||
it('does not render anything on missing trace', () => {
|
||||
it('only renders noDataMsg on missing trace', () => {
|
||||
// Simulating Explore's access to empty response data
|
||||
const { container } = renderTraceView([]);
|
||||
expect(container.hasChildNodes()).toBeFalsy();
|
||||
expect(container.childNodes.length === 1).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles detailState', async () => {
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import React, { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { TopOfViewRefType } from '@jaegertracing/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView';
|
||||
import React, { RefObject, useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
DataLink,
|
||||
DataQuery,
|
||||
DataSourceApi,
|
||||
DataSourceJsonData,
|
||||
Field,
|
||||
GrafanaTheme2,
|
||||
LinkModel,
|
||||
LoadingState,
|
||||
mapInternalLinkToExplore,
|
||||
PanelData,
|
||||
SplitOpen,
|
||||
} from '@grafana/data';
|
||||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
import {
|
||||
Trace,
|
||||
TracePageHeader,
|
||||
TraceSpan,
|
||||
TraceTimelineViewer,
|
||||
TTraceTimeline,
|
||||
} from '@jaegertracing/jaeger-ui-components';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Trace, TracePageHeader, TraceTimelineViewer, TTraceTimeline } from '@jaegertracing/jaeger-ui-components';
|
||||
import { TraceToLogsData } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||
@@ -29,44 +28,43 @@ import { ExploreId } from 'app/types/explore';
|
||||
import { changePanelState } from '../state/explorePane';
|
||||
|
||||
import { createSpanLinkFactory } from './createSpanLink';
|
||||
import { useChildrenState } from './useChildrenState';
|
||||
import { useDetailState } from './useDetailState';
|
||||
import { useHoverIndentGuide } from './useHoverIndentGuide';
|
||||
import { useViewRange } from './useViewRange';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
noDataMsg: css`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: ${theme.typography.h4.fontSize};
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
});
|
||||
|
||||
function noop(): {} {
|
||||
return {};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dataFrames: DataFrame[];
|
||||
splitOpenFn: SplitOpen;
|
||||
exploreId: ExploreId;
|
||||
splitOpenFn?: SplitOpen;
|
||||
exploreId?: ExploreId;
|
||||
scrollElement?: Element;
|
||||
topOfExploreViewRef?: RefObject<HTMLDivElement>;
|
||||
traceProp: Trace;
|
||||
spanFindMatches?: Set<string>;
|
||||
search: string;
|
||||
focusedSpanIdForSearch: string;
|
||||
expandOne: (spans: TraceSpan[]) => void;
|
||||
expandAll: () => void;
|
||||
collapseOne: (spans: TraceSpan[]) => void;
|
||||
collapseAll: (spans: TraceSpan[]) => void;
|
||||
childrenToggle: (spanId: string) => void;
|
||||
childrenHiddenIDs: Set<string>;
|
||||
queryResponse: PanelData;
|
||||
datasource: DataSourceApi<DataQuery, DataSourceJsonData, {}> | undefined;
|
||||
topOfViewRef: RefObject<HTMLDivElement>;
|
||||
topOfViewRefType: TopOfViewRefType;
|
||||
};
|
||||
|
||||
export function TraceView(props: Props) {
|
||||
const {
|
||||
expandOne,
|
||||
expandAll,
|
||||
collapseOne,
|
||||
collapseAll,
|
||||
childrenToggle,
|
||||
childrenHiddenIDs,
|
||||
spanFindMatches,
|
||||
traceProp,
|
||||
} = props;
|
||||
const { spanFindMatches, traceProp, datasource, topOfViewRef, topOfViewRefType } = props;
|
||||
|
||||
const {
|
||||
detailStates,
|
||||
@@ -83,6 +81,9 @@ export function TraceView(props: Props) {
|
||||
|
||||
const { removeHoverIndentGuideId, addHoverIndentGuideId, hoverIndentGuideIds } = useHoverIndentGuide();
|
||||
const { viewRange, updateViewRangeTime, updateNextViewRangeTime } = useViewRange();
|
||||
const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
/**
|
||||
* Keeps state of resizable name column width
|
||||
@@ -93,13 +94,9 @@ export function TraceView(props: Props) {
|
||||
*/
|
||||
const [slim, setSlim] = useState(false);
|
||||
|
||||
const datasource = useSelector(
|
||||
(state: StoreState) => state.explore[props.exploreId]?.datasourceInstance ?? undefined
|
||||
);
|
||||
|
||||
const [focusedSpanId, createFocusSpanLink] = useFocusSpanLink({
|
||||
refId: props.dataFrames[0]?.refId,
|
||||
exploreId: props.exploreId,
|
||||
exploreId: props.exploreId!,
|
||||
datasource,
|
||||
});
|
||||
|
||||
@@ -120,79 +117,77 @@ export function TraceView(props: Props) {
|
||||
[childrenHiddenIDs, detailStates, hoverIndentGuideIds, spanNameColumnWidth, props.traceProp?.traceID]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.queryResponse.state === LoadingState.Done) {
|
||||
props.topOfExploreViewRef?.current?.scrollIntoView();
|
||||
}
|
||||
}, [props.queryResponse, props.topOfExploreViewRef]);
|
||||
|
||||
const traceToLogsOptions = (getDatasourceSrv().getInstanceSettings(datasource?.name)?.jsonData as TraceToLogsData)
|
||||
?.tracesToLogs;
|
||||
const createSpanLink = useMemo(
|
||||
() => createSpanLinkFactory({ splitOpenFn: props.splitOpenFn, traceToLogsOptions, dataFrame: props.dataFrames[0] }),
|
||||
() =>
|
||||
createSpanLinkFactory({ splitOpenFn: props.splitOpenFn!, traceToLogsOptions, dataFrame: props.dataFrames[0] }),
|
||||
[props.splitOpenFn, traceToLogsOptions, props.dataFrames]
|
||||
);
|
||||
const onSlimViewClicked = useCallback(() => setSlim(!slim), [slim]);
|
||||
const timeZone = useSelector((state: StoreState) => getTimeZone(state.user));
|
||||
|
||||
if (!props.dataFrames?.length || !traceProp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TracePageHeader
|
||||
canCollapse={false}
|
||||
hideMap={false}
|
||||
hideSummary={false}
|
||||
onSlimViewClicked={onSlimViewClicked}
|
||||
onTraceGraphViewClicked={noop}
|
||||
slimView={slim}
|
||||
trace={traceProp}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
viewRange={viewRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<TraceTimelineViewer
|
||||
registerAccessors={noop}
|
||||
scrollToFirstVisibleSpan={noop}
|
||||
findMatchesIDs={spanFindMatches}
|
||||
trace={traceProp}
|
||||
traceTimeline={traceTimeline}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
viewRange={viewRange}
|
||||
focusSpan={noop}
|
||||
createLinkToExternalSpan={createLinkToExternalSpan}
|
||||
setSpanNameColumnWidth={setSpanNameColumnWidth}
|
||||
collapseAll={collapseAll}
|
||||
collapseOne={collapseOne}
|
||||
expandAll={expandAll}
|
||||
expandOne={expandOne}
|
||||
childrenToggle={childrenToggle}
|
||||
clearShouldScrollToFirstUiFindMatch={noop}
|
||||
detailLogItemToggle={detailLogItemToggle}
|
||||
detailLogsToggle={detailLogsToggle}
|
||||
detailWarningsToggle={detailWarningsToggle}
|
||||
detailStackTracesToggle={detailStackTracesToggle}
|
||||
detailReferencesToggle={detailReferencesToggle}
|
||||
detailReferenceItemToggle={detailReferenceItemToggle}
|
||||
detailProcessToggle={detailProcessToggle}
|
||||
detailTagsToggle={detailTagsToggle}
|
||||
detailToggle={toggleDetail}
|
||||
setTrace={noop}
|
||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||
linksGetter={noop as any}
|
||||
uiFind={props.search}
|
||||
createSpanLink={createSpanLink}
|
||||
scrollElement={props.scrollElement}
|
||||
focusedSpanId={focusedSpanId}
|
||||
focusedSpanIdForSearch={props.focusedSpanIdForSearch}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
topOfExploreViewRef={props.topOfExploreViewRef}
|
||||
/>
|
||||
{props.dataFrames?.length && props.dataFrames[0]?.meta?.preferredVisualisationType === 'trace' && traceProp ? (
|
||||
<>
|
||||
<TracePageHeader
|
||||
canCollapse={false}
|
||||
hideMap={false}
|
||||
hideSummary={false}
|
||||
onSlimViewClicked={onSlimViewClicked}
|
||||
onTraceGraphViewClicked={noop}
|
||||
slimView={slim}
|
||||
trace={traceProp}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
viewRange={viewRange}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
<TraceTimelineViewer
|
||||
registerAccessors={noop}
|
||||
scrollToFirstVisibleSpan={noop}
|
||||
findMatchesIDs={spanFindMatches}
|
||||
trace={traceProp}
|
||||
traceTimeline={traceTimeline}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
viewRange={viewRange}
|
||||
focusSpan={noop}
|
||||
createLinkToExternalSpan={createLinkToExternalSpan}
|
||||
setSpanNameColumnWidth={setSpanNameColumnWidth}
|
||||
collapseAll={collapseAll}
|
||||
collapseOne={collapseOne}
|
||||
expandAll={expandAll}
|
||||
expandOne={expandOne}
|
||||
childrenToggle={childrenToggle}
|
||||
clearShouldScrollToFirstUiFindMatch={noop}
|
||||
detailLogItemToggle={detailLogItemToggle}
|
||||
detailLogsToggle={detailLogsToggle}
|
||||
detailWarningsToggle={detailWarningsToggle}
|
||||
detailStackTracesToggle={detailStackTracesToggle}
|
||||
detailReferencesToggle={detailReferencesToggle}
|
||||
detailReferenceItemToggle={detailReferenceItemToggle}
|
||||
detailProcessToggle={detailProcessToggle}
|
||||
detailTagsToggle={detailTagsToggle}
|
||||
detailToggle={toggleDetail}
|
||||
setTrace={noop}
|
||||
addHoverIndentGuideId={addHoverIndentGuideId}
|
||||
removeHoverIndentGuideId={removeHoverIndentGuideId}
|
||||
linksGetter={noop as any}
|
||||
uiFind={props.search}
|
||||
createSpanLink={createSpanLink}
|
||||
scrollElement={props.scrollElement}
|
||||
focusedSpanId={focusedSpanId}
|
||||
focusedSpanIdForSearch={props.focusedSpanIdForSearch!}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
topOfViewRef={topOfViewRef}
|
||||
topOfViewRefType={topOfViewRefType}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.noDataMsg}>No data</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { getDefaultTimeRange, LoadingState } from '@grafana/data';
|
||||
@@ -18,6 +18,7 @@ function renderTraceViewContainer(frames = [frameOld]) {
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
};
|
||||
const topOfViewRef = createRef<HTMLDivElement>();
|
||||
|
||||
const { container, baseElement } = render(
|
||||
<Provider store={store}>
|
||||
@@ -26,6 +27,7 @@ function renderTraceViewContainer(frames = [frameOld]) {
|
||||
dataFrames={frames}
|
||||
splitOpenFn={() => {}}
|
||||
queryResponse={mockPanelData}
|
||||
topOfViewRef={topOfViewRef}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import TracePageSearchBar from '@jaegertracing/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar';
|
||||
import { TopOfViewRefType } from '@jaegertracing/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView';
|
||||
import React, { RefObject, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { DataFrame, SplitOpen, PanelData } from '@grafana/data';
|
||||
import { Collapse } from '@grafana/ui';
|
||||
import { StoreState } from 'app/types';
|
||||
import { ExploreId } from 'app/types/explore';
|
||||
|
||||
import { TraceView } from './TraceView';
|
||||
import { useChildrenState } from './useChildrenState';
|
||||
import { useSearch } from './useSearch';
|
||||
import { transformDataFrames } from './utils/transform';
|
||||
interface Props {
|
||||
@@ -14,71 +16,20 @@ interface Props {
|
||||
splitOpenFn: SplitOpen;
|
||||
exploreId: ExploreId;
|
||||
scrollElement?: Element;
|
||||
topOfExploreViewRef?: RefObject<HTMLDivElement>;
|
||||
queryResponse: PanelData;
|
||||
topOfViewRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
export function TraceViewContainer(props: Props) {
|
||||
// At this point we only show single trace
|
||||
const frame = props.dataFrames[0];
|
||||
|
||||
const { dataFrames, splitOpenFn, exploreId, scrollElement, topOfExploreViewRef, queryResponse } = props;
|
||||
const { dataFrames, splitOpenFn, exploreId, scrollElement, topOfViewRef, queryResponse } = props;
|
||||
const traceProp = useMemo(() => transformDataFrames(frame), [frame]);
|
||||
const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans);
|
||||
const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState();
|
||||
|
||||
const [focusedSpanIdForSearch, setFocusedSpanIdForSearch] = useState('');
|
||||
const [searchBarSuffix, setSearchBarSuffix] = useState('');
|
||||
|
||||
const setTraceSearch = (value: string) => {
|
||||
setFocusedSpanIdForSearch('');
|
||||
setSearchBarSuffix('');
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
const nextResult = () => {
|
||||
expandAll();
|
||||
const spanMatches = Array.from(spanFindMatches!);
|
||||
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
? spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
: 0;
|
||||
|
||||
// new query || at end, go to start
|
||||
if (prevMatchedIndex === -1 || prevMatchedIndex === spanMatches.length - 1) {
|
||||
setFocusedSpanIdForSearch(spanMatches[0]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// get next
|
||||
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex + 1]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex + 2));
|
||||
};
|
||||
|
||||
const prevResult = () => {
|
||||
expandAll();
|
||||
const spanMatches = Array.from(spanFindMatches!);
|
||||
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
? spanMatches.indexOf(focusedSpanIdForSearch)
|
||||
: 0;
|
||||
|
||||
// new query || at start, go to end
|
||||
if (prevMatchedIndex === -1 || prevMatchedIndex === 0) {
|
||||
setFocusedSpanIdForSearch(spanMatches[spanMatches.length - 1]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(spanMatches.length));
|
||||
return;
|
||||
}
|
||||
|
||||
// get prev
|
||||
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex - 1]);
|
||||
setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex));
|
||||
};
|
||||
|
||||
const getSearchBarSuffix = (index: number): string => {
|
||||
if (spanFindMatches?.size && spanFindMatches?.size > 0) {
|
||||
return index + ' of ' + spanFindMatches?.size;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const datasource = useSelector(
|
||||
(state: StoreState) => state.explore[props.exploreId!]?.datasourceInstance ?? undefined
|
||||
);
|
||||
|
||||
if (!traceProp) {
|
||||
return null;
|
||||
@@ -87,12 +38,14 @@ export function TraceViewContainer(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<TracePageSearchBar
|
||||
nextResult={nextResult}
|
||||
prevResult={prevResult}
|
||||
navigable={true}
|
||||
searchValue={search}
|
||||
onSearchValueChange={setTraceSearch}
|
||||
setSearch={setSearch}
|
||||
spanFindMatches={spanFindMatches}
|
||||
searchBarSuffix={searchBarSuffix}
|
||||
setSearchBarSuffix={setSearchBarSuffix}
|
||||
focusedSpanIdForSearch={focusedSpanIdForSearch}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
/>
|
||||
|
||||
<Collapse label="Trace View" isOpen>
|
||||
@@ -101,18 +54,14 @@ export function TraceViewContainer(props: Props) {
|
||||
dataFrames={dataFrames}
|
||||
splitOpenFn={splitOpenFn}
|
||||
scrollElement={scrollElement}
|
||||
topOfExploreViewRef={topOfExploreViewRef}
|
||||
traceProp={traceProp}
|
||||
spanFindMatches={spanFindMatches}
|
||||
search={search}
|
||||
focusedSpanIdForSearch={focusedSpanIdForSearch}
|
||||
expandOne={expandOne}
|
||||
collapseOne={collapseOne}
|
||||
collapseAll={collapseAll}
|
||||
expandAll={expandAll}
|
||||
childrenToggle={childrenToggle}
|
||||
childrenHiddenIDs={childrenHiddenIDs}
|
||||
queryResponse={queryResponse}
|
||||
datasource={datasource}
|
||||
topOfViewRef={topOfViewRef}
|
||||
topOfViewRefType={TopOfViewRefType.Explore}
|
||||
/>
|
||||
</Collapse>
|
||||
</>
|
||||
|
||||
@@ -63,6 +63,7 @@ import * as statusHistoryPanel from 'app/plugins/panel/status-history/module';
|
||||
import * as tablePanel from 'app/plugins/panel/table/module';
|
||||
import * as textPanel from 'app/plugins/panel/text/module';
|
||||
import * as timeseriesPanel from 'app/plugins/panel/timeseries/module';
|
||||
import * as tracesPanel from 'app/plugins/panel/traces/module';
|
||||
import * as welcomeBanner from 'app/plugins/panel/welcome/module';
|
||||
import * as xyChartPanel from 'app/plugins/panel/xychart/module';
|
||||
|
||||
@@ -125,6 +126,7 @@ const builtInPlugins: any = {
|
||||
'app/plugins/panel/bargauge/module': barGaugePanel,
|
||||
'app/plugins/panel/barchart/module': barChartPanel,
|
||||
'app/plugins/panel/logs/module': logsPanel,
|
||||
'app/plugins/panel/traces/module': tracesPanel,
|
||||
'app/plugins/panel/welcome/module': welcomeBanner,
|
||||
'app/plugins/panel/nodeGraph/module': nodeGraph,
|
||||
'app/plugins/panel/histogram/module': histogramPanel,
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { Node } from 'slate';
|
||||
|
||||
import { GrafanaTheme2, isValidGoDuration, SelectableValue } from '@grafana/data';
|
||||
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime';
|
||||
import {
|
||||
InlineFieldRow,
|
||||
InlineField,
|
||||
@@ -152,6 +153,8 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
|
||||
}
|
||||
};
|
||||
|
||||
const templateSrv: TemplateSrv = getTemplateSrv();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
@@ -235,7 +238,8 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
|
||||
value={query.minDuration || ''}
|
||||
placeholder={durationPlaceholder}
|
||||
onBlur={() => {
|
||||
if (query.minDuration && !isValidGoDuration(query.minDuration)) {
|
||||
const templatedMinDuration = templateSrv.replace(query.minDuration ?? '');
|
||||
if (query.minDuration && !isValidGoDuration(templatedMinDuration)) {
|
||||
setInputErrors({ ...inputErrors, minDuration: true });
|
||||
} else {
|
||||
setInputErrors({ ...inputErrors, minDuration: false });
|
||||
@@ -258,7 +262,8 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
|
||||
value={query.maxDuration || ''}
|
||||
placeholder={durationPlaceholder}
|
||||
onBlur={() => {
|
||||
if (query.maxDuration && !isValidGoDuration(query.maxDuration)) {
|
||||
const templatedMaxDuration = templateSrv.replace(query.maxDuration ?? '');
|
||||
if (query.maxDuration && !isValidGoDuration(templatedMaxDuration)) {
|
||||
setInputErrors({ ...inputErrors, maxDuration: true });
|
||||
} else {
|
||||
setInputErrors({ ...inputErrors, maxDuration: false });
|
||||
|
||||
@@ -32,6 +32,57 @@ describe('Tempo data source', () => {
|
||||
expect(response).toBe('empty');
|
||||
});
|
||||
|
||||
describe('Variables should be interpolated correctly', () => {
|
||||
function getQuery(): TempoQuery {
|
||||
return {
|
||||
refId: 'x',
|
||||
queryType: 'traceId',
|
||||
linkedQuery: {
|
||||
refId: 'linked',
|
||||
expr: '{instance="$interpolationVar"}',
|
||||
},
|
||||
query: '$interpolationVar',
|
||||
search: '$interpolationVar',
|
||||
minDuration: '$interpolationVar',
|
||||
maxDuration: '$interpolationVar',
|
||||
};
|
||||
}
|
||||
|
||||
it('when traceId query for dashboard->explore', async () => {
|
||||
const templateSrv: any = { replace: jest.fn() };
|
||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||
const text = 'interpolationText';
|
||||
templateSrv.replace.mockReturnValue(text);
|
||||
|
||||
const queries = ds.interpolateVariablesInQueries([getQuery()], {
|
||||
interpolationVar: { text: text, value: text },
|
||||
});
|
||||
expect(templateSrv.replace).toBeCalledTimes(5);
|
||||
expect(queries[0].linkedQuery?.expr).toBe(text);
|
||||
expect(queries[0].query).toBe(text);
|
||||
expect(queries[0].search).toBe(text);
|
||||
expect(queries[0].minDuration).toBe(text);
|
||||
expect(queries[0].maxDuration).toBe(text);
|
||||
});
|
||||
|
||||
it('when traceId query for template variable', async () => {
|
||||
const templateSrv: any = { replace: jest.fn() };
|
||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||
const text = 'interpolationText';
|
||||
templateSrv.replace.mockReturnValue(text);
|
||||
|
||||
const resp = ds.applyTemplateVariables(getQuery(), {
|
||||
interpolationVar: { text: text, value: text },
|
||||
});
|
||||
expect(templateSrv.replace).toBeCalledTimes(5);
|
||||
expect(resp.linkedQuery?.expr).toBe(text);
|
||||
expect(resp.query).toBe(text);
|
||||
expect(resp.search).toBe(text);
|
||||
expect(resp.minDuration).toBe(text);
|
||||
expect(resp.maxDuration).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
it('parses json fields from backend', async () => {
|
||||
setupBackendSrv(
|
||||
new MutableDataFrame({
|
||||
@@ -49,7 +100,8 @@ describe('Tempo data source', () => {
|
||||
],
|
||||
})
|
||||
);
|
||||
const ds = new TempoDatasource(defaultSettings);
|
||||
const templateSrv: any = { replace: jest.fn() };
|
||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||
const response = await lastValueFrom(ds.query({ targets: [{ refId: 'refid1', query: '12345' }] } as any));
|
||||
|
||||
expect(
|
||||
@@ -152,7 +204,10 @@ describe('Tempo data source', () => {
|
||||
});
|
||||
|
||||
it('should build search query correctly', () => {
|
||||
const ds = new TempoDatasource(defaultSettings);
|
||||
const templateSrv: any = { replace: jest.fn() };
|
||||
const ds = new TempoDatasource(defaultSettings, templateSrv);
|
||||
const duration = '10ms';
|
||||
templateSrv.replace.mockReturnValue(duration);
|
||||
const tempoQuery: TempoQuery = {
|
||||
queryType: 'search',
|
||||
refId: 'A',
|
||||
@@ -160,15 +215,15 @@ describe('Tempo data source', () => {
|
||||
serviceName: 'frontend',
|
||||
spanName: '/config',
|
||||
search: 'root.http.status_code=500',
|
||||
minDuration: '1ms',
|
||||
maxDuration: '100s',
|
||||
minDuration: '$interpolationVar',
|
||||
maxDuration: '$interpolationVar',
|
||||
limit: 10,
|
||||
};
|
||||
const builtQuery = ds.buildSearchQuery(tempoQuery);
|
||||
expect(builtQuery).toStrictEqual({
|
||||
tags: 'root.http.status_code=500 service.name="frontend" name="/config"',
|
||||
minDuration: '1ms',
|
||||
maxDuration: '100s',
|
||||
minDuration: duration,
|
||||
maxDuration: duration,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,16 @@ import {
|
||||
DataSourceJsonData,
|
||||
isValidGoDuration,
|
||||
LoadingState,
|
||||
ScopedVars,
|
||||
} from '@grafana/data';
|
||||
import { config, BackendSrvRequest, DataSourceWithBackend, getBackendSrv } from '@grafana/runtime';
|
||||
import {
|
||||
config,
|
||||
BackendSrvRequest,
|
||||
DataSourceWithBackend,
|
||||
getBackendSrv,
|
||||
TemplateSrv,
|
||||
getTemplateSrv,
|
||||
} from '@grafana/runtime';
|
||||
import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings';
|
||||
import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
|
||||
import { serializeParams } from 'app/core/utils/fetch';
|
||||
@@ -92,7 +100,10 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
};
|
||||
uploadedJson?: string | ArrayBuffer | null = null;
|
||||
|
||||
constructor(private instanceSettings: DataSourceInstanceSettings<TempoJsonData>) {
|
||||
constructor(
|
||||
private instanceSettings: DataSourceInstanceSettings<TempoJsonData>,
|
||||
private readonly templateSrv: TemplateSrv = getTemplateSrv()
|
||||
) {
|
||||
super(instanceSettings);
|
||||
this.tracesToLogs = instanceSettings.jsonData.tracesToLogs;
|
||||
this.serviceMap = instanceSettings.jsonData.serviceMap;
|
||||
@@ -151,7 +162,8 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
const timeRange = config.featureToggles.tempoBackendSearch
|
||||
? { startTime: options.range.from.unix(), endTime: options.range.to.unix() }
|
||||
: undefined;
|
||||
const searchQuery = this.buildSearchQuery(targets.nativeSearch[0], timeRange);
|
||||
const query = this.applyVariables(targets.nativeSearch[0], options.scopedVars);
|
||||
const searchQuery = this.buildSearchQuery(query, timeRange);
|
||||
subQueries.push(
|
||||
this._request('/api/search', searchQuery).pipe(
|
||||
map((response) => {
|
||||
@@ -193,6 +205,43 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
return merge(...subQueries);
|
||||
}
|
||||
|
||||
applyTemplateVariables(query: TempoQuery, scopedVars: ScopedVars): Record<string, any> {
|
||||
return this.applyVariables(query, scopedVars);
|
||||
}
|
||||
|
||||
interpolateVariablesInQueries(queries: TempoQuery[], scopedVars: ScopedVars): TempoQuery[] {
|
||||
if (!queries || queries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return queries.map((query) => {
|
||||
return {
|
||||
...query,
|
||||
datasource: this.getRef(),
|
||||
...this.applyVariables(query, scopedVars),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
applyVariables(query: TempoQuery, scopedVars: ScopedVars) {
|
||||
const expandedQuery = { ...query };
|
||||
|
||||
if (query.linkedQuery) {
|
||||
expandedQuery.linkedQuery = {
|
||||
...query.linkedQuery,
|
||||
expr: this.templateSrv.replace(query.linkedQuery?.expr ?? '', scopedVars),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...expandedQuery,
|
||||
query: this.templateSrv.replace(query.query ?? '', scopedVars),
|
||||
search: this.templateSrv.replace(query.search ?? '', scopedVars),
|
||||
minDuration: this.templateSrv.replace(query.minDuration ?? '', scopedVars),
|
||||
maxDuration: this.templateSrv.replace(query.maxDuration ?? '', scopedVars),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the simplest of the queries where we have just a trace id and return trace data for it.
|
||||
* @param options
|
||||
@@ -278,12 +327,14 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
|
||||
// Validate query inputs and remove spaces if valid
|
||||
if (tempoQuery.minDuration) {
|
||||
tempoQuery.minDuration = this.templateSrv.replace(tempoQuery.minDuration ?? '');
|
||||
if (!isValidGoDuration(tempoQuery.minDuration)) {
|
||||
throw new Error('Please enter a valid min duration.');
|
||||
}
|
||||
tempoQuery.minDuration = tempoQuery.minDuration.replace(/\s/g, '');
|
||||
}
|
||||
if (tempoQuery.maxDuration) {
|
||||
tempoQuery.maxDuration = this.templateSrv.replace(tempoQuery.maxDuration ?? '');
|
||||
if (!isValidGoDuration(tempoQuery.maxDuration)) {
|
||||
throw new Error('Please enter a valid max duration.');
|
||||
}
|
||||
|
||||
22
public/app/plugins/panel/traces/TracesPanel.test.tsx
Normal file
22
public/app/plugins/panel/traces/TracesPanel.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { LoadingState, PanelProps } from '@grafana/data';
|
||||
|
||||
import { TracesPanel } from './TracesPanel';
|
||||
|
||||
describe('TracesPanel', () => {
|
||||
it('shows no data message when no data supplied', async () => {
|
||||
const props = {
|
||||
data: {
|
||||
error: undefined,
|
||||
series: [],
|
||||
state: LoadingState.Done,
|
||||
},
|
||||
} as unknown as PanelProps;
|
||||
|
||||
render(<TracesPanel {...props} />);
|
||||
|
||||
await screen.findByText('No data found in response');
|
||||
});
|
||||
});
|
||||
69
public/app/plugins/panel/traces/TracesPanel.tsx
Normal file
69
public/app/plugins/panel/traces/TracesPanel.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { css } from '@emotion/css';
|
||||
import TracePageSearchBar from '@jaegertracing/jaeger-ui-components/src/TracePageHeader/TracePageSearchBar';
|
||||
import { TopOfViewRefType } from '@jaegertracing/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView';
|
||||
import React, { useMemo, useState, createRef } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { PanelProps } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { TraceView } from 'app/features/explore/TraceView/TraceView';
|
||||
import { useSearch } from 'app/features/explore/TraceView/useSearch';
|
||||
import { transformDataFrames } from 'app/features/explore/TraceView/utils/transform';
|
||||
|
||||
const styles = {
|
||||
wrapper: css`
|
||||
height: 100%;
|
||||
overflow: scroll;
|
||||
`,
|
||||
};
|
||||
|
||||
export const TracesPanel: React.FunctionComponent<PanelProps> = ({ data }) => {
|
||||
const topOfViewRef = createRef<HTMLDivElement>();
|
||||
const traceProp = useMemo(() => transformDataFrames(data.series[0]), [data.series]);
|
||||
const { search, setSearch, spanFindMatches } = useSearch(traceProp?.spans);
|
||||
const [focusedSpanIdForSearch, setFocusedSpanIdForSearch] = useState('');
|
||||
const [searchBarSuffix, setSearchBarSuffix] = useState('');
|
||||
const dataSource = useAsync(async () => {
|
||||
return await getDataSourceSrv().get(data.request?.targets[0].datasource?.uid);
|
||||
});
|
||||
const scrollElement = document.getElementsByClassName(styles.wrapper)[0];
|
||||
|
||||
if (!data || !data.series.length || !traceProp) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div ref={topOfViewRef}></div>
|
||||
{data.series[0]?.meta?.preferredVisualisationType === 'trace' ? (
|
||||
<TracePageSearchBar
|
||||
navigable={true}
|
||||
searchValue={search}
|
||||
setSearch={setSearch}
|
||||
spanFindMatches={spanFindMatches}
|
||||
searchBarSuffix={searchBarSuffix}
|
||||
setSearchBarSuffix={setSearchBarSuffix}
|
||||
focusedSpanIdForSearch={focusedSpanIdForSearch}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TraceView
|
||||
dataFrames={data.series}
|
||||
scrollElement={scrollElement}
|
||||
traceProp={traceProp}
|
||||
spanFindMatches={spanFindMatches}
|
||||
search={search}
|
||||
focusedSpanIdForSearch={focusedSpanIdForSearch}
|
||||
queryResponse={data}
|
||||
datasource={dataSource.value}
|
||||
topOfViewRef={topOfViewRef}
|
||||
topOfViewRefType={TopOfViewRefType.Panel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
public/app/plugins/panel/traces/img/traces-panel.svg
Normal file
1
public/app/plugins/panel/traces/img/traces-panel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 280.12 263.29"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#1f60c4;}</style><linearGradient id="linear-gradient" x1="181.23" y1="167.35" x2="280.12" y2="167.35" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Icons"><rect class="cls-1" x="181.23" y="148.69" width="98.89" height="37.33"/><rect class="cls-2" x="153.84" y="142.83" width="12.44" height="49.04"/><rect class="cls-2" x="27.39" y="220.11" width="252.73" height="37.33"/><rect class="cls-2" y="214.25" width="12.44" height="49.04"/><rect class="cls-2" x="81.85" y="77.27" width="133.33" height="37.33"/><rect class="cls-2" x="8.08" y="71.42" width="12.44" height="49.03"/><rect class="cls-2" x="35.46" y="5.85" width="244.65" height="37.33"/><rect class="cls-2" x="8.08" width="12.44" height="49.03"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1016 B |
5
public/app/plugins/panel/traces/module.tsx
Normal file
5
public/app/plugins/panel/traces/module.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PanelPlugin } from '@grafana/data';
|
||||
|
||||
import { TracesPanel } from './TracesPanel';
|
||||
|
||||
export const plugin = new PanelPlugin(TracesPanel);
|
||||
17
public/app/plugins/panel/traces/plugin.json
Normal file
17
public/app/plugins/panel/traces/plugin.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Traces",
|
||||
"id": "traces",
|
||||
"state": "beta",
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/traces-panel.svg",
|
||||
"large": "img/traces-panel.svg"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user