Skip to content

Commit a4d1554

Browse files
Carry search filters into widget replay search (#26151)
* Carry search filters into widget replay search Replaying a dashboard widget previously dropped any search filters configured on it — only the query string, time range, streams, and parameters travelled along. ReplaySearchButton now also accepts the widget's `filters` (a `FiltersType` list of `SearchFilter`), serialises them into the local-storage payload that the new search page pulls out via the `session-id` query param, and ensures the session-id is added to the URL whenever filters or parameters are present. NewSearchPage's session reader is widened to extract `filters` from the stored payload and forwards them via `useCreateSavedSearch` → `ViewGenerator` → `QueryGenerator` (which already accepts a 6th `searchFilters` positional arg, just lacking a caller). Also realigns `QueryGenerator`'s `SearchFilter` import to `views/types` to match what `Query.filters` actually stores — the previous `components/event-definitions/event-definitions-types` flavour required fields (`id`, `title`, `disabled`, `negation`) that widget filters don't necessarily carry. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add changelog snippet for #26151 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6e9303d commit a4d1554

8 files changed

Lines changed: 126 additions & 22 deletions

File tree

changelog/unreleased/pr-26151.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
type = "f"
2+
message = "Carry search filters into widget replay search so the replayed search matches what the widget displayed."
3+
issues = ["Graylog2/graylog-plugin-enterprise#8869"]
4+
pulls = ["26151"]

graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.test.tsx

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,35 @@
1616
*/
1717
import * as React from 'react';
1818
import { asElement, render, screen } from 'wrappedTestingLibrary';
19+
import * as Immutable from 'immutable';
20+
import userEvent from '@testing-library/user-event';
21+
import URI from 'urijs';
1922

2023
import type { TimeRange } from 'views/logic/queries/Query';
2124
import { createElasticsearchQueryString } from 'views/logic/queries/Query';
2225
import type { QueryString } from 'views/logic/queries/types';
26+
import type { FiltersType, SearchFilter } from 'views/types';
27+
import Store from 'logic/local-storage/Store';
2328

2429
import ReplaySearchButton from './ReplaySearchButton';
2530

2631
type OptionalOverrides = {
2732
streams?: Array<string>;
2833
query?: QueryString;
2934
timerange?: TimeRange;
35+
filters?: FiltersType;
3036
};
3137

38+
const filter = (queryString: string): SearchFilter => ({
39+
type: 'inlineQueryString',
40+
queryString,
41+
disabled: false,
42+
negation: false,
43+
});
44+
45+
const findReplayButton = async () =>
46+
asElement(await screen.findByRole('link', { name: /replay search/i }), HTMLAnchorElement);
47+
3248
describe('ReplaySearchButton', () => {
3349
it('renders play button', async () => {
3450
render(<ReplaySearchButton />);
@@ -37,10 +53,17 @@ describe('ReplaySearchButton', () => {
3753
});
3854

3955
describe('generates link', () => {
40-
const renderWithContext = async ({ query, timerange, streams }: OptionalOverrides = {}) => {
41-
render(<ReplaySearchButton queryString={query?.query_string} timerange={timerange} streams={streams} />);
56+
const renderWithContext = async ({ query, timerange, streams, filters }: OptionalOverrides = {}) => {
57+
render(
58+
<ReplaySearchButton
59+
queryString={query?.query_string}
60+
timerange={timerange}
61+
streams={streams}
62+
filters={filters}
63+
/>,
64+
);
4265

43-
return asElement(await screen.findByRole('link', { name: /replay search/i }), HTMLAnchorElement);
66+
return findReplayButton();
4467
};
4568

4669
it('including query string', async () => {
@@ -68,5 +91,43 @@ describe('ReplaySearchButton', () => {
6891

6992
expect(button.href).toContain('streams=stream1%2Cstream2%2Csomeotherstream');
7093
});
94+
95+
it('adds a session-id when search filters are configured', async () => {
96+
const button = await renderWithContext({ filters: Immutable.List([filter('foo:bar')]) });
97+
98+
expect(button.href).toMatch(/session-id=replay-search-/);
99+
});
100+
101+
it('omits the session-id when neither parameters nor filters are configured', async () => {
102+
const button = await renderWithContext();
103+
104+
expect(button.href).not.toContain('session-id=');
105+
});
106+
107+
it('omits the session-id when filters are empty', async () => {
108+
const button = await renderWithContext({ filters: Immutable.List() });
109+
110+
expect(button.href).not.toContain('session-id=');
111+
});
112+
});
113+
114+
describe('on click', () => {
115+
it('persists search filters to local storage so the replayed search can consume them', async () => {
116+
const filters = Immutable.List([filter('source:my-host'), filter('level:ERROR')]);
117+
render(<ReplaySearchButton filters={filters} />);
118+
const button = await findReplayButton();
119+
const sessionId = new URI(button.href).search(true)['session-id'] as string | undefined;
120+
121+
expect(sessionId).toMatch(/^replay-search-/);
122+
123+
await userEvent.click(button);
124+
125+
const raw = Store.get<string>(sessionId);
126+
127+
expect(raw).toBeDefined();
128+
expect(JSON.parse(raw!)).toEqual({
129+
filters: [filter('source:my-host'), filter('level:ERROR')],
130+
});
131+
});
71132
});
72133
});

graylog2-web-interface/src/views/components/widgets/ReplaySearchButton.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { createElasticsearchQueryString } from 'views/logic/queries/Query';
3030
import generateId from 'logic/generateId';
3131
import type Parameter from 'views/logic/parameters/Parameter';
3232
import type { ParameterBindings } from 'views/logic/search/SearchExecutionState';
33+
import type { FiltersType } from 'views/types';
3334

3435
const NeutralLink = styled.a`
3536
display: inline-flex;
@@ -57,6 +58,7 @@ const buildSearchLink = (
5758
streams: Array<string>,
5859
streamCategories: Array<string>,
5960
parameters?: Immutable.Set<Parameter>,
61+
filters?: FiltersType,
6062
) => {
6163
let searchLink = SearchLink.builder()
6264
.query(createElasticsearchQueryString(queryString))
@@ -66,7 +68,7 @@ const buildSearchLink = (
6668
.build()
6769
.toURL();
6870

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

@@ -117,6 +119,7 @@ type Props = {
117119
streams?: string[] | undefined;
118120
streamCategories?: string[] | undefined;
119121
parameters?: Immutable.Set<Parameter>;
122+
filters?: FiltersType;
120123
children?: React.ReactNode;
121124
parameterBindings?: ParameterBindings;
122125
};
@@ -127,17 +130,33 @@ const ReplaySearchButton = ({
127130
streams = undefined,
128131
streamCategories = undefined,
129132
parameters = undefined,
133+
filters = undefined,
130134
children = undefined,
131135
parameterBindings = undefined,
132136
}: Props) => {
133137
const sessionId = useMemo(() => `replay-search-${generateId()}`, []);
134-
const searchLink = buildSearchLink(sessionId, timerange, queryString, streams, streamCategories, parameters);
138+
const searchLink = buildSearchLink(
139+
sessionId,
140+
timerange,
141+
queryString,
142+
streams,
143+
streamCategories,
144+
parameters,
145+
filters,
146+
);
135147

136148
const onReplaySearch = useCallback(() => {
137-
if (parameters?.size) {
138-
Store.set(sessionId, JSON.stringify({ parameters, parameterBindings }));
149+
if (parameters?.size || filters?.size) {
150+
Store.set(
151+
sessionId,
152+
JSON.stringify({
153+
parameters,
154+
parameterBindings,
155+
filters: filters?.toArray() ?? [],
156+
}),
157+
);
139158
}
140-
}, [sessionId, parameters, parameterBindings]);
159+
}, [sessionId, parameters, parameterBindings, filters]);
141160

142161
return (
143162
<ReplaySearchButtonComponent searchLink={searchLink} onClick={onReplaySearch}>

graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ const WidgetActionsMenu = ({ isFocused, onPositionsChange, position, title, togg
239239
streamCategories={streamCategories}
240240
parameterBindings={parameterBindings}
241241
parameters={parameters}
242+
filters={widget.filters}
242243
/>
243244
</IfDashboard>
244245
<ExtraMenuWidgetActions widget={widget} />

graylog2-web-interface/src/views/logic/queries/QueryGenerator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { DEFAULT_TIMERANGE } from 'views/Constants';
2121
import type { TimeRange, QueryId, FilterType } from 'views/logic/queries/Query';
2222
import Query, { createElasticsearchQueryString, newFiltersForQuery } from 'views/logic/queries/Query';
2323
import generateId from 'logic/generateId';
24-
import type { SearchFilter } from 'components/event-definitions/event-definitions-types';
24+
import type { SearchFilter } from 'views/types';
2525
import type { QueryString } from 'views/logic/queries/types';
2626

2727
export default (

graylog2-web-interface/src/views/logic/views/UseCreateSavedSearch.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,33 @@ import type { TimeRange } from 'views/logic/queries/Query';
2121
import ViewGenerator from 'views/logic/views/ViewGenerator';
2222
import type Parameter from 'views/logic/parameters/Parameter';
2323
import type { QueryString } from 'views/logic/queries/types';
24+
import type { SearchFilter } from 'views/types';
2425

2526
type Props = {
2627
streamId?: string | string[];
2728
streamCategory?: string | string[];
2829
timeRange?: TimeRange;
2930
queryString?: QueryString;
3031
parameters?: Array<Parameter>;
32+
searchFilters?: Array<SearchFilter>;
3133
};
3234

3335
type Deps = Array<Props[keyof Props]> | [];
3436
const useCreateSavedSearch = (
35-
{ streamId, streamCategory, timeRange, queryString, parameters }: Props,
37+
{ streamId, streamCategory, timeRange, queryString, parameters, searchFilters }: Props,
3638
deps: Deps = [],
3739
) =>
3840
useMemo(
39-
() => ViewGenerator({ type: View.Type.Search, streamId, streamCategory, timeRange, queryString, parameters }),
41+
() =>
42+
ViewGenerator({
43+
type: View.Type.Search,
44+
streamId,
45+
streamCategory,
46+
timeRange,
47+
queryString,
48+
parameters,
49+
searchFilters,
50+
}),
4051
// eslint-disable-next-line react-hooks/exhaustive-deps
4152
deps,
4253
);

graylog2-web-interface/src/views/logic/views/ViewGenerator.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { TimeRange } from 'views/logic/queries/Query';
1818
import UpdateSearchForWidgets from 'views/logic/views/UpdateSearchForWidgets';
1919
import type Parameter from 'views/logic/parameters/Parameter';
2020
import type { QueryString } from 'views/logic/queries/types';
21+
import type { SearchFilter } from 'views/types';
2122

2223
import View from './View';
2324
import ViewStateGenerator from './ViewStateGenerator';
@@ -33,15 +34,17 @@ export default async ({
3334
timeRange,
3435
queryString,
3536
parameters,
37+
searchFilters,
3638
}: {
3739
type: ViewType;
3840
streamId?: string | string[];
3941
streamCategory?: string | string[];
4042
timeRange?: TimeRange;
4143
queryString?: QueryString;
4244
parameters?: Array<Parameter>;
45+
searchFilters?: Array<SearchFilter>;
4346
}) => {
44-
const query = QueryGenerator(streamId, streamCategory, undefined, timeRange, queryString);
47+
const query = QueryGenerator(streamId, streamCategory, undefined, timeRange, queryString, searchFilters);
4548
const search = Search.create().toBuilder().queries([query]).parameters(parameters).build();
4649
const viewState = await ViewStateGenerator(type, streamId);
4750

graylog2-web-interface/src/views/pages/NewSearchPage.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ import Parameter from 'views/logic/parameters/Parameter';
2727
import type { ParameterBindingJsonRepresentation } from 'views/logic/parameters/ParameterBinding';
2828
import ParameterBinding from 'views/logic/parameters/ParameterBinding';
2929
import SearchExecutionState from 'views/logic/search/SearchExecutionState';
30+
import type { SearchFilter } from 'views/types';
3031

3132
import SearchPage from './SearchPage';
3233

33-
const useParametersFromStore = () => {
34+
const useReplayStateFromStore = () => {
3435
const { 'session-id': sessionId } = useQuery();
3536

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

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

43-
if (searchData?.parameters) {
44+
if (searchData) {
4445
return {
45-
parameters: searchData.parameters.map((param) => Parameter.fromJSON(param)),
46-
parameterBindings: Immutable.Map<string, ParameterBinding>(
47-
Object.entries<ParameterBindingJsonRepresentation>(searchData.parameterBindings ?? {}).map(
48-
([paramName, paramBinding]) => [paramName, ParameterBinding.fromJSON(paramBinding)],
49-
),
50-
),
46+
parameters: searchData.parameters?.map((param) => Parameter.fromJSON(param)),
47+
parameterBindings: searchData.parameterBindings
48+
? Immutable.Map<string, ParameterBinding>(
49+
Object.entries<ParameterBindingJsonRepresentation>(searchData.parameterBindings).map(
50+
([paramName, paramBinding]) => [paramName, ParameterBinding.fromJSON(paramBinding)],
51+
),
52+
)
53+
: undefined,
54+
searchFilters: searchData.filters as SearchFilter[] | undefined,
5155
};
5256
}
5357
}
5458

55-
return { parameters: undefined, parameterBindings: undefined };
59+
return { parameters: undefined, parameterBindings: undefined, searchFilters: undefined };
5660
}, [sessionId]);
5761
};
5862

5963
const NewSearchPage = () => {
60-
const { parameters, parameterBindings } = useParametersFromStore();
64+
const { parameters, parameterBindings, searchFilters } = useReplayStateFromStore();
6165
const { timeRange, queryString, streams, streamCategories } = useSearchURLQueryParams();
6266
const viewPromise = useCreateSavedSearch({
6367
streamId: streams,
6468
streamCategory: streamCategories,
6569
timeRange,
6670
queryString,
6771
parameters,
72+
searchFilters,
6873
});
6974
const view = useCreateSearch(viewPromise);
7075

0 commit comments

Comments
 (0)