Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/unreleased/pr-26151.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "f"
message = "Carry search filters into widget replay search so the replayed search matches what the widget displayed."
issues = ["Graylog2/graylog-plugin-enterprise#8869"]
pulls = ["26151"]
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,35 @@
*/
import * as React from 'react';
import { asElement, render, screen } from 'wrappedTestingLibrary';
import * as Immutable from 'immutable';
import userEvent from '@testing-library/user-event';
import URI from 'urijs';

import type { TimeRange } from 'views/logic/queries/Query';
import { createElasticsearchQueryString } from 'views/logic/queries/Query';
import type { QueryString } from 'views/logic/queries/types';
import type { FiltersType, SearchFilter } from 'views/types';
import Store from 'logic/local-storage/Store';

import ReplaySearchButton from './ReplaySearchButton';

type OptionalOverrides = {
streams?: Array<string>;
query?: QueryString;
timerange?: TimeRange;
filters?: FiltersType;
};

const filter = (queryString: string): SearchFilter => ({
type: 'inlineQueryString',
queryString,
disabled: false,
negation: false,
});

const findReplayButton = async () =>
asElement(await screen.findByRole('link', { name: /replay search/i }), HTMLAnchorElement);

describe('ReplaySearchButton', () => {
it('renders play button', async () => {
render(<ReplaySearchButton />);
Expand All @@ -37,10 +53,17 @@ describe('ReplaySearchButton', () => {
});

describe('generates link', () => {
const renderWithContext = async ({ query, timerange, streams }: OptionalOverrides = {}) => {
render(<ReplaySearchButton queryString={query?.query_string} timerange={timerange} streams={streams} />);
const renderWithContext = async ({ query, timerange, streams, filters }: OptionalOverrides = {}) => {
render(
<ReplaySearchButton
queryString={query?.query_string}
timerange={timerange}
streams={streams}
filters={filters}
/>,
);

return asElement(await screen.findByRole('link', { name: /replay search/i }), HTMLAnchorElement);
return findReplayButton();
};

it('opening in a new page', async () => {
Expand Down Expand Up @@ -75,5 +98,43 @@ describe('ReplaySearchButton', () => {

expect(button.href).toContain('streams=stream1%2Cstream2%2Csomeotherstream');
});

it('adds a session-id when search filters are configured', async () => {
const button = await renderWithContext({ filters: Immutable.List([filter('foo:bar')]) });

expect(button.href).toMatch(/session-id=replay-search-/);
});

it('omits the session-id when neither parameters nor filters are configured', async () => {
const button = await renderWithContext();

expect(button.href).not.toContain('session-id=');
});

it('omits the session-id when filters are empty', async () => {
const button = await renderWithContext({ filters: Immutable.List() });

expect(button.href).not.toContain('session-id=');
});
});

describe('on click', () => {
it('persists search filters to local storage so the replayed search can consume them', async () => {
const filters = Immutable.List([filter('source:my-host'), filter('level:ERROR')]);
render(<ReplaySearchButton filters={filters} />);
const button = await findReplayButton();
const sessionId = new URI(button.href).search(true)['session-id'] as string | undefined;

expect(sessionId).toMatch(/^replay-search-/);

await userEvent.click(button);

const raw = Store.get<string>(sessionId);

expect(raw).toBeDefined();
expect(JSON.parse(raw!)).toEqual({
filters: [filter('source:my-host'), filter('level:ERROR')],
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { createElasticsearchQueryString } from 'views/logic/queries/Query';
import generateId from 'logic/generateId';
import type Parameter from 'views/logic/parameters/Parameter';
import type { ParameterBindings } from 'views/logic/search/SearchExecutionState';
import type { FiltersType } from 'views/types';

const NeutralLink = styled.a`
display: inline-flex;
Expand Down Expand Up @@ -56,6 +57,7 @@ const buildSearchLink = (
streams: Array<string>,
streamCategories: Array<string>,
parameters?: Immutable.Set<Parameter>,
filters?: FiltersType,
) => {
let searchLink = SearchLink.builder()
.query(createElasticsearchQueryString(queryString))
Expand All @@ -65,7 +67,7 @@ const buildSearchLink = (
.build()
.toURL();

if (parameters?.size) {
if (parameters?.size || filters?.size) {
searchLink = new URI(searchLink).setSearch('session-id', sessionId).toString();
}

Expand Down Expand Up @@ -114,6 +116,7 @@ type Props = {
streams?: string[] | undefined;
streamCategories?: string[] | undefined;
parameters?: Immutable.Set<Parameter>;
filters?: FiltersType;
children?: React.ReactNode;
parameterBindings?: ParameterBindings;
};
Expand All @@ -124,17 +127,33 @@ const ReplaySearchButton = ({
streams = undefined,
streamCategories = undefined,
parameters = undefined,
filters = undefined,
children = undefined,
parameterBindings = undefined,
}: Props) => {
const sessionId = useMemo(() => `replay-search-${generateId()}`, []);
const searchLink = buildSearchLink(sessionId, timerange, queryString, streams, streamCategories, parameters);
const searchLink = buildSearchLink(
sessionId,
timerange,
queryString,
streams,
streamCategories,
parameters,
filters,
);

const onReplaySearch = useCallback(() => {
if (parameters?.size) {
Store.set(sessionId, JSON.stringify({ parameters, parameterBindings }));
if (parameters?.size || filters?.size) {
Store.set(
sessionId,
JSON.stringify({
parameters,
parameterBindings,
filters: filters?.toArray() ?? [],
}),
);
}
}, [sessionId, parameters, parameterBindings]);
}, [sessionId, parameters, parameterBindings, filters]);

return (
<ReplaySearchButtonComponent searchLink={searchLink} onClick={onReplaySearch}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ const WidgetActionsMenu = ({ isFocused, onPositionsChange, position, title, togg
streamCategories={streamCategories}
parameterBindings={parameterBindings}
parameters={parameters}
filters={widget.filters}
/>
</IfDashboard>
<ExtraMenuWidgetActions widget={widget} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@
import type { TimeRange, QueryId, FilterType } from 'views/logic/queries/Query';
import Query, { createElasticsearchQueryString, newFiltersForQuery } from 'views/logic/queries/Query';
import generateId from 'logic/generateId';
import type { SearchFilter } from 'components/event-definitions/event-definitions-types';
import type { SearchFilter } from 'views/types';
import type { QueryString } from 'views/logic/queries/types';

export default (
streamId?: string | string[],
streamCategory?: string | string[],
// eslint-disable-next-line default-param-last

Check warning on line 30 in graylog2-web-interface/src/views/logic/queries/QueryGenerator.ts

View workflow job for this annotation

GitHub Actions / Reviewbot

Unused eslint-disable directive (no problems were reported from 'default-param-last').

No further rule information available.
id: QueryId | undefined = generateId(),
timeRange?: TimeRange,
queryString?: QueryString,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,33 @@ import type { TimeRange } from 'views/logic/queries/Query';
import ViewGenerator from 'views/logic/views/ViewGenerator';
import type Parameter from 'views/logic/parameters/Parameter';
import type { QueryString } from 'views/logic/queries/types';
import type { SearchFilter } from 'views/types';

type Props = {
streamId?: string | string[];
streamCategory?: string | string[];
timeRange?: TimeRange;
queryString?: QueryString;
parameters?: Array<Parameter>;
searchFilters?: Array<SearchFilter>;
};

type Deps = Array<Props[keyof Props]> | [];
const useCreateSavedSearch = (
{ streamId, streamCategory, timeRange, queryString, parameters }: Props,
{ streamId, streamCategory, timeRange, queryString, parameters, searchFilters }: Props,
deps: Deps = [],
) =>
useMemo(
() => ViewGenerator({ type: View.Type.Search, streamId, streamCategory, timeRange, queryString, parameters }),
() =>
ViewGenerator({
type: View.Type.Search,
streamId,
streamCategory,
timeRange,
queryString,
parameters,
searchFilters,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
deps,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { TimeRange } from 'views/logic/queries/Query';
import UpdateSearchForWidgets from 'views/logic/views/UpdateSearchForWidgets';
import type Parameter from 'views/logic/parameters/Parameter';
import type { QueryString } from 'views/logic/queries/types';
import type { SearchFilter } from 'views/types';

import View from './View';
import ViewStateGenerator from './ViewStateGenerator';
Expand All @@ -33,15 +34,17 @@ export default async ({
timeRange,
queryString,
parameters,
searchFilters,
}: {
type: ViewType;
streamId?: string | string[];
streamCategory?: string | string[];
timeRange?: TimeRange;
queryString?: QueryString;
parameters?: Array<Parameter>;
searchFilters?: Array<SearchFilter>;
}) => {
const query = QueryGenerator(streamId, streamCategory, undefined, timeRange, queryString);
const query = QueryGenerator(streamId, streamCategory, undefined, timeRange, queryString, searchFilters);
const search = Search.create().toBuilder().queries([query]).parameters(parameters).build();
const viewState = await ViewStateGenerator(type, streamId);

Expand Down
25 changes: 15 additions & 10 deletions graylog2-web-interface/src/views/pages/NewSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ import Parameter from 'views/logic/parameters/Parameter';
import type { ParameterBindingJsonRepresentation } from 'views/logic/parameters/ParameterBinding';
import ParameterBinding from 'views/logic/parameters/ParameterBinding';
import SearchExecutionState from 'views/logic/search/SearchExecutionState';
import type { SearchFilter } from 'views/types';

import SearchPage from './SearchPage';

const useParametersFromStore = () => {
const useReplayStateFromStore = () => {
const { 'session-id': sessionId } = useQuery();

return useMemo(() => {
Expand All @@ -40,31 +41,35 @@ const useParametersFromStore = () => {

const searchData = searchDataFromStore ? JSON.parse(searchDataFromStore) : undefined;

if (searchData?.parameters) {
if (searchData) {
return {
parameters: searchData.parameters.map((param) => Parameter.fromJSON(param)),
parameterBindings: Immutable.Map<string, ParameterBinding>(
Object.entries<ParameterBindingJsonRepresentation>(searchData.parameterBindings ?? {}).map(
([paramName, paramBinding]) => [paramName, ParameterBinding.fromJSON(paramBinding)],
),
),
parameters: searchData.parameters?.map((param) => Parameter.fromJSON(param)),
parameterBindings: searchData.parameterBindings
? Immutable.Map<string, ParameterBinding>(
Object.entries<ParameterBindingJsonRepresentation>(searchData.parameterBindings).map(
([paramName, paramBinding]) => [paramName, ParameterBinding.fromJSON(paramBinding)],
),
)
: undefined,
searchFilters: searchData.filters as SearchFilter[] | undefined,
};
}
}

return { parameters: undefined, parameterBindings: undefined };
return { parameters: undefined, parameterBindings: undefined, searchFilters: undefined };
}, [sessionId]);
};

const NewSearchPage = () => {
const { parameters, parameterBindings } = useParametersFromStore();
const { parameters, parameterBindings, searchFilters } = useReplayStateFromStore();
const { timeRange, queryString, streams, streamCategories } = useSearchURLQueryParams();
const viewPromise = useCreateSavedSearch({
streamId: streams,
streamCategory: streamCategories,
timeRange,
queryString,
parameters,
searchFilters,
});
const view = useCreateSearch(viewPromise);

Expand Down
Loading