mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-10 10:20:42 +00:00
Compare commits
3 Commits
fix/genera
...
backup/thr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b941fe606 | ||
|
|
f45d683ec2 | ||
|
|
edca5d2e3e |
58
frontend/src/hooks/trace/__tests__/useCopySpanLink.test.tsx
Normal file
58
frontend/src/hooks/trace/__tests__/useCopySpanLink.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
__resetSearchParamsGetter,
|
||||||
|
__setSearchParamsGetterForTest,
|
||||||
|
} from 'utils/getUnstableCurrentSearchParams';
|
||||||
|
|
||||||
|
import { useCopySpanLink } from '../useCopySpanLink';
|
||||||
|
|
||||||
|
const mockCopy = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('react-use', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@signozhq/ui/sonner', () => ({
|
||||||
|
toast: { success: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The router only knows the stale URL; nuqs has since written ?tab=thread.
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||||
|
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
|
||||||
|
{children}
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
const clickEvent = {
|
||||||
|
preventDefault: jest.fn(),
|
||||||
|
stopPropagation: jest.fn(),
|
||||||
|
} as unknown as MouseEvent<HTMLElement>;
|
||||||
|
|
||||||
|
describe('useCopySpanLink', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCopy.mockClear();
|
||||||
|
__setSearchParamsGetterForTest(
|
||||||
|
() => new URLSearchParams('tab=thread&spanId=old'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetSearchParamsGetter();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the link from the live URL, keeping its tab', () => {
|
||||||
|
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.current.onSpanCopy(clickEvent);
|
||||||
|
|
||||||
|
const link = new URL(mockCopy.mock.calls[0][0]);
|
||||||
|
expect(link.pathname).toBe('/trace/abc');
|
||||||
|
expect(link.searchParams.get('tab')).toBe('thread');
|
||||||
|
expect(link.searchParams.get('spanId')).toBe('new');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useCopyToClipboard } from 'react-use';
|
import { useCopyToClipboard } from 'react-use';
|
||||||
import { toast } from '@signozhq/ui/sonner';
|
import { toast } from '@signozhq/ui/sonner';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
|
||||||
import { getAbsoluteUrl } from 'utils/basePath';
|
import { getAbsoluteUrl } from 'utils/basePath';
|
||||||
|
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||||
|
|
||||||
// Accepts both V2 (spanId) and V3 (span_id) span shapes
|
// Accepts both V2 (spanId) and V3 (span_id) span shapes
|
||||||
// TODO: Remove V2 (spanId) support when phasing out V2
|
// TODO: Remove V2 (spanId) support when phasing out V2
|
||||||
@@ -15,7 +15,6 @@ interface SpanLike {
|
|||||||
export const useCopySpanLink = (
|
export const useCopySpanLink = (
|
||||||
span?: SpanLike,
|
span?: SpanLike,
|
||||||
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
|
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
|
||||||
const urlQuery = useUrlQuery();
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [, setCopy] = useCopyToClipboard();
|
const [, setCopy] = useCopyToClipboard();
|
||||||
|
|
||||||
@@ -28,21 +27,22 @@ export const useCopySpanLink = (
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
urlQuery.delete('spanId');
|
const params = getUnstableCurrentSearchParams();
|
||||||
|
params.delete('spanId');
|
||||||
|
|
||||||
const id = span.span_id || span.spanId;
|
const id = span.span_id || span.spanId;
|
||||||
if (id) {
|
if (id) {
|
||||||
urlQuery.set('spanId', id);
|
params.set('spanId', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
|
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
|
||||||
|
|
||||||
setCopy(link);
|
setCopy(link);
|
||||||
toast.success('Copied to clipboard', {
|
toast.success('Copied to clipboard', {
|
||||||
position: 'top-right',
|
position: 'top-right',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[span, urlQuery, pathname, setCopy],
|
[span, pathname, setCopy],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.filterSection {
|
.filterSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Button } from '@signozhq/ui/button';
|
import { Button } from '@signozhq/ui/button';
|
||||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||||
@@ -13,16 +13,20 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
|||||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import { TraceDetailsTab } from '../constants';
|
||||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||||
|
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||||
import { useTraceStore } from '../stores/traceStore';
|
import { useTraceStore } from '../stores/traceStore';
|
||||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||||
|
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||||
import MissingSpansBanner from './MissingSpansBanner';
|
import MissingSpansBanner from './MissingSpansBanner';
|
||||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
|
||||||
import { useTraceSummary } from './useTraceSummary';
|
import { useTraceSummary } from './useTraceSummary';
|
||||||
|
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||||
|
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||||
|
|
||||||
import styles from './TraceDetailsHeader.module.scss';
|
import styles from './TraceDetailsHeader.module.scss';
|
||||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||||
@@ -60,6 +64,14 @@ function TraceDetailsHeader({
|
|||||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||||
const { data: traceSummary } = useTraceSummary(traceID);
|
const { data: traceSummary } = useTraceSummary(traceID);
|
||||||
|
const [tab] = useTraceDetailsTab();
|
||||||
|
const isOverview = tab === TraceDetailsTab.Overview;
|
||||||
|
|
||||||
|
// Overview-only panels must not linger over another tab.
|
||||||
|
useEffect(() => {
|
||||||
|
setIsPreviewFieldsOpen(false);
|
||||||
|
setIsAnalyticsOpen(false);
|
||||||
|
}, [tab]);
|
||||||
const previewFields = useTraceStore((s) => s.previewFields);
|
const previewFields = useTraceStore((s) => s.previewFields);
|
||||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||||
|
|
||||||
@@ -93,13 +105,14 @@ function TraceDetailsHeader({
|
|||||||
setShowTraceDetails((prev) => !prev);
|
setShowTraceDetails((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useTraceDetailsStripInfo({
|
||||||
|
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
|
||||||
|
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||||
|
|
||||||
const durationMs = traceSummary
|
|
||||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@@ -120,9 +133,10 @@ function TraceDetailsHeader({
|
|||||||
badgeValue={traceID}
|
badgeValue={traceID}
|
||||||
maxCharacters={100}
|
maxCharacters={100}
|
||||||
/>
|
/>
|
||||||
|
<TraceDetailsTabs />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
styles.filterSection,
|
styles.filterSection,
|
||||||
@@ -138,6 +152,7 @@ function TraceDetailsHeader({
|
|||||||
icon
|
icon
|
||||||
color="secondary"
|
color="secondary"
|
||||||
aria-label="Analytics"
|
aria-label="Analytics"
|
||||||
|
testId="trace-analytics-button"
|
||||||
onClick={handleToggleAnalytics}
|
onClick={handleToggleAnalytics}
|
||||||
>
|
>
|
||||||
<ChartPie size={14} />
|
<ChartPie size={14} />
|
||||||
@@ -172,16 +187,20 @@ function TraceDetailsHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTraceDetails && (
|
{(showTraceDetails || !isOverview) && (
|
||||||
<div className={styles.subHeader}>
|
<div className={styles.subHeader}>
|
||||||
{traceSummary ? (
|
{!traceSummary ? (
|
||||||
|
<DetailsLoader />
|
||||||
|
) : (
|
||||||
<EntityMetadataRow
|
<EntityMetadataRow
|
||||||
entity="trace"
|
entity="trace"
|
||||||
service={{
|
service={{
|
||||||
name: traceSummary.rootServiceName,
|
name: traceSummary.rootServiceName,
|
||||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||||
}}
|
}}
|
||||||
durationMs={durationMs}
|
durationMs={
|
||||||
|
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||||
|
}
|
||||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||||
)}
|
)}
|
||||||
@@ -189,13 +208,11 @@ function TraceDetailsHeader({
|
|||||||
tokens={traceSummary.ai?.tokens}
|
tokens={traceSummary.ai?.tokens}
|
||||||
cost={traceSummary.ai?.totalCost}
|
cost={traceSummary.ai?.totalCost}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<DetailsLoader />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||||
|
|
||||||
<FieldsSelector
|
<FieldsSelector
|
||||||
isOpen={isPreviewFieldsOpen}
|
isOpen={isPreviewFieldsOpen}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||||
|
|
||||||
|
import { TraceDetailsTab } from '../constants';
|
||||||
|
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||||
|
|
||||||
|
import styles from './TraceDetailsHeader.module.scss';
|
||||||
|
|
||||||
|
const TAB_ITEMS = [
|
||||||
|
{
|
||||||
|
value: TraceDetailsTab.Overview,
|
||||||
|
label: 'Overview',
|
||||||
|
testId: 'trace-details-tab-overview',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: TraceDetailsTab.Thread,
|
||||||
|
label: 'Thread',
|
||||||
|
testId: 'trace-details-tab-thread',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function TraceDetailsTabs(): JSX.Element {
|
||||||
|
const [tab, setTab] = useTraceDetailsTab();
|
||||||
|
|
||||||
|
const handleChange = (value: string): void => {
|
||||||
|
void setTab(value as TraceDetailsTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
<ToggleGroup
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
type="single"
|
||||||
|
value={tab}
|
||||||
|
onChange={handleChange}
|
||||||
|
size="sm"
|
||||||
|
items={TAB_ITEMS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TraceDetailsTabs;
|
||||||
@@ -50,6 +50,7 @@ function TraceOptionsMenu({
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
value: 'toggle-trace-details',
|
value: 'toggle-trace-details',
|
||||||
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
|
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
|
||||||
|
testId: 'toggle-trace-details',
|
||||||
onClick: onToggleTraceDetails,
|
onClick: onToggleTraceDetails,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,6 +128,7 @@ function TraceOptionsMenu({
|
|||||||
icon
|
icon
|
||||||
color="secondary"
|
color="secondary"
|
||||||
aria-label="Trace options"
|
aria-label="Trace options"
|
||||||
|
testId="trace-options-button"
|
||||||
>
|
>
|
||||||
<Settings2 size={14} />
|
<Settings2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fireEvent, screen } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||||
|
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import { render } from 'tests/test-utils';
|
import { render } from 'tests/test-utils';
|
||||||
|
|
||||||
@@ -315,3 +316,88 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
|||||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const metadataText = /Missing Span/;
|
||||||
|
|
||||||
|
describe('TraceDetailsHeader – tabs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(useTraceSummary).mockReturnValue({
|
||||||
|
data: {
|
||||||
|
...SUMMARY,
|
||||||
|
rootServiceEntryPoint: 'Missing Span',
|
||||||
|
hasMissingSpans: true,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest
|
||||||
|
.mocked(useTraceSummary)
|
||||||
|
.mockReturnValue({ data: undefined, isLoading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||||
|
const renderHeader = (): void => {
|
||||||
|
render(
|
||||||
|
<NuqsTestingAdapter hasMemory>
|
||||||
|
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
|
||||||
|
</NuqsTestingAdapter>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('shows the Overview-only sections on the Overview tab', () => {
|
||||||
|
renderHeader();
|
||||||
|
|
||||||
|
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('trace-analytics-button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('trace-options-button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only the metadata row on the Thread tab', async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
renderHeader();
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('trace-analytics-button'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('trace-options-button')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
renderHeader();
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('trace-options-button'));
|
||||||
|
await user.click(await screen.findByTestId('toggle-trace-details'));
|
||||||
|
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||||
|
|
||||||
|
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
renderHeader();
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('trace-analytics-button'));
|
||||||
|
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||||
|
'data-open',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||||
|
'data-open',
|
||||||
|
'false',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
.root {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layoutRow {
|
.layoutRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
import {
|
||||||
|
MutableRefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ChartNoAxesGantt,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Info,
|
||||||
|
TriangleAlert,
|
||||||
|
} from '@signozhq/icons';
|
||||||
|
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||||
|
import { Collapse } from 'antd';
|
||||||
|
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||||
|
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||||
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||||
|
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||||
|
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||||
|
|
||||||
|
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||||
|
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||||
|
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||||
|
import NoData from '../NoData/NoData';
|
||||||
|
import {
|
||||||
|
setTraceStoreAvailableColorByFields,
|
||||||
|
useTraceStore,
|
||||||
|
} from '../stores/traceStore';
|
||||||
|
import {
|
||||||
|
SPAN_DETAILS_PANEL_MIN_WIDTH,
|
||||||
|
SpanDetailVariant,
|
||||||
|
} from '../SpanDetailsPanel/constants';
|
||||||
|
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||||
|
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||||
|
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||||
|
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||||
|
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||||
|
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||||
|
import { getAvailableColorByFieldNames } from '../utils';
|
||||||
|
|
||||||
|
import cx from 'classnames';
|
||||||
|
|
||||||
|
import styles from './TraceDetailsOverview.module.scss';
|
||||||
|
|
||||||
|
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||||
|
// span-tree chevrons in the waterfall.
|
||||||
|
function renderPanelExpandIcon({
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
isActive?: boolean;
|
||||||
|
}): JSX.Element {
|
||||||
|
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceDetailsOverviewProps {
|
||||||
|
filteredSpanIds: string[];
|
||||||
|
isFilterActive: boolean;
|
||||||
|
onHasTraceDataChange: (hasTraceData: boolean) => void;
|
||||||
|
dataLoadedFiredForRef: MutableRefObject<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceDetailsOverview({
|
||||||
|
filteredSpanIds,
|
||||||
|
isFilterActive,
|
||||||
|
onHasTraceDataChange,
|
||||||
|
dataLoadedFiredForRef,
|
||||||
|
}: TraceDetailsOverviewProps): JSX.Element {
|
||||||
|
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||||
|
const urlQuery = useUrlQuery();
|
||||||
|
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||||
|
() => ({
|
||||||
|
spanId: urlQuery.get('spanId') || '',
|
||||||
|
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||||
|
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||||
|
Set<string>
|
||||||
|
>(new Set());
|
||||||
|
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||||
|
|
||||||
|
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||||
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
|
||||||
|
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||||
|
const colorByField = useTraceStore((s) => s.colorByField);
|
||||||
|
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||||
|
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||||
|
|
||||||
|
const handleSpanDetailsClose = useCallback((): void => {
|
||||||
|
const params = getUnstableCurrentSearchParams();
|
||||||
|
params.delete('spanId');
|
||||||
|
safeNavigate({ search: params.toString() }, { replace: true });
|
||||||
|
}, [safeNavigate]);
|
||||||
|
|
||||||
|
const panelState = useDetailsPanel({
|
||||||
|
entityId: selectedSpanId,
|
||||||
|
onClose: handleSpanDetailsClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSpansRef = useRef<SpanV3[]>([]);
|
||||||
|
const deepLinkResolvedRef = useRef(false);
|
||||||
|
|
||||||
|
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||||
|
// and other in-window URL navigation from triggering a backend window slide.
|
||||||
|
useEffect(() => {
|
||||||
|
const spanId = urlQuery.get('spanId') || '';
|
||||||
|
if (!spanId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
setSelectedSpan(allSpansRef.current[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||||
|
}, [urlQuery]);
|
||||||
|
|
||||||
|
// Once all spans are loaded (frontend mode), freeze query params so
|
||||||
|
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||||
|
const fullDataLoadedRef = useRef(false);
|
||||||
|
const frozenParamsRef = useRef({
|
||||||
|
selectedSpanId: interestedSpanId.spanId,
|
||||||
|
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||||
|
uncollapsedSpans: uncollapsedNodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryParams = fullDataLoadedRef.current
|
||||||
|
? frozenParamsRef.current
|
||||||
|
: {
|
||||||
|
selectedSpanId: interestedSpanId.spanId,
|
||||||
|
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||||
|
uncollapsedSpans: uncollapsedNodes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: traceData,
|
||||||
|
isFetching: isFetchingTraceData,
|
||||||
|
error: errorFetchingTraceData,
|
||||||
|
} = useGetTraceV4({
|
||||||
|
traceId,
|
||||||
|
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||||
|
selectedSpanId: queryParams.selectedSpanId,
|
||||||
|
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSpans = traceData?.payload?.spans || [];
|
||||||
|
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||||
|
|
||||||
|
const isFullDataLoaded =
|
||||||
|
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||||
|
|
||||||
|
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||||
|
// trace-wide, so any window has the full set — no need to accumulate.
|
||||||
|
const availableColorByFields = useMemo(() => {
|
||||||
|
const spans = traceData?.payload?.spans;
|
||||||
|
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||||
|
}, [traceData?.payload?.spans]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||||
|
}, [availableColorByFields]);
|
||||||
|
|
||||||
|
// Lock the ref once we confirm all data is loaded
|
||||||
|
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||||
|
fullDataLoadedRef.current = true;
|
||||||
|
frozenParamsRef.current = {
|
||||||
|
selectedSpanId: interestedSpanId.spanId,
|
||||||
|
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||||
|
uncollapsedSpans: uncollapsedNodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
allSpansRef.current = allSpans;
|
||||||
|
}, [allSpans]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!traceId ||
|
||||||
|
dataLoadedFiredForRef.current === traceId ||
|
||||||
|
!userPrefsReady
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = traceData?.payload;
|
||||||
|
if (!payload?.spans?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dataLoadedFiredForRef.current = traceId;
|
||||||
|
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||||
|
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||||
|
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||||
|
[TraceDetailEventKeys.NumServices]: numServices,
|
||||||
|
[TraceDetailEventKeys.TraceDurationMs]:
|
||||||
|
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||||
|
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||||
|
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||||
|
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||||
|
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||||
|
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||||
|
SpanDetailVariant.DOCKED_RIGHT,
|
||||||
|
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||||
|
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
traceId,
|
||||||
|
userPrefsReady,
|
||||||
|
traceData,
|
||||||
|
totalSpansCount,
|
||||||
|
colorByField,
|
||||||
|
previewFieldsCount,
|
||||||
|
logTraceEvent,
|
||||||
|
dataLoadedFiredForRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Frontend mode: expand all parents by default when full data arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFullDataLoaded && allSpans.length > 0) {
|
||||||
|
const parentIds = new Set(
|
||||||
|
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||||
|
);
|
||||||
|
setLocalUncollapsedNodes(parentIds);
|
||||||
|
}
|
||||||
|
}, [isFullDataLoaded, allSpans]);
|
||||||
|
|
||||||
|
// Backend mode: sync uncollapsed state from API response
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isFullDataLoaded &&
|
||||||
|
traceData &&
|
||||||
|
traceData.payload &&
|
||||||
|
traceData.payload.uncollapsedSpans
|
||||||
|
) {
|
||||||
|
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||||
|
}
|
||||||
|
}, [traceData, isFullDataLoaded]);
|
||||||
|
|
||||||
|
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||||
|
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (deepLinkResolvedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allSpans.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedSpanId) {
|
||||||
|
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||||
|
if (!span) {
|
||||||
|
// Span not in the current window — wait for more data (backend
|
||||||
|
// pagination) before marking resolved.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedSpan(span);
|
||||||
|
} else {
|
||||||
|
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||||
|
}
|
||||||
|
deepLinkResolvedRef.current = true;
|
||||||
|
}, [selectedSpanId, allSpans]);
|
||||||
|
|
||||||
|
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||||
|
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||||
|
if (ancestors.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalUncollapsedNodes((prev) => {
|
||||||
|
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||||
|
let allPresent = true;
|
||||||
|
for (const id of ancestors) {
|
||||||
|
if (!prev.has(id)) {
|
||||||
|
allPresent = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allPresent) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of ancestors) {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||||
|
|
||||||
|
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||||
|
|
||||||
|
const handleCollapseChange = (key: string): void => {
|
||||||
|
logTraceEvent(
|
||||||
|
key === 'flame'
|
||||||
|
? TraceDetailEvents.FlameGraphToggled
|
||||||
|
: TraceDetailEvents.WaterfallToggled,
|
||||||
|
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||||
|
);
|
||||||
|
setActiveKeys((prev) =>
|
||||||
|
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
|
||||||
|
|
||||||
|
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
|
||||||
|
const RIGHT_DOCK_MAX = 720;
|
||||||
|
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||||
|
|
||||||
|
const showNoData =
|
||||||
|
!isFetchingTraceData &&
|
||||||
|
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||||
|
|
||||||
|
const hasTraceData = !showNoData && allSpans.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onHasTraceDataChange(hasTraceData);
|
||||||
|
return (): void => onHasTraceDataChange(false);
|
||||||
|
}, [hasTraceData, onHasTraceDataChange]);
|
||||||
|
|
||||||
|
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||||
|
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||||
|
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||||
|
const showRightDock = panelState.isOpen && isRightDocked;
|
||||||
|
|
||||||
|
const waterfallChildren = (
|
||||||
|
<ResizableBox
|
||||||
|
defaultHeight={300}
|
||||||
|
minHeight={150}
|
||||||
|
disabled={!isWaterfallDocked}
|
||||||
|
>
|
||||||
|
<TraceWaterfall
|
||||||
|
traceData={traceData}
|
||||||
|
isFetchingTraceData={isFetchingTraceData}
|
||||||
|
errorFetchingTraceData={errorFetchingTraceData}
|
||||||
|
traceId={traceId || ''}
|
||||||
|
interestedSpanId={interestedSpanId}
|
||||||
|
setInterestedSpanId={setInterestedSpanId}
|
||||||
|
uncollapsedNodes={uncollapsedNodes}
|
||||||
|
isFullDataLoaded={isFullDataLoaded}
|
||||||
|
localUncollapsedNodes={localUncollapsedNodes}
|
||||||
|
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||||
|
selectedSpan={selectedSpan}
|
||||||
|
setSelectedSpan={setSelectedSpan}
|
||||||
|
filteredSpanIds={filteredSpanIds}
|
||||||
|
isFilterActive={isFilterActive}
|
||||||
|
/>
|
||||||
|
</ResizableBox>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showNoData ? (
|
||||||
|
<NoData />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.layoutRow}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Collapse
|
||||||
|
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||||
|
motion={false}
|
||||||
|
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||||
|
onChange={(): void => handleCollapseChange('flame')}
|
||||||
|
size="small"
|
||||||
|
expandIcon={renderPanelExpandIcon}
|
||||||
|
className={styles.flameCollapse}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'flame',
|
||||||
|
label: (
|
||||||
|
<div className={styles.collapseLabel}>
|
||||||
|
<span className={styles.collapseTitle}>
|
||||||
|
Flame Graph
|
||||||
|
{traceData?.payload?.totalSpansCount &&
|
||||||
|
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||||
|
<WarningPopover
|
||||||
|
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<Info
|
||||||
|
size={16}
|
||||||
|
color="var(--l2-foreground)"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</WarningPopover>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{traceData?.payload?.totalSpansCount ? (
|
||||||
|
<span className={styles.collapseCount}>
|
||||||
|
<span className={styles.collapseCountItem}>
|
||||||
|
<ChartNoAxesGantt size={13} />
|
||||||
|
Spans: {traceData.payload.totalSpansCount}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cx(styles.collapseCountItem, {
|
||||||
|
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<TriangleAlert size={13} />
|
||||||
|
Errors:{' '}
|
||||||
|
{traceData.payload.totalErrorSpansCount ?? (
|
||||||
|
<span className="translate-safe">{0}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||||
|
<TraceFlamegraph
|
||||||
|
filteredSpanIds={filteredSpanIds}
|
||||||
|
isFilterActive={isFilterActive}
|
||||||
|
selectedSpan={selectedSpan}
|
||||||
|
totalSpansCount={totalSpansCount}
|
||||||
|
/>
|
||||||
|
</ResizableBox>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||||
|
motion={false}
|
||||||
|
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||||
|
onChange={(): void => handleCollapseChange('waterfall')}
|
||||||
|
size="small"
|
||||||
|
expandIcon={renderPanelExpandIcon}
|
||||||
|
className={cx(styles.waterfallCollapse, {
|
||||||
|
[styles.isDocked]: isWaterfallDocked,
|
||||||
|
})}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'waterfall',
|
||||||
|
label: 'Waterfall',
|
||||||
|
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{panelState.isOpen && isDocked && (
|
||||||
|
<div className={styles.dockedSpanDetails}>
|
||||||
|
<SpanDetailsPanel
|
||||||
|
panelState={panelState}
|
||||||
|
selectedSpan={selectedSpan}
|
||||||
|
variant={SpanDetailVariant.DOCKED}
|
||||||
|
onVariantChange={handleVariantChange}
|
||||||
|
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||||
|
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRightDock && (
|
||||||
|
<ResizableBox
|
||||||
|
handle="left"
|
||||||
|
defaultWidth={rightDockWidth}
|
||||||
|
minWidth={RIGHT_DOCK_MIN}
|
||||||
|
maxWidth={RIGHT_DOCK_MAX}
|
||||||
|
onResize={setRightDockWidth}
|
||||||
|
className={styles.rightDock}
|
||||||
|
>
|
||||||
|
<SpanDetailsPanel
|
||||||
|
panelState={panelState}
|
||||||
|
selectedSpan={selectedSpan}
|
||||||
|
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||||
|
onVariantChange={handleVariantChange}
|
||||||
|
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||||
|
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||||
|
/>
|
||||||
|
</ResizableBox>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||||
|
<SpanDetailsPanel
|
||||||
|
panelState={panelState}
|
||||||
|
selectedSpan={selectedSpan}
|
||||||
|
variant={SpanDetailVariant.DIALOG}
|
||||||
|
onVariantChange={handleVariantChange}
|
||||||
|
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||||
|
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TraceDetailsOverview;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
function TraceDetailsThread(): JSX.Element {
|
||||||
|
return <div data-testid="trace-details-thread" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TraceDetailsThread;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.root {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import TraceStoreSync from '../stores/TraceStoreSync';
|
||||||
|
|
||||||
|
import styles from './TraceDetailsWrapper.module.scss';
|
||||||
|
|
||||||
|
interface TraceDetailsWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceDetailsWrapper({
|
||||||
|
children,
|
||||||
|
}: TraceDetailsWrapperProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<TraceStoreSync>
|
||||||
|
<div className={styles.root}>{children}</div>
|
||||||
|
</TraceStoreSync>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TraceDetailsWrapper;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
import { useHistory, useParams } from 'react-router-dom';
|
||||||
import { Skeleton } from 'antd';
|
import { Skeleton } from 'antd';
|
||||||
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
|
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||||
|
|
||||||
@@ -30,7 +31,6 @@ function TraceFlamegraph({
|
|||||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||||
const urlQuery = useUrlQuery();
|
const urlQuery = useUrlQuery();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { search } = useLocation();
|
|
||||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||||
urlQuery.get('spanId') || '',
|
urlQuery.get('spanId') || '',
|
||||||
);
|
);
|
||||||
@@ -42,14 +42,14 @@ function TraceFlamegraph({
|
|||||||
const handleSpanClick = useCallback(
|
const handleSpanClick = useCallback(
|
||||||
(spanId: string): void => {
|
(spanId: string): void => {
|
||||||
setFirstSpanAtFetchLevel(spanId);
|
setFirstSpanAtFetchLevel(spanId);
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = getUnstableCurrentSearchParams();
|
||||||
//tood: use from query params constants
|
//tood: use from query params constants
|
||||||
if (searchParams.get('spanId') !== spanId) {
|
if (searchParams.get('spanId') !== spanId) {
|
||||||
searchParams.set('spanId', spanId);
|
searchParams.set('spanId', spanId);
|
||||||
history.replace({ search: searchParams.toString() });
|
history.replace({ search: searchParams.toString() });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[history, search],
|
[history],
|
||||||
);
|
);
|
||||||
|
|
||||||
const previewFields = useTraceStore((s) => s.previewFields);
|
const previewFields = useTraceStore((s) => s.previewFields);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
|
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
|
||||||
import { Switch } from '@signozhq/ui/switch';
|
import { Switch } from '@signozhq/ui/switch';
|
||||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||||
@@ -12,6 +12,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
|||||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||||
import { uniqBy } from 'lodash-es';
|
import { uniqBy } from 'lodash-es';
|
||||||
|
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||||
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
|
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
|
||||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
@@ -163,12 +164,11 @@ function Filters({
|
|||||||
const { selectedCategory, categories, handleCategoryChange } =
|
const { selectedCategory, categories, handleCategoryChange } =
|
||||||
useSpanCategoryFilter(filterProps);
|
useSpanCategoryFilter(filterProps);
|
||||||
|
|
||||||
const { search } = useLocation();
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const handlePrevNext = useCallback(
|
const handlePrevNext = useCallback(
|
||||||
(index: number, spanId?: string): void => {
|
(index: number, spanId?: string): void => {
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = getUnstableCurrentSearchParams();
|
||||||
if (spanId) {
|
if (spanId) {
|
||||||
searchParams.set('spanId', spanId);
|
searchParams.set('spanId', spanId);
|
||||||
} else {
|
} else {
|
||||||
@@ -177,7 +177,7 @@ function Filters({
|
|||||||
|
|
||||||
history.replace({ search: searchParams.toString() });
|
history.replace({ search: searchParams.toString() });
|
||||||
},
|
},
|
||||||
[filteredSpanIds, history, search],
|
[filteredSpanIds, history],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isFilterActive = committedExpression.trim().length > 0;
|
const isFilterActive = committedExpression.trim().length > 0;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
|||||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -676,19 +676,19 @@ function Success(props: ISuccessProps): JSX.Element {
|
|||||||
setSelectedSpanToAddToFunnel(span);
|
setSelectedSpanToAddToFunnel(span);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const urlQuery = useUrlQuery();
|
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
|
||||||
const handleSpanClick = useCallback(
|
const handleSpanClick = useCallback(
|
||||||
(span: SpanV3): void => {
|
(span: SpanV3): void => {
|
||||||
setSelectedSpan(span);
|
setSelectedSpan(span);
|
||||||
|
const params = getUnstableCurrentSearchParams();
|
||||||
if (span?.span_id) {
|
if (span?.span_id) {
|
||||||
urlQuery.set('spanId', span?.span_id);
|
params.set('spanId', span?.span_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
safeNavigate({ search: params.toString() }, { replace: true });
|
||||||
},
|
},
|
||||||
[setSelectedSpan, urlQuery, safeNavigate],
|
[setSelectedSpan, safeNavigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Left side columns using TanStack React Table (extensible for future columns)
|
// Left side columns using TanStack React Table (extensible for future columns)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||||
|
import {
|
||||||
|
__resetSearchParamsGetter,
|
||||||
|
__setSearchParamsGetterForTest,
|
||||||
|
} from 'utils/getUnstableCurrentSearchParams';
|
||||||
|
|
||||||
// Local identity-proxy mock for this module so `styles.foo` resolves to
|
// Local identity-proxy mock for this module so `styles.foo` resolves to
|
||||||
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
|
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
|
||||||
@@ -226,6 +230,11 @@ describe('Span Click User Flows', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
// Clear all URL parameters
|
// Clear all URL parameters
|
||||||
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
||||||
|
__setSearchParamsGetterForTest(() => new URLSearchParams(mockUrlQuery));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetSearchParamsGetter();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking span updates URL with spanId parameter', async () => {
|
it('clicking span updates URL with spanId parameter', async () => {
|
||||||
@@ -260,7 +269,6 @@ describe('Span Click User Flows', () => {
|
|||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
await user.click(spanElement);
|
await user.click(spanElement);
|
||||||
|
|
||||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
|
||||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
search: expect.stringContaining('spanId=span-1'),
|
search: expect.stringContaining('spanId=span-1'),
|
||||||
@@ -428,10 +436,6 @@ describe('Span Click User Flows', () => {
|
|||||||
await user.click(spanElement);
|
await user.click(spanElement);
|
||||||
|
|
||||||
// Verify existing parameters are preserved and spanId is added
|
// Verify existing parameters are preserved and spanId is added
|
||||||
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
|
|
||||||
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
|
|
||||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
|
||||||
|
|
||||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
search: expect.stringMatching(
|
search: expect.stringMatching(
|
||||||
@@ -441,4 +445,41 @@ describe('Span Click User Flows', () => {
|
|||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a tab param the router has not seen when selecting spans', async () => {
|
||||||
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||||
|
|
||||||
|
// nuqs wrote ?tab=thread to the real URL; react-router's copy is stale.
|
||||||
|
__setSearchParamsGetterForTest(() => new URLSearchParams('tab=thread'));
|
||||||
|
|
||||||
|
renderWithTraceProvider(
|
||||||
|
<Success
|
||||||
|
spans={mockSpans}
|
||||||
|
traceMetadata={mockTraceMetadata}
|
||||||
|
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||||
|
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
|
||||||
|
isFullDataLoaded={false}
|
||||||
|
localUncollapsedNodes={new Set()}
|
||||||
|
setLocalUncollapsedNodes={jest.fn()}
|
||||||
|
setInterestedSpanId={jest.fn()}
|
||||||
|
selectedSpan={undefined}
|
||||||
|
setSelectedSpan={jest.fn()}
|
||||||
|
filteredSpanIds={[]}
|
||||||
|
isFilterActive={false}
|
||||||
|
/>,
|
||||||
|
undefined,
|
||||||
|
{ initialRoute: '/trace' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||||
|
const spanElement = spanOverview.querySelector(
|
||||||
|
SPAN_OVERVIEW_CLASS,
|
||||||
|
) as HTMLElement;
|
||||||
|
await user.click(spanElement);
|
||||||
|
|
||||||
|
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||||
|
{ search: expect.stringMatching(/tab=thread.*spanId=span-1/) },
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,3 +53,8 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
||||||
|
|
||||||
|
export enum TraceDetailsTab {
|
||||||
|
Overview = 'overview',
|
||||||
|
Thread = 'thread',
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||||
|
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
|
||||||
|
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||||
|
|
||||||
|
/** Span details position, persisted under the same key Overview uses. */
|
||||||
|
export function useSpanDetailVariant(): [
|
||||||
|
SpanDetailVariant,
|
||||||
|
(variant: SpanDetailVariant) => void,
|
||||||
|
] {
|
||||||
|
const [variant, setVariant] = useState<SpanDetailVariant>(
|
||||||
|
() =>
|
||||||
|
(getLocalStorageKey(
|
||||||
|
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||||
|
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeVariant = useCallback((next: SpanDetailVariant): void => {
|
||||||
|
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, next);
|
||||||
|
setVariant(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [variant, changeVariant];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||||
|
|
||||||
|
import { TraceDetailsTab } from '../constants';
|
||||||
|
|
||||||
|
export function useTraceDetailsTab(): UseQueryStateReturn<
|
||||||
|
TraceDetailsTab,
|
||||||
|
TraceDetailsTab
|
||||||
|
> {
|
||||||
|
return useQueryState(
|
||||||
|
'tab',
|
||||||
|
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
|
||||||
|
TraceDetailsTab.Overview,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,90 +1,21 @@
|
|||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
ChartNoAxesGantt,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Info,
|
|
||||||
TriangleAlert,
|
|
||||||
} from '@signozhq/icons';
|
|
||||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
|
||||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
|
||||||
import { Collapse } from 'antd';
|
|
||||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
|
||||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
|
||||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
|
||||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
|
||||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
|
||||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
|
||||||
|
|
||||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
import { TraceDetailsTab } from './constants';
|
||||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
|
||||||
import NoData from './NoData/NoData';
|
|
||||||
import TraceStoreSync from './stores/TraceStoreSync';
|
|
||||||
import { useTraceStore } from './stores/traceStore';
|
|
||||||
import {
|
|
||||||
SPAN_DETAILS_PANEL_MIN_WIDTH,
|
|
||||||
SpanDetailVariant,
|
|
||||||
} from './SpanDetailsPanel/constants';
|
|
||||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
|
||||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
|
||||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
|
||||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
|
||||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
|
||||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
|
||||||
import { getAvailableColorByFieldNames } from './utils';
|
|
||||||
|
|
||||||
import cx from 'classnames';
|
|
||||||
|
|
||||||
import styles from './TraceDetailsV3.module.scss';
|
|
||||||
|
|
||||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
|
||||||
// span-tree chevrons in the waterfall.
|
|
||||||
function renderPanelExpandIcon({
|
|
||||||
isActive,
|
|
||||||
}: {
|
|
||||||
isActive?: boolean;
|
|
||||||
}): JSX.Element {
|
|
||||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TraceDetailsV3(): JSX.Element {
|
function TraceDetailsV3(): JSX.Element {
|
||||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
const [tab] = useTraceDetailsTab();
|
||||||
const urlQuery = useUrlQuery();
|
const isOverview = tab === TraceDetailsTab.Overview;
|
||||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
|
||||||
() => ({
|
|
||||||
spanId: urlQuery.get('spanId') || '',
|
|
||||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
|
||||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
|
||||||
Set<string>
|
|
||||||
>(new Set());
|
|
||||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
|
||||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||||
const [isFilterActive, setIsFilterActive] = useState(false);
|
const [isFilterActive, setIsFilterActive] = useState(false);
|
||||||
|
const [hasTraceData, setHasTraceData] = useState(false);
|
||||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
|
||||||
const { safeNavigate } = useSafeNavigate();
|
|
||||||
|
|
||||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
|
||||||
// Tracks which traceId the load event already fired for, so navigating
|
// Tracks which traceId the load event already fired for, so navigating
|
||||||
// between traces (the route component stays mounted) re-fires it once each.
|
// between traces (the route component stays mounted) re-fires it once each.
|
||||||
const dataLoadedFiredForRef = useRef('');
|
const dataLoadedFiredForRef = useRef<string>('');
|
||||||
const colorByField = useTraceStore((s) => s.colorByField);
|
|
||||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
|
||||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
|
||||||
|
|
||||||
const handleSpanDetailsClose = useCallback((): void => {
|
|
||||||
urlQuery.delete('spanId');
|
|
||||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
|
||||||
}, [urlQuery, safeNavigate]);
|
|
||||||
|
|
||||||
const handleFilteredSpansChange = useCallback(
|
const handleFilteredSpansChange = useCallback(
|
||||||
(spanIds: string[], isActive: boolean): void => {
|
(spanIds: string[], isActive: boolean): void => {
|
||||||
@@ -94,421 +25,31 @@ function TraceDetailsV3(): JSX.Element {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const panelState = useDetailsPanel({
|
// The filter query lives in Filters' local state, which unmounts with the
|
||||||
entityId: selectedSpanId,
|
// Overview tab; drop its result too so both start empty on return.
|
||||||
onClose: handleSpanDetailsClose,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allSpansRef = useRef<SpanV3[]>([]);
|
|
||||||
const deepLinkResolvedRef = useRef(false);
|
|
||||||
|
|
||||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
|
||||||
// and other in-window URL navigation from triggering a backend window slide.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const spanId = urlQuery.get('spanId') || '';
|
if (!isOverview) {
|
||||||
if (!spanId) {
|
handleFilteredSpansChange([], false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
}, [isOverview, handleFilteredSpansChange]);
|
||||||
if (idx !== -1) {
|
|
||||||
setSelectedSpan(allSpansRef.current[idx]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
|
||||||
}, [urlQuery]);
|
|
||||||
|
|
||||||
// Once all spans are loaded (frontend mode), freeze query params so
|
|
||||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
|
||||||
const fullDataLoadedRef = useRef(false);
|
|
||||||
const frozenParamsRef = useRef({
|
|
||||||
selectedSpanId: interestedSpanId.spanId,
|
|
||||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
|
||||||
uncollapsedSpans: uncollapsedNodes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryParams = fullDataLoadedRef.current
|
|
||||||
? frozenParamsRef.current
|
|
||||||
: {
|
|
||||||
selectedSpanId: interestedSpanId.spanId,
|
|
||||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
|
||||||
uncollapsedSpans: uncollapsedNodes,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: traceData,
|
|
||||||
isFetching: isFetchingTraceData,
|
|
||||||
error: errorFetchingTraceData,
|
|
||||||
} = useGetTraceV4({
|
|
||||||
traceId,
|
|
||||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
|
||||||
selectedSpanId: queryParams.selectedSpanId,
|
|
||||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allSpans = traceData?.payload?.spans || [];
|
|
||||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
|
||||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
|
||||||
|
|
||||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
|
||||||
|
|
||||||
const isFullDataLoaded =
|
|
||||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
|
||||||
|
|
||||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
|
||||||
// trace-wide, so any window has the full set — no need to accumulate.
|
|
||||||
const availableColorByFields = useMemo(() => {
|
|
||||||
const spans = traceData?.payload?.spans;
|
|
||||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
|
||||||
}, [traceData?.payload?.spans]);
|
|
||||||
|
|
||||||
// Lock the ref once we confirm all data is loaded
|
|
||||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
|
||||||
fullDataLoadedRef.current = true;
|
|
||||||
frozenParamsRef.current = {
|
|
||||||
selectedSpanId: interestedSpanId.spanId,
|
|
||||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
|
||||||
uncollapsedSpans: uncollapsedNodes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
allSpansRef.current = allSpans;
|
|
||||||
}, [allSpans]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!traceId ||
|
|
||||||
dataLoadedFiredForRef.current === traceId ||
|
|
||||||
!userPrefsReady
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = traceData?.payload;
|
|
||||||
if (!payload?.spans?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dataLoadedFiredForRef.current = traceId;
|
|
||||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
|
||||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
|
||||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
|
||||||
[TraceDetailEventKeys.NumServices]: numServices,
|
|
||||||
[TraceDetailEventKeys.TraceDurationMs]:
|
|
||||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
|
||||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
|
||||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
|
||||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
|
||||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
|
||||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
|
||||||
SpanDetailVariant.DOCKED_RIGHT,
|
|
||||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
|
||||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
traceId,
|
|
||||||
userPrefsReady,
|
|
||||||
traceData,
|
|
||||||
totalSpansCount,
|
|
||||||
colorByField,
|
|
||||||
previewFieldsCount,
|
|
||||||
logTraceEvent,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Frontend mode: expand all parents by default when full data arrives
|
|
||||||
useEffect(() => {
|
|
||||||
if (isFullDataLoaded && allSpans.length > 0) {
|
|
||||||
const parentIds = new Set(
|
|
||||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
|
||||||
);
|
|
||||||
setLocalUncollapsedNodes(parentIds);
|
|
||||||
}
|
|
||||||
}, [isFullDataLoaded, allSpans]);
|
|
||||||
|
|
||||||
// Backend mode: sync uncollapsed state from API response
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isFullDataLoaded &&
|
|
||||||
traceData &&
|
|
||||||
traceData.payload &&
|
|
||||||
traceData.payload.uncollapsedSpans
|
|
||||||
) {
|
|
||||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
|
||||||
}
|
|
||||||
}, [traceData, isFullDataLoaded]);
|
|
||||||
|
|
||||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
|
||||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
|
||||||
useEffect(() => {
|
|
||||||
if (deepLinkResolvedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (allSpans.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedSpanId) {
|
|
||||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
|
||||||
if (!span) {
|
|
||||||
// Span not in the current window — wait for more data (backend
|
|
||||||
// pagination) before marking resolved.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedSpan(span);
|
|
||||||
} else {
|
|
||||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
|
||||||
}
|
|
||||||
deepLinkResolvedRef.current = true;
|
|
||||||
}, [selectedSpanId, allSpans]);
|
|
||||||
|
|
||||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
|
||||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
|
||||||
if (ancestors.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLocalUncollapsedNodes((prev) => {
|
|
||||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
|
||||||
let allPresent = true;
|
|
||||||
for (const id of ancestors) {
|
|
||||||
if (!prev.has(id)) {
|
|
||||||
allPresent = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allPresent) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new Set(prev);
|
|
||||||
for (const id of ancestors) {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
|
||||||
|
|
||||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
|
||||||
|
|
||||||
const handleCollapseChange = (key: string): void => {
|
|
||||||
logTraceEvent(
|
|
||||||
key === 'flame'
|
|
||||||
? TraceDetailEvents.FlameGraphToggled
|
|
||||||
: TraceDetailEvents.WaterfallToggled,
|
|
||||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
|
||||||
);
|
|
||||||
setActiveKeys((prev) =>
|
|
||||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
|
||||||
() =>
|
|
||||||
(getLocalStorageKey(
|
|
||||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
|
||||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
|
|
||||||
const RIGHT_DOCK_MAX = 720;
|
|
||||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
|
||||||
|
|
||||||
const handleVariantChange = useCallback(
|
|
||||||
(newVariant: SpanDetailVariant): void => {
|
|
||||||
setLocalStorageKey(
|
|
||||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
|
||||||
newVariant,
|
|
||||||
);
|
|
||||||
setSpanDetailVariant(newVariant);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showNoData =
|
|
||||||
!isFetchingTraceData &&
|
|
||||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
|
||||||
|
|
||||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
|
||||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
|
||||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
|
||||||
const showRightDock = panelState.isOpen && isRightDocked;
|
|
||||||
|
|
||||||
const waterfallChildren = (
|
|
||||||
<ResizableBox
|
|
||||||
defaultHeight={300}
|
|
||||||
minHeight={150}
|
|
||||||
disabled={!isWaterfallDocked}
|
|
||||||
>
|
|
||||||
<TraceWaterfall
|
|
||||||
traceData={traceData}
|
|
||||||
isFetchingTraceData={isFetchingTraceData}
|
|
||||||
errorFetchingTraceData={errorFetchingTraceData}
|
|
||||||
traceId={traceId || ''}
|
|
||||||
interestedSpanId={interestedSpanId}
|
|
||||||
setInterestedSpanId={setInterestedSpanId}
|
|
||||||
uncollapsedNodes={uncollapsedNodes}
|
|
||||||
isFullDataLoaded={isFullDataLoaded}
|
|
||||||
localUncollapsedNodes={localUncollapsedNodes}
|
|
||||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
|
||||||
selectedSpan={selectedSpan}
|
|
||||||
setSelectedSpan={setSelectedSpan}
|
|
||||||
filteredSpanIds={filteredSpanIds}
|
|
||||||
isFilterActive={isFilterActive}
|
|
||||||
/>
|
|
||||||
</ResizableBox>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
<TraceDetailsWrapper>
|
||||||
<div className={styles.root}>
|
<TraceDetailsHeader
|
||||||
<TraceDetailsHeader
|
onFilteredSpansChange={handleFilteredSpansChange}
|
||||||
onFilteredSpansChange={handleFilteredSpansChange}
|
showTraceDetailsHeaderOptions={hasTraceData}
|
||||||
showTraceDetailsHeaderOptions={
|
/>
|
||||||
!!traceData?.payload?.spans?.length && !showNoData
|
{isOverview ? (
|
||||||
}
|
<TraceDetailsOverview
|
||||||
|
filteredSpanIds={filteredSpanIds}
|
||||||
|
isFilterActive={isFilterActive}
|
||||||
|
onHasTraceDataChange={setHasTraceData}
|
||||||
|
dataLoadedFiredForRef={dataLoadedFiredForRef}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
{showNoData ? (
|
<TraceDetailsThread />
|
||||||
<NoData />
|
)}
|
||||||
) : (
|
</TraceDetailsWrapper>
|
||||||
<>
|
|
||||||
<div className={styles.layoutRow}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
<Collapse
|
|
||||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
|
||||||
motion={false}
|
|
||||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
|
||||||
onChange={(): void => handleCollapseChange('flame')}
|
|
||||||
size="small"
|
|
||||||
expandIcon={renderPanelExpandIcon}
|
|
||||||
className={styles.flameCollapse}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'flame',
|
|
||||||
label: (
|
|
||||||
<div className={styles.collapseLabel}>
|
|
||||||
<span className={styles.collapseTitle}>
|
|
||||||
Flame Graph
|
|
||||||
{traceData?.payload?.totalSpansCount &&
|
|
||||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
|
||||||
<WarningPopover
|
|
||||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
|
||||||
placement="bottomLeft"
|
|
||||||
>
|
|
||||||
<Info
|
|
||||||
size={16}
|
|
||||||
color="var(--l2-foreground)"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
</WarningPopover>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{traceData?.payload?.totalSpansCount ? (
|
|
||||||
<span className={styles.collapseCount}>
|
|
||||||
<span className={styles.collapseCountItem}>
|
|
||||||
<ChartNoAxesGantt size={13} />
|
|
||||||
Spans: {traceData.payload.totalSpansCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cx(styles.collapseCountItem, {
|
|
||||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<TriangleAlert size={13} />
|
|
||||||
Errors:{' '}
|
|
||||||
{traceData.payload.totalErrorSpansCount ?? (
|
|
||||||
<span className="translate-safe">{0}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
|
||||||
<TraceFlamegraph
|
|
||||||
filteredSpanIds={filteredSpanIds}
|
|
||||||
isFilterActive={isFilterActive}
|
|
||||||
selectedSpan={selectedSpan}
|
|
||||||
totalSpansCount={totalSpansCount}
|
|
||||||
/>
|
|
||||||
</ResizableBox>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Collapse
|
|
||||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
|
||||||
motion={false}
|
|
||||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
|
||||||
onChange={(): void => handleCollapseChange('waterfall')}
|
|
||||||
size="small"
|
|
||||||
expandIcon={renderPanelExpandIcon}
|
|
||||||
className={cx(styles.waterfallCollapse, {
|
|
||||||
[styles.isDocked]: isWaterfallDocked,
|
|
||||||
})}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'waterfall',
|
|
||||||
label: 'Waterfall',
|
|
||||||
children: activeKeys.includes('waterfall')
|
|
||||||
? waterfallChildren
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{panelState.isOpen && isDocked && (
|
|
||||||
<div className={styles.dockedSpanDetails}>
|
|
||||||
<SpanDetailsPanel
|
|
||||||
panelState={panelState}
|
|
||||||
selectedSpan={selectedSpan}
|
|
||||||
variant={SpanDetailVariant.DOCKED}
|
|
||||||
onVariantChange={handleVariantChange}
|
|
||||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
|
||||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showRightDock && (
|
|
||||||
<ResizableBox
|
|
||||||
handle="left"
|
|
||||||
defaultWidth={rightDockWidth}
|
|
||||||
minWidth={RIGHT_DOCK_MIN}
|
|
||||||
maxWidth={RIGHT_DOCK_MAX}
|
|
||||||
onResize={setRightDockWidth}
|
|
||||||
className={styles.rightDock}
|
|
||||||
>
|
|
||||||
<SpanDetailsPanel
|
|
||||||
panelState={panelState}
|
|
||||||
selectedSpan={selectedSpan}
|
|
||||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
|
||||||
onVariantChange={handleVariantChange}
|
|
||||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
|
||||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
|
||||||
/>
|
|
||||||
</ResizableBox>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
|
||||||
<SpanDetailsPanel
|
|
||||||
panelState={panelState}
|
|
||||||
selectedSpan={selectedSpan}
|
|
||||||
variant={SpanDetailVariant.DIALOG}
|
|
||||||
onVariantChange={handleVariantChange}
|
|
||||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
|
||||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TraceStoreSync>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,36 +4,28 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
|||||||
import { useAppContext } from 'providers/App/App';
|
import { useAppContext } from 'providers/App/App';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
setTraceStoreAvailableColorByFields,
|
|
||||||
setTraceStoreCallbacks,
|
setTraceStoreCallbacks,
|
||||||
setTraceStoreUserPreferences,
|
setTraceStoreUserPreferences,
|
||||||
} from './traceStore';
|
} from './traceStore';
|
||||||
|
|
||||||
interface TraceStoreSyncProps {
|
interface TraceStoreSyncProps {
|
||||||
availableColorByFields: string[] | undefined;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
|
* Bridges React-managed inputs (`userPreferences` from AppContext and the
|
||||||
* from AppContext, and the user-pref mutation hook) into the Zustand store.
|
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
|
||||||
|
* is pushed by TraceDetailsOverview, which owns the span data.
|
||||||
*
|
*
|
||||||
* Renders nothing until `userPreferences` resolves so the flamegraph never
|
* Renders nothing until `userPreferences` resolves so the flamegraph never
|
||||||
* paints with the default colour first and then swaps to the user's
|
* paints with the default colour first and then swaps to the user's
|
||||||
* persisted choice. AppProvider fires the prefs query as soon as the user
|
* persisted choice. AppProvider fires the prefs query as soon as the user
|
||||||
* is logged in, so this gate is usually already settled by mount time.
|
* is logged in, so this gate is usually already settled by mount time.
|
||||||
*/
|
*/
|
||||||
function TraceStoreSync({
|
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
|
||||||
availableColorByFields,
|
|
||||||
children,
|
|
||||||
}: TraceStoreSyncProps): JSX.Element | null {
|
|
||||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||||
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
|
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
|
||||||
}, [availableColorByFields]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTraceStoreUserPreferences(userPreferences ?? null);
|
setTraceStoreUserPreferences(userPreferences ?? null);
|
||||||
}, [userPreferences]);
|
}, [userPreferences]);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ interface TraceStoreState {
|
|||||||
availableColorByOptions: ColorByOption[];
|
availableColorByOptions: ColorByOption[];
|
||||||
previewFields: TelemetryFieldKey[];
|
previewFields: TelemetryFieldKey[];
|
||||||
|
|
||||||
// --- Setters used only by TraceStoreSync ---
|
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
|
||||||
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
|
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
|
||||||
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
|
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
|
||||||
setCallbacks: (callbacks: {
|
setCallbacks: (callbacks: {
|
||||||
|
|||||||
Reference in New Issue
Block a user