diff --git a/changelog/unreleased/pr-26151.toml b/changelog/unreleased/pr-26151.toml new file mode 100644 index 000000000000..20d61460d7e0 --- /dev/null +++ b/changelog/unreleased/pr-26151.toml @@ -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"] diff --git a/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.test.tsx b/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.test.tsx index 46ff7a3b45c7..d730e97a86ff 100644 --- a/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.test.tsx +++ b/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.test.tsx @@ -16,10 +16,15 @@ */ 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'; @@ -27,8 +32,19 @@ type OptionalOverrides = { streams?: Array; 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(); @@ -37,10 +53,17 @@ describe('ReplaySearchButton', () => { }); describe('generates link', () => { - const renderWithContext = async ({ query, timerange, streams }: OptionalOverrides = {}) => { - render(); + const renderWithContext = async ({ query, timerange, streams, filters }: OptionalOverrides = {}) => { + render( + , + ); - return asElement(await screen.findByRole('link', { name: /replay search/i }), HTMLAnchorElement); + return findReplayButton(); }; it('including query string', async () => { @@ -68,5 +91,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(); + 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(sessionId); + + expect(raw).toBeDefined(); + expect(JSON.parse(raw!)).toEqual({ + filters: [filter('source:my-host'), filter('level:ERROR')], + }); + }); }); }); diff --git a/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.tsx b/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.tsx index 9c4ed2dec404..863d39d38277 100644 --- a/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.tsx +++ b/graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.tsx @@ -30,6 +30,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; @@ -57,6 +58,7 @@ const buildSearchLink = ( streams: Array, streamCategories: Array, parameters?: Immutable.Set, + filters?: FiltersType, ) => { let searchLink = SearchLink.builder() .query(createElasticsearchQueryString(queryString)) @@ -66,7 +68,7 @@ const buildSearchLink = ( .build() .toURL(); - if (parameters?.size) { + if (parameters?.size || filters?.size) { searchLink = new URI(searchLink).setSearch('session-id', sessionId).toString(); } @@ -117,6 +119,7 @@ type Props = { streams?: string[] | undefined; streamCategories?: string[] | undefined; parameters?: Immutable.Set; + filters?: FiltersType; children?: React.ReactNode; parameterBindings?: ParameterBindings; }; @@ -127,17 +130,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 ( diff --git a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx index e5bafa2c4f9c..d44267fde8d8 100644 --- a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx +++ b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx @@ -239,6 +239,7 @@ const WidgetActionsMenu = ({ isFocused, onPositionsChange, position, title, togg streamCategories={streamCategories} parameterBindings={parameterBindings} parameters={parameters} + filters={widget.filters} /> diff --git a/graylog2-web-interface/src/views/logic/queries/QueryGenerator.ts b/graylog2-web-interface/src/views/logic/queries/QueryGenerator.ts index 345e6cf9bcaa..4e72a341240d 100644 --- a/graylog2-web-interface/src/views/logic/queries/QueryGenerator.ts +++ b/graylog2-web-interface/src/views/logic/queries/QueryGenerator.ts @@ -21,7 +21,7 @@ import { DEFAULT_TIMERANGE } from 'views/Constants'; 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 ( diff --git a/graylog2-web-interface/src/views/logic/views/UseCreateSavedSearch.ts b/graylog2-web-interface/src/views/logic/views/UseCreateSavedSearch.ts index 8d80db05a6af..ac617ce993aa 100644 --- a/graylog2-web-interface/src/views/logic/views/UseCreateSavedSearch.ts +++ b/graylog2-web-interface/src/views/logic/views/UseCreateSavedSearch.ts @@ -21,6 +21,7 @@ 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[]; @@ -28,15 +29,25 @@ type Props = { timeRange?: TimeRange; queryString?: QueryString; parameters?: Array; + searchFilters?: Array; }; type Deps = Array | []; 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, ); diff --git a/graylog2-web-interface/src/views/logic/views/ViewGenerator.ts b/graylog2-web-interface/src/views/logic/views/ViewGenerator.ts index a1b0c7b738a6..634fbaac3b92 100644 --- a/graylog2-web-interface/src/views/logic/views/ViewGenerator.ts +++ b/graylog2-web-interface/src/views/logic/views/ViewGenerator.ts @@ -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'; @@ -33,6 +34,7 @@ export default async ({ timeRange, queryString, parameters, + searchFilters, }: { type: ViewType; streamId?: string | string[]; @@ -40,8 +42,9 @@ export default async ({ timeRange?: TimeRange; queryString?: QueryString; parameters?: Array; + searchFilters?: Array; }) => { - 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); diff --git a/graylog2-web-interface/src/views/pages/NewSearchPage.tsx b/graylog2-web-interface/src/views/pages/NewSearchPage.tsx index 8d3f8380821e..30d27d37b48e 100644 --- a/graylog2-web-interface/src/views/pages/NewSearchPage.tsx +++ b/graylog2-web-interface/src/views/pages/NewSearchPage.tsx @@ -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(() => { @@ -40,24 +41,27 @@ 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( - Object.entries(searchData.parameterBindings ?? {}).map( - ([paramName, paramBinding]) => [paramName, ParameterBinding.fromJSON(paramBinding)], - ), - ), + parameters: searchData.parameters?.map((param) => Parameter.fromJSON(param)), + parameterBindings: searchData.parameterBindings + ? Immutable.Map( + Object.entries(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, @@ -65,6 +69,7 @@ const NewSearchPage = () => { timeRange, queryString, parameters, + searchFilters, }); const view = useCreateSearch(viewPromise);