Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Copyright (c) 2026 The Jaeger Authors.
SPDX-License-Identifier: Apache-2.0
*/

.LogicalViewToggle--button {
cursor: pointer;
color: var(--text-secondary);
font-size: 1em;
display: inline-flex;
align-items: center;
padding: 0 0.25rem;
vertical-align: middle;
}

.LogicalViewToggle--button:hover,
.LogicalViewToggle--button:focus-visible {
color: var(--text-primary);
outline: none;
}

.LogicalViewToggle--button:focus-visible {
box-shadow: 0 0 0 2px var(--interactive-primary);
border-radius: 3px;
}

.LogicalViewToggle--button.is-active {
color: var(--interactive-primary);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { describe, expect, it, beforeEach } from 'vitest';

import LogicalViewToggle from './index';
import { useTraceTimelineStore } from '../store';
import DetailState from '../SpanDetail/DetailState';
import { IOtelTrace, IOtelSpan } from '../../../../types/otel';

function makeTrace(spanIDs: string[]): IOtelTrace {
const spanMap = new Map(
spanIDs.map(spanID => [spanID, { spanID } as unknown as IOtelSpan])
) as unknown as Map<string, IOtelSpan>;
return { spanMap } as unknown as IOtelTrace;
}

const initialState = useTraceTimelineStore.getState();

describe('LogicalViewToggle', () => {
beforeEach(() => {
useTraceTimelineStore.setState(initialState, true);
});

it('renders nothing when there are no services to hide', () => {
useTraceTimelineStore.setState({ logicalViewPrunedServices: new Set() });
const { container } = render(<LogicalViewToggle trace={makeTrace([])} detailPanelMode="inline" />);
expect(container.firstChild).toBeNull();
});

it('renders the toggle button when there are non-GenAI services to hide', () => {
useTraceTimelineStore.setState({ logicalViewPrunedServices: new Set(['gateway-svc']) });
render(<LogicalViewToggle trace={makeTrace([])} detailPanelMode="inline" />);
expect(screen.getByTestId('logical-view-toggle-button')).toBeInTheDocument();
});

it('is inactive by default', () => {
useTraceTimelineStore.setState({ logicalViewPrunedServices: new Set(['gateway-svc']) });
render(<LogicalViewToggle trace={makeTrace([])} detailPanelMode="inline" />);
const button = screen.getByTestId('logical-view-toggle-button');
expect(button).not.toHaveClass('is-active');
expect(button).toHaveAttribute('aria-pressed', 'false');
});

it('toggles logicalViewEnabled in the store on click', () => {
useTraceTimelineStore.setState({ logicalViewPrunedServices: new Set(['gateway-svc']) });
render(<LogicalViewToggle trace={makeTrace([])} detailPanelMode="inline" />);
fireEvent.click(screen.getByTestId('logical-view-toggle-button'));
expect(useTraceTimelineStore.getState().logicalViewEnabled).toBe(true);
fireEvent.click(screen.getByTestId('logical-view-toggle-button'));
expect(useTraceTimelineStore.getState().logicalViewEnabled).toBe(false);
});

it('reflects active state and aria-pressed once enabled', () => {
useTraceTimelineStore.setState({ logicalViewPrunedServices: new Set(['gateway-svc']) });
render(<LogicalViewToggle trace={makeTrace([])} detailPanelMode="inline" />);
fireEvent.click(screen.getByTestId('logical-view-toggle-button'));
const button = screen.getByTestId('logical-view-toggle-button');
expect(button).toHaveClass('is-active');
expect(button).toHaveAttribute('aria-pressed', 'true');
});

it('toggles via Enter key', () => {
useTraceTimelineStore.setState({ logicalViewPrunedServices: new Set(['gateway-svc']) });
render(<LogicalViewToggle trace={makeTrace([])} detailPanelMode="inline" />);
fireEvent.keyDown(screen.getByTestId('logical-view-toggle-button'), { key: 'Enter' });
expect(useTraceTimelineStore.getState().logicalViewEnabled).toBe(true);
});

it('toggles via Space key', () => {
useTraceTimelineStore.setState({ logicalViewPrunedServices: new Set(['gateway-svc']) });
render(<LogicalViewToggle trace={makeTrace([])} detailPanelMode="inline" />);
fireEvent.keyDown(screen.getByTestId('logical-view-toggle-button'), { key: ' ' });
expect(useTraceTimelineStore.getState().logicalViewEnabled).toBe(true);
});

it('deselects a side-panel-selected span that becomes hidden when enabling in sidepanel mode', () => {
const trace = makeTrace(['selected-span']);
const detailStates = new Map([['selected-span', DetailState.forDetailPanelMode('sidepanel')]]);
useTraceTimelineStore.setState({
logicalViewPrunedServices: new Set(['gateway-svc']),
detailStates,
prunedServices: new Set(),
});
// The selected span belongs to the service the logical view is about to prune.
(trace.spanMap.get('selected-span') as unknown as { resource: { serviceName: string } }).resource = {
serviceName: 'gateway-svc',
};
render(<LogicalViewToggle trace={trace} detailPanelMode="sidepanel" />);
fireEvent.click(screen.getByTestId('logical-view-toggle-button'));
expect(useTraceTimelineStore.getState().detailStates.size).toBe(0);
});

it('does not touch detailStates when the selected span is unaffected', () => {
const trace = makeTrace(['selected-span']);
(trace.spanMap.get('selected-span') as unknown as { resource: { serviceName: string } }).resource = {
serviceName: 'agent-svc',
};
const detailStates = new Map([['selected-span', DetailState.forDetailPanelMode('sidepanel')]]);
useTraceTimelineStore.setState({
logicalViewPrunedServices: new Set(['gateway-svc']),
detailStates,
prunedServices: new Set(),
});
render(<LogicalViewToggle trace={trace} detailPanelMode="sidepanel" />);
fireEvent.click(screen.getByTestId('logical-view-toggle-button'));
expect(useTraceTimelineStore.getState().detailStates.size).toBe(1);
});

it('does not touch detailStates when not in sidepanel mode', () => {
const trace = makeTrace(['selected-span']);
(trace.spanMap.get('selected-span') as unknown as { resource: { serviceName: string } }).resource = {
serviceName: 'gateway-svc',
};
const detailStates = new Map([['selected-span', new DetailState()]]);
useTraceTimelineStore.setState({
logicalViewPrunedServices: new Set(['gateway-svc']),
detailStates,
prunedServices: new Set(),
});
render(<LogicalViewToggle trace={trace} detailPanelMode="inline" />);
fireEvent.click(screen.getByTestId('logical-view-toggle-button'));
expect(useTraceTimelineStore.getState().detailStates.size).toBe(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2026 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

import React, { useCallback } from 'react';
import { Tooltip } from 'antd';
import { IoGitNetwork, IoGitNetworkOutline } from 'react-icons/io5';

import { isSpanPruned } from '../generateRowStates';
import { getSelectedSpanID, selectEffectivePrunedServices, useTraceTimelineStore } from '../store';
import { IOtelTrace } from '../../../../types/otel';
import type { SpanDetailPanelMode } from '../../../../types/config';

import './LogicalViewToggle.css';

type LogicalViewToggleProps = {
trace: IOtelTrace;
detailPanelMode: SpanDetailPanelMode;
};

/**
* Toggle that hides every service with zero GenAI spans, approximating the agent's
* "logical" flow by folding away infrastructure services. Per yurishkuro's steer on
* jaegertracing/jaeger-ui#4272, this reuses the service filter's own pruning mechanism
* rather than a new span-level predicate: `logicalViewPrunedServices` (computed once per
* trace in store.timeline.ts) is unioned into the effective pruned set through
* `selectEffectivePrunedServices` when this toggle is on.
*/
export default function LogicalViewToggle({ trace, detailPanelMode }: LogicalViewToggleProps) {
const logicalViewEnabled = useTraceTimelineStore(s => s.logicalViewEnabled);
const logicalViewPrunedServices = useTraceTimelineStore(s => s.logicalViewPrunedServices);
const setLogicalViewEnabled = useTraceTimelineStore(s => s.setLogicalViewEnabled);

const handleToggle = useCallback(() => {
const nextEnabled = !logicalViewEnabled;
setLogicalViewEnabled(nextEnabled);

// Mirrors useServiceFilter's handleServiceFilterApply: if enabling the toggle hides
// the span currently open in the side panel, deselect it rather than leaving a
// side panel pointed at a row that no longer renders.
if (nextEnabled && detailPanelMode === 'sidepanel') {
const nextPruned = selectEffectivePrunedServices({
...useTraceTimelineStore.getState(),
logicalViewEnabled: true,
});
const currentSelectedID = getSelectedSpanID(useTraceTimelineStore.getState().detailStates);
if (currentSelectedID) {
const selectedSpan = trace.spanMap.get(currentSelectedID);
if (selectedSpan && isSpanPruned(selectedSpan, nextPruned)) {
useTraceTimelineStore.setState({ detailStates: new Map() });
}
}
}
}, [logicalViewEnabled, setLogicalViewEnabled, detailPanelMode, trace.spanMap]);

// Nothing to hide: no GenAI spans in this trace, or every service already has some.
if (logicalViewPrunedServices.size === 0) {
return null;
}

const label = logicalViewEnabled
? 'Show infrastructure services'
: 'Hide infrastructure services, show only the GenAI/agent flow';

return (
<Tooltip title={label}>
<span
className={`LogicalViewToggle--button ${logicalViewEnabled ? 'is-active' : ''}`}
role="button"
tabIndex={0}
aria-label={label}
aria-pressed={logicalViewEnabled}
data-testid="logical-view-toggle-button"
onClick={handleToggle}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleToggle();
}
}}
>
{logicalViewEnabled ? <IoGitNetwork /> : <IoGitNetworkOutline />}
</span>
</Tooltip>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,24 @@ describe('<TimelineHeaderRow>', () => {
expect(screen.getByTestId('timeline-collapser')).toBeInTheDocument();
});

it('renders serviceFilterNode and logicalViewToggleNode when provided', () => {
render(
<TimelineHeaderRow
{...props}
serviceFilterNode={<div data-testid="service-filter-node" />}
logicalViewToggleNode={<div data-testid="logical-view-toggle-node" />}
/>
);
expect(screen.getByTestId('service-filter-node')).toBeInTheDocument();
expect(screen.getByTestId('logical-view-toggle-node')).toBeInTheDocument();
});

it('renders nothing extra when serviceFilterNode and logicalViewToggleNode are omitted', () => {
render(<TimelineHeaderRow {...props} />);
expect(screen.queryByTestId('service-filter-node')).not.toBeInTheDocument();
expect(screen.queryByTestId('logical-view-toggle-node')).not.toBeInTheDocument();
});

describe('tree-only mode (timelineBarsVisible=false)', () => {
// In tree-only mode TraceTimelineViewer passes nameColumnWidth=1 (name fills the main area).
const barsHiddenProps = { ...props, timelineBarsVisible: false, nameColumnWidth: 1 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type TimelineHeaderRowProps = {
onExpandOne: () => void;
resizerMax: number;
serviceFilterNode?: React.ReactNode;
logicalViewToggleNode?: React.ReactNode;
sidePanelVisible: boolean;
sidePanelWidth: number;
sidePanelLabel: string;
Expand All @@ -51,6 +52,7 @@ export default function TimelineHeaderRow(props: TimelineHeaderRowProps) {
onExpandOne,
resizerMax,
serviceFilterNode,
logicalViewToggleNode,
sidePanelVisible,
sidePanelWidth,
sidePanelLabel,
Expand All @@ -70,6 +72,7 @@ export default function TimelineHeaderRow(props: TimelineHeaderRowProps) {
Service &amp; {props.useOtelTerms ? 'Span Name' : 'Operation'}
</h3>
{serviceFilterNode}
{logicalViewToggleNode}
<TimelineCollapser
onCollapseAll={onCollapseAll}
onExpandAll={onExpandAll}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ import { TNil, ReduxState } from '../../../types';
import { CriticalPathSection } from '../../../types/critical_path';
import { IOtelSpan, IOtelTrace, IAttributes, IEvent } from '../../../types/otel';
import TTraceTimeline from '../../../types/TTraceTimeline';
import { getSelectedSpanID, useLayoutPrefsStore, useTraceTimelineStore } from './store';
import {
getSelectedSpanID,
selectEffectivePrunedServices,
useLayoutPrefsStore,
useTraceTimelineStore,
} from './store';

import './VirtualizedTraceView.css';
import updateUiFind from '../../../utils/update-ui-find';
Expand Down Expand Up @@ -588,7 +593,7 @@ function VirtualizedTraceViewWrapper(
const childrenHiddenIDs = useTraceTimelineStore(s => s.childrenHiddenIDs);
const detailStates = useTraceTimelineStore(s => s.detailStates);
const shouldScrollToFirstUiFindMatch = useTraceTimelineStore(s => s.shouldScrollToFirstUiFindMatch);
const prunedServices = useTraceTimelineStore(s => s.prunedServices);
const prunedServices = useTraceTimelineStore(selectEffectivePrunedServices);
const selectedSpanID = detailPanelMode === 'sidepanel' ? getSelectedSpanID(detailStates) : null;

const zustandSetTrace = useTraceTimelineStore(s => s.setTrace);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useLayoutPrefsStore,
useTraceTimelineStore,
} from './store';
import LogicalViewToggle from './LogicalViewToggle';
import SpanDetailSidePanel from './SpanDetailSidePanel';
import TimelineHeaderRow from './TimelineHeaderRow';
import { useServiceFilter } from './useServiceFilter';
Expand Down Expand Up @@ -224,6 +225,7 @@ export const TraceTimelineViewerImpl = (props: TProps) => {
onExpandOne={expandOne}
resizerMax={resizerMax}
serviceFilterNode={serviceFilterNode}
logicalViewToggleNode={<LogicalViewToggle trace={trace} detailPanelMode={detailPanelMode} />}
sidePanelVisible={sidePanelActive}
sidePanelWidth={effectiveSidePanelWidth}
sidePanelLabel={sidePanelLabel}
Expand Down
Loading