Tracing: Show next/prev buttons when span filters are collapsed (#71025)

* Show next/prev buttons when span filters are collapsed

* Update test

* Remove imports

* Update lint

* Prettier

* Update test

* Update styling
This commit is contained in:
Joey 2023-07-12 07:52:07 +01:00 committed by GitHub
parent a2a890e85b
commit 6615418df8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 513 additions and 301 deletions

View File

@ -83,6 +83,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
font-weight: ${theme.typography.fontWeightMedium};
margin-right: ${theme.spacing(1)};
font-size: ${theme.typography.size.md};
display: flex;
flex: 0 0 100%;
`,
icon: css`
label: collapse__icon;

View File

@ -81,6 +81,7 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
const spacing = css({
display: 'flex',
justifyContent: 'space-between',
flex: '1',
});
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,

View File

@ -136,13 +136,13 @@ describe('TraceViewContainer', () => {
it('can select next/prev results', async () => {
config.featureToggles.newTraceViewHeader = true;
renderTraceViewContainer();
const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters' });
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' });
expect((nextResultButton as HTMLButtonElement)['disabled']).toBe(true);
expect((prevResultButton as HTMLButtonElement)['disabled']).toBe(true);
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');
@ -161,8 +161,8 @@ describe('TraceViewContainer', () => {
).toContain('rowMatchingFilter');
});
expect((nextResultButton as HTMLButtonElement)['disabled']).toBe(false);
expect((prevResultButton as HTMLButtonElement)['disabled']).toBe(false);
expect(nextResultButton.getAttribute('tabindex')).toBe('0');
expect(prevResultButton.getAttribute('tabindex')).toBe('0');
await user.click(nextResultButton);
await waitFor(() => {
expect(
@ -186,7 +186,7 @@ describe('TraceViewContainer', () => {
it('show matches only works as expected', async () => {
config.featureToggles.newTraceViewHeader = true;
renderTraceViewContainer();
const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters' });
const spanFiltersButton = screen.getByRole('button', { name: 'Span Filters 3 spans Prev Next' });
await user.click(spanFiltersButton);
await user.click(screen.getByLabelText('Select tag key'));

View File

@ -7,7 +7,7 @@ import { useStyles2 } from '@grafana/ui';
import { StoreState, useSelector } from 'app/types';
import { TraceView } from './TraceView';
import TracePageSearchBar from './components/TracePageHeader/TracePageSearchBar';
import TracePageSearchBar from './components/TracePageHeader/SearchBar/TracePageSearchBar';
import { TopOfViewRefType } from './components/TraceTimelineViewer/VirtualizedTraceView';
import { useSearch } from './useSearch';
import { transformDataFrames } from './utils/transform';

View File

@ -1,236 +0,0 @@
// Copyright (c) 2018 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { css } from '@emotion/css';
import React, { memo, Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Icon, Switch, Tooltip, useStyles2 } from '@grafana/ui';
import { SearchProps } from '../../useSearch';
import { convertTimeFilter } from '../utils/filter-spans';
export type TracePageSearchBarProps = {
search: SearchProps;
spanFilterMatches: Set<string> | undefined;
showSpanFilterMatchesOnly: boolean;
setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void;
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
datasourceType: string;
clear: () => void;
totalSpans: number;
};
export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProps) {
const {
search,
spanFilterMatches,
showSpanFilterMatchesOnly,
setShowSpanFilterMatchesOnly,
setFocusedSpanIdForSearch,
datasourceType,
clear,
totalSpans,
} = props;
const [currentSpanIndex, setCurrentSpanIndex] = useState(-1);
const styles = useStyles2(getStyles);
useEffect(() => {
setCurrentSpanIndex(-1);
setFocusedSpanIdForSearch('');
}, [setFocusedSpanIdForSearch, spanFilterMatches]);
useEffect(() => {
if (spanFilterMatches) {
const spanMatches = Array.from(spanFilterMatches!);
setFocusedSpanIdForSearch(spanMatches[currentSpanIndex]);
}
}, [currentSpanIndex, setFocusedSpanIdForSearch, spanFilterMatches]);
const nextResult = () => {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'next',
});
// new query || at end, go to start
if (currentSpanIndex === -1 || (spanFilterMatches && currentSpanIndex === spanFilterMatches.size - 1)) {
setCurrentSpanIndex(0);
return;
}
// get next
setCurrentSpanIndex(currentSpanIndex + 1);
};
const prevResult = () => {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'prev',
});
// new query || at start, go to end
if (spanFilterMatches && (currentSpanIndex === -1 || currentSpanIndex === 0)) {
setCurrentSpanIndex(spanFilterMatches.size - 1);
return;
}
// get prev
setCurrentSpanIndex(currentSpanIndex - 1);
};
const buttonEnabled = spanFilterMatches && spanFilterMatches?.size > 0;
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.serviceName, search.spanName, search.from, search.to, search.tags]);
const amountText = spanFilterMatches?.size === 1 ? 'match' : 'matches';
const matches =
spanFilterMatches?.size === 0 ? (
<>
<span>0 matches</span>
<Tooltip
content="There are 0 span matches for the filters selected. Please try removing some of the selected filters."
placement="left"
>
<span className={styles.matchesTooltip}>
<Icon name="info-circle" size="lg" />
</span>
</Tooltip>
</>
) : currentSpanIndex !== -1 ? (
`${currentSpanIndex + 1}/${spanFilterMatches?.size} ${amountText}`
) : (
`${spanFilterMatches?.size} ${amountText}`
);
return (
<div className={styles.searchBar}>
<div className={styles.buttons}>
<>
<div className={styles.clearButton}>
<Button
variant="destructive"
disabled={!clearEnabled}
type="button"
fill="outline"
aria-label="Clear filters button"
onClick={clear}
>
Clear
</Button>
<div className={styles.matchesOnly}>
<Switch
value={showSpanFilterMatchesOnly}
onChange={(value) => setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)}
label="Show matches only switch"
/>
<Button
onClick={() => setShowSpanFilterMatchesOnly(!showSpanFilterMatchesOnly)}
className={styles.clearMatchesButton}
variant="secondary"
fill="text"
>
Show matches only
</Button>
</div>
</div>
<div className={styles.nextPrevButtons}>
<span className={styles.matches}>{spanFilterMatches ? matches : `${totalSpans} spans`}</span>
<Button
variant="secondary"
disabled={!buttonEnabled}
type="button"
fill="outline"
aria-label="Prev result button"
onClick={prevResult}
>
Prev
</Button>
<Button
variant="secondary"
disabled={!buttonEnabled}
type="button"
fill="outline"
aria-label="Next result button"
onClick={nextResult}
>
Next
</Button>
</div>
</>
</div>
</div>
);
});
export const getStyles = (theme: GrafanaTheme2) => {
return {
searchBar: css`
display: inline;
`,
matchesOnly: css`
display: inline-flex;
margin: 0 0 0 10px;
vertical-align: middle;
align-items: center;
span {
cursor: pointer;
}
`,
buttons: css`
display: flex;
justify-content: flex-end;
margin: 5px 0 0 0;
`,
clearButton: css`
order: 1;
`,
clearMatchesButton: css`
color: ${theme.colors.text.primary};
&:hover {
background: inherit;
color: inherit;
}
`,
nextPrevButtons: css`
margin-left: auto;
order: 2;
button {
margin-left: 8px;
}
`,
matches: css`
margin-right: 5px;
`,
matchesTooltip: css`
color: #aaa;
margin: -2px 0 0 10px;
`,
};
};

View File

@ -0,0 +1,57 @@
// Copyright (c) 2017 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { render, screen } from '@testing-library/react';
import React from 'react';
import { defaultFilters } from '../../../useSearch';
import NewTracePageSearchBar from './NewTracePageSearchBar';
describe('<NewTracePageSearchBar>', () => {
const NewTracePageSearchBarWithProps = (props: { matches: string[] | undefined }) => {
const searchBarProps = {
search: defaultFilters,
spanFilterMatches: props.matches ? new Set(props.matches) : undefined,
showSpanFilterMatchesOnly: false,
setShowSpanFilterMatchesOnly: jest.fn(),
setFocusedSpanIdForSearch: jest.fn(),
focusedSpanIndexForSearch: -1,
setFocusedSpanIndexForSearch: jest.fn(),
datasourceType: '',
clear: jest.fn(),
totalSpans: 100,
showSpanFilters: true,
};
return <NewTracePageSearchBar {...searchBarProps} />;
};
it('should render', () => {
expect(() => render(<NewTracePageSearchBarWithProps matches={[]} />)).not.toThrow();
});
it('renders clear filter button', () => {
render(<NewTracePageSearchBarWithProps 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(<NewTracePageSearchBarWithProps matches={[]} />);
const matchesSwitch = screen.getByRole('checkbox', { name: 'Show matches only switch' });
expect(matchesSwitch).toBeInTheDocument();
});
});

View File

@ -0,0 +1,158 @@
// Copyright (c) 2018 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { css } from '@emotion/css';
import React, { memo, Dispatch, SetStateAction, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Switch, useStyles2 } from '@grafana/ui';
import { getButtonStyles } from '@grafana/ui/src/components/Button';
import { SearchProps } from '../../../useSearch';
import { convertTimeFilter } from '../../utils/filter-spans';
import NextPrevResult from './NextPrevResult';
export type TracePageSearchBarProps = {
search: SearchProps;
spanFilterMatches: Set<string> | undefined;
showSpanFilterMatchesOnly: boolean;
setShowSpanFilterMatchesOnly: (showMatchesOnly: boolean) => void;
focusedSpanIndexForSearch: number;
setFocusedSpanIndexForSearch: Dispatch<SetStateAction<number>>;
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
datasourceType: string;
totalSpans: number;
clear: () => void;
showSpanFilters: boolean;
};
export default memo(function NewTracePageSearchBar(props: TracePageSearchBarProps) {
const {
search,
spanFilterMatches,
showSpanFilterMatchesOnly,
setShowSpanFilterMatchesOnly,
focusedSpanIndexForSearch,
setFocusedSpanIndexForSearch,
setFocusedSpanIdForSearch,
datasourceType,
totalSpans,
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.serviceName, search.spanName, search.from, search.to, search.tags]);
return (
<div className={styles.container}>
<div className={styles.controls}>
<>
<div className={styles.clearButton}>
<Button
variant="destructive"
disabled={!clearEnabled}
type="button"
fill="outline"
aria-label="Clear filters button"
onClick={clear}
>
Clear
</Button>
<div className={styles.matchesOnly}>
<Switch
value={showSpanFilterMatchesOnly}
onChange={(value) => setShowSpanFilterMatchesOnly(value.currentTarget.checked ?? false)}
label="Show matches only switch"
/>
<Button
onClick={() => setShowSpanFilterMatchesOnly(!showSpanFilterMatchesOnly)}
className={styles.clearMatchesButton}
variant="secondary"
fill="text"
>
Show matches only
</Button>
</div>
</div>
<div className={styles.nextPrevResult}>
<NextPrevResult
spanFilterMatches={spanFilterMatches}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
datasourceType={datasourceType}
totalSpans={totalSpans}
showSpanFilters={showSpanFilters}
/>
</div>
</>
</div>
</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;
justify-content: flex-end;
margin: 5px 0 0 0;
`,
clearButton: css`
order: 1;
`,
matchesOnly: css`
display: inline-flex;
margin: 0 0 0 10px;
vertical-align: middle;
align-items: center;
span {
cursor: pointer;
margin: 0 0 0 5px;
}
`,
clearMatchesButton: css`
color: ${theme.colors.text.primary};
&:hover {
background: inherit;
color: inherit;
}
`,
nextPrevResult: css`
margin-left: auto;
order: 2;
`,
};
};

View File

@ -14,15 +14,15 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import React, { useState } from 'react';
import { createTheme } from '@grafana/data';
import { defaultFilters } from '../../useSearch';
import { defaultFilters } from '../../../useSearch';
import NewTracePageSearchBar, { getStyles } from './NewTracePageSearchBar';
import NextPrevResult, { getStyles } from './NextPrevResult';
describe('<NewTracePageSearchBar>', () => {
describe('<NextPrevResult>', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
@ -34,56 +34,58 @@ describe('<NewTracePageSearchBar>', () => {
jest.useRealTimers();
});
const NewTracePageSearchBarWithProps = (props: { matches: string[] | undefined }) => {
const NextPrevResultWithProps = (props: { matches: string[] | undefined }) => {
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
const searchBarProps = {
search: defaultFilters,
spanFilterMatches: props.matches ? new Set(props.matches) : undefined,
showSpanFilterMatchesOnly: false,
setShowSpanFilterMatchesOnly: jest.fn(),
setFocusedSpanIdForSearch: jest.fn(),
focusedSpanIndexForSearch: focusedSpanIndexForSearch,
setFocusedSpanIndexForSearch: setFocusedSpanIndexForSearch,
datasourceType: '',
clear: jest.fn(),
totalSpans: 100,
showSpanFilters: true,
};
return <NewTracePageSearchBar {...searchBarProps} />;
return <NextPrevResult {...searchBarProps} />;
};
it('should render', () => {
expect(() => render(<NewTracePageSearchBarWithProps matches={[]} />)).not.toThrow();
expect(() => render(<NextPrevResultWithProps matches={[]} />)).not.toThrow();
});
it('renders buttons', () => {
render(<NewTracePageSearchBarWithProps matches={[]} />);
it('renders UI properly', () => {
render(<NextPrevResultWithProps matches={[]} />);
const nextResButton = screen.queryByRole('button', { name: 'Next result button' });
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
const clearFiltersButton = screen.getByRole('button', { name: 'Clear filters button' });
expect(nextResButton).toBeInTheDocument();
expect(prevResButton).toBeInTheDocument();
expect(clearFiltersButton).toBeInTheDocument();
expect((nextResButton as HTMLButtonElement)['disabled']).toBe(true);
expect((prevResButton as HTMLButtonElement)['disabled']).toBe(true);
expect((clearFiltersButton as HTMLButtonElement)['disabled']).toBe(true);
expect(nextResButton as HTMLDivElement).toHaveStyle('pointer-events: none');
expect(prevResButton as HTMLDivElement).toHaveStyle('pointer-events: none');
expect(screen.getByText('0 matches')).toBeDefined();
});
it('renders total spans', async () => {
render(<NewTracePageSearchBarWithProps matches={undefined} />);
render(<NextPrevResultWithProps matches={undefined} />);
expect(screen.getByText('100 spans')).toBeDefined();
});
it('renders buttons that can be used to search if filters added', () => {
render(<NewTracePageSearchBarWithProps matches={['2ed38015486087ca']} />);
render(<NextPrevResultWithProps matches={['2ed38015486087ca']} />);
const nextResButton = screen.queryByRole('button', { name: 'Next result button' });
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
expect(nextResButton).toBeInTheDocument();
expect(prevResButton).toBeInTheDocument();
expect((nextResButton as HTMLButtonElement)['disabled']).toBe(false);
expect((prevResButton as HTMLButtonElement)['disabled']).toBe(false);
expect(nextResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none');
expect(prevResButton as HTMLDivElement).not.toHaveStyle('pointer-events: none');
expect(screen.getByText('1 match')).toBeDefined();
});
it('renders correctly when moving through matches', async () => {
render(<NewTracePageSearchBarWithProps matches={['1ed38015486087ca', '2ed38015486087ca', '3ed38015486087ca']} />);
render(<NextPrevResultWithProps matches={['1ed38015486087ca', '2ed38015486087ca', '3ed38015486087ca']} />);
const nextResButton = screen.queryByRole('button', { name: 'Next result button' });
const prevResButton = screen.queryByRole('button', { name: 'Prev result button' });
expect(screen.getByText('3 matches')).toBeDefined();
@ -102,9 +104,9 @@ describe('<NewTracePageSearchBar>', () => {
});
it('renders correctly when there are no matches i.e. too many filters added', async () => {
const { container } = render(<NewTracePageSearchBarWithProps matches={[]} />);
const styles = getStyles(createTheme());
const tooltip = container.querySelector('.' + styles.matchesTooltip);
const { container } = render(<NextPrevResultWithProps matches={[]} />);
const theme = createTheme();
const tooltip = container.querySelector('.' + getStyles(theme, true).matchesTooltip);
expect(screen.getByText('0 matches')).toBeDefined();
userEvent.hover(tooltip!);
jest.advanceTimersByTime(1000);
@ -112,10 +114,4 @@ describe('<NewTracePageSearchBar>', () => {
expect(screen.getByText(/0 span matches for the filters selected/)).toBeDefined();
});
});
it('renders show span filter matches only switch', async () => {
render(<NewTracePageSearchBarWithProps matches={[]} />);
const matchesSwitch = screen.getByRole('checkbox', { name: 'Show matches only switch' });
expect(matchesSwitch).toBeInTheDocument();
});
});

View File

@ -0,0 +1,194 @@
// Copyright (c) 2018 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { css, cx } from '@emotion/css';
import React, { memo, Dispatch, SetStateAction, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { getButtonStyles } from '@grafana/ui/src/components/Button';
export type NextPrevResultProps = {
spanFilterMatches: Set<string> | undefined;
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
focusedSpanIndexForSearch: number;
setFocusedSpanIndexForSearch: Dispatch<SetStateAction<number>>;
datasourceType: string;
totalSpans: number;
showSpanFilters: boolean;
};
export default memo(function NextPrevResult(props: NextPrevResultProps) {
const {
spanFilterMatches,
setFocusedSpanIdForSearch,
focusedSpanIndexForSearch,
setFocusedSpanIndexForSearch,
datasourceType,
totalSpans,
showSpanFilters,
} = props;
const styles = getStyles(useTheme2(), showSpanFilters);
useEffect(() => {
if (spanFilterMatches && focusedSpanIndexForSearch !== -1) {
const spanMatches = Array.from(spanFilterMatches!);
setFocusedSpanIdForSearch(spanMatches[focusedSpanIndexForSearch]);
}
}, [focusedSpanIndexForSearch, setFocusedSpanIdForSearch, spanFilterMatches]);
const nextResult = (event: React.UIEvent, buttonEnabled: boolean) => {
event.preventDefault();
event.stopPropagation();
if (buttonEnabled) {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'next',
});
// new query || at end, go to start
if (
focusedSpanIndexForSearch === -1 ||
(spanFilterMatches && focusedSpanIndexForSearch === spanFilterMatches.size - 1)
) {
setFocusedSpanIndexForSearch(0);
return;
}
// get next
setFocusedSpanIndexForSearch(focusedSpanIndexForSearch + 1);
}
};
const prevResult = (event: React.UIEvent, buttonEnabled: boolean) => {
event.preventDefault();
event.stopPropagation();
if (buttonEnabled) {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'prev',
});
// new query || at start, go to end
if (spanFilterMatches && (focusedSpanIndexForSearch === -1 || focusedSpanIndexForSearch === 0)) {
setFocusedSpanIndexForSearch(spanFilterMatches.size - 1);
return;
}
// get prev
setFocusedSpanIndexForSearch(focusedSpanIndexForSearch - 1);
}
};
const nextResultOnKeyDown = (event: React.KeyboardEvent, buttonEnabled: boolean) => {
if (event.key === 'Enter') {
nextResult(event, buttonEnabled);
}
};
const prevResultOnKeyDown = (event: React.KeyboardEvent, buttonEnabled: boolean) => {
if (event.key === 'Enter') {
prevResult(event, buttonEnabled);
}
};
const buttonEnabled = (spanFilterMatches && spanFilterMatches?.size > 0) ?? false;
const amountText = spanFilterMatches?.size === 1 ? 'match' : 'matches';
const matches =
spanFilterMatches?.size === 0 ? (
<>
<span>0 matches</span>
<Tooltip
content="There are 0 span matches for the filters selected. Please try removing some of the selected filters."
placement="left"
>
<span className={styles.matchesTooltip}>
<Icon name="info-circle" size="lg" />
</span>
</Tooltip>
</>
) : focusedSpanIndexForSearch !== -1 ? (
`${focusedSpanIndexForSearch + 1}/${spanFilterMatches?.size} ${amountText}`
) : (
`${spanFilterMatches?.size} ${amountText}`
);
const buttonClass = buttonEnabled ? styles.button : cx(styles.button, styles.buttonDisabled);
return (
<>
<span className={styles.matches}>{spanFilterMatches ? matches : `${totalSpans} spans`}</span>
<div className={buttonEnabled ? styles.buttons : cx(styles.buttons, styles.buttonsDisabled)}>
<div
aria-label="Prev result button"
className={buttonClass}
onClick={(event) => prevResult(event, buttonEnabled)}
onKeyDown={(event) => prevResultOnKeyDown(event, buttonEnabled)}
role="button"
tabIndex={buttonEnabled ? 0 : -1}
>
Prev
</div>
<div
aria-label="Next result button"
className={buttonClass}
onClick={(event) => nextResult(event, buttonEnabled)}
onKeyDown={(event) => nextResultOnKeyDown(event, buttonEnabled)}
role="button"
tabIndex={buttonEnabled ? 0 : -1}
>
Next
</div>
</div>
</>
);
});
export const getStyles = (theme: GrafanaTheme2, showSpanFilters: boolean) => {
const buttonStyles = getButtonStyles({
theme,
variant: 'secondary',
size: showSpanFilters ? 'md' : 'sm',
iconOnly: false,
fill: 'outline',
});
return {
buttons: css`
display: inline-flex;
gap: 4px;
`,
buttonsDisabled: css`
cursor: not-allowed;
`,
button: css`
${buttonStyles.button};
`,
buttonDisabled: css`
${buttonStyles.disabled};
pointer-events: none;
`,
matches: css`
margin-right: ${theme.spacing(2)};
`,
matchesTooltip: css`
color: #aaa;
margin: -2px 0 0 10px;
`,
};
};

View File

@ -20,8 +20,8 @@ import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import SearchBarInput from '../common/SearchBarInput';
import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles';
import SearchBarInput from '../../common/SearchBarInput';
import { ubFlexAuto, ubJustifyEnd } from '../../uberUtilityStyles';
// eslint-disable-next-line no-duplicate-imports

View File

@ -43,11 +43,11 @@ const trace: Trace = {
describe('SpanFilters', () => {
let user: ReturnType<typeof userEvent.setup>;
const SpanFiltersWithProps = () => {
const SpanFiltersWithProps = ({ showFilters = true }) => {
const [search, setSearch] = useState(defaultFilters);
const props = {
trace: trace,
showSpanFilters: true,
showSpanFilters: showFilters,
setShowSpanFilters: jest.fn(),
showSpanFilterMatchesOnly: false,
setShowSpanFilterMatchesOnly: jest.fn(),
@ -217,6 +217,12 @@ describe('SpanFilters', () => {
expect(screen.queryByText('TagKey0')).not.toBeInTheDocument();
expect(screen.queryByText('TagValue0')).not.toBeInTheDocument();
});
it('renders buttons when span filters is collapsed', async () => {
render(<SpanFiltersWithProps showFilters={false} />);
expect(screen.queryByRole('button', { name: 'Next result button' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Prev result button' })).toBeInTheDocument();
});
});
const selectAndCheckValue = async (user: ReturnType<typeof userEvent.setup>, elem: HTMLElement, text: string) => {

View File

@ -17,7 +17,7 @@ import { SpanStatusCode } from '@opentelemetry/api';
import { uniq } from 'lodash';
import React, { useState, useEffect, memo, useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental';
import {
Collapse,
@ -34,7 +34,8 @@ import {
import { defaultFilters, randomId, SearchProps, Tag } from '../../../useSearch';
import { KIND, LIBRARY_NAME, LIBRARY_VERSION, STATUS, STATUS_MESSAGE, TRACE_STATE, ID } from '../../constants/span';
import { Trace } from '../../types';
import NewTracePageSearchBar from '../NewTracePageSearchBar';
import NewTracePageSearchBar from '../SearchBar/NewTracePageSearchBar';
import NextPrevResult from '../SearchBar/NextPrevResult';
export type SpanFilterProps = {
trace: Trace;
@ -67,6 +68,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
const [spanNames, setSpanNames] = useState<Array<SelectableValue<string>>>();
const [tagKeys, setTagKeys] = useState<Array<SelectableValue<string>>>();
const [tagValues, setTagValues] = useState<{ [key: string]: Array<SelectableValue<string>> }>({});
const [focusedSpanIndexForSearch, setFocusedSpanIndexForSearch] = useState(-1);
const clear = useCallback(() => {
setServiceNames(undefined);
@ -84,6 +86,12 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
return null;
}
const setSpanFiltersSearch = (spanSearch: SearchProps) => {
setFocusedSpanIndexForSearch(-1);
setFocusedSpanIdForSearch('');
setSearch(spanSearch);
};
const getServiceNames = () => {
if (!serviceNames) {
const serviceNames = trace.spans.map((span) => {
@ -262,15 +270,31 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
};
const collapseLabel = (
<Tooltip
content="Filter your spans below. The more filters, the more specific the filtered spans."
placement="right"
>
<span className={styles.collapseLabel}>
Span Filters
<Icon size="md" name="info-circle" />
</span>
</Tooltip>
<>
<Tooltip
content="Filter your spans below. You can continue to apply filters until you have narrowed down your resulting spans to the select few you are most interested in."
placement="right"
>
<span className={styles.collapseLabel}>
Span Filters
<Icon size="md" name="info-circle" />
</span>
</Tooltip>
{!showSpanFilters && (
<div className={styles.nextPrevResult}>
<NextPrevResult
spanFilterMatches={spanFilterMatches}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
datasourceType={datasourceType}
totalSpans={trace.spans.length}
showSpanFilters={showSpanFilters}
/>
</div>
)}
</>
);
return (
@ -281,14 +305,14 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
<HorizontalGroup spacing={'xs'}>
<Select
aria-label="Select service name operator"
onChange={(v) => setSearch({ ...search, serviceNameOperator: v.value! })}
onChange={(v) => setSpanFiltersSearch({ ...search, serviceNameOperator: v.value! })}
options={[toOption('='), toOption('!=')]}
value={search.serviceNameOperator}
/>
<Select
aria-label="Select service name"
isClearable
onChange={(v) => setSearch({ ...search, serviceName: v?.value || '' })}
onChange={(v) => setSpanFiltersSearch({ ...search, serviceName: v?.value || '' })}
onOpenMenu={getServiceNames}
options={serviceNames}
placeholder="All service names"
@ -302,14 +326,14 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
<HorizontalGroup spacing={'xs'}>
<Select
aria-label="Select span name operator"
onChange={(v) => setSearch({ ...search, spanNameOperator: v.value! })}
onChange={(v) => setSpanFiltersSearch({ ...search, spanNameOperator: v.value! })}
options={[toOption('='), toOption('!=')]}
value={search.spanNameOperator}
/>
<Select
aria-label="Select span name"
isClearable
onChange={(v) => setSearch({ ...search, spanName: v?.value || '' })}
onChange={(v) => setSpanFiltersSearch({ ...search, spanName: v?.value || '' })}
onOpenMenu={getSpanNames}
options={spanNames}
placeholder="All span names"
@ -323,26 +347,26 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
<HorizontalGroup spacing={'xs'}>
<Select
aria-label="Select from operator"
onChange={(v) => setSearch({ ...search, fromOperator: v.value! })}
onChange={(v) => setSpanFiltersSearch({ ...search, fromOperator: v.value! })}
options={[toOption('>'), toOption('>=')]}
value={search.fromOperator}
/>
<Input
aria-label="Select from value"
onChange={(v) => setSearch({ ...search, from: v.currentTarget.value })}
onChange={(v) => setSpanFiltersSearch({ ...search, from: v.currentTarget.value })}
placeholder="e.g. 100ms, 1.2s"
value={search.from || ''}
width={18}
/>
<Select
aria-label="Select to operator"
onChange={(v) => setSearch({ ...search, toOperator: v.value! })}
onChange={(v) => setSpanFiltersSearch({ ...search, toOperator: v.value! })}
options={[toOption('<'), toOption('<=')]}
value={search.toOperator}
/>
<Input
aria-label="Select to value"
onChange={(v) => setSearch({ ...search, to: v.currentTarget.value })}
onChange={(v) => setSpanFiltersSearch({ ...search, to: v.currentTarget.value })}
placeholder="e.g. 100ms, 1.2s"
value={search.to || ''}
width={18}
@ -369,7 +393,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
<Select
aria-label="Select tag operator"
onChange={(v) => {
setSearch({
setSpanFiltersSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, operator: v.value! } : x;
@ -385,7 +409,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
isClearable
key={tag.value}
onChange={(v) => {
setSearch({
setSpanFiltersSearch({
...search,
tags: search.tags?.map((x) => {
return x.id === tag.id ? { ...x, value: v?.value || '' } : x;
@ -423,14 +447,17 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
</InlineFieldRow>
<NewTracePageSearchBar
totalSpans={trace.spans.length}
search={search}
spanFilterMatches={spanFilterMatches}
showSpanFilterMatchesOnly={showSpanFilterMatchesOnly}
setShowSpanFilterMatchesOnly={setShowSpanFilterMatchesOnly}
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
focusedSpanIndexForSearch={focusedSpanIndexForSearch}
setFocusedSpanIndexForSearch={setFocusedSpanIndexForSearch}
datasourceType={datasourceType}
clear={clear}
totalSpans={trace.spans.length}
showSpanFilters={showSpanFilters}
/>
</Collapse>
</div>
@ -439,10 +466,10 @@ export const SpanFilters = memo((props: SpanFilterProps) => {
SpanFilters.displayName = 'SpanFilters';
const getStyles = () => {
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
margin: 0.5em 0 -8px 0;
margin: 0.5em 0 -${theme.spacing(1)} 0;
z-index: 5;
& > div {
@ -462,5 +489,12 @@ const getStyles = () => {
tagValues: css`
max-width: 200px;
`,
nextPrevResult: css`
flex: 1;
align-items: center;
display: flex;
justify-content: flex-end;
margin-right: ${theme.spacing(1)};
`,
};
};

View File

@ -74,7 +74,7 @@ function getStyles(theme: GrafanaTheme2) {
text-align: start;
line-break: anywhere;
margin-top: -${theme.spacing(0.25)};
margin-right: ${theme.spacing(4)};
margin-right: ${theme.spacing(6)};
min-height: ${theme.spacing(4)};
`,
ui: css`

View File

@ -5,7 +5,7 @@ import { useAsync } from 'react-use';
import { PanelProps } from '@grafana/data';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { TraceView } from 'app/features/explore/TraceView/TraceView';
import TracePageSearchBar from 'app/features/explore/TraceView/components/TracePageHeader/TracePageSearchBar';
import TracePageSearchBar from 'app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar';
import { TopOfViewRefType } from 'app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView';
import { useSearch } from 'app/features/explore/TraceView/useSearch';
import { transformDataFrames } from 'app/features/explore/TraceView/utils/transform';