Trace View: Span filters updated to use combobox filters (#112287)
* Start using adhoc filters component in trace view * Migrate old span filters to adhoc filters * Add support for duration filter * Set placeholder in adhoc filters input * Moved the span graph to the header, above the filters * Update next and prev buttons, added filter pills, style fixes * Fix types * Use canary scenes version * Remove copyright * More duration operators * Added tests for the controller * More consistent spacing * Remove unused container style from SpanGraph component * Update scenes canary * Update scenes to 6.42.0 * Fix all spans toggle * Use InlineSwitch instead of Switch * Fix critical path * Fix duration filtering * Add tooltips to filter pills * Improve duration filter pill * Improvements to backgrounds of span row. Fixed size of collapsible overview section * Fix sticky trace view header * Removed old span filters from panel options * Migrate old filters to adhocfilters in panel. Use the adhoc filters component in panel options. * i18n * Fix tests * Fix tests
This commit is contained in:
@@ -41,7 +41,7 @@ describe('Trace view', () => {
|
||||
e2e.components.TraceViewer.spanBar()
|
||||
.its('length')
|
||||
.should(($span) => {
|
||||
expect($span).to.be.equal(50);
|
||||
expect($span).to.be.at.most(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2772,11 +2772,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/TraceView/TraceView.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx": {
|
||||
"react-prefer-function-component/react-prefer-function-component": {
|
||||
"count": 1
|
||||
@@ -2787,11 +2782,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx": {
|
||||
"react-hooks/rules-of-hooks": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/explore/TraceView/components/TraceTimelineViewer/ListView/index.tsx": {
|
||||
"react-prefer-function-component/react-prefer-function-component": {
|
||||
"count": 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
|
||||
import { PreferredVisualisationType } from './data';
|
||||
import { SelectableValue } from './select';
|
||||
import { TimeRange } from './time';
|
||||
|
||||
type AnyQuery = DataQuery & Record<string, any>;
|
||||
@@ -31,6 +32,7 @@ export interface TraceSearchProps {
|
||||
to?: string;
|
||||
toOperator: string;
|
||||
tags: TraceSearchTag[];
|
||||
adhocFilters?: Array<SelectableValue<string>>;
|
||||
query?: string;
|
||||
matchesOnly: boolean;
|
||||
criticalPathOnly: boolean;
|
||||
|
||||
@@ -30,7 +30,6 @@ import { useDispatch, useSelector } from 'app/types/store';
|
||||
import { changePanelState } from '../state/explorePane';
|
||||
|
||||
import memoizedTraceCriticalPath from './components/CriticalPath';
|
||||
import SpanGraph from './components/TracePageHeader/SpanGraph';
|
||||
import { TracePageHeader } from './components/TracePageHeader/TracePageHeader';
|
||||
import TraceTimelineViewer from './components/TraceTimelineViewer';
|
||||
import { TraceFlameGraphs } from './components/TraceTimelineViewer/SpanDetail';
|
||||
@@ -100,7 +99,10 @@ export function TraceView(props: Props) {
|
||||
const { removeHoverIndentGuideId, addHoverIndentGuideId, hoverIndentGuideIds } = useHoverIndentGuide();
|
||||
const { viewRange, updateViewRangeTime, updateNextViewRangeTime } = useViewRange();
|
||||
const { expandOne, collapseOne, childrenToggle, collapseAll, childrenHiddenIDs, expandAll } = useChildrenState();
|
||||
const { search, setSearch, spanFilterMatches } = useSearch(exploreId, traceProp?.spans, spanFilters);
|
||||
|
||||
const criticalPath = useMemo(() => memoizedTraceCriticalPath(traceProp), [traceProp]);
|
||||
const { search, setSearch, spanFilterMatches } = useSearch(exploreId, traceProp?.spans, spanFilters, criticalPath);
|
||||
|
||||
const [focusedSpanIdForSearch, setFocusedSpanIdForSearch] = useState('');
|
||||
const [showSpanFilters, setShowSpanFilters] = useToggle(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(100);
|
||||
@@ -178,8 +180,6 @@ export function TraceView(props: Props) {
|
||||
? props.scrollElement
|
||||
: document.getElementsByClassName(props.scrollElementClass ?? '')[0];
|
||||
|
||||
const criticalPath = memoizedTraceCriticalPath(traceProp);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.dataFrames?.length && traceProp ? (
|
||||
@@ -199,13 +199,11 @@ export function TraceView(props: Props) {
|
||||
datasourceUid={datasourceUid}
|
||||
setHeaderHeight={setHeaderHeight}
|
||||
app={exploreId ? CoreApp.Explore : CoreApp.Unknown}
|
||||
/>
|
||||
<SpanGraph
|
||||
trace={traceProp}
|
||||
viewRange={viewRange}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
viewRange={viewRange}
|
||||
/>
|
||||
|
||||
<TraceTimelineViewer
|
||||
findMatchesIDs={spanFilterMatches}
|
||||
trace={traceProp}
|
||||
@@ -240,7 +238,6 @@ export function TraceView(props: Props) {
|
||||
focusedSpanId={focusedSpanId}
|
||||
focusedSpanIdForSearch={focusedSpanIdForSearch}
|
||||
showSpanFilterMatchesOnly={search.matchesOnly}
|
||||
showCriticalPathSpansOnly={search.criticalPathOnly}
|
||||
createFocusSpanLink={createFocusSpanLink}
|
||||
topOfViewRef={topOfViewRef}
|
||||
headerHeight={headerHeight}
|
||||
@@ -314,12 +311,14 @@ function useFocusSpanLink(options: {
|
||||
// Check if the link is to a different trace or not.
|
||||
// If it's the same trace, only update panel state with setFocusedSpanId (no navigation).
|
||||
// If it's a different trace, use splitOpenFn to open a new explore panel
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const sameTrace = query?.queryType === 'traceql' && (query as TempoQuery).query === traceId;
|
||||
|
||||
return mapInternalLinkToExplore({
|
||||
link,
|
||||
internalLink: link.internal!,
|
||||
scopedVars: {},
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
field: {} as Field,
|
||||
onClickFn: sameTrace
|
||||
? () => setFocusedSpanId(focusedSpanId === spanId ? undefined : spanId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
@@ -84,68 +84,27 @@ describe('TraceViewContainer', () => {
|
||||
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3);
|
||||
});
|
||||
|
||||
it('can select next/prev results', async () => {
|
||||
it('renders next/prev result buttons', async () => {
|
||||
renderTraceViewContainer();
|
||||
const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters 3 spans Prev Next' });
|
||||
await user.click(spanFiltersButton);
|
||||
|
||||
const nextResultButton = screen.getByRole('button', { name: 'Next result button' });
|
||||
const prevResultButton = screen.getByRole('button', { name: 'Prev result button' });
|
||||
|
||||
// Buttons should be disabled when there are no filters applied
|
||||
expect(nextResultButton).toBeDisabled();
|
||||
expect(prevResultButton).toBeDisabled();
|
||||
expect(nextResultButton.getAttribute('tabindex')).toBe('-1');
|
||||
expect(prevResultButton.getAttribute('tabindex')).toBe('-1');
|
||||
|
||||
await user.click(screen.getByLabelText('Select tag key'));
|
||||
const tagOption = screen.getByText('component');
|
||||
await waitFor(() => expect(tagOption).toBeInTheDocument());
|
||||
await user.click(tagOption);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className
|
||||
).toContain('rowMatchingFilter');
|
||||
expect(
|
||||
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className
|
||||
).toContain('rowMatchingFilter');
|
||||
expect(
|
||||
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[2].parentElement!.className
|
||||
).toContain('rowMatchingFilter');
|
||||
});
|
||||
|
||||
expect(nextResultButton.getAttribute('tabindex')).toBe('0');
|
||||
expect(prevResultButton.getAttribute('tabindex')).toBe('0');
|
||||
await user.click(nextResultButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className
|
||||
).toContain('rowFocused');
|
||||
});
|
||||
await user.click(nextResultButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[1].parentElement!.className
|
||||
).toContain('rowFocused');
|
||||
});
|
||||
await user.click(prevResultButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' })[0].parentElement!.className
|
||||
).toContain('rowFocused');
|
||||
});
|
||||
});
|
||||
|
||||
it('show matches only works as expected', async () => {
|
||||
it('renders show all spans switch', async () => {
|
||||
renderTraceViewContainer();
|
||||
const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters 3 spans Prev Next' });
|
||||
await user.click(spanFiltersButton);
|
||||
|
||||
await user.click(screen.getByLabelText('Select tag key'));
|
||||
const tagOption = screen.getByText('http.status_code');
|
||||
await waitFor(() => expect(tagOption).toBeInTheDocument());
|
||||
await user.click(tagOption);
|
||||
|
||||
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(3);
|
||||
const matchesSwitch = screen.getByRole('switch', { name: 'Show matches only switch' });
|
||||
// Find the show all spans switch in the search bar
|
||||
const matchesSwitch = await screen.findByRole('switch', { name: 'Show all spans' });
|
||||
expect(matchesSwitch).toBeInTheDocument();
|
||||
await user.click(matchesSwitch);
|
||||
expect(screen.queryAllByText('', { selector: 'div[data-testid="span-view"]' }).length).toBe(1);
|
||||
// Switch should be checked (showing all spans) and disabled by default (no filters)
|
||||
expect(matchesSwitch).toBeChecked();
|
||||
expect(matchesSwitch).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DataFrame, DataLinksContext, SplitOpen, TimeRange } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PanelChrome } from '@grafana/ui';
|
||||
import { PanelChrome, useStyles2 } from '@grafana/ui';
|
||||
import { StoreState, useSelector } from 'app/types/store';
|
||||
|
||||
import { useExploreDataLinkPostProcessor } from '../hooks/useExploreDataLinkPostProcessor';
|
||||
@@ -26,7 +27,7 @@ export function TraceViewContainer(props: Props) {
|
||||
const datasource = useSelector(
|
||||
(state: StoreState) => state.explore.panes[props.exploreId]?.datasourceInstance ?? undefined
|
||||
);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const dataLinkPostProcessor = useExploreDataLinkPostProcessor(splitOpenFn, timeRange);
|
||||
|
||||
if (!traceProp) {
|
||||
@@ -34,18 +35,35 @@ export function TraceViewContainer(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelChrome padding="none" title={t('explore.trace-view-container.title-trace', 'Trace')}>
|
||||
<DataLinksContext.Provider value={{ dataLinkPostProcessor }}>
|
||||
<TraceView
|
||||
exploreId={exploreId}
|
||||
dataFrames={dataFrames}
|
||||
splitOpenFn={splitOpenFn}
|
||||
scrollElement={scrollElement}
|
||||
traceProp={traceProp}
|
||||
datasource={datasource}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</DataLinksContext.Provider>
|
||||
</PanelChrome>
|
||||
<div className={styles.container}>
|
||||
<PanelChrome padding="none" title={t('explore.trace-view-container.title-trace', 'Trace')}>
|
||||
<DataLinksContext.Provider value={{ dataLinkPostProcessor }}>
|
||||
<TraceView
|
||||
exploreId={exploreId}
|
||||
dataFrames={dataFrames}
|
||||
splitOpenFn={splitOpenFn}
|
||||
scrollElement={scrollElement}
|
||||
traceProp={traceProp}
|
||||
datasource={datasource}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</DataLinksContext.Provider>
|
||||
</PanelChrome>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
container: css({
|
||||
'& > section': {
|
||||
/*
|
||||
The PanelChrome component sets the overflow property, which prevents the Trace View header from
|
||||
being sticky by creating a new scrolling ancestor.
|
||||
This is a workaround to allow the header to be sticky.
|
||||
*/
|
||||
overflow: 'initial',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
+4
-4
@@ -64,8 +64,8 @@ describe('<NextPrevResult>', () => {
|
||||
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
|
||||
expect(nextResButton).toBeInTheDocument();
|
||||
expect(prevResButton).toBeInTheDocument();
|
||||
expect(nextResButton as HTMLDivElement).toHaveStyle('pointer-events: none');
|
||||
expect(prevResButton as HTMLDivElement).toHaveStyle('pointer-events: none');
|
||||
expect(nextResButton).toBeDisabled();
|
||||
expect(prevResButton).toBeDisabled();
|
||||
expect(screen.getByText('0 matches')).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -80,8 +80,8 @@ describe('<NextPrevResult>', () => {
|
||||
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
|
||||
expect(nextResButton).toBeInTheDocument();
|
||||
expect(prevResButton).toBeInTheDocument();
|
||||
expect(nextResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none');
|
||||
expect(prevResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none');
|
||||
expect(nextResButton).not.toBeDisabled();
|
||||
expect(prevResButton).not.toBeDisabled();
|
||||
expect(screen.getByText('1 match')).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
+41
-31
@@ -20,8 +20,7 @@ import * as React from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui';
|
||||
import { getButtonStyles } from '@grafana/ui/internal';
|
||||
import { Button, Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { Trace } from '../../types/trace';
|
||||
|
||||
@@ -114,14 +113,13 @@ export default memo(function NextPrevResult(props: NextPrevResultProps) {
|
||||
};
|
||||
|
||||
const buttonEnabled = (spanFilterMatches && spanFilterMatches?.size > 0) ?? false;
|
||||
const buttonClass = buttonEnabled ? styles.button : cx(styles.button, styles.buttonDisabled);
|
||||
|
||||
const getTooltip = useCallback(
|
||||
(content: PopoverContent) => {
|
||||
return (
|
||||
<Tooltip content={content} placement="top">
|
||||
<span className={styles.tooltip}>
|
||||
<Icon name="info-circle" size="md" />
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -205,60 +203,72 @@ export default memo(function NextPrevResult(props: NextPrevResultProps) {
|
||||
const depth = get(maxBy(trace.spans, 'depth'), 'depth', 0) + 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.matches}>{getMatchesMetadata(depth, services)}</span>
|
||||
<div className={styles.container}>
|
||||
<div className={buttonEnabled ? styles.buttons : cx(styles.buttons, styles.buttonsDisabled)}>
|
||||
<div
|
||||
<Button
|
||||
aria-label={t('explore.next-prev-result.aria-label-prev', 'Prev result button')}
|
||||
className={buttonClass}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon="arrow-up"
|
||||
disabled={!buttonEnabled}
|
||||
onClick={(event) => prevResult(event, buttonEnabled)}
|
||||
onKeyDown={(event) => prevResultOnKeyDown(event, buttonEnabled)}
|
||||
role="button"
|
||||
tabIndex={buttonEnabled ? 0 : -1}
|
||||
>
|
||||
<Trans i18nKey="explore.prev">Prev</Trans>
|
||||
</div>
|
||||
<div
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('explore.next-prev-result.aria-label-next', 'Next result button')}
|
||||
className={buttonClass}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon="arrow-down"
|
||||
disabled={!buttonEnabled}
|
||||
onClick={(event) => nextResult(event, buttonEnabled)}
|
||||
onKeyDown={(event) => nextResultOnKeyDown(event, buttonEnabled)}
|
||||
role="button"
|
||||
tabIndex={buttonEnabled ? 0 : -1}
|
||||
>
|
||||
<Trans i18nKey="explore.next">Next</Trans>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<span className={styles.matches}>{getMatchesMetadata(depth, services)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2, showSpanFilters: boolean) => {
|
||||
const buttonStyles = getButtonStyles({
|
||||
theme,
|
||||
variant: 'secondary',
|
||||
size: showSpanFilters ? 'md' : 'sm',
|
||||
iconOnly: false,
|
||||
fill: 'outline',
|
||||
});
|
||||
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
buttons: css({
|
||||
display: 'inline-flex',
|
||||
gap: '4px',
|
||||
gap: 1,
|
||||
}),
|
||||
buttonsDisabled: css({
|
||||
cursor: 'not-allowed',
|
||||
}),
|
||||
button: buttonStyles.button,
|
||||
buttonDisabled: css(buttonStyles.disabled, { pointerEvents: 'none' }),
|
||||
button: {
|
||||
padding: theme.spacing(0, 1),
|
||||
},
|
||||
iconButton: css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
matches: css({
|
||||
marginRight: theme.spacing(2),
|
||||
textWrap: 'nowrap',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.colors.text.primary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
tooltip: css({
|
||||
color: '#aaa',
|
||||
margin: '0 0 0 5px',
|
||||
marginLeft: theme.spacing(0.5),
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
+3
-15
@@ -26,17 +26,12 @@ describe('<TracePageSearchBar>', () => {
|
||||
trace: trace,
|
||||
search: DEFAULT_SPAN_FILTERS,
|
||||
spanFilterMatches: props.matches ? new Set(props.matches) : undefined,
|
||||
showSpanFilterMatchesOnly: false,
|
||||
setShowSpanFilterMatchesOnly: jest.fn(),
|
||||
setFocusedSpanIdForSearch: jest.fn(),
|
||||
focusedSpanIndexForSearch: -1,
|
||||
setFocusedSpanIndexForSearch: jest.fn(),
|
||||
setShowCriticalPathSpansOnly: jest.fn(),
|
||||
datasourceType: '',
|
||||
clear: jest.fn(),
|
||||
totalSpans: 100,
|
||||
showSpanFilters: true,
|
||||
showCriticalPathSpansOnly: false,
|
||||
};
|
||||
|
||||
return <TracePageSearchBar {...searchBarProps} />;
|
||||
@@ -46,16 +41,9 @@ describe('<TracePageSearchBar>', () => {
|
||||
expect(() => render(<TracePageSearchBarWithProps matches={[]} />)).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders clear filter button', () => {
|
||||
render(<TracePageSearchBarWithProps matches={[]} />);
|
||||
const clearFiltersButton = screen.getByRole('button', { name: 'Clear filters button' });
|
||||
expect(clearFiltersButton).toBeInTheDocument();
|
||||
expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('renders show span filter matches only switch', async () => {
|
||||
render(<TracePageSearchBarWithProps matches={[]} />);
|
||||
const matchesSwitch = screen.getByRole('switch', { name: 'Show matches only switch' });
|
||||
it('renders show all spans switch', async () => {
|
||||
render(<TracePageSearchBarWithProps matches={['span1']} />);
|
||||
const matchesSwitch = await screen.findByRole('switch', { name: 'Show all spans' });
|
||||
expect(matchesSwitch).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+35
-107
@@ -13,15 +13,13 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { memo, Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { memo, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { GrafanaTheme2, TraceSearchProps } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Switch, useStyles2 } from '@grafana/ui';
|
||||
import { getButtonStyles } from '@grafana/ui/internal';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { InlineSwitch, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Trace } from '../../types/trace';
|
||||
import { convertTimeFilter } from '../../utils/filter-spans';
|
||||
|
||||
import NextPrevResult from './NextPrevResult';
|
||||
|
||||
@@ -30,12 +28,10 @@ export type TracePageSearchBarProps = {
|
||||
search: TraceSearchProps;
|
||||
spanFilterMatches: Set<string> | undefined;
|
||||
setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void;
|
||||
setShowCriticalPathSpansOnly: (showCriticalPath: boolean) => void;
|
||||
focusedSpanIndexForSearch: number;
|
||||
setFocusedSpanIndexForSearch: Dispatch<SetStateAction<number>>;
|
||||
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
|
||||
datasourceType: string;
|
||||
clear: () => void;
|
||||
showSpanFilters: boolean;
|
||||
};
|
||||
|
||||
@@ -45,127 +41,59 @@ export default memo(function TracePageSearchBar(props: TracePageSearchBarProps)
|
||||
search,
|
||||
spanFilterMatches,
|
||||
setShowSpanFilterMatchesOnly,
|
||||
setShowCriticalPathSpansOnly,
|
||||
focusedSpanIndexForSearch,
|
||||
setFocusedSpanIndexForSearch,
|
||||
setFocusedSpanIdForSearch,
|
||||
datasourceType,
|
||||
clear,
|
||||
showSpanFilters,
|
||||
} = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const clearEnabled = useMemo(() => {
|
||||
return (
|
||||
(search.serviceName && search.serviceName !== '') ||
|
||||
(search.spanName && search.spanName !== '') ||
|
||||
convertTimeFilter(search.from || '') ||
|
||||
convertTimeFilter(search.to || '') ||
|
||||
search.tags.length > 1 ||
|
||||
search.tags.some((tag) => {
|
||||
return tag.key;
|
||||
}) ||
|
||||
(search.query && search.query !== '') ||
|
||||
search.matchesOnly
|
||||
);
|
||||
}, [search.serviceName, search.spanName, search.from, search.to, search.tags, search.query, search.matchesOnly]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.controls}>
|
||||
<>
|
||||
<div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!clearEnabled}
|
||||
type="button"
|
||||
fill="outline"
|
||||
aria-label={t('explore.trace-page-search-bar.aria-label-clear-filters', 'Clear filters button')}
|
||||
onClick={clear}
|
||||
>
|
||||
<Trans i18nKey="explore.clear">Clear</Trans>
|
||||
</Button>
|
||||
<div className={styles.matchesOnly}>
|
||||
<Switch
|
||||
value={search.matchesOnly}
|
||||
onChange={(value) => setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)}
|
||||
label={t('explore.trace-page-search-bar.label-show-matches', 'Show matches only switch')}
|
||||
disabled={!spanFilterMatches?.size}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShowSpanFilterMatchesOnly(!search.matchesOnly)}
|
||||
className={styles.clearMatchesButton}
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
disabled={!spanFilterMatches?.size}
|
||||
>
|
||||
<Trans i18nKey="explore.show-matches-only">Show matches only</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.matchesOnly}>
|
||||
<Switch
|
||||
value={search.criticalPathOnly}
|
||||
onChange={(value) => setShowCriticalPathSpansOnly(value.currentTarget.checked ?? false)}
|
||||
label={t('explore.trace-page-search-bar.label-show-paths', 'Show critical path only switch')}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShowCriticalPathSpansOnly(!search.criticalPathOnly)}
|
||||
className={styles.clearMatchesButton}
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
>
|
||||
<Trans i18nKey="explore.show-critical-path-only">Show critical path only</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.nextPrevResult}>
|
||||
<NextPrevResult
|
||||
trace={trace}
|
||||
spanFilterMatches={spanFilterMatches}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
|
||||
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
|
||||
datasourceType={datasourceType}
|
||||
showSpanFilters={showSpanFilters}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
<div className={styles.controls}>
|
||||
<NextPrevResult
|
||||
trace={trace}
|
||||
spanFilterMatches={spanFilterMatches}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
|
||||
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
|
||||
datasourceType={datasourceType}
|
||||
showSpanFilters={showSpanFilters}
|
||||
/>
|
||||
<InlineSwitch
|
||||
showLabel={true}
|
||||
value={!search.matchesOnly}
|
||||
label={t('explore.show-all-spans', 'Show all spans')}
|
||||
disabled={!spanFilterMatches?.size}
|
||||
className={styles.switch}
|
||||
onChange={(e) => {
|
||||
setShowSpanFilterMatchesOnly(!search.matchesOnly);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
const buttonStyles = getButtonStyles({ theme, variant: 'secondary', size: 'md', iconOnly: false, fill: 'outline' });
|
||||
|
||||
return {
|
||||
button: css(buttonStyles.button),
|
||||
buttonDisabled: css(buttonStyles.disabled, { pointerEvents: 'none', cursor: 'not-allowed' }),
|
||||
container: css({
|
||||
display: 'inline',
|
||||
}),
|
||||
controls: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
margin: '5px 0 0 0',
|
||||
}),
|
||||
matchesOnly: css({
|
||||
display: 'inline-flex',
|
||||
margin: '0 0 0 25px',
|
||||
verticalAlign: 'middle',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
switch: css({
|
||||
flexDirection: 'row-reverse',
|
||||
gap: theme.spacing(0.5),
|
||||
|
||||
label: {
|
||||
padding: 0,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
},
|
||||
}),
|
||||
clearMatchesButton: css({
|
||||
color: theme.colors.text.primary,
|
||||
|
||||
'&:hover': {
|
||||
background: 'inherit',
|
||||
},
|
||||
}),
|
||||
nextPrevResult: css({
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
-37
@@ -52,16 +52,10 @@ describe('SpanFilters', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
const SpanFiltersWithProps = ({ showFilters = true, matches }: { showFilters?: boolean; matches?: Set<string> }) => {
|
||||
const [search, setSearch] = useState(DEFAULT_SPAN_FILTERS);
|
||||
const [showSpanFilterMatchesOnly, setShowSpanFilterMatchesOnly] = useState(false);
|
||||
const [showCriticalPathSpansOnly, setShowCriticalPathSpansOnly] = useState(false);
|
||||
const props = {
|
||||
trace: trace,
|
||||
showSpanFilters: showFilters,
|
||||
setShowSpanFilters: jest.fn(),
|
||||
showSpanFilterMatchesOnly,
|
||||
setShowSpanFilterMatchesOnly,
|
||||
showCriticalPathSpansOnly,
|
||||
setShowCriticalPathSpansOnly,
|
||||
search,
|
||||
setSearch,
|
||||
spanFilterMatches: matches,
|
||||
@@ -246,37 +240,6 @@ describe('SpanFilters', () => {
|
||||
expect(screen.getAllByLabelText('Select tag key').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow resetting filters', async () => {
|
||||
render(<SpanFiltersWithProps matches={new Set('1ed38015486087ca')} />);
|
||||
const clearFiltersButton = screen.getByRole('button', { name: 'Clear filters button' });
|
||||
expect(clearFiltersButton).toBeInTheDocument();
|
||||
expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(true);
|
||||
|
||||
const serviceValue = screen.getByLabelText('Select service name');
|
||||
const spanValue = screen.getByLabelText('Select span name');
|
||||
const tagKey = screen.getByLabelText('Select tag key');
|
||||
const tagValue = screen.getByLabelText('Select tag value');
|
||||
await selectAndCheckValue(user, serviceValue, 'Service0');
|
||||
await selectAndCheckValue(user, spanValue, 'Span0');
|
||||
await selectAndCheckValue(user, tagKey, 'TagKey0');
|
||||
await selectAndCheckValue(user, tagValue, 'TagValue0');
|
||||
|
||||
const matchesSwitch = screen.getByRole('switch', { name: 'Show matches only switch' });
|
||||
expect(matchesSwitch).not.toBeChecked();
|
||||
await user.click(matchesSwitch);
|
||||
expect(matchesSwitch).toBeChecked();
|
||||
|
||||
expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(false);
|
||||
await user.click(clearFiltersButton);
|
||||
expect(screen.queryByText('Service0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Span0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('TagKey0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('TagValue0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Add tag')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Remove tag')).not.toBeInTheDocument();
|
||||
expect(matchesSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders buttons when span filters is collapsed', async () => {
|
||||
render(<SpanFiltersWithProps showFilters={false} />);
|
||||
expect(screen.queryByRole('button', { name: 'Next result button' })).toBeInTheDocument();
|
||||
|
||||
-9
@@ -89,13 +89,6 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
|
||||
[search, setSearch]
|
||||
);
|
||||
|
||||
const setShowCriticalPathSpansOnly = useCallback(
|
||||
(showCriticalPathSpansOnly: boolean) => {
|
||||
setSearch({ ...search, criticalPathOnly: showCriticalPathSpansOnly });
|
||||
},
|
||||
[search, setSearch]
|
||||
);
|
||||
|
||||
if (!trace) {
|
||||
return null;
|
||||
}
|
||||
@@ -276,12 +269,10 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
|
||||
search={search}
|
||||
spanFilterMatches={spanFilterMatches}
|
||||
setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly}
|
||||
setShowCriticalPathSpansOnly={setShowCriticalPathSpansOnly}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
|
||||
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
|
||||
datasourceType={datasourceType}
|
||||
clear={clear}
|
||||
showSpanFilters={showSpanFilters}
|
||||
/>
|
||||
</Collapse>
|
||||
|
||||
+1
-4
@@ -25,9 +25,6 @@ import ViewingLayer from './ViewingLayer';
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
container: css({
|
||||
padding: '0 0.5rem 0.5rem 0.5rem',
|
||||
}),
|
||||
canvasContainer: css({
|
||||
position: 'relative',
|
||||
}),
|
||||
@@ -75,7 +72,7 @@ const SpanGraph = memo(
|
||||
|
||||
const items = memoizedGetitems(trace);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
<TickLabels numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} />
|
||||
<div className={styles.canvasContainer}>
|
||||
<CanvasSpanGraph valueWidth={trace.duration} items={items} />
|
||||
|
||||
+644
@@ -0,0 +1,644 @@
|
||||
import { SelectableValue, TraceSearchProps } from '@grafana/data';
|
||||
import { AdHocFilterWithLabels } from '@grafana/scenes';
|
||||
|
||||
import { getTraceTagKeys, getTraceTagValues } from '../../utils/tags';
|
||||
import { Trace, TraceSpan } from '../types/trace';
|
||||
|
||||
import { TraceAdHocFiltersController } from './TraceAdHocFiltersController';
|
||||
|
||||
// Mock the tag utilities
|
||||
jest.mock('../../utils/tags', () => ({
|
||||
getTraceTagKeys: jest.fn(),
|
||||
getTraceTagValues: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock i18n
|
||||
jest.mock('@grafana/i18n', () => ({
|
||||
t: (key: string, defaultValue: string) => defaultValue,
|
||||
}));
|
||||
|
||||
const mockGetTraceTagKeys = getTraceTagKeys as jest.MockedFunction<typeof getTraceTagKeys>;
|
||||
const mockGetTraceTagValues = getTraceTagValues as jest.MockedFunction<typeof getTraceTagValues>;
|
||||
|
||||
describe('TraceAdHocFiltersController', () => {
|
||||
let mockTrace: Trace;
|
||||
let mockSearch: TraceSearchProps;
|
||||
let mockSetSearch: jest.Mock;
|
||||
let mockWip: AdHocFilterWithLabels | undefined;
|
||||
let mockSetWip: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a minimal mock trace
|
||||
mockTrace = {
|
||||
traceID: 'trace1',
|
||||
spans: [
|
||||
{
|
||||
spanID: 'span1',
|
||||
operationName: 'operation1',
|
||||
process: {
|
||||
serviceName: 'service1',
|
||||
tags: [],
|
||||
},
|
||||
tags: [
|
||||
{ key: 'http.method', value: 'GET' },
|
||||
{ key: 'http.status_code', value: 200 },
|
||||
],
|
||||
logs: [],
|
||||
} as unknown as TraceSpan,
|
||||
],
|
||||
duration: 1000,
|
||||
startTime: 0,
|
||||
endTime: 1000,
|
||||
processes: {},
|
||||
traceName: 'test-trace',
|
||||
services: [],
|
||||
};
|
||||
|
||||
mockSearch = {
|
||||
serviceNameOperator: '=',
|
||||
spanNameOperator: '=',
|
||||
fromOperator: '>=',
|
||||
toOperator: '<=',
|
||||
tags: [],
|
||||
matchesOnly: false,
|
||||
criticalPathOnly: false,
|
||||
adhocFilters: [],
|
||||
};
|
||||
|
||||
mockSetSearch = jest.fn();
|
||||
mockWip = undefined;
|
||||
mockSetWip = jest.fn();
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('initializes with provided values', () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useState', () => {
|
||||
it('returns current state with no filters', () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const state = controller.useState();
|
||||
|
||||
expect(state).toEqual({
|
||||
filters: [],
|
||||
readOnly: false,
|
||||
allowCustomValue: true,
|
||||
supportsMultiValueOperators: false,
|
||||
wip: undefined,
|
||||
inputPlaceholder: 'Filter by attribute or text',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns current state with existing filters', () => {
|
||||
mockSearch.adhocFilters = [
|
||||
{ key: 'http.method', operator: '=', value: 'GET' },
|
||||
{ key: 'service.name', operator: '!=', value: 'test' },
|
||||
];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const state = controller.useState();
|
||||
|
||||
expect(state.filters).toHaveLength(2);
|
||||
expect(state.filters[0]).toMatchObject({
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
});
|
||||
expect(state.filters[1]).toMatchObject({
|
||||
key: 'service.name',
|
||||
operator: '!=',
|
||||
value: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes wip filter in state', () => {
|
||||
mockWip = {
|
||||
key: 'http.status_code',
|
||||
operator: '=',
|
||||
value: '',
|
||||
};
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const state = controller.useState();
|
||||
|
||||
expect(state.wip).toEqual(mockWip);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKeys', () => {
|
||||
it('returns available keys including special keys', async () => {
|
||||
mockGetTraceTagKeys.mockReturnValue(['http.method', 'http.status_code', 'service.name']);
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const keys = await controller.getKeys(null);
|
||||
|
||||
expect(keys).toHaveLength(5); // Text search, duration, + 3 trace keys
|
||||
expect(keys[0]).toMatchObject({
|
||||
label: 'Text search',
|
||||
value: '_textSearch_',
|
||||
});
|
||||
expect(keys[1]).toMatchObject({
|
||||
label: 'duration',
|
||||
value: 'duration',
|
||||
});
|
||||
expect(keys[2]).toMatchObject({
|
||||
value: 'http.method',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls getTraceTagKeys with trace', async () => {
|
||||
mockGetTraceTagKeys.mockReturnValue(['key1', 'key2']);
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
await controller.getKeys(null);
|
||||
|
||||
expect(mockGetTraceTagKeys).toHaveBeenCalledWith(mockTrace);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValuesFor', () => {
|
||||
it('returns empty array when no key is provided', async () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const values = await controller.getValuesFor({ key: '', operator: '=', value: '' });
|
||||
|
||||
expect(values).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns duration values for duration key', async () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const values = await controller.getValuesFor({ key: 'duration', operator: '=', value: '' });
|
||||
|
||||
expect(values).toHaveLength(4);
|
||||
expect(values[0]).toMatchObject({ label: '1ms', value: '1ms' });
|
||||
expect(values[1]).toMatchObject({ label: '1s', value: '1s' });
|
||||
expect(values[2]).toMatchObject({ label: '1m', value: '1m' });
|
||||
expect(values[3]).toMatchObject({ label: '1h', value: '1h' });
|
||||
});
|
||||
|
||||
it('returns placeholder for text search key', async () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const values = await controller.getValuesFor({ key: '_textSearch_', operator: '=~', value: '' });
|
||||
|
||||
expect(values).toHaveLength(1);
|
||||
expect(values[0]).toMatchObject({
|
||||
label: 'Type a value',
|
||||
value: 'customValue',
|
||||
isDisabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns values from trace for regular keys', async () => {
|
||||
mockGetTraceTagValues.mockReturnValue(['GET', 'POST', 'PUT']);
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const values = await controller.getValuesFor({ key: 'http.method', operator: '=', value: '' });
|
||||
|
||||
expect(mockGetTraceTagValues).toHaveBeenCalledWith(mockTrace, 'http.method');
|
||||
expect(values).toHaveLength(3);
|
||||
expect(values[0]).toMatchObject({ value: 'GET' });
|
||||
expect(values[1]).toMatchObject({ value: 'POST' });
|
||||
expect(values[2]).toMatchObject({ value: 'PUT' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperators', () => {
|
||||
it('returns default operators', () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const operators = controller.getOperators();
|
||||
|
||||
expect(operators).toHaveLength(4);
|
||||
expect(operators).toEqual([
|
||||
{ label: '=', value: '=' },
|
||||
{ label: '!=', value: '!=' },
|
||||
{ label: '=~', value: '=~' },
|
||||
{ label: '!~', value: '!~' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only =~ for text search', () => {
|
||||
mockWip = { key: '_textSearch_', operator: '=~', value: '' };
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const operators = controller.getOperators();
|
||||
|
||||
expect(operators).toHaveLength(1);
|
||||
expect(operators[0]).toEqual({ label: '=~', value: '=~' });
|
||||
});
|
||||
|
||||
it('returns comparison operators for duration', () => {
|
||||
mockWip = { key: 'duration', operator: '>=', value: '' };
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const operators = controller.getOperators();
|
||||
|
||||
expect(operators).toHaveLength(5);
|
||||
expect(operators).toEqual([
|
||||
{ label: '=', value: '=' },
|
||||
{ label: '>=', value: '>=' },
|
||||
{ label: '<=', value: '<=' },
|
||||
{ label: '>', value: '>' },
|
||||
{ label: '<', value: '<' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFilter', () => {
|
||||
it('updates wip filter without adding to filters', () => {
|
||||
mockWip = { key: 'http.method', operator: '=', value: '' };
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.updateFilter(mockWip, { key: 'http.status_code' });
|
||||
|
||||
expect(mockSetWip).toHaveBeenCalledWith({
|
||||
key: 'http.status_code',
|
||||
operator: '=',
|
||||
value: '',
|
||||
});
|
||||
expect(mockSetSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes wip filter when value is set', () => {
|
||||
mockWip = { key: 'http.method', operator: '=', value: '' };
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.updateFilter(mockWip, { value: 'GET' });
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [
|
||||
{
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockSetWip).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('does not complete wip filter when value is empty string', () => {
|
||||
mockWip = { key: 'http.method', operator: '=', value: 'GET' };
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.updateFilter(mockWip, { value: '' });
|
||||
|
||||
expect(mockSetWip).toHaveBeenCalledWith({
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: '',
|
||||
});
|
||||
expect(mockSetSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates existing filter', () => {
|
||||
mockSearch.adhocFilters = [
|
||||
{ key: 'http.method', operator: '=', value: 'GET' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const filterToUpdate: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
controller.updateFilter(filterToUpdate, { value: 'POST' });
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [
|
||||
{ key: 'http.method', operator: '=', value: 'POST' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates operator of existing filter', () => {
|
||||
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const filterToUpdate: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
controller.updateFilter(filterToUpdate, { operator: '!=' });
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [{ key: 'http.method', operator: '!=', value: 'GET' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateToMatchAll', () => {
|
||||
it('updates filter to match all pattern', () => {
|
||||
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const filterToUpdate: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
controller.updateToMatchAll(filterToUpdate);
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [
|
||||
{
|
||||
key: 'http.method',
|
||||
operator: '=~',
|
||||
value: '.*',
|
||||
matchAllFilter: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFilter', () => {
|
||||
it('removes a filter from the list', () => {
|
||||
mockSearch.adhocFilters = [
|
||||
{ key: 'http.method', operator: '=', value: 'GET' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const filterToRemove: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
controller.removeFilter(filterToRemove);
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [{ key: 'http.status_code', operator: '=', value: '200' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles removing from empty list', () => {
|
||||
mockSearch.adhocFilters = [];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const filterToRemove: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
controller.removeFilter(filterToRemove);
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('removes only matching filter', () => {
|
||||
mockSearch.adhocFilters = [
|
||||
{ key: 'http.method', operator: '=', value: 'GET' },
|
||||
{ key: 'http.method', operator: '!=', value: 'POST' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const filterToRemove: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
controller.removeFilter(filterToRemove);
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [
|
||||
{ key: 'http.method', operator: '!=', value: 'POST' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeLastFilter', () => {
|
||||
it('removes the last filter', () => {
|
||||
mockSearch.adhocFilters = [
|
||||
{ key: 'http.method', operator: '=', value: 'GET' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.removeLastFilter();
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [{ key: 'http.method', operator: '=', value: 'GET' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty filter list', () => {
|
||||
mockSearch.adhocFilters = [];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.removeLastFilter();
|
||||
|
||||
expect(mockSetSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the only filter', () => {
|
||||
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.removeLastFilter();
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleComboboxBackspace', () => {
|
||||
it('sets forceEdit on previous filter', () => {
|
||||
mockSearch.adhocFilters = [
|
||||
{ key: 'http.method', operator: '=', value: 'GET' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const currentFilter: AdHocFilterWithLabels = {
|
||||
key: 'http.status_code',
|
||||
operator: '=',
|
||||
value: '200',
|
||||
};
|
||||
|
||||
controller.handleComboboxBackspace(currentFilter);
|
||||
|
||||
expect(mockSetSearch).toHaveBeenCalledWith({
|
||||
...mockSearch,
|
||||
adhocFilters: [
|
||||
{ key: 'http.method', operator: '=', value: 'GET', forceEdit: true },
|
||||
{ key: 'http.status_code', operator: '=', value: '200', forceEdit: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing for first filter', () => {
|
||||
mockSearch.adhocFilters = [
|
||||
{ key: 'http.method', operator: '=', value: 'GET' },
|
||||
{ key: 'http.status_code', operator: '=', value: '200' },
|
||||
];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const currentFilter: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
controller.handleComboboxBackspace(currentFilter);
|
||||
|
||||
expect(mockSetSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles filter not in list', () => {
|
||||
mockSearch.adhocFilters = [{ key: 'http.method', operator: '=', value: 'GET' }];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const nonExistentFilter: AdHocFilterWithLabels = {
|
||||
key: 'nonexistent',
|
||||
operator: '=',
|
||||
value: 'value',
|
||||
};
|
||||
|
||||
controller.handleComboboxBackspace(nonExistentFilter);
|
||||
|
||||
expect(mockSetSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addWip', () => {
|
||||
it('creates a new wip filter with default values', () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.addWip();
|
||||
|
||||
expect(mockSetWip).toHaveBeenCalledWith({
|
||||
key: '',
|
||||
operator: '=',
|
||||
value: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreOriginalFilter', () => {
|
||||
it('does nothing as trace filters do not support origin filters', () => {
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const filter: AdHocFilterWithLabels = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
};
|
||||
|
||||
// Should not throw and should not call any setters
|
||||
expect(() => controller.restoreOriginalFilter(filter)).not.toThrow();
|
||||
expect(mockSetSearch).not.toHaveBeenCalled();
|
||||
expect(mockSetWip).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles undefined adhocFilters in search', () => {
|
||||
mockSearch.adhocFilters = undefined;
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const state = controller.useState();
|
||||
expect(state.filters).toEqual([]);
|
||||
|
||||
controller.removeLastFilter();
|
||||
expect(mockSetSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles filters with additional properties', () => {
|
||||
const filterWithExtraProps: SelectableValue<string> = {
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
label: 'HTTP Method',
|
||||
description: 'The HTTP method',
|
||||
};
|
||||
|
||||
mockSearch.adhocFilters = [filterWithExtraProps];
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
const state = controller.useState();
|
||||
expect(state.filters[0]).toMatchObject({
|
||||
key: 'http.method',
|
||||
operator: '=',
|
||||
value: 'GET',
|
||||
label: 'HTTP Method',
|
||||
description: 'The HTTP method',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles updateFilter with multiple properties at once', () => {
|
||||
mockWip = { key: '', operator: '=', value: '' };
|
||||
|
||||
const controller = new TraceAdHocFiltersController(mockTrace, mockSearch, mockSetSearch, mockWip, mockSetWip);
|
||||
|
||||
controller.updateFilter(mockWip, {
|
||||
key: 'http.method',
|
||||
operator: '!=',
|
||||
});
|
||||
|
||||
expect(mockSetWip).toHaveBeenCalledWith({
|
||||
key: 'http.method',
|
||||
operator: '!=',
|
||||
value: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { SelectableValue, toOption, TraceSearchProps } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { AdHocFiltersController, AdHocFiltersControllerState, AdHocFilterWithLabels } from '@grafana/scenes';
|
||||
|
||||
import { getTraceTagKeys, getTraceTagValues } from '../../utils/tags';
|
||||
import { Trace } from '../types/trace';
|
||||
|
||||
/**
|
||||
* Convert AdHocFilterItem to AdHocFilterWithLabels for use with the combobox.
|
||||
*/
|
||||
function toAdHocFilterWithLabels(item: SelectableValue<string>): AdHocFilterWithLabels {
|
||||
return {
|
||||
...item,
|
||||
key: item.key,
|
||||
operator: item.operator,
|
||||
value: item.value || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AdHocFilterWithLabels back to AdHocFilterItem for storage.
|
||||
*/
|
||||
function toAdHocFilterItem(filter: AdHocFilterWithLabels): SelectableValue<string> {
|
||||
return {
|
||||
...filter,
|
||||
key: filter.key || '',
|
||||
operator: filter.operator || '=',
|
||||
value: filter.value || '',
|
||||
};
|
||||
}
|
||||
|
||||
const TRACE_OPERATORS = [
|
||||
{ label: '=', value: '=' },
|
||||
{ label: '!=', value: '!=' },
|
||||
{ label: '=~', value: '=~' },
|
||||
{ label: '!~', value: '!~' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Controller for adhoc filters in trace view.
|
||||
* Provides keys and values from trace spans and syncs state with URL.
|
||||
*/
|
||||
export class TraceAdHocFiltersController implements AdHocFiltersController {
|
||||
private trace: Trace;
|
||||
private search: TraceSearchProps;
|
||||
private setSearch: (search: TraceSearchProps) => void;
|
||||
private wip: AdHocFilterWithLabels | undefined;
|
||||
private setWip: (wip: AdHocFilterWithLabels | undefined) => void;
|
||||
|
||||
constructor(
|
||||
trace: Trace,
|
||||
search: TraceSearchProps,
|
||||
setSearch: (search: TraceSearchProps) => void,
|
||||
wip: AdHocFilterWithLabels | undefined,
|
||||
setWip: (wip: AdHocFilterWithLabels | undefined) => void
|
||||
) {
|
||||
this.trace = trace;
|
||||
this.search = search;
|
||||
this.setSearch = setSearch;
|
||||
this.wip = wip;
|
||||
this.setWip = setWip;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook to access controller state.
|
||||
*/
|
||||
useState(): AdHocFiltersControllerState {
|
||||
const filters = (this.search.adhocFilters || []).map(toAdHocFilterWithLabels);
|
||||
|
||||
return {
|
||||
filters,
|
||||
readOnly: false,
|
||||
allowCustomValue: true,
|
||||
supportsMultiValueOperators: false,
|
||||
wip: this.wip,
|
||||
inputPlaceholder: 'Filter by attribute or text',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get possible keys from trace spans.
|
||||
*/
|
||||
async getKeys(currentKey: string | null): Promise<Array<SelectableValue<string>>> {
|
||||
const keys = getTraceTagKeys(this.trace);
|
||||
return [
|
||||
{
|
||||
label: t('traces.adhocFilters.textSearchLabel', 'Text search'),
|
||||
value: '_textSearch_',
|
||||
description: t('traces.adhocFilters.textSearchDescription', 'Search for text in the trace'),
|
||||
},
|
||||
{ label: t('traces.adhocFilters.durationLabel', 'duration'), value: 'duration' },
|
||||
...keys.map(toOption),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get possible values for a specific filter key from trace spans.
|
||||
*/
|
||||
async getValuesFor(filter: AdHocFilterWithLabels): Promise<Array<SelectableValue<string>>> {
|
||||
if (!filter.key) {
|
||||
return [];
|
||||
}
|
||||
if (filter.key === 'duration') {
|
||||
return [
|
||||
{ label: t('traces.adhocFilters.duration1ms', '1ms'), value: '1ms' },
|
||||
{ label: t('traces.adhocFilters.duration1s', '1s'), value: '1s' },
|
||||
{ label: t('traces.adhocFilters.duration1m', '1m'), value: '1m' },
|
||||
{ label: t('traces.adhocFilters.duration1h', '1h'), value: '1h' },
|
||||
];
|
||||
}
|
||||
|
||||
if (filter.key === '_textSearch_') {
|
||||
return [{ label: t('traces.adhocFilters.customValue', 'Type a value'), value: 'customValue', isDisabled: true }];
|
||||
}
|
||||
const values = getTraceTagValues(this.trace, filter.key);
|
||||
return values.map(toOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available operators.
|
||||
*/
|
||||
getOperators(): Array<SelectableValue<string>> {
|
||||
if (this.wip?.key === '_textSearch_') {
|
||||
return [{ label: '=~', value: '=~' }];
|
||||
}
|
||||
if (this.wip?.key === 'duration') {
|
||||
return [
|
||||
{ label: '=', value: '=' },
|
||||
{ label: '>=', value: '>=' },
|
||||
{ label: '<=', value: '<=' },
|
||||
{ label: '>', value: '>' },
|
||||
{ label: '<', value: '<' },
|
||||
];
|
||||
}
|
||||
return TRACE_OPERATORS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a filter with partial changes.
|
||||
*/
|
||||
updateFilter(filter: AdHocFilterWithLabels, update: Partial<AdHocFilterWithLabels>): void {
|
||||
const items = this.search.adhocFilters || [];
|
||||
const filters = items.map(toAdHocFilterWithLabels);
|
||||
|
||||
if (filter === this.wip) {
|
||||
// If we set value we are done with this "work in progress" filter and we can add it
|
||||
if ('value' in update && update['value'] !== '') {
|
||||
this.setSearch({
|
||||
...this.search,
|
||||
adhocFilters: [...filters, { ...this.wip, ...update }],
|
||||
});
|
||||
this.setWip(undefined);
|
||||
} else {
|
||||
this.setWip({ ...this.wip, ...update });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedFilters = filters.map((f) => {
|
||||
return isEqual(f, filter) ? { ...f, ...update } : f;
|
||||
});
|
||||
|
||||
this.setSearch({
|
||||
...this.search,
|
||||
adhocFilters: updatedFilters.map(toAdHocFilterItem),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a filter to match all values (=~ .*).
|
||||
*/
|
||||
updateToMatchAll(filter: AdHocFilterWithLabels): void {
|
||||
this.updateFilter(filter, {
|
||||
operator: '=~',
|
||||
value: '.*',
|
||||
matchAllFilter: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a filter.
|
||||
*/
|
||||
removeFilter(filter: AdHocFilterWithLabels): void {
|
||||
const items = this.search.adhocFilters || [];
|
||||
const filters = items.map(toAdHocFilterWithLabels);
|
||||
|
||||
const updatedFilters = filters.filter((f) => !isEqual(f, filter));
|
||||
|
||||
this.setSearch({
|
||||
...this.search,
|
||||
adhocFilters: updatedFilters.map(toAdHocFilterItem),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the last filter in the list.
|
||||
*/
|
||||
removeLastFilter(): void {
|
||||
const filters = this.search.adhocFilters || [];
|
||||
if (filters.length > 0) {
|
||||
const updatedFilters = filters.slice(0, -1);
|
||||
this.setSearch({
|
||||
...this.search,
|
||||
adhocFilters: updatedFilters,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle backspace key in combobox.
|
||||
*/
|
||||
handleComboboxBackspace(filter: AdHocFilterWithLabels): void {
|
||||
const items = this.search.adhocFilters || [];
|
||||
const filters = items.map(toAdHocFilterWithLabels);
|
||||
|
||||
const index = filters.findIndex((f) => isEqual(f, filter));
|
||||
|
||||
if (index > 0) {
|
||||
// Focus previous filter by setting forceEdit
|
||||
const updatedFilters = filters.map((f, i) => {
|
||||
if (i === index - 1) {
|
||||
return { ...f, forceEdit: true };
|
||||
}
|
||||
return { ...f, forceEdit: false };
|
||||
});
|
||||
|
||||
this.setSearch({
|
||||
...this.search,
|
||||
adhocFilters: updatedFilters.map(toAdHocFilterItem),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new work-in-progress filter.
|
||||
*/
|
||||
addWip(): void {
|
||||
this.setWip(toAdHocFilterWithLabels({ key: '', operator: '=', value: '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an origin filter to its original value.
|
||||
* Not applicable for trace filters.
|
||||
*/
|
||||
restoreOriginalFilter(filter: AdHocFilterWithLabels): void {
|
||||
// Not applicable for trace filters as they don't have origin filters
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { TraceSearchProps } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { FilterPill, Stack, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { Trace } from '../types/trace';
|
||||
|
||||
export interface TraceFilterPillsProps {
|
||||
trace: Trace;
|
||||
search: TraceSearchProps;
|
||||
setSearch: (search: TraceSearchProps) => void;
|
||||
}
|
||||
|
||||
export function TraceFilterPills({ trace, search, setSearch }: TraceFilterPillsProps) {
|
||||
// Calculate max duration for high latency filter
|
||||
const sortedDurations = useMemo(() => {
|
||||
return trace.spans.map((span) => span.duration).sort((a, b) => a - b);
|
||||
}, [trace.spans]);
|
||||
|
||||
const highLatencyThreshold = Math.floor(sortedDurations[Math.floor(sortedDurations.length * 0.9)]);
|
||||
|
||||
return (
|
||||
<Stack gap={1} direction="row">
|
||||
<Tooltip
|
||||
content={t(
|
||||
'explore.trace-page-header.critical-path-tooltip',
|
||||
'Selects spans in the critical path—the longest sequence of dependent tasks determining the trace minimum duration.'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<FilterPill
|
||||
selected={search.criticalPathOnly}
|
||||
label={t('explore.trace-page-header.critical-path', 'Critical path')}
|
||||
onClick={() => setSearch({ ...search, criticalPathOnly: !search.criticalPathOnly })}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('explore.trace-page-header.errors-tooltip', 'Selects spans where status equals error.')}>
|
||||
<div>
|
||||
<FilterPill
|
||||
selected={
|
||||
!!search.adhocFilters?.some((f) => f.key === 'status' && f.operator === '=' && f.value === 'error')
|
||||
}
|
||||
label={t('explore.trace-page-header.errors', 'Errors')}
|
||||
onClick={() => {
|
||||
const hasErrorFilter = search.adhocFilters?.some(
|
||||
(f) => f.key === 'status' && f.operator === '=' && f.value === 'error'
|
||||
);
|
||||
if (hasErrorFilter) {
|
||||
// Remove error filter
|
||||
setSearch({
|
||||
...search,
|
||||
adhocFilters: search.adhocFilters?.filter(
|
||||
(f) => !(f.key === 'status' && f.operator === '=' && f.value === 'error')
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Add error filter
|
||||
setSearch({
|
||||
...search,
|
||||
adhocFilters: [...(search.adhocFilters || []), { key: 'status', operator: '=', value: 'error' }],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t(
|
||||
'explore.trace-page-header.high-latency-tooltip',
|
||||
'Selects the 10% longest spans in the trace (p90).'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<FilterPill
|
||||
selected={
|
||||
!!search.adhocFilters?.some(
|
||||
(f) =>
|
||||
f.key === 'duration' && f.operator === '>=' && parseFloat(f.value || '0') === highLatencyThreshold
|
||||
)
|
||||
}
|
||||
label={t('explore.trace-page-header.high-latency', 'High latency')}
|
||||
onClick={() => {
|
||||
const hasHighLatencyFilter = search.adhocFilters?.some(
|
||||
(f) =>
|
||||
f.key === 'duration' && f.operator === '>=' && parseFloat(f.value || '0') === highLatencyThreshold
|
||||
);
|
||||
if (hasHighLatencyFilter) {
|
||||
// Remove high latency filter
|
||||
setSearch({
|
||||
...search,
|
||||
adhocFilters: search.adhocFilters?.filter((f) => f.key !== 'duration'),
|
||||
});
|
||||
} else {
|
||||
// Add high latency filter (duration >= 70% of max)
|
||||
setSearch({
|
||||
...search,
|
||||
adhocFilters: [
|
||||
...(search.adhocFilters || []),
|
||||
{ key: 'duration', operator: '>=', value: highLatencyThreshold.toString() },
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+9
-14
@@ -96,6 +96,7 @@ const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean }
|
||||
const mockUsePluginComponents = usePluginComponents as jest.MockedFunction<typeof usePluginComponents>;
|
||||
mockUsePluginComponents.mockReturnValue({ components: [], isLoading: false });
|
||||
|
||||
const viewRangeTime: [number, number] = [0, 0];
|
||||
const defaultProps = {
|
||||
trace,
|
||||
timeZone: '',
|
||||
@@ -103,10 +104,6 @@ const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean }
|
||||
setSearch: jest.fn(),
|
||||
showSpanFilters: true,
|
||||
setShowSpanFilters: jest.fn(),
|
||||
showSpanFilterMatchesOnly: false,
|
||||
setShowSpanFilterMatchesOnly: jest.fn(),
|
||||
showCriticalPathSpansOnly: false,
|
||||
setShowCriticalPathSpansOnly: jest.fn(),
|
||||
spanFilterMatches: undefined,
|
||||
setFocusedSpanIdForSearch: jest.fn(),
|
||||
datasourceType: 'tempo',
|
||||
@@ -114,6 +111,9 @@ const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean }
|
||||
data: new MutableDataFrame(),
|
||||
datasourceName: 'test-datasource',
|
||||
datasourceUid: 'test-datasource-uid',
|
||||
updateNextViewRangeTime: jest.fn(),
|
||||
updateViewRangeTime: jest.fn(),
|
||||
viewRange: { time: { current: viewRangeTime } },
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -133,16 +133,11 @@ describe('TracePageHeader test', () => {
|
||||
setup();
|
||||
|
||||
const header = document.querySelector('header');
|
||||
const method = getByText(header!, 'POST');
|
||||
const status = getByText(header!, '200');
|
||||
const url = getByText(header!, '/v2/gamma/792edh2w897y2huehd2h89');
|
||||
const duration = getByText(header!, '2.36s');
|
||||
const timestampElement = getByText(header!, '2023-02-05 08:50:56.289');
|
||||
expect(method).toBeInTheDocument();
|
||||
expect(status).toBeInTheDocument();
|
||||
expect(url).toBeInTheDocument();
|
||||
expect(duration).toBeInTheDocument();
|
||||
expect(timestampElement).toBeInTheDocument();
|
||||
expect(getByText(header!, 'POST')).toBeInTheDocument();
|
||||
expect(getByText(header!, '200')).toBeInTheDocument();
|
||||
expect(getByText(header!, '/v2/gamma/792edh2w897y2huehd2h89')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('2.36s')[0]).toBeInTheDocument();
|
||||
expect(getByText(header!, '2023-02-05 08:50:56.289')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Plugin Extensions', () => {
|
||||
|
||||
+96
-18
@@ -27,14 +27,17 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { reportInteraction, renderLimitedComponents, usePluginComponents, usePluginLinks } from '@grafana/runtime';
|
||||
import { AdHocFiltersComboboxRenderer } from '@grafana/scenes';
|
||||
import { TimeZone } from '@grafana/schema';
|
||||
import {
|
||||
Badge,
|
||||
BadgeColor,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
CollapsableSection,
|
||||
Dropdown,
|
||||
Icon,
|
||||
Label,
|
||||
LinkButton,
|
||||
Menu,
|
||||
Tooltip,
|
||||
@@ -45,11 +48,15 @@ import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
|
||||
import { config } from '../../../../../core/config';
|
||||
import { downloadTraceAsJson } from '../../../../inspector/utils/download';
|
||||
import { ViewRangeTimeUpdate, TUpdateViewRangeTimeFunction, ViewRange } from '../TraceTimelineViewer/types';
|
||||
import { getHeaderTags, getTraceName } from '../model/trace-viewer';
|
||||
import { Trace, TraceViewPluginExtensionContext } from '../types/trace';
|
||||
import { formatDuration } from '../utils/date';
|
||||
|
||||
import { SpanFilters } from './SpanFilters/SpanFilters';
|
||||
import TracePageSearchBar from './SearchBar/TracePageSearchBar';
|
||||
import SpanGraph from './SpanGraph';
|
||||
import { TraceFilterPills } from './TraceFilterPills';
|
||||
import { useTraceAdHocFiltersController } from './useTraceAdHocFiltersController';
|
||||
|
||||
export type TracePageHeaderProps = {
|
||||
trace: Trace | null;
|
||||
@@ -66,6 +73,9 @@ export type TracePageHeaderProps = {
|
||||
datasourceName: string;
|
||||
datasourceUid: string;
|
||||
setHeaderHeight: (height: number) => void;
|
||||
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
|
||||
updateViewRangeTime: TUpdateViewRangeTimeFunction;
|
||||
viewRange: ViewRange;
|
||||
};
|
||||
|
||||
export const TracePageHeader = memo((props: TracePageHeaderProps) => {
|
||||
@@ -77,19 +87,26 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
|
||||
search,
|
||||
setSearch,
|
||||
showSpanFilters,
|
||||
setShowSpanFilters,
|
||||
setFocusedSpanIdForSearch,
|
||||
spanFilterMatches,
|
||||
datasourceType,
|
||||
datasourceName,
|
||||
datasourceUid,
|
||||
setHeaderHeight,
|
||||
updateNextViewRangeTime,
|
||||
updateViewRangeTime,
|
||||
viewRange,
|
||||
} = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const notifyApp = useAppNotification();
|
||||
const [copyTraceIdClicked, setCopyTraceIdClicked] = useState(false);
|
||||
const [isOverviewOpen, setIsOverviewOpen] = useState(true);
|
||||
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
|
||||
|
||||
// Create controller for adhoc filters
|
||||
const controller = useTraceAdHocFiltersController(trace, search, setSearch);
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderHeight(document.querySelector('.' + styles.header)?.scrollHeight ?? 0);
|
||||
@@ -117,6 +134,11 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
|
||||
extensionPointId: PluginExtensionPoints.TraceViewHeaderActions,
|
||||
});
|
||||
|
||||
// Memoize service count to avoid recomputing on every render
|
||||
const serviceCount = useMemo(() => {
|
||||
return new Set(trace?.spans.map((span) => span.process?.serviceName)).size;
|
||||
}, [trace?.spans]);
|
||||
|
||||
if (!trace) {
|
||||
return null;
|
||||
}
|
||||
@@ -127,11 +149,6 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
|
||||
// Convert date from micro to milli seconds
|
||||
const formattedTimestamp = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true });
|
||||
|
||||
// Memoize service count to avoid recomputing on every render
|
||||
const serviceCount = useMemo(() => {
|
||||
return new Set(trace.spans.map((span) => span.process?.serviceName)).size;
|
||||
}, [trace.spans]);
|
||||
|
||||
let statusColor: BadgeColor = 'green';
|
||||
if (status && status.length > 0) {
|
||||
if (status[0].value.toString().charAt(0) === '4') {
|
||||
@@ -351,16 +368,45 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SpanFilters
|
||||
trace={trace}
|
||||
showSpanFilters={showSpanFilters}
|
||||
setShowSpanFilters={setShowSpanFilters}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
spanFilterMatches={spanFilterMatches}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
datasourceType={datasourceType}
|
||||
/>
|
||||
<CollapsableSection
|
||||
label={<span className={styles.overviewLabel}>{t('explore.trace-page-header.overview', 'Overview')}</span>}
|
||||
isOpen={isOverviewOpen}
|
||||
onToggle={setIsOverviewOpen}
|
||||
className={styles.overviewCollapsableSection}
|
||||
contentClassName={styles.overviewCollapsableSectionContent}
|
||||
>
|
||||
<SpanGraph
|
||||
trace={trace}
|
||||
viewRange={viewRange}
|
||||
updateNextViewRangeTime={updateNextViewRangeTime}
|
||||
updateViewRangeTime={updateViewRangeTime}
|
||||
/>
|
||||
</CollapsableSection>
|
||||
|
||||
<div className={styles.filtersContainer}>
|
||||
<Label>{t('explore.trace-page-header.filters', 'Filters')}</Label>
|
||||
<div className={styles.adhocFiltersRow}>
|
||||
{controller && <AdHocFiltersComboboxRenderer controller={controller} />}
|
||||
</div>
|
||||
{trace && (
|
||||
<div className={styles.searchAndPillsRow}>
|
||||
<TraceFilterPills trace={trace} search={search} setSearch={setSearch} />
|
||||
<TracePageSearchBar
|
||||
trace={trace}
|
||||
search={search}
|
||||
spanFilterMatches={spanFilterMatches}
|
||||
setShowSpanFilterMatchesOnly={(showMatchesOnly: boolean) =>
|
||||
setSearch({ ...search, matchesOnly: showMatchesOnly })
|
||||
}
|
||||
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
|
||||
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
|
||||
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
|
||||
datasourceType={datasourceType}
|
||||
showSpanFilters={showSpanFilters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
@@ -425,7 +471,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: theme.spacing(3),
|
||||
marginBottom: theme.spacing(1),
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
color: theme.colors.text.secondary,
|
||||
flexWrap: 'wrap',
|
||||
@@ -488,5 +533,38 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
display: 'inline-block',
|
||||
color: theme.colors.text.primary,
|
||||
}),
|
||||
overviewLabel: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
color: theme.colors.text.primary,
|
||||
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
overviewCollapsableSection: css({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
overviewCollapsableSectionContent: css({
|
||||
padding: theme.spacing(0, 1, 2, 1),
|
||||
}),
|
||||
filtersContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
adhocFiltersRow: css({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
}),
|
||||
searchAndPillsRow: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing(2),
|
||||
width: '100%',
|
||||
marginTop: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2025 Grafana Labs
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { TraceSearchProps } from '@grafana/data';
|
||||
import { AdHocFiltersController, AdHocFilterWithLabels } from '@grafana/scenes';
|
||||
|
||||
import { Trace } from '../types/trace';
|
||||
|
||||
import { TraceAdHocFiltersController } from './TraceAdHocFiltersController';
|
||||
|
||||
/**
|
||||
* Hook to create and manage a TraceAdHocFiltersController instance.
|
||||
* The controller provides keys and values from trace spans and syncs with URL state.
|
||||
*
|
||||
* @param trace - The trace to extract keys and values from
|
||||
* @param search - Current search state including adhoc filters
|
||||
* @param setSearch - Function to update search state
|
||||
* @returns Controller instance for use with AdHocFiltersComboboxRenderer
|
||||
*/
|
||||
export function useTraceAdHocFiltersController(
|
||||
trace: Trace | null,
|
||||
search: TraceSearchProps,
|
||||
setSearch: (search: TraceSearchProps) => void
|
||||
): AdHocFiltersController | null {
|
||||
const [wip, setWip] = useState<AdHocFilterWithLabels | undefined>({
|
||||
key: '',
|
||||
operator: '=',
|
||||
value: '',
|
||||
});
|
||||
const controller = useMemo(() => {
|
||||
if (!trace) {
|
||||
return null;
|
||||
}
|
||||
return new TraceAdHocFiltersController(trace, search, setSearch, wip, setWip);
|
||||
}, [trace, search, setSearch, wip, setWip]);
|
||||
|
||||
return controller;
|
||||
}
|
||||
@@ -198,11 +198,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly
|
||||
}),
|
||||
rowFocused: css({
|
||||
label: 'rowFocused',
|
||||
backgroundColor: autoColor(theme, '#cbe7ff'),
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
animation: `${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0)`,
|
||||
},
|
||||
[`& .${nameWrapperClassName}, .${viewClassName}, .${nameWrapperMatchingFilterClassName}`]: {
|
||||
[`& .${viewClassName}`]: {
|
||||
backgroundColor: autoColor(theme, '#cbe7ff'),
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
animation: `${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0)`,
|
||||
@@ -218,15 +217,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly
|
||||
|
||||
rowError: css({
|
||||
label: 'rowError',
|
||||
backgroundColor: theme.colors.error.transparent,
|
||||
|
||||
[`&:hover .${nameWrapperClassName}`]: {
|
||||
background: theme.colors.error.borderTransparent,
|
||||
},
|
||||
[`&:hover .${viewClassName}`]: {
|
||||
backgroundColor: theme.colors.error.borderTransparent,
|
||||
outline: `1px solid ${theme.colors.error.borderTransparent}`,
|
||||
},
|
||||
|
||||
[`& .${nameWrapperClassName} > *`]: {
|
||||
background: theme.colors.error.transparent,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { useStyles2, MenuItem, Icon, ContextMenu } from '@grafana/ui';
|
||||
import { useStyles2, MenuItem, Icon, ContextMenu, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { SpanLinkDef } from '../types/links';
|
||||
|
||||
@@ -50,7 +51,8 @@ const renderMenuItems = (
|
||||
};
|
||||
|
||||
export const SpanLinksMenu = ({ links, datasourceType, color }: SpanLinksProps) => {
|
||||
const styles = useStyles2(() => getStyles(color));
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(() => getStyles(theme, color));
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
@@ -84,11 +86,10 @@ export const SpanLinksMenu = ({ links, datasourceType, color }: SpanLinksProps)
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (color: string) => ({
|
||||
const getStyles = (theme: GrafanaTheme2, color: string) => ({
|
||||
wrapper: css({
|
||||
border: 'none',
|
||||
background: `${color}10`,
|
||||
borderBottom: `1px solid ${color}CF`,
|
||||
borderBottom: `2px solid ${color}CF`,
|
||||
paddingInline: '4px',
|
||||
}),
|
||||
button: css({
|
||||
|
||||
+4
-18
@@ -101,7 +101,6 @@ type TVirtualizedTraceViewOwnProps = {
|
||||
focusedSpanId?: string;
|
||||
focusedSpanIdForSearch: string;
|
||||
showSpanFilterMatchesOnly: boolean;
|
||||
showCriticalPathSpansOnly: boolean;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
topOfViewRef?: RefObject<HTMLDivElement>;
|
||||
datasourceType: string;
|
||||
@@ -134,20 +133,17 @@ function generateRowStates(
|
||||
detailStates: Map<string, DetailState | TNil>,
|
||||
findMatchesIDs: Set<string> | TNil,
|
||||
showSpanFilterMatchesOnly: boolean,
|
||||
showCriticalPathSpansOnly: boolean,
|
||||
criticalPath: CriticalPathSection[]
|
||||
): RowState[] {
|
||||
if (!spans) {
|
||||
return [];
|
||||
}
|
||||
// Apply filtering when matchesOnly is enabled
|
||||
// Critical path filtering is now integrated into findMatchesIDs
|
||||
if (showSpanFilterMatchesOnly && findMatchesIDs) {
|
||||
spans = spans.filter((span) => findMatchesIDs.has(span.spanID));
|
||||
}
|
||||
|
||||
if (showCriticalPathSpansOnly && criticalPath) {
|
||||
spans = spans.filter((span) => criticalPath.find((section) => section.spanId === span.spanID));
|
||||
}
|
||||
|
||||
let collapseDepth = null;
|
||||
const rowStates = [];
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
@@ -197,7 +193,6 @@ function generateRowStatesFromTrace(
|
||||
detailStates: Map<string, DetailState | TNil>,
|
||||
findMatchesIDs: Set<string> | TNil,
|
||||
showSpanFilterMatchesOnly: boolean,
|
||||
showCriticalPathSpansOnly: boolean,
|
||||
criticalPath: CriticalPathSection[]
|
||||
): RowState[] {
|
||||
return trace
|
||||
@@ -207,7 +202,6 @@ function generateRowStatesFromTrace(
|
||||
detailStates,
|
||||
findMatchesIDs,
|
||||
showSpanFilterMatchesOnly,
|
||||
showCriticalPathSpansOnly,
|
||||
criticalPath
|
||||
)
|
||||
: [];
|
||||
@@ -269,22 +263,14 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
}
|
||||
|
||||
getRowStates(): RowState[] {
|
||||
const {
|
||||
childrenHiddenIDs,
|
||||
detailStates,
|
||||
trace,
|
||||
findMatchesIDs,
|
||||
showSpanFilterMatchesOnly,
|
||||
showCriticalPathSpansOnly,
|
||||
criticalPath,
|
||||
} = this.props;
|
||||
const { childrenHiddenIDs, detailStates, trace, findMatchesIDs, showSpanFilterMatchesOnly, criticalPath } =
|
||||
this.props;
|
||||
return memoizedGenerateRowStates(
|
||||
trace,
|
||||
childrenHiddenIDs,
|
||||
detailStates,
|
||||
findMatchesIDs,
|
||||
showSpanFilterMatchesOnly,
|
||||
showCriticalPathSpansOnly,
|
||||
criticalPath
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ export type TProps = {
|
||||
focusedSpanId?: string;
|
||||
focusedSpanIdForSearch: string;
|
||||
showSpanFilterMatchesOnly: boolean;
|
||||
showCriticalPathSpansOnly: boolean;
|
||||
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
|
||||
topOfViewRef?: RefObject<HTMLDivElement>;
|
||||
headerHeight: number;
|
||||
|
||||
@@ -673,4 +673,27 @@ describe('filterSpans', () => {
|
||||
)
|
||||
).toEqual(new Set([spanID0]));
|
||||
});
|
||||
|
||||
it('filters by critical path', () => {
|
||||
const criticalPath = [
|
||||
{ spanId: spanID0, section_start: 0, section_end: 1000 },
|
||||
{ spanId: spanID2, section_start: 1000, section_end: 2000 },
|
||||
];
|
||||
|
||||
// Only critical path spans should be returned
|
||||
expect(filterSpans({ ...DEFAULT_SPAN_FILTERS, criticalPathOnly: true }, spans, criticalPath)).toEqual(
|
||||
new Set([spanID0, spanID2])
|
||||
);
|
||||
|
||||
// Critical path filtering combined with other filters
|
||||
expect(
|
||||
filterSpans({ ...DEFAULT_SPAN_FILTERS, serviceName: 'serviceName0', criticalPathOnly: true }, spans, criticalPath)
|
||||
).toEqual(new Set([spanID0]));
|
||||
|
||||
// When criticalPathOnly is false, critical path should not be applied
|
||||
expect(filterSpans({ ...DEFAULT_SPAN_FILTERS, criticalPathOnly: false }, spans, criticalPath)).toEqual(undefined);
|
||||
|
||||
// When no criticalPath is provided, filtering should not be applied
|
||||
expect(filterSpans({ ...DEFAULT_SPAN_FILTERS, criticalPathOnly: true }, spans)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,20 +14,173 @@
|
||||
|
||||
import { SpanStatusCode } from '@opentelemetry/api';
|
||||
|
||||
import { TraceKeyValuePair, TraceSearchProps, TraceSearchTag } from '@grafana/data';
|
||||
import { SelectableValue, TraceKeyValuePair, TraceSearchProps, TraceSearchTag } from '@grafana/data';
|
||||
|
||||
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../constants/span';
|
||||
import TNil from '../types/TNil';
|
||||
import { TraceSpan } from '../types/trace';
|
||||
import { TraceSpan, CriticalPathSection } from '../types/trace';
|
||||
|
||||
/**
|
||||
* Filter spans using adhoc filters.
|
||||
* Returns filtered spans or undefined if no filters match.
|
||||
*/
|
||||
const getAdhocFilterMatches = (spans: TraceSpan[], adhocFilters: Array<SelectableValue<string>>) => {
|
||||
// Remove empty filters
|
||||
const validFilters = adhocFilters.filter((filter) => {
|
||||
return filter.key && filter.key.trim() !== '' && filter.value && filter.value.trim() !== '';
|
||||
});
|
||||
|
||||
if (validFilters.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return spans.filter((span: TraceSpan) => {
|
||||
// All filters must match for the span to be included
|
||||
return validFilters.every((filter) => {
|
||||
const key = filter.key || '';
|
||||
const operator = filter.operator || '=';
|
||||
const value = filter.value || '';
|
||||
|
||||
// Special handling for _textSearch_
|
||||
if (key === '_textSearch_') {
|
||||
return matchTextSearch(value, span);
|
||||
}
|
||||
|
||||
// Special handling for serviceName
|
||||
if (key === 'serviceName') {
|
||||
return matchField(span.process.serviceName, operator, value);
|
||||
}
|
||||
|
||||
// Special handling for spanName (operationName)
|
||||
if (key === 'spanName') {
|
||||
return matchField(span.operationName, operator, value);
|
||||
}
|
||||
|
||||
if (key === 'duration') {
|
||||
return matchTimeField(span.duration, operator, value);
|
||||
}
|
||||
|
||||
// Handle tag filters (same logic as getTagMatches)
|
||||
const tagFilter: TraceSearchTag = {
|
||||
id: '', // Not needed for matching
|
||||
key,
|
||||
operator,
|
||||
value,
|
||||
};
|
||||
|
||||
if (operator === '=' || operator === '!=') {
|
||||
const matches = checkKeyValConditionForMatch(tagFilter, span);
|
||||
return operator === '=' ? matches : !matches;
|
||||
} else if (operator === '=~' || operator === '!~') {
|
||||
const matches = checkKeyValConditionForRegex(tagFilter, span);
|
||||
return operator === '=~' ? matches : !matches;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Match a field value against an operator and expected value.
|
||||
*/
|
||||
const matchField = (fieldValue: string, operator: string, expectedValue: string): boolean => {
|
||||
if (operator === '=') {
|
||||
return fieldValue === expectedValue;
|
||||
} else if (operator === '!=') {
|
||||
return fieldValue !== expectedValue;
|
||||
} else if (operator === '=~') {
|
||||
return fieldValue.includes(expectedValue);
|
||||
} else if (operator === '!~') {
|
||||
return !fieldValue.includes(expectedValue);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Match a field value against an operator and expected value.
|
||||
*/
|
||||
const matchTimeField = (fieldValue: number, operator: string, expectedValue: string): boolean => {
|
||||
const timeFilter = convertTimeFilter(expectedValue);
|
||||
|
||||
if (!timeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case '>':
|
||||
return fieldValue > timeFilter;
|
||||
case '<':
|
||||
return fieldValue < timeFilter;
|
||||
case '>=':
|
||||
return fieldValue >= timeFilter;
|
||||
case '<=':
|
||||
return fieldValue <= timeFilter;
|
||||
case '=':
|
||||
return fieldValue === timeFilter;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Match text search across all span fields.
|
||||
*/
|
||||
const matchTextSearch = (query: string, span: TraceSpan): boolean => {
|
||||
const queryParts = query
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.toLowerCase());
|
||||
|
||||
const isTextInQuery = (text: string) => queryParts.some((queryPart) => text.toLowerCase().includes(queryPart));
|
||||
|
||||
const isTextInKeyValues = (kvs: TraceKeyValuePair[]) =>
|
||||
kvs
|
||||
? kvs.some((kv) => {
|
||||
return isTextInQuery(kv.key) || isTextInQuery(getStringValue(kv.value));
|
||||
})
|
||||
: false;
|
||||
|
||||
return (
|
||||
isTextInQuery(span.operationName) ||
|
||||
isTextInQuery(span.process.serviceName) ||
|
||||
isTextInKeyValues(span.tags) ||
|
||||
(span.kind && isTextInQuery(span.kind)) ||
|
||||
(span.statusCode !== undefined && isTextInQuery(SpanStatusCode[span.statusCode])) ||
|
||||
(span.statusMessage && isTextInQuery(span.statusMessage)) ||
|
||||
(span.instrumentationLibraryName && isTextInQuery(span.instrumentationLibraryName)) ||
|
||||
(span.instrumentationLibraryVersion && isTextInQuery(span.instrumentationLibraryVersion)) ||
|
||||
(span.traceState && isTextInQuery(span.traceState)) ||
|
||||
(span.logs !== null &&
|
||||
span.logs.some((log) => (log.name && isTextInQuery(log.name)) || isTextInKeyValues(log.fields))) ||
|
||||
isTextInKeyValues(span.process.tags) ||
|
||||
queryParts.some((queryPart) => queryPart === span.spanID)
|
||||
);
|
||||
};
|
||||
|
||||
// filter spans where all filters added need to be true for each individual span that is returned
|
||||
// i.e. the more filters added -> the more specific that the returned results are
|
||||
export function filterSpans(searchProps: TraceSearchProps, spans: TraceSpan[] | TNil) {
|
||||
export function filterSpans(
|
||||
searchProps: TraceSearchProps,
|
||||
spans: TraceSpan[] | TNil,
|
||||
criticalPath?: CriticalPathSection[]
|
||||
) {
|
||||
if (!spans) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let filteredSpans = false;
|
||||
|
||||
// New adhoc filters approach
|
||||
if (searchProps.adhocFilters && searchProps.adhocFilters.length > 0) {
|
||||
const adhocMatches = getAdhocFilterMatches(spans, searchProps.adhocFilters);
|
||||
if (adhocMatches) {
|
||||
spans = adhocMatches;
|
||||
filteredSpans = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy filters (kept for backward compatibility)
|
||||
if (searchProps.serviceName) {
|
||||
spans = getServiceNameMatches(spans, searchProps);
|
||||
filteredSpans = true;
|
||||
@@ -54,6 +207,12 @@ export function filterSpans(searchProps: TraceSearchProps, spans: TraceSpan[] |
|
||||
}
|
||||
}
|
||||
|
||||
// Critical path filtering
|
||||
if (searchProps.criticalPathOnly && criticalPath) {
|
||||
spans = getCriticalPathMatches(spans, criticalPath);
|
||||
filteredSpans = true;
|
||||
}
|
||||
|
||||
return filteredSpans ? new Set(spans.map((span: TraceSpan) => span.spanID)) : undefined;
|
||||
}
|
||||
|
||||
@@ -243,6 +402,12 @@ const getDurationMatches = (spans: TraceSpan[], searchProps: TraceSearchProps) =
|
||||
return filteredSpans;
|
||||
};
|
||||
|
||||
const getCriticalPathMatches = (spans: TraceSpan[], criticalPath: CriticalPathSection[]) => {
|
||||
return spans.filter((span: TraceSpan) => {
|
||||
return criticalPath.some((section) => section.spanId === span.spanID);
|
||||
});
|
||||
};
|
||||
|
||||
export const convertTimeFilter = (time: string) => {
|
||||
if (time.includes('ns')) {
|
||||
return parseFloat(time.split('ns')[0]) / 1000;
|
||||
@@ -259,5 +424,5 @@ export const convertTimeFilter = (time: string) => {
|
||||
} else if (time.includes('h')) {
|
||||
return parseFloat(time.split('h')[0]) * 1000 * 1000 * 60 * 60;
|
||||
}
|
||||
return undefined;
|
||||
return parseFloat(time);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,9 @@ import { act, renderHook } from '@testing-library/react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { DEFAULT_SPAN_FILTERS } from '../state/constants';
|
||||
import { TraceSearchProps } from '@grafana/data';
|
||||
|
||||
import { DEFAULT_SPAN_FILTERS, randomId } from '../state/constants';
|
||||
|
||||
import { TraceSpan } from './components/types/trace';
|
||||
import { useSearch } from './useSearch';
|
||||
@@ -80,4 +82,235 @@ describe('useSearch', () => {
|
||||
act(() => result.current.setSearch({ ...DEFAULT_SPAN_FILTERS, serviceName: 'service1' }));
|
||||
expect(result.current.spanFilterMatches).toBe(undefined);
|
||||
});
|
||||
|
||||
describe('migration to adhoc filters', () => {
|
||||
it('migrates serviceName to adhoc filter', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
serviceName: 'my-service',
|
||||
serviceNameOperator: '=',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that adhoc filter was created
|
||||
expect(result.current.search.adhocFilters).toHaveLength(1);
|
||||
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
|
||||
key: 'serviceName',
|
||||
operator: '=',
|
||||
value: 'my-service',
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates spanName to adhoc filter', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
spanName: 'my-operation',
|
||||
spanNameOperator: '!=',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that adhoc filter was created
|
||||
expect(result.current.search.adhocFilters).toHaveLength(1);
|
||||
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
|
||||
key: 'spanName',
|
||||
operator: '!=',
|
||||
value: 'my-operation',
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates query to _textSearch_ adhoc filter', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
query: 'error timeout',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that adhoc filter was created
|
||||
expect(result.current.search.adhocFilters).toHaveLength(1);
|
||||
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
|
||||
key: '_textSearch_',
|
||||
operator: '=',
|
||||
value: 'error timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates tags to adhoc filters', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
tags: [
|
||||
{ id: randomId(), key: 'http.status_code', operator: '=', value: '500' },
|
||||
{ id: randomId(), key: 'error', operator: '=~', value: 'timeout' },
|
||||
],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that adhoc filters were created
|
||||
expect(result.current.search.adhocFilters).toHaveLength(2);
|
||||
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
|
||||
key: 'http.status_code',
|
||||
operator: '=',
|
||||
value: '500',
|
||||
});
|
||||
expect(result.current.search.adhocFilters?.[1]).toMatchObject({
|
||||
key: 'error',
|
||||
operator: '=~',
|
||||
value: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates multiple filter types together', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
serviceName: 'my-service',
|
||||
serviceNameOperator: '=',
|
||||
spanName: 'my-operation',
|
||||
spanNameOperator: '!=',
|
||||
query: 'error',
|
||||
tags: [{ id: randomId(), key: 'http.status_code', operator: '=', value: '500' }],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that all filters were migrated (serviceName, spanName, query, 1 tag = 4 total)
|
||||
expect(result.current.search.adhocFilters).toHaveLength(4);
|
||||
|
||||
// Verify each filter
|
||||
const filters = result.current.search.adhocFilters || [];
|
||||
expect(filters.find((f) => f.key === 'serviceName')).toMatchObject({
|
||||
key: 'serviceName',
|
||||
operator: '=',
|
||||
value: 'my-service',
|
||||
});
|
||||
expect(filters.find((f) => f.key === 'spanName')).toMatchObject({
|
||||
key: 'spanName',
|
||||
operator: '!=',
|
||||
value: 'my-operation',
|
||||
});
|
||||
expect(filters.find((f) => f.key === '_textSearch_')).toMatchObject({
|
||||
key: '_textSearch_',
|
||||
operator: '=',
|
||||
value: 'error',
|
||||
});
|
||||
expect(filters.find((f) => f.key === 'http.status_code')).toMatchObject({
|
||||
key: 'http.status_code',
|
||||
operator: '=',
|
||||
value: '500',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not migrate if adhoc filters already exist', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
serviceName: 'my-service',
|
||||
serviceNameOperator: '=',
|
||||
adhocFilters: [
|
||||
{
|
||||
key: 'existing-key',
|
||||
operator: '=',
|
||||
value: 'existing-value',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that only existing adhoc filter remains (no migration happened)
|
||||
expect(result.current.search.adhocFilters).toHaveLength(1);
|
||||
expect(result.current.search.adhocFilters?.[0]).toMatchObject({
|
||||
key: 'existing-key',
|
||||
operator: '=',
|
||||
value: 'existing-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips empty or whitespace-only filters during migration', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
serviceName: ' ', // whitespace only
|
||||
spanName: '', // empty
|
||||
query: ' ', // whitespace only
|
||||
tags: [
|
||||
{ id: randomId(), key: '', operator: '=', value: 'some-value' }, // empty key
|
||||
{ id: randomId(), key: 'some-key', operator: '=', value: '' }, // empty value
|
||||
{ id: randomId(), key: ' ', operator: '=', value: ' ' }, // whitespace only
|
||||
],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that no adhoc filters were created
|
||||
expect(result.current.search.adhocFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applies adhoc filters to span matching', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
serviceName: 'service1',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// The serviceName filter should be migrated to adhoc filter and applied
|
||||
expect(result.current.spanFilterMatches?.size).toBe(1);
|
||||
expect(result.current.spanFilterMatches?.has('span1')).toBe(true);
|
||||
expect(result.current.spanFilterMatches?.has('span2')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles different operators during migration', () => {
|
||||
const store = createMockStore();
|
||||
const wrapper = createWrapper(store);
|
||||
|
||||
const initialFilters: TraceSearchProps = {
|
||||
...DEFAULT_SPAN_FILTERS,
|
||||
serviceName: 'my-service',
|
||||
serviceNameOperator: '!=',
|
||||
tags: [
|
||||
{ id: randomId(), key: 'tag1', operator: '=', value: 'value1' },
|
||||
{ id: randomId(), key: 'tag2', operator: '!=', value: 'value2' },
|
||||
{ id: randomId(), key: 'tag3', operator: '=~', value: 'pattern' },
|
||||
{ id: randomId(), key: 'tag4', operator: '!~', value: 'pattern' },
|
||||
],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useSearch(undefined, spans, initialFilters), { wrapper });
|
||||
|
||||
// Check that operators were preserved
|
||||
expect(result.current.search.adhocFilters).toHaveLength(5);
|
||||
|
||||
const filters = result.current.search.adhocFilters || [];
|
||||
expect(filters.find((f) => f.key === 'serviceName')?.operator).toBe('!=');
|
||||
expect(filters.find((f) => f.key === 'tag1')?.operator).toBe('=');
|
||||
expect(filters.find((f) => f.key === 'tag2')?.operator).toBe('!=');
|
||||
expect(filters.find((f) => f.key === 'tag3')?.operator).toBe('=~');
|
||||
expect(filters.find((f) => f.key === 'tag4')?.operator).toBe('!~');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,94 @@
|
||||
import { cloneDeep, merge } from 'lodash';
|
||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
|
||||
import { InterpolateFunction, TraceSearchProps } from '@grafana/data';
|
||||
import { InterpolateFunction, SelectableValue, TraceSearchProps } from '@grafana/data';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { DEFAULT_SPAN_FILTERS, randomId } from '../state/constants';
|
||||
import { changePanelState } from '../state/explorePane';
|
||||
|
||||
import { TraceSpan } from './components/types/trace';
|
||||
import { TraceSpan, CriticalPathSection } from './components/types/trace';
|
||||
import { filterSpans } from './components/utils/filter-spans';
|
||||
|
||||
/**
|
||||
* Migrate old span filters to new adhoc filters approach.
|
||||
* Maps serviceName, spanName, tags, and query to adhoc filters.
|
||||
*/
|
||||
export function migrateToAdhocFilters(search: TraceSearchProps): TraceSearchProps {
|
||||
// If we already have adhoc filters, don't migrate
|
||||
if (search.adhocFilters && search.adhocFilters.length > 0) {
|
||||
return search;
|
||||
}
|
||||
|
||||
const adhocFilters: Array<SelectableValue<string>> = [];
|
||||
|
||||
// Migrate serviceName
|
||||
if (search.serviceName && search.serviceName.trim() !== '') {
|
||||
adhocFilters.push({
|
||||
key: 'serviceName',
|
||||
operator: search.serviceNameOperator || '=',
|
||||
value: search.serviceName,
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate spanName
|
||||
if (search.spanName && search.spanName.trim() !== '') {
|
||||
adhocFilters.push({
|
||||
key: 'spanName',
|
||||
operator: search.spanNameOperator || '=',
|
||||
value: search.spanName,
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate tags
|
||||
if (search.tags && search.tags.length > 0) {
|
||||
search.tags.forEach((tag) => {
|
||||
// Only migrate tags that have both key and value
|
||||
if (tag.key && tag.key.trim() !== '' && tag.value && tag.value.trim() !== '') {
|
||||
adhocFilters.push({
|
||||
key: tag.key,
|
||||
operator: tag.operator || '=',
|
||||
value: tag.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate query to _textSearch_
|
||||
if (search.query && search.query.trim() !== '') {
|
||||
adhocFilters.push({
|
||||
key: '_textSearch_',
|
||||
operator: '=',
|
||||
value: search.query,
|
||||
});
|
||||
}
|
||||
|
||||
// Return search with migrated adhoc filters
|
||||
return {
|
||||
...search,
|
||||
adhocFilters,
|
||||
// Clear old filters after migration
|
||||
serviceName: undefined,
|
||||
spanName: undefined,
|
||||
tags: [{ id: randomId(), operator: '=' }],
|
||||
query: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls the state of search input that highlights spans if they match the search string.
|
||||
* Uses global state for Explore (when exploreId is provided) or local state for panels (when no exploreId).
|
||||
* @param exploreId - The explore pane ID (optional, for global state management)
|
||||
* @param spans - The trace spans to filter
|
||||
* @param initialFilters - Initial filters to set
|
||||
* @param criticalPath - The critical path sections (optional)
|
||||
*/
|
||||
export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilters?: TraceSearchProps) {
|
||||
export function useSearch(
|
||||
exploreId?: string,
|
||||
spans?: TraceSpan[],
|
||||
initialFilters?: TraceSearchProps,
|
||||
criticalPath?: CriticalPathSection[]
|
||||
) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Global state logic (for Explore)
|
||||
@@ -31,7 +102,8 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
|
||||
if (!merged.tags || !Array.isArray(merged.tags)) {
|
||||
merged.tags = [{ id: randomId(), operator: '=' }];
|
||||
}
|
||||
return merged;
|
||||
// Migrate to adhoc filters
|
||||
return migrateToAdhocFilters(merged);
|
||||
});
|
||||
|
||||
// Determine which state to use based on exploreId presence
|
||||
@@ -45,15 +117,39 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
|
||||
}
|
||||
|
||||
// Global state initialization (only when exploreId exists)
|
||||
// Also handle migration for existing global filters
|
||||
useEffect(() => {
|
||||
if (exploreId && !globalFilters) {
|
||||
const mergedFilters = merge(cloneDeep(DEFAULT_SPAN_FILTERS), initialFilters ?? {});
|
||||
// Ensure tags is always an array
|
||||
if (!mergedFilters.tags || !Array.isArray(mergedFilters.tags)) {
|
||||
mergedFilters.tags = [{ id: randomId(), operator: '=' }];
|
||||
}
|
||||
if (exploreId) {
|
||||
if (!globalFilters) {
|
||||
// Initialize with migrated filters
|
||||
let mergedFilters: TraceSearchProps = merge(cloneDeep(DEFAULT_SPAN_FILTERS), initialFilters ?? {});
|
||||
// Ensure tags is always an array
|
||||
if (!mergedFilters.tags || !Array.isArray(mergedFilters.tags)) {
|
||||
mergedFilters.tags = [{ id: randomId(), operator: '=' }];
|
||||
}
|
||||
// Ensure adhocFilters is always an array
|
||||
if (!mergedFilters.adhocFilters) {
|
||||
mergedFilters.adhocFilters = [];
|
||||
}
|
||||
// Migrate to adhoc filters
|
||||
mergedFilters = migrateToAdhocFilters(mergedFilters);
|
||||
|
||||
dispatch(changePanelState(exploreId, 'trace', { ...panelState, spanFilters: mergedFilters }));
|
||||
dispatch(changePanelState(exploreId, 'trace', { ...panelState, spanFilters: mergedFilters }));
|
||||
} else {
|
||||
// Check if existing filters need migration
|
||||
const needsMigration = !globalFilters.adhocFilters || globalFilters.adhocFilters.length === 0;
|
||||
|
||||
const hasOldFilters =
|
||||
globalFilters.serviceName ||
|
||||
globalFilters.spanName ||
|
||||
globalFilters.query ||
|
||||
(globalFilters.tags && globalFilters.tags.some((tag) => tag.key && tag.value));
|
||||
|
||||
if (needsMigration && hasOldFilters) {
|
||||
const migratedFilters = migrateToAdhocFilters(globalFilters);
|
||||
dispatch(changePanelState(exploreId, 'trace', { ...panelState, spanFilters: migratedFilters }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [exploreId, initialFilters, globalFilters, dispatch, panelState]);
|
||||
|
||||
@@ -61,11 +157,13 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
|
||||
useEffect(() => {
|
||||
if (!exploreId && initialFilters) {
|
||||
setLocalSearch((prev) => {
|
||||
const merged = merge(cloneDeep(prev), initialFilters);
|
||||
let merged = merge(cloneDeep(prev), initialFilters);
|
||||
// Ensure tags is always an array
|
||||
if (!merged.tags || !Array.isArray(merged.tags)) {
|
||||
merged.tags = [{ id: randomId(), operator: '=' }];
|
||||
}
|
||||
// Migrate to adhoc filters
|
||||
merged = migrateToAdhocFilters(merged);
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
@@ -84,8 +182,8 @@ export function useSearch(exploreId?: string, spans?: TraceSpan[], initialFilter
|
||||
);
|
||||
|
||||
const spanFilterMatches: Set<string> | undefined = useMemo(() => {
|
||||
return spans && filterSpans(search, spans);
|
||||
}, [search, spans]);
|
||||
return spans && filterSpans(search, spans, criticalPath);
|
||||
}, [search, spans, criticalPath]);
|
||||
|
||||
return { search, setSearch, spanFilterMatches };
|
||||
}
|
||||
@@ -102,6 +200,18 @@ export function replaceSearchVariables(replaceVariables: InterpolateFunction, se
|
||||
newSearch.tags = [{ id: randomId(), operator: '=' }];
|
||||
}
|
||||
|
||||
// Replace variables in adhoc filters
|
||||
if (newSearch.adhocFilters) {
|
||||
newSearch.adhocFilters = newSearch.adhocFilters.map((filter) => {
|
||||
return {
|
||||
...filter,
|
||||
key: replaceVariables(filter.key ?? ''),
|
||||
value: replaceVariables(filter.value ?? ''),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy filters (kept for backward compatibility)
|
||||
if (newSearch.query) {
|
||||
newSearch.query = replaceVariables(newSearch.query);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { TraceSearchProps } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export const DEFAULT_RANGE = {
|
||||
@@ -14,12 +15,13 @@ export const DEFAULT_TAG_FILTERS = {
|
||||
operator: '=',
|
||||
};
|
||||
|
||||
export const DEFAULT_SPAN_FILTERS = {
|
||||
export const DEFAULT_SPAN_FILTERS: TraceSearchProps = {
|
||||
spanNameOperator: '=',
|
||||
serviceNameOperator: '=',
|
||||
fromOperator: '>',
|
||||
toOperator: '<',
|
||||
tags: [DEFAULT_TAG_FILTERS],
|
||||
adhocFilters: [],
|
||||
matchesOnly: false,
|
||||
criticalPathOnly: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { StandardEditorProps, TraceSearchProps } from '@grafana/data';
|
||||
import { AdHocFiltersComboboxRenderer } from '@grafana/scenes';
|
||||
|
||||
import { useTraceAdHocFiltersController } from '../../../features/explore/TraceView/components/TracePageHeader/useTraceAdHocFiltersController';
|
||||
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
|
||||
|
||||
type Props = StandardEditorProps<TraceSearchProps, unknown, TraceSearchProps>;
|
||||
|
||||
export const FiltersEditor = ({ value, onChange, context }: Props) => {
|
||||
const trace = useMemo(() => transformDataFrames(context.data[0]), [context.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.adhocFilters) {
|
||||
onChange({ ...value, adhocFilters: [] });
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
const controller = useTraceAdHocFiltersController(trace, value, onChange);
|
||||
|
||||
if (!trace || !controller) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AdHocFiltersComboboxRenderer controller={controller} />;
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { SelectableValue, StandardEditorProps, TraceSearchProps } from '@grafana/data';
|
||||
|
||||
import { SpanFiltersTags } from '../../../features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags';
|
||||
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
|
||||
import { DEFAULT_TAG_FILTERS } from '../../../features/explore/state/constants';
|
||||
|
||||
type Props = StandardEditorProps<TraceSearchProps, unknown, TraceSearchProps>;
|
||||
|
||||
export const TagsEditor = ({ value, onChange, context }: Props) => {
|
||||
const trace = useMemo(() => transformDataFrames(context.data[0]), [context.data]);
|
||||
const [tagKeys, setTagKeys] = useState<Array<SelectableValue<string>>>();
|
||||
const [tagValues, setTagValues] = useState<{ [key: string]: Array<SelectableValue<string>> }>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.tags) {
|
||||
onChange({ ...value, tags: [DEFAULT_TAG_FILTERS] });
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
if (!trace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SpanFiltersTags
|
||||
search={value}
|
||||
setSearch={onChange}
|
||||
trace={trace}
|
||||
tagKeys={tagKeys}
|
||||
setTagKeys={setTagKeys}
|
||||
tagValues={tagValues}
|
||||
setTagValues={setTagValues}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,36 @@
|
||||
import { PanelPlugin, toOption } from '@grafana/data';
|
||||
import { PanelPlugin } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { getTraceServiceNames, getTraceSpanNames } from '../../../features/explore/TraceView/utils/tags';
|
||||
import { transformDataFrames } from '../../../features/explore/TraceView/utils/transform';
|
||||
import { migrateToAdhocFilters } from '../../../features/explore/TraceView/useSearch';
|
||||
|
||||
import { TagsEditor } from './TagsEditor';
|
||||
import { FiltersEditor } from './FiltersEditor';
|
||||
import { TracesPanel } from './TracesPanel';
|
||||
import { TracesSuggestionsSupplier } from './suggestions';
|
||||
|
||||
export const plugin = new PanelPlugin(TracesPanel)
|
||||
.setPanelOptions((builder, context) => {
|
||||
.setMigrationHandler((panel) => {
|
||||
// Migrate old span filters to new adhoc filters format
|
||||
if (panel.options?.spanFilters) {
|
||||
return {
|
||||
spanFilters: migrateToAdhocFilters(panel.options.spanFilters),
|
||||
};
|
||||
}
|
||||
return panel.options;
|
||||
})
|
||||
.setPanelOptions((builder) => {
|
||||
const category = [t('traces.category-span-filters', 'Span filters')];
|
||||
const trace = transformDataFrames(context?.data?.[0]);
|
||||
|
||||
builder.addCustomEditor({
|
||||
id: 'filters',
|
||||
name: t('traces.name-filters', 'Filters'),
|
||||
path: 'spanFilters',
|
||||
category,
|
||||
editor: FiltersEditor,
|
||||
defaultValue: undefined,
|
||||
});
|
||||
|
||||
// Find
|
||||
builder
|
||||
.addTextInput({
|
||||
path: 'spanFilters.query',
|
||||
name: t('traces.name-find-in-trace', 'Find in trace'),
|
||||
category,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'spanFilters.matchesOnly',
|
||||
name: t('traces.name-show-matches-only', 'Show matches only'),
|
||||
@@ -28,81 +39,9 @@ export const plugin = new PanelPlugin(TracesPanel)
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'spanFilters.criticalPathOnly',
|
||||
name: t('traces.name-critical-path-only', 'Show critical path only'),
|
||||
name: t('traces.name-critical-path-only', 'Select critical path'),
|
||||
defaultValue: false,
|
||||
category,
|
||||
});
|
||||
|
||||
// Service name
|
||||
builder
|
||||
.addSelect({
|
||||
path: 'spanFilters.serviceName',
|
||||
name: t('traces.name-service-name', 'Service name'),
|
||||
category,
|
||||
settings: {
|
||||
options: trace ? getTraceServiceNames(trace).map(toOption) : [],
|
||||
allowCustomValue: true,
|
||||
isClearable: true,
|
||||
},
|
||||
})
|
||||
.addRadio({
|
||||
path: 'spanFilters.serviceNameOperator',
|
||||
name: t('traces.name-service-name-operator', 'Service name operator'),
|
||||
defaultValue: '=',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: '!=', label: '!=' },
|
||||
],
|
||||
},
|
||||
category,
|
||||
});
|
||||
|
||||
// Span name
|
||||
builder
|
||||
.addSelect({
|
||||
path: 'spanFilters.spanName',
|
||||
name: t('traces.name-span-name', 'Span name'),
|
||||
category,
|
||||
settings: {
|
||||
options: trace ? getTraceSpanNames(trace).map(toOption) : [],
|
||||
allowCustomValue: true,
|
||||
isClearable: true,
|
||||
},
|
||||
})
|
||||
.addRadio({
|
||||
path: 'spanFilters.spanNameOperator',
|
||||
name: t('traces.name-span-name-operator', 'Span name operator'),
|
||||
defaultValue: '=',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: '!=', label: '!=' },
|
||||
],
|
||||
},
|
||||
category,
|
||||
});
|
||||
|
||||
// Duration
|
||||
builder
|
||||
.addTextInput({
|
||||
path: 'spanFilters.from',
|
||||
name: t('traces.name-min-duration', 'Min duration'),
|
||||
category,
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'spanFilters.to',
|
||||
name: t('traces.name-max-duration', 'Max duration'),
|
||||
category,
|
||||
});
|
||||
|
||||
builder.addCustomEditor({
|
||||
id: 'tags',
|
||||
name: t('traces.name-tags', 'Tags'),
|
||||
path: 'spanFilters',
|
||||
category,
|
||||
editor: TagsEditor,
|
||||
defaultValue: undefined,
|
||||
});
|
||||
})
|
||||
.setSuggestionsSupplier(new TracesSuggestionsSupplier());
|
||||
|
||||
@@ -7045,7 +7045,6 @@
|
||||
"new-panel": "New Panel"
|
||||
}
|
||||
},
|
||||
"clear": "Clear",
|
||||
"confirm-navigation-modal": {
|
||||
"cancel": "Cancel",
|
||||
"new-tab": "Do you want to proceed in the current tab or open a new tab?",
|
||||
@@ -7269,7 +7268,6 @@
|
||||
"logs-volumne-panel-list": {
|
||||
"body-no-logs-volume-available": "No volume information available for the current queries and time range."
|
||||
},
|
||||
"next": "Next",
|
||||
"next-prev-result": {
|
||||
"aria-label-next": "Next result button",
|
||||
"aria-label-prev": "Prev result button",
|
||||
@@ -7290,7 +7288,6 @@
|
||||
"pane": {
|
||||
"loading-placeholder": "Loading..."
|
||||
},
|
||||
"prev": "Prev",
|
||||
"queryless-apps-extensions": {
|
||||
"aria-label-go-queryless": "Go queryless"
|
||||
},
|
||||
@@ -7439,8 +7436,7 @@
|
||||
"query-inspector-button": "Query inspector",
|
||||
"query-inspector-button-aria-label": "Query inspector"
|
||||
},
|
||||
"show-critical-path-only": "Show critical path only",
|
||||
"show-matches-only": "Show matches only",
|
||||
"show-all-spans": "Show all spans",
|
||||
"span-bar": {
|
||||
"tooltip-critical-path": "A segment on the <1>critical path</1> of the overall trace / request / workflow."
|
||||
},
|
||||
@@ -7560,10 +7556,18 @@
|
||||
},
|
||||
"trace-page-header": {
|
||||
"aria-label-share-dropdown": "Open share trace options menu",
|
||||
"critical-path": "Critical path",
|
||||
"critical-path-tooltip": "Selects spans in the critical path—the longest sequence of dependent tasks determining the trace minimum duration.",
|
||||
"duration": "Duration",
|
||||
"errors": "Errors",
|
||||
"errors-tooltip": "Selects spans where status equals error.",
|
||||
"export-started": "Export started",
|
||||
"filters": "Filters",
|
||||
"give-feedback": "Feedback",
|
||||
"high-latency": "High latency",
|
||||
"high-latency-tooltip": "Selects the 10% longest spans in the trace (p90).",
|
||||
"link-copied": "Link copied to clipboard",
|
||||
"overview": "Overview",
|
||||
"path": "Path",
|
||||
"route": "Route",
|
||||
"services": "Services",
|
||||
@@ -7578,11 +7582,6 @@
|
||||
"trace-id": "Trace ID",
|
||||
"url": "URL"
|
||||
},
|
||||
"trace-page-search-bar": {
|
||||
"aria-label-clear-filters": "Clear filters button",
|
||||
"label-show-matches": "Show matches only switch",
|
||||
"label-show-paths": "Show critical path only switch"
|
||||
},
|
||||
"trace-view": {
|
||||
"aria-label-copy": "Copy to clipboard",
|
||||
"no-data": "No data",
|
||||
@@ -13396,17 +13395,20 @@
|
||||
}
|
||||
},
|
||||
"traces": {
|
||||
"adhocFilters": {
|
||||
"customValue": "Type a value",
|
||||
"duration1h": "1h",
|
||||
"duration1m": "1m",
|
||||
"duration1ms": "1ms",
|
||||
"duration1s": "1s",
|
||||
"durationLabel": "duration",
|
||||
"textSearchDescription": "Search for text in the trace",
|
||||
"textSearchLabel": "Text search"
|
||||
},
|
||||
"category-span-filters": "Span filters",
|
||||
"name-critical-path-only": "Show critical path only",
|
||||
"name-find-in-trace": "Find in trace",
|
||||
"name-max-duration": "Max duration",
|
||||
"name-min-duration": "Min duration",
|
||||
"name-service-name": "Service name",
|
||||
"name-service-name-operator": "Service name operator",
|
||||
"name-critical-path-only": "Select critical path",
|
||||
"name-filters": "Filters",
|
||||
"name-show-matches-only": "Show matches only",
|
||||
"name-span-name": "Span name",
|
||||
"name-span-name-operator": "Span name operator",
|
||||
"name-tags": "Tags",
|
||||
"traces-panel": {
|
||||
"no-data-found-in-response": "No data found in response"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user