mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-18 16:07:10 +00:00
feat: highlight the searched spans and dim other spans in trace details v2 (#9032)
This commit is contained in:
parent
ba8a49929a
commit
d96073f478
@ -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<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const handleFilterChange = (value: TagFilter): void => {
|
||||
setFilters(value);
|
||||
};
|
||||
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 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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<SetStateAction<Span | undefined>>;
|
||||
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 (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx(
|
||||
'span-overview',
|
||||
selectedSpan?.spanId === span.spanId ? 'interested-span' : '',
|
||||
)}
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
style={{
|
||||
paddingLeft: `${
|
||||
isRootSpan
|
||||
@ -199,11 +213,15 @@ export function SpanDuration({
|
||||
traceMetadata,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
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 (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx(
|
||||
'span-duration',
|
||||
selectedSpan?.spanId === span.spanId ? 'interested-span' : '',
|
||||
)}
|
||||
className={cx('span-duration', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(): void => {
|
||||
@ -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<SetStateAction<Span | undefined>>;
|
||||
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): ColumnDef<Span, any>[] {
|
||||
const waterfallColumns: ColumnDef<Span, any>[] = [
|
||||
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<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
|
||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
|
||||
|
||||
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}
|
||||
/>
|
||||
<TableV3
|
||||
columns={columns}
|
||||
|
||||
@ -6,6 +6,14 @@ import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
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
|
||||
jest.mock('hooks/useSafeNavigate');
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
@ -87,11 +95,13 @@ describe('SpanDuration', () => {
|
||||
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(
|
||||
<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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user