Trace View: UI improvements (#114156)

* Fix unclear child span relationship

* Trace view UI improvements

* Fix tests
This commit is contained in:
Andre Pereira
2025-11-24 11:15:03 +00:00
committed by GitHub
parent bca58f5626
commit 4e228cab00
5 changed files with 40 additions and 29 deletions
@@ -157,7 +157,7 @@ const getStyles = (theme: GrafanaTheme2) => {
label: 'ServiceNameAndLinks', label: 'ServiceNameAndLinks',
display: 'flex', display: 'flex',
width: '100%', width: '100%',
marginBottom: '16px', marginBottom: theme.spacing(1),
}), }),
operationName: css({ operationName: css({
label: 'SpanDetailOperationName', label: 'SpanDetailOperationName',
@@ -77,6 +77,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
label: 'detailWrapper', label: 'detailWrapper',
flex: '1', flex: '1',
minWidth: 0, minWidth: 0,
backgroundColor: theme.colors.background.canvas,
border: `1px solid ${theme.colors.border.weak}`,
}), }),
}; };
}); });
@@ -165,6 +167,7 @@ const UnthemedSpanDetailRow = React.memo<SpanDetailRowProps>((props) => {
addHoverIndentGuideId={addHoverIndentGuideId} addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId} removeHoverIndentGuideId={removeHoverIndentGuideId}
visibleSpanIds={visibleSpanIds} visibleSpanIds={visibleSpanIds}
removeLastIndentGuide={true}
/> />
</div> </div>
<div className={styles.detailWrapper}> <div className={styles.detailWrapper}>
@@ -47,11 +47,12 @@ describe('SpanTreeOffset', () => {
}); });
describe('.SpanTreeOffset--indentGuide', () => { describe('.SpanTreeOffset--indentGuide', () => {
it('renders no indentGuide if span has no ancestors and no children', () => { it('renders only the trace-level indentGuide if span has no ancestors and no children', () => {
jest.mocked(spanAncestorIdsSpy).mockReturnValue([]); jest.mocked(spanAncestorIdsSpy).mockReturnValue([]);
render(<SpanTreeOffset {...props} />); render(<SpanTreeOffset {...props} />);
const indentGuides = screen.queryAllByTestId('SpanTreeOffset--indentGuide'); const indentGuides = screen.queryAllByTestId('SpanTreeOffset--indentGuide');
expect(indentGuides.length).toBe(0); expect(indentGuides.length).toBe(1);
expect(indentGuides[0]).toHaveAttribute('data-ancestor-id', specialRootID);
}); });
it('renders only one SpanTreeOffset--indentGuide for entire trace if span has no ancestors but has children', () => { it('renders only one SpanTreeOffset--indentGuide for entire trace if span has no ancestors but has children', () => {
@@ -66,9 +67,10 @@ describe('SpanTreeOffset', () => {
it('renders one SpanTreeOffset--indentGuide per ancestor span when span has no children', () => { it('renders one SpanTreeOffset--indentGuide per ancestor span when span has no children', () => {
render(<SpanTreeOffset {...props} />); render(<SpanTreeOffset {...props} />);
const indentGuides = screen.getAllByTestId('SpanTreeOffset--indentGuide'); const indentGuides = screen.getAllByTestId('SpanTreeOffset--indentGuide');
expect(indentGuides.length).toBe(2); expect(indentGuides.length).toBe(3);
expect(indentGuides[0]).toHaveAttribute('data-ancestor-id', specialRootID); expect(indentGuides[0]).toHaveAttribute('data-ancestor-id', specialRootID);
expect(indentGuides[1]).toHaveAttribute('data-ancestor-id', rootSpanID); expect(indentGuides[1]).toHaveAttribute('data-ancestor-id', rootSpanID);
expect(indentGuides[2]).toHaveAttribute('data-ancestor-id', parentSpanID);
}); });
it('renders one SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace when span has children', () => { it('renders one SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace when span has children', () => {
@@ -112,16 +114,20 @@ describe('SpanTreeOffset', () => {
props = { ...props, span: { ...props.span, hasChildren: true } }; props = { ...props, span: { ...props.span, hasChildren: true } };
}); });
it('does not render icon if props.span.hasChildren is false', () => { it('renders placeholder content when props.span.hasChildren is false', () => {
props.span.hasChildren = false; props.span.hasChildren = false;
render(<SpanTreeOffset {...props} />); render(<SpanTreeOffset {...props} />);
expect(screen.queryByTestId('icon-wrapper')).not.toBeInTheDocument(); expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-arrow-down')).not.toBeInTheDocument();
expect(screen.getByTestId('icon-wrapper')).toHaveTextContent('-');
}); });
it('does not render icon if props.span.hasChildren is true and showChildrenIcon is false', () => { it('renders placeholder content when props.span.hasChildren is true but showChildrenIcon is false', () => {
props.showChildrenIcon = false; props.showChildrenIcon = false;
render(<SpanTreeOffset {...props} />); render(<SpanTreeOffset {...props} />);
expect(screen.queryByTestId('icon-wrapper')).not.toBeInTheDocument(); expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-arrow-down')).not.toBeInTheDocument();
expect(screen.getByTestId('icon-wrapper')).toHaveTextContent('-');
}); });
it('renders arrow-right if props.span.hasChildren is true and props.childrenVisible is false', () => { it('renders arrow-right if props.span.hasChildren is true and props.childrenVisible is false', () => {
@@ -66,6 +66,8 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
right: 0, right: 0,
height: '100%', height: '100%',
paddingTop: '1px', paddingTop: '1px',
width: '1rem',
textAlign: 'center',
}), }),
})); }));
@@ -80,6 +82,7 @@ export type TProps = {
removeHoverIndentGuideId: (spanID: string) => void; removeHoverIndentGuideId: (spanID: string) => void;
theme: GrafanaTheme2; theme: GrafanaTheme2;
visibleSpanIds: string[]; visibleSpanIds: string[];
removeLastIndentGuide?: boolean;
}; };
const UnthemedSpanTreeOffset = React.memo<TProps>((props) => { const UnthemedSpanTreeOffset = React.memo<TProps>((props) => {
@@ -93,6 +96,7 @@ const UnthemedSpanTreeOffset = React.memo<TProps>((props) => {
hoverIndentGuideIds, hoverIndentGuideIds,
addHoverIndentGuideId, addHoverIndentGuideId,
removeHoverIndentGuideId, removeHoverIndentGuideId,
removeLastIndentGuide = false,
} = props; } = props;
const ancestorIds = React.useMemo(() => { const ancestorIds = React.useMemo(() => {
@@ -101,8 +105,12 @@ const UnthemedSpanTreeOffset = React.memo<TProps>((props) => {
// necessary padding for the collapse icon on root-level spans. // necessary padding for the collapse icon on root-level spans.
ids.push('root'); ids.push('root');
ids.reverse(); ids.reverse();
if (removeLastIndentGuide) {
ids.pop();
}
return ids; return ids;
}, [span]); }, [span, removeLastIndentGuide]);
/** /**
* If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is * If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is
@@ -156,20 +164,15 @@ const UnthemedSpanTreeOffset = React.memo<TProps>((props) => {
)); ));
const styles = getStyles(theme); const styles = getStyles(theme);
// If span has no children, don't show the last indent guide
const displayedAncestorIds = hasChildren ? ancestorIds : ancestorIds.slice(0, -1);
return ( return (
<span className={cx(styles.SpanTreeOffset, { [styles.SpanTreeOffsetParent]: hasChildren })} {...wrapperProps}> <span className={cx(styles.SpanTreeOffset, { [styles.SpanTreeOffsetParent]: hasChildren })} {...wrapperProps}>
{displayedAncestorIds.map((ancestorId, index) => ( {ancestorIds.map((ancestorId, index) => (
<span <span
key={ancestorId} key={ancestorId}
className={cx(styles.indentGuide, { className={cx(styles.indentGuide, {
[styles.indentGuideActive]: hoverIndentGuideIds.has(ancestorId), [styles.indentGuideActive]: hoverIndentGuideIds.has(ancestorId),
[styles.indentGuideThin]: [styles.indentGuideThin]:
index !== displayedAncestorIds.length - 1 && index !== ancestorIds.length - 1 && ancestorId !== 'root' && !visibleSpanIds.includes(ancestorId),
ancestorId !== 'root' &&
!visibleSpanIds.includes(ancestorId),
})} })}
data-ancestor-id={ancestorId} data-ancestor-id={ancestorId}
data-testid="SpanTreeOffset--indentGuide" data-testid="SpanTreeOffset--indentGuide"
@@ -177,16 +180,14 @@ const UnthemedSpanTreeOffset = React.memo<TProps>((props) => {
onMouseLeave={(event) => handleMouseLeave(event, ancestorId)} onMouseLeave={(event) => handleMouseLeave(event, ancestorId)}
/> />
))} ))}
{icon && ( <span
<span className={cx(styles.iconWrapper, 'icon-wrapper')}
className={cx(styles.iconWrapper, 'icon-wrapper')} onMouseEnter={(event) => icon && handleMouseEnter(event, spanID)}
onMouseEnter={(event) => handleMouseEnter(event, spanID)} onMouseLeave={(event) => icon && handleMouseLeave(event, spanID)}
onMouseLeave={(event) => handleMouseLeave(event, spanID)} data-testid="icon-wrapper"
data-testid="icon-wrapper" >
> {icon || (!removeLastIndentGuide && '-')}
{icon} </span>
</span>
)}
</span> </span>
); );
}); });
@@ -28,6 +28,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => {
listStyle: 'none', listStyle: 'none',
margin: 0, margin: 0,
padding: 0, padding: 0,
fontSize: theme.typography.size.sm,
...(divider ...(divider
? { ? {
marginRight: '-8px', marginRight: '-8px',
@@ -49,7 +50,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => {
}), }),
LabeledListLabel: css({ LabeledListLabel: css({
label: 'LabeledListLabel', label: 'LabeledListLabel',
color: theme.isLight ? '#999' : '#666', color: theme.colors.text.secondary,
marginRight: '0.25rem', marginRight: '0.25rem',
}), }),
LabeledListValue: css({ LabeledListValue: css({
@@ -66,7 +67,7 @@ const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => {
LabeledListServiceLine: css({ LabeledListServiceLine: css({
label: 'LabeledListServiceLine', label: 'LabeledListServiceLine',
display: 'inline-block', display: 'inline-block',
width: '1.25rem', width: '1rem',
height: '0.35rem', height: '0.35rem',
marginRight: '0.5rem', marginRight: '0.5rem',
verticalAlign: 'middle', verticalAlign: 'middle',
@@ -95,7 +96,7 @@ export default function LabeledList(props: LabeledListProps) {
{label === 'Service:' && ( {label === 'Service:' && (
<div className={styles.LabeledListServiceLine} style={{ backgroundColor: color }} /> <div className={styles.LabeledListServiceLine} style={{ backgroundColor: color }} />
)} )}
{icon && <Icon name={icon} className={styles.LabeledListIcon} />} {icon && <Icon name={icon} className={styles.LabeledListIcon} size="sm" />}
<span className={styles.LabeledListLabel}>{label}</span> <span className={styles.LabeledListLabel}>{label}</span>
<strong className={styles.LabeledListValue}>{value}</strong> <strong className={styles.LabeledListValue}>{value}</strong>
</li> </li>