From d96073f478c7eb2946549783db7faae79dd1ffbc Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 16 Sep 2025 18:59:48 +0430 Subject: [PATCH] feat: highlight the searched spans and dim other spans in trace details v2 (#9032) --- .../Success/Filters/Filters.tsx | 25 ++- .../Success/Success.styles.scss | 17 +- .../TraceWaterfallStates/Success/Success.tsx | 68 ++++++-- .../Success/__tests__/SpanDuration.test.tsx | 145 +++++++++++++++++- 4 files changed, 238 insertions(+), 17 deletions(-) diff --git a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters.tsx b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters.tsx index 57ad370051f3..212bad6e43fc 100644 --- a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters.tsx +++ b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters.tsx @@ -54,20 +54,32 @@ function Filters({ startTime, endTime, traceID, + onFilteredSpansChange = (): void => {}, }: { startTime: number; endTime: number; traceID: string; + onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void; }): JSX.Element { const [filters, setFilters] = useState( BASE_FILTER_QUERY.filters || { items: [], op: 'AND' }, ); const [noData, setNoData] = useState(false); const [filteredSpanIds, setFilteredSpanIds] = useState([]); - const handleFilterChange = (value: TagFilter): void => { - setFilters(value); - }; const [currentSearchedIndex, setCurrentSearchedIndex] = useState(0); + + const handleFilterChange = useCallback( + (value: TagFilter): void => { + if (value.items.length === 0) { + setFilteredSpanIds([]); + onFilteredSpansChange?.([], false); + setCurrentSearchedIndex(0); + setNoData(false); + } + setFilters(value); + }, + [onFilteredSpansChange], + ); const { search } = useLocation(); const history = useHistory(); @@ -116,6 +128,7 @@ function Filters({ queryKey: [filters], enabled: filters.items.length > 0, onSuccess: (data) => { + const isFilterActive = filters.items.length > 0; if (data?.payload.data.newResult.data.result[0].list) { const uniqueSpans = uniqBy( data?.payload.data.newResult.data.result[0].list, @@ -124,11 +137,13 @@ function Filters({ const spanIds = uniqueSpans.map((val) => val.data.spanID); setFilteredSpanIds(spanIds); + onFilteredSpansChange?.(spanIds, isFilterActive); handlePrevNext(0, spanIds[0]); setNoData(false); } else { setNoData(true); setFilteredSpanIds([]); + onFilteredSpansChange?.([], isFilterActive); setCurrentSearchedIndex(0); } }, @@ -184,4 +199,8 @@ function Filters({ ); } +Filters.defaultProps = { + onFilteredSpansChange: undefined, +}; + export default Filters; diff --git a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss index a2400a188e82..ee68acb23192 100644 --- a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss +++ b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss @@ -315,7 +315,8 @@ } } - .interested-span { + .interested-span, + .selected-non-matching-span { border-radius: 4px; background: rgba(171, 189, 255, 0.06) !important; @@ -323,6 +324,20 @@ background: unset; } } + + .dimmed-span { + opacity: 0.4; + } + .highlighted-span { + opacity: 1; + } + + .selected-non-matching-span { + .span-overview-content, + .span-line-text { + opacity: 0.5; + } + } } .div-td + .div-td { diff --git a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx index b7d48b420860..04079edda31f 100644 --- a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx +++ b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx @@ -67,6 +67,8 @@ function SpanOverview({ setSelectedSpan, handleAddSpanToFunnel, selectedSpan, + filteredSpanIds, + isFilterActive, traceMetadata, }: { span: Span; @@ -75,6 +77,8 @@ function SpanOverview({ selectedSpan: Span | undefined; setSelectedSpan: Dispatch>; handleAddSpanToFunnel: (span: Span) => void; + filteredSpanIds: string[]; + isFilterActive: boolean; traceMetadata: ITraceMetadata; }): JSX.Element { const isRootSpan = span.level === 0; @@ -85,13 +89,23 @@ function SpanOverview({ color = `var(--bg-cherry-500)`; } + // Smart highlighting logic + const isMatching = + isFilterActive && (filteredSpanIds || []).includes(span.spanId); + const isSelected = selectedSpan?.spanId === span.spanId; + const isDimmed = isFilterActive && !isMatching && !isSelected; + const isHighlighted = isFilterActive && isMatching && !isSelected; + const isSelectedNonMatching = isSelected && isFilterActive && !isMatching; + return (
>; + filteredSpanIds: string[]; + isFilterActive: boolean; }): JSX.Element { const { time, timeUnitName } = convertTimeToRelevantUnit( span.durationNano / 1e6, @@ -224,6 +242,13 @@ export function SpanDuration({ const [hasActionButtons, setHasActionButtons] = useState(false); + const isMatching = + isFilterActive && (filteredSpanIds || []).includes(span.spanId); + const isSelected = selectedSpan?.spanId === span.spanId; + const isDimmed = isFilterActive && !isMatching && !isSelected; + const isHighlighted = isFilterActive && isMatching && !isSelected; + const isSelectedNonMatching = isSelected && isFilterActive && !isMatching; + const handleMouseEnter = (): void => { setHasActionButtons(true); }; @@ -256,10 +281,12 @@ export function SpanDuration({ return (
{ @@ -325,14 +352,17 @@ function getWaterfallColumns({ selectedSpan, setSelectedSpan, handleAddSpanToFunnel, + filteredSpanIds, + isFilterActive, }: { handleCollapseUncollapse: (id: string, collapse: boolean) => void; uncollapsedNodes: string[]; traceMetadata: ITraceMetadata; selectedSpan: Span | undefined; setSelectedSpan: Dispatch>; - handleAddSpanToFunnel: (span: Span) => void; + filteredSpanIds: string[]; + isFilterActive: boolean; }): ColumnDef[] { const waterfallColumns: ColumnDef[] = [ columnDefHelper.display({ @@ -347,6 +377,8 @@ function getWaterfallColumns({ setSelectedSpan={setSelectedSpan} handleAddSpanToFunnel={handleAddSpanToFunnel} traceMetadata={traceMetadata} + filteredSpanIds={filteredSpanIds} + isFilterActive={isFilterActive} /> ), size: 450, @@ -371,6 +403,8 @@ function getWaterfallColumns({ traceMetadata={traceMetadata} selectedSpan={selectedSpan} setSelectedSpan={setSelectedSpan} + filteredSpanIds={filteredSpanIds} + isFilterActive={isFilterActive} /> ), }), @@ -390,8 +424,19 @@ function Success(props: ISuccessProps): JSX.Element { setSelectedSpan, selectedSpan, } = props; + + const [filteredSpanIds, setFilteredSpanIds] = useState([]); + const [isFilterActive, setIsFilterActive] = useState(false); const virtualizerRef = useRef>(); + const handleFilteredSpansChange = useCallback( + (spanIds: string[], isActive: boolean) => { + setFilteredSpanIds(spanIds); + setIsFilterActive(isActive); + }, + [], + ); + const handleCollapseUncollapse = useCallback( (spanId: string, collapse: boolean) => { setInterestedSpanId({ spanId, isUncollapsed: !collapse }); @@ -443,6 +488,8 @@ function Success(props: ISuccessProps): JSX.Element { selectedSpan, setSelectedSpan, handleAddSpanToFunnel, + filteredSpanIds, + isFilterActive, }), [ handleCollapseUncollapse, @@ -451,6 +498,8 @@ function Success(props: ISuccessProps): JSX.Element { selectedSpan, setSelectedSpan, handleAddSpanToFunnel, + filteredSpanIds, + isFilterActive, ], ); @@ -508,6 +557,7 @@ function Success(props: ISuccessProps): JSX.Element { startTime={traceMetadata.startTime / 1e3} endTime={traceMetadata.endTime / 1e3} traceID={traceMetadata.traceId} + onFilteredSpansChange={handleFilteredSpansChange} /> { traceMetadata={mockTraceMetadata} selectedSpan={undefined} setSelectedSpan={mockSetSelectedSpan} + filteredSpanIds={[]} + isFilterActive={false} />, ); // Find and click the span duration element - const spanElement = screen.getByText('1.16 ms'); + const spanElement = screen.getByText(SPAN_DURATION_TEXT); fireEvent.click(spanElement); // Verify setSelectedSpan was called with the correct span @@ -113,10 +123,12 @@ describe('SpanDuration', () => { traceMetadata={mockTraceMetadata} selectedSpan={undefined} setSelectedSpan={mockSetSelectedSpan} + filteredSpanIds={[]} + isFilterActive={false} />, ); - const spanElement = screen.getByText('1.16 ms'); + const spanElement = screen.getByText(SPAN_DURATION_TEXT); // Initially, action buttons should not be visible expect(screen.queryByRole('button')).not.toBeInTheDocument(); @@ -139,10 +151,135 @@ describe('SpanDuration', () => { traceMetadata={mockTraceMetadata} selectedSpan={mockSpan} setSelectedSpan={mockSetSelectedSpan} + filteredSpanIds={[]} + isFilterActive={false} />, ); - const spanElement = screen.getByText('1.16 ms').closest('.span-duration'); - expect(spanElement).toHaveClass('interested-span'); + // eslint-disable-next-line sonarjs/no-duplicate-string + const spanElement = screen + .getByText(SPAN_DURATION_TEXT) + .closest(SPAN_DURATION_CLASS); + expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS); + }); + + it('applies highlighted-span class when span matches filter', () => { + render( + , + ); + + const spanElement = screen + .getByText(SPAN_DURATION_TEXT) + .closest(SPAN_DURATION_CLASS); + expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS); + }); + + it('applies dimmed-span class when span does not match filter', () => { + render( + , + ); + + const spanElement = screen + .getByText(SPAN_DURATION_TEXT) + .closest(SPAN_DURATION_CLASS); + expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS); + }); + + it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => { + render( + , + ); + + const spanElement = screen + .getByText(SPAN_DURATION_TEXT) + .closest(SPAN_DURATION_CLASS); + expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS); + }); + + it('applies selected-non-matching-span class when span is selected but does not match filter', () => { + render( + , + ); + + const spanElement = screen + .getByText(SPAN_DURATION_TEXT) + .closest(SPAN_DURATION_CLASS); + expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS); + expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS); + }); + + it('applies interested-span class when span is selected and no filter is active', () => { + render( + , + ); + + const spanElement = screen + .getByText(SPAN_DURATION_TEXT) + .closest(SPAN_DURATION_CLASS); + expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS); + expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS); + }); + + it('dims span when filter is active but no matches found', () => { + render( + , + ); + + const spanElement = screen + .getByText(SPAN_DURATION_TEXT) + .closest(SPAN_DURATION_CLASS); + expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS); + expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS); }); });