diff --git a/frontend/src/pages/TraceDetailsV3/TraceDetailsV3.module.scss b/frontend/src/pages/TraceDetailsV3/TraceDetailsOverview/TraceDetailsOverview.module.scss similarity index 100% rename from frontend/src/pages/TraceDetailsV3/TraceDetailsV3.module.scss rename to frontend/src/pages/TraceDetailsV3/TraceDetailsOverview/TraceDetailsOverview.module.scss diff --git a/frontend/src/pages/TraceDetailsV3/TraceDetailsOverview/TraceDetailsOverview.tsx b/frontend/src/pages/TraceDetailsV3/TraceDetailsOverview/TraceDetailsOverview.tsx new file mode 100644 index 0000000000..ee9da66293 --- /dev/null +++ b/frontend/src/pages/TraceDetailsV3/TraceDetailsOverview/TraceDetailsOverview.tsx @@ -0,0 +1,512 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +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 { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent'; +import NoData from '../NoData/NoData'; +import TraceStoreSync from '../stores/TraceStoreSync'; +import { useTraceStore } from '../stores/traceStore'; +import { SpanDetailVariant } from '../SpanDetailsPanel/constants'; +import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel'; +import TraceDetailsHeader from '../TraceDetailsHeader/TraceDetailsHeader'; +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 ? : ; +} + +function TraceDetailsOverview(): JSX.Element { + const { id: traceId } = useParams(); + const urlQuery = useUrlQuery(); + const [interestedSpanId, setInterestedSpanId] = useState( + () => ({ + spanId: urlQuery.get('spanId') || '', + isUncollapsed: urlQuery.get('spanId') !== '', + }), + ); + const [uncollapsedNodes, setUncollapsedNodes] = useState([]); + const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState< + Set + >(new Set()); + const [selectedSpan, setSelectedSpan] = useState(); + const [filteredSpanIds, setFilteredSpanIds] = useState([]); + const [isFilterActive, setIsFilterActive] = 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 + // between traces (the route component stays mounted) re-fires it once each. + const dataLoadedFiredForRef = useRef(''); + 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( + (spanIds: string[], isActive: boolean): void => { + setFilteredSpanIds(spanIds); + setIsFilterActive(isActive); + }, + [], + ); + + const panelState = useDetailsPanel({ + entityId: selectedSpanId, + onClose: handleSpanDetailsClose, + }); + + const allSpansRef = useRef([]); + 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 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(['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( + () => + (getLocalStorageKey( + LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, + ) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT, + ); + + const RIGHT_DOCK_MIN = 480; + 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 = ( + + + + ); + + return ( + +
+ + + {showNoData ? ( + + ) : ( + <> +
+
+ k === 'flame')} + onChange={(): void => handleCollapseChange('flame')} + size="small" + expandIcon={renderPanelExpandIcon} + className={styles.flameCollapse} + items={[ + { + key: 'flame', + label: ( +
+ + Flame Graph + {traceData?.payload?.totalSpansCount && + traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && ( + + + + )} + + {traceData?.payload?.totalSpansCount ? ( + + + + Spans: {traceData.payload.totalSpansCount} + + 0, + })} + > + + Errors:{' '} + {traceData.payload.totalErrorSpansCount ?? ( + {0} + )} + + + ) : null} +
+ ), + children: ( + + + + ), + }, + ]} + /> + + 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 && ( +
+ +
+ )} +
+ + {showRightDock && ( + + + + )} +
+ + {panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && ( + + )} + + )} +
+
+ ); +} + +export default TraceDetailsOverview; diff --git a/frontend/src/pages/TraceDetailsV3/index.tsx b/frontend/src/pages/TraceDetailsV3/index.tsx index 4538005240..21c54e45be 100644 --- a/frontend/src/pages/TraceDetailsV3/index.tsx +++ b/frontend/src/pages/TraceDetailsV3/index.tsx @@ -1,512 +1,7 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -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 { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent'; -import NoData from './NoData/NoData'; -import TraceStoreSync from './stores/TraceStoreSync'; -import { useTraceStore } from './stores/traceStore'; -import { SpanDetailVariant } from './SpanDetailsPanel/constants'; -import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel'; -import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader'; -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 './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 ? : ; -} +import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview'; function TraceDetailsV3(): JSX.Element { - const { id: traceId } = useParams(); - const urlQuery = useUrlQuery(); - const [interestedSpanId, setInterestedSpanId] = useState( - () => ({ - spanId: urlQuery.get('spanId') || '', - isUncollapsed: urlQuery.get('spanId') !== '', - }), - ); - const [uncollapsedNodes, setUncollapsedNodes] = useState([]); - const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState< - Set - >(new Set()); - const [selectedSpan, setSelectedSpan] = useState(); - const [filteredSpanIds, setFilteredSpanIds] = useState([]); - const [isFilterActive, setIsFilterActive] = 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 - // between traces (the route component stays mounted) re-fires it once each. - const dataLoadedFiredForRef = useRef(''); - 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( - (spanIds: string[], isActive: boolean): void => { - setFilteredSpanIds(spanIds); - setIsFilterActive(isActive); - }, - [], - ); - - const panelState = useDetailsPanel({ - entityId: selectedSpanId, - onClose: handleSpanDetailsClose, - }); - - const allSpansRef = useRef([]); - 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 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(['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( - () => - (getLocalStorageKey( - LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, - ) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT, - ); - - const RIGHT_DOCK_MIN = 480; - 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 = ( - - - - ); - - return ( - -
- - - {showNoData ? ( - - ) : ( - <> -
-
- k === 'flame')} - onChange={(): void => handleCollapseChange('flame')} - size="small" - expandIcon={renderPanelExpandIcon} - className={styles.flameCollapse} - items={[ - { - key: 'flame', - label: ( -
- - Flame Graph - {traceData?.payload?.totalSpansCount && - traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && ( - - - - )} - - {traceData?.payload?.totalSpansCount ? ( - - - - Spans: {traceData.payload.totalSpansCount} - - 0, - })} - > - - Errors:{' '} - {traceData.payload.totalErrorSpansCount ?? ( - {0} - )} - - - ) : null} -
- ), - children: ( - - - - ), - }, - ]} - /> - - 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 && ( -
- -
- )} -
- - {showRightDock && ( - - - - )} -
- - {panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && ( - - )} - - )} -
-
- ); + return ; } export default TraceDetailsV3;