feat: highlight the searched spans and dim other spans in trace details v2 (#9032)

This commit is contained in:
Shaheer Kochai 2025-09-16 18:59:48 +04:30 committed by GitHub
parent ba8a49929a
commit d96073f478
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 238 additions and 17 deletions

View File

@ -54,20 +54,32 @@ function Filters({
startTime, startTime,
endTime, endTime,
traceID, traceID,
onFilteredSpansChange = (): void => {},
}: { }: {
startTime: number; startTime: number;
endTime: number; endTime: number;
traceID: string; traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}): JSX.Element { }): JSX.Element {
const [filters, setFilters] = useState<TagFilter>( const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' }, BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
); );
const [noData, setNoData] = useState<boolean>(false); const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]); const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const handleFilterChange = (value: TagFilter): void => {
setFilters(value);
};
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0); const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(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 { search } = useLocation();
const history = useHistory(); const history = useHistory();
@ -116,6 +128,7 @@ function Filters({
queryKey: [filters], queryKey: [filters],
enabled: filters.items.length > 0, enabled: filters.items.length > 0,
onSuccess: (data) => { onSuccess: (data) => {
const isFilterActive = filters.items.length > 0;
if (data?.payload.data.newResult.data.result[0].list) { if (data?.payload.data.newResult.data.result[0].list) {
const uniqueSpans = uniqBy( const uniqueSpans = uniqBy(
data?.payload.data.newResult.data.result[0].list, data?.payload.data.newResult.data.result[0].list,
@ -124,11 +137,13 @@ function Filters({
const spanIds = uniqueSpans.map((val) => val.data.spanID); const spanIds = uniqueSpans.map((val) => val.data.spanID);
setFilteredSpanIds(spanIds); setFilteredSpanIds(spanIds);
onFilteredSpansChange?.(spanIds, isFilterActive);
handlePrevNext(0, spanIds[0]); handlePrevNext(0, spanIds[0]);
setNoData(false); setNoData(false);
} else { } else {
setNoData(true); setNoData(true);
setFilteredSpanIds([]); setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0); setCurrentSearchedIndex(0);
} }
}, },
@ -184,4 +199,8 @@ function Filters({
); );
} }
Filters.defaultProps = {
onFilteredSpansChange: undefined,
};
export default Filters; export default Filters;

View File

@ -315,7 +315,8 @@
} }
} }
.interested-span { .interested-span,
.selected-non-matching-span {
border-radius: 4px; border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important; background: rgba(171, 189, 255, 0.06) !important;
@ -323,6 +324,20 @@
background: unset; 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 { .div-td + .div-td {

View File

@ -67,6 +67,8 @@ function SpanOverview({
setSelectedSpan, setSelectedSpan,
handleAddSpanToFunnel, handleAddSpanToFunnel,
selectedSpan, selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata, traceMetadata,
}: { }: {
span: Span; span: Span;
@ -75,6 +77,8 @@ function SpanOverview({
selectedSpan: Span | undefined; selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>; setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void; handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata; traceMetadata: ITraceMetadata;
}): JSX.Element { }): JSX.Element {
const isRootSpan = span.level === 0; const isRootSpan = span.level === 0;
@ -85,13 +89,23 @@ function SpanOverview({
color = `var(--bg-cherry-500)`; 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 ( return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}> <SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div <div
className={cx( className={cx('span-overview', {
'span-overview', 'interested-span': isSelected && (!isFilterActive || isMatching),
selectedSpan?.spanId === span.spanId ? 'interested-span' : '', 'highlighted-span': isHighlighted,
)} 'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
style={{ style={{
paddingLeft: `${ paddingLeft: `${
isRootSpan isRootSpan
@ -199,11 +213,15 @@ export function SpanDuration({
traceMetadata, traceMetadata,
setSelectedSpan, setSelectedSpan,
selectedSpan, selectedSpan,
filteredSpanIds,
isFilterActive,
}: { }: {
span: Span; span: Span;
traceMetadata: ITraceMetadata; traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined; selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>; setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
filteredSpanIds: string[];
isFilterActive: boolean;
}): JSX.Element { }): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit( const { time, timeUnitName } = convertTimeToRelevantUnit(
span.durationNano / 1e6, span.durationNano / 1e6,
@ -224,6 +242,13 @@ export function SpanDuration({
const [hasActionButtons, setHasActionButtons] = useState(false); 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 => { const handleMouseEnter = (): void => {
setHasActionButtons(true); setHasActionButtons(true);
}; };
@ -256,10 +281,12 @@ export function SpanDuration({
return ( return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}> <SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div <div
className={cx( className={cx('span-duration', {
'span-duration', 'interested-span': isSelected && (!isFilterActive || isMatching),
selectedSpan?.spanId === span.spanId ? 'interested-span' : '', 'highlighted-span': isHighlighted,
)} 'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onClick={(): void => { onClick={(): void => {
@ -325,14 +352,17 @@ function getWaterfallColumns({
selectedSpan, selectedSpan,
setSelectedSpan, setSelectedSpan,
handleAddSpanToFunnel, handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}: { }: {
handleCollapseUncollapse: (id: string, collapse: boolean) => void; handleCollapseUncollapse: (id: string, collapse: boolean) => void;
uncollapsedNodes: string[]; uncollapsedNodes: string[];
traceMetadata: ITraceMetadata; traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined; selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>; setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void; handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): ColumnDef<Span, any>[] { }): ColumnDef<Span, any>[] {
const waterfallColumns: ColumnDef<Span, any>[] = [ const waterfallColumns: ColumnDef<Span, any>[] = [
columnDefHelper.display({ columnDefHelper.display({
@ -347,6 +377,8 @@ function getWaterfallColumns({
setSelectedSpan={setSelectedSpan} setSelectedSpan={setSelectedSpan}
handleAddSpanToFunnel={handleAddSpanToFunnel} handleAddSpanToFunnel={handleAddSpanToFunnel}
traceMetadata={traceMetadata} traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/> />
), ),
size: 450, size: 450,
@ -371,6 +403,8 @@ function getWaterfallColumns({
traceMetadata={traceMetadata} traceMetadata={traceMetadata}
selectedSpan={selectedSpan} selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan} setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/> />
), ),
}), }),
@ -390,8 +424,19 @@ function Success(props: ISuccessProps): JSX.Element {
setSelectedSpan, setSelectedSpan,
selectedSpan, selectedSpan,
} = props; } = props;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>(); const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean) => {
setFilteredSpanIds(spanIds);
setIsFilterActive(isActive);
},
[],
);
const handleCollapseUncollapse = useCallback( const handleCollapseUncollapse = useCallback(
(spanId: string, collapse: boolean) => { (spanId: string, collapse: boolean) => {
setInterestedSpanId({ spanId, isUncollapsed: !collapse }); setInterestedSpanId({ spanId, isUncollapsed: !collapse });
@ -443,6 +488,8 @@ function Success(props: ISuccessProps): JSX.Element {
selectedSpan, selectedSpan,
setSelectedSpan, setSelectedSpan,
handleAddSpanToFunnel, handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}), }),
[ [
handleCollapseUncollapse, handleCollapseUncollapse,
@ -451,6 +498,8 @@ function Success(props: ISuccessProps): JSX.Element {
selectedSpan, selectedSpan,
setSelectedSpan, setSelectedSpan,
handleAddSpanToFunnel, handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
], ],
); );
@ -508,6 +557,7 @@ function Success(props: ISuccessProps): JSX.Element {
startTime={traceMetadata.startTime / 1e3} startTime={traceMetadata.startTime / 1e3}
endTime={traceMetadata.endTime / 1e3} endTime={traceMetadata.endTime / 1e3}
traceID={traceMetadata.traceId} traceID={traceMetadata.traceId}
onFilteredSpansChange={handleFilteredSpansChange}
/> />
<TableV3 <TableV3
columns={columns} columns={columns}

View File

@ -6,6 +6,14 @@ import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success'; import { SpanDuration } from '../Success';
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
// Mock the hooks // Mock the hooks
jest.mock('hooks/useSafeNavigate'); jest.mock('hooks/useSafeNavigate');
jest.mock('hooks/useUrlQuery'); jest.mock('hooks/useUrlQuery');
@ -87,11 +95,13 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata} traceMetadata={mockTraceMetadata}
selectedSpan={undefined} selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan} setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>, />,
); );
// Find and click the span duration element // Find and click the span duration element
const spanElement = screen.getByText('1.16 ms'); const spanElement = screen.getByText(SPAN_DURATION_TEXT);
fireEvent.click(spanElement); fireEvent.click(spanElement);
// Verify setSelectedSpan was called with the correct span // Verify setSelectedSpan was called with the correct span
@ -113,10 +123,12 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata} traceMetadata={mockTraceMetadata}
selectedSpan={undefined} selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan} 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 // Initially, action buttons should not be visible
expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument();
@ -139,10 +151,135 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata} traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan} selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan} setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>, />,
); );
const spanElement = screen.getByText('1.16 ms').closest('.span-duration'); // eslint-disable-next-line sonarjs/no-duplicate-string
expect(spanElement).toHaveClass('interested-span'); 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(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
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(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={['other-span-id']}
isFilterActive
/>,
);
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(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
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(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={['different-span-id']}
isFilterActive
/>,
);
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(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
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(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]} // Empty array but filter is active
isFilterActive // This is the key difference
/>,
);
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);
}); });
}); });