Skip to content

Commit 9810e09

Browse files
authored
[Osquery] Persist history page filters across tab and detail-page navigation (elastic#260891)
1 parent f912fdc commit 9810e09

10 files changed

Lines changed: 214 additions & 7 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { saveHistoryFilters, getHistoryFilters } from './history_filter_storage';
9+
10+
describe('history_filter_storage', () => {
11+
beforeEach(() => {
12+
sessionStorage.clear();
13+
});
14+
15+
describe('saveHistoryFilters / getHistoryFilters', () => {
16+
it('should round-trip a query string', () => {
17+
saveHistoryFilters('?q=uptime&sources=live');
18+
expect(getHistoryFilters()).toBe('?q=uptime&sources=live');
19+
});
20+
21+
it('should return empty string when nothing is stored', () => {
22+
expect(getHistoryFilters()).toBe('');
23+
});
24+
25+
it('should store empty string for default filters', () => {
26+
saveHistoryFilters('');
27+
expect(getHistoryFilters()).toBe('');
28+
});
29+
30+
it('should overwrite previous value', () => {
31+
saveHistoryFilters('?q=first');
32+
saveHistoryFilters('?q=second');
33+
expect(getHistoryFilters()).toBe('?q=second');
34+
});
35+
});
36+
37+
describe('graceful degradation', () => {
38+
it('should not throw when sessionStorage.setItem throws', () => {
39+
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
40+
throw new Error('QuotaExceededError');
41+
});
42+
43+
expect(() => saveHistoryFilters('?q=test')).not.toThrow();
44+
45+
jest.restoreAllMocks();
46+
});
47+
48+
it('should return empty string when sessionStorage.getItem throws', () => {
49+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
50+
throw new Error('SecurityError');
51+
});
52+
53+
expect(getHistoryFilters()).toBe('');
54+
55+
jest.restoreAllMocks();
56+
});
57+
});
58+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
const STORAGE_KEY = 'osquery:historyFilters';
9+
10+
export const saveHistoryFilters = (qs: string): void => {
11+
try {
12+
sessionStorage.setItem(STORAGE_KEY, qs);
13+
} catch {
14+
// sessionStorage may be unavailable (e.g. private browsing quota exceeded)
15+
}
16+
};
17+
18+
export const getHistoryFilters = (): string => {
19+
try {
20+
return sessionStorage.getItem(STORAGE_KEY) ?? '';
21+
} catch {
22+
return '';
23+
}
24+
};

x-pack/platform/plugins/shared/osquery/public/actions/use_history_url_params.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,17 @@
55
* 2.0.
66
*/
77

8-
import { parseHistoryUrlParams, serializeHistoryUrlParams } from './use_history_url_params';
8+
import { renderHook, act } from '@testing-library/react';
9+
import { createMemoryHistory } from 'history';
10+
import React from 'react';
11+
import { Router } from '@kbn/shared-ux-router';
12+
import {
13+
parseHistoryUrlParams,
14+
serializeHistoryUrlParams,
15+
useHistoryUrlParams,
16+
} from './use_history_url_params';
917
import type { HistoryUrlFilters } from './use_history_url_params';
18+
import { getHistoryFilters } from './history_filter_storage';
1019

1120
describe('parseHistoryUrlParams', () => {
1221
it('returns defaults for empty search string', () => {
@@ -228,3 +237,57 @@ describe('serializeHistoryUrlParams', () => {
228237
});
229238
});
230239
});
240+
241+
describe('useHistoryUrlParams', () => {
242+
beforeEach(() => {
243+
sessionStorage.clear();
244+
});
245+
246+
const renderWithRouter = (initialPath: string) => {
247+
const history = createMemoryHistory({ initialEntries: [initialPath] });
248+
const wrapper = ({ children }: { children: React.ReactNode }) =>
249+
React.createElement(Router, { history }, children);
250+
251+
return { ...renderHook(() => useHistoryUrlParams(), { wrapper }), history };
252+
};
253+
254+
it('persists filters to sessionStorage when setFilter is called', () => {
255+
const { result } = renderWithRouter('/history');
256+
257+
act(() => {
258+
result.current.setFilter('q', 'uptime');
259+
});
260+
261+
expect(getHistoryFilters()).toContain('q=uptime');
262+
});
263+
264+
it('persists filters to sessionStorage when setFilters is called', () => {
265+
const { result } = renderWithRouter('/history');
266+
267+
act(() => {
268+
result.current.setFilters({ q: 'dns', sources: ['live'] });
269+
});
270+
271+
const stored = getHistoryFilters();
272+
expect(stored).toContain('q=dns');
273+
expect(stored).toContain('sources=live');
274+
});
275+
276+
it('URL wins over stale sessionStorage on mount', () => {
277+
sessionStorage.setItem('osquery:historyFilters', '?q=stale');
278+
const { result } = renderWithRouter('/history?q=fromUrl');
279+
280+
expect(result.current.filters.q).toBe('fromUrl');
281+
expect(getHistoryFilters()).toContain('q=fromUrl');
282+
});
283+
284+
it('stores empty string when all filters are at defaults', () => {
285+
const { result } = renderWithRouter('/history?q=test');
286+
287+
act(() => {
288+
result.current.setFilter('q', '');
289+
});
290+
291+
expect(getHistoryFilters()).toBe('');
292+
});
293+
});

x-pack/platform/plugins/shared/osquery/public/actions/use_history_url_params.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
* 2.0.
66
*/
77

8-
import { useMemo, useCallback } from 'react';
8+
import { useMemo, useCallback, useEffect } from 'react';
99
import { useHistory, useLocation } from 'react-router-dom';
1010
import { parse, stringify } from 'query-string';
1111
import type { SourceFilter } from '../../common/api/unified_history/types';
12+
import { saveHistoryFilters } from './history_filter_storage';
1213

1314
export const DEFAULT_START_DATE = 'now-24h';
1415
export const DEFAULT_END_DATE = 'now';
@@ -111,12 +112,22 @@ export const useHistoryUrlParams = () => {
111112

112113
const filters = useMemo(() => parseHistoryUrlParams(search), [search]);
113114

115+
// Sync sessionStorage with the current URL on mount and whenever the
116+
// search string changes (e.g. user manually edits the URL bar).
117+
useEffect(() => {
118+
saveHistoryFilters(search);
119+
}, [search]);
120+
114121
const replaceUrl = useCallback(
115122
(nextFilters: HistoryUrlFilters) => {
116123
const serialized = serializeHistoryUrlParams(nextFilters);
117124
const qs = stringify(serialized, { sort: false, skipNull: true });
125+
const nextSearch = qs ? `?${qs}` : '';
126+
// Eager save — the useEffect above will also fire after history.replace,
127+
// but writing here avoids a brief window where sessionStorage is stale.
128+
saveHistoryFilters(nextSearch);
118129
const currentPathname = history.location.pathname;
119-
history.replace({ pathname: currentPathname, search: qs ? `?${qs}` : '' });
130+
history.replace({ pathname: currentPathname, search: nextSearch });
120131
},
121132
[history]
122133
);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { pagePathGetters } from './page_paths';
9+
10+
describe('pagePathGetters', () => {
11+
beforeEach(() => {
12+
sessionStorage.clear();
13+
});
14+
15+
describe('history', () => {
16+
it('returns bare /history when no persisted filters exist', () => {
17+
expect(pagePathGetters.history()).toBe('/history');
18+
});
19+
20+
it('appends persisted filters from sessionStorage', () => {
21+
sessionStorage.setItem('osquery:historyFilters', '?q=uptime&sources=live');
22+
expect(pagePathGetters.history()).toBe('/history?q=uptime&sources=live');
23+
});
24+
25+
it('returns bare /history when persisted filters are empty string', () => {
26+
sessionStorage.setItem('osquery:historyFilters', '');
27+
expect(pagePathGetters.history()).toBe('/history');
28+
});
29+
});
30+
});

x-pack/platform/plugins/shared/osquery/public/common/page_paths.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* 2.0.
66
*/
77

8+
import { getHistoryFilters } from '../actions/history_filter_storage';
9+
810
export type StaticPage =
911
| 'base'
1012
| 'overview'
@@ -61,7 +63,8 @@ export const pagePathGetters: {
6163
live_queries: () => '/live_queries',
6264
live_query_new: () => '/live_queries/new',
6365
live_query_details: ({ liveQueryId }) => `/live_queries/${liveQueryId}`,
64-
history: () => '/history',
66+
// Note: unlike other getters, history() reads sessionStorage to restore persisted filters.
67+
history: () => `/history${getHistoryFilters()}`,
6568
history_details: ({ liveQueryId }) => `/history/${liveQueryId}`,
6669
history_scheduled_details: ({ scheduleId, executionCount }) =>
6770
`/history/scheduled/${scheduleId}/${executionCount}`,

x-pack/platform/plugins/shared/osquery/public/common/use_go_back.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('useGoBack', () => {
2626
jest.clearAllMocks();
2727
mockLocationState = null;
2828
mockHistoryLength = 2;
29+
sessionStorage.clear();
2930
});
3031

3132
it('pushes to fallback path when location state has no fromHistory flag', () => {
@@ -81,4 +82,15 @@ describe('useGoBack', () => {
8182
expect(mockPush).toHaveBeenCalledWith('/history');
8283
expect(mockGoBack).not.toHaveBeenCalled();
8384
});
85+
86+
it('pushes fallback path with query string when provided by caller', () => {
87+
const { result } = renderHook(() => useGoBack('/history?q=uptime&sources=live'));
88+
const event = createMouseEvent();
89+
90+
act(() => {
91+
result.current(event);
92+
});
93+
94+
expect(mockPush).toHaveBeenCalledWith('/history?q=uptime&sources=live');
95+
});
8496
});

x-pack/platform/plugins/shared/osquery/public/components/main_navigation.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { PAGE_ROUTING_PATHS } from '../common/page_paths';
2626
import { ManageIntegrationLink } from './manage_integration_link';
2727
import { useKibana } from '../common/lib/kibana';
2828
import { useIsExperimentalFeatureEnabled } from '../common/experimental_features_context';
29+
import { getHistoryFilters } from '../actions/history_filter_storage';
2930

3031
enum Section {
3132
LiveQueries = 'live_queries',
@@ -64,7 +65,10 @@ export const MainNavigation = () => {
6465
});
6566

6667
const historySection = isHistoryEnabled ? Section.History : Section.LiveQueries;
67-
const historyNavProps = useRouterNavigate(historySection);
68+
const persistedHistoryQs = isHistoryEnabled ? getHistoryFilters() : '';
69+
const historyNavProps = useRouterNavigate(
70+
persistedHistoryQs ? `${historySection}${persistedHistoryQs}` : historySection
71+
);
6872
const packsNavProps = useRouterNavigate(Section.Packs);
6973
const savedQueriesNavProps = useRouterNavigate(Section.SavedQueries);
7074
const newQueryNavProps = useRouterNavigate('/new');

x-pack/platform/plugins/shared/osquery/public/routes/live_queries/details/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '../../../components/layouts';
2020
import { useLiveQueryDetails } from '../../../actions/use_live_query_details';
2121
import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs';
22+
import { pagePathGetters } from '../../../common/page_paths';
2223
import { PackQueriesStatusTable } from '../../../live_queries/form/pack_queries_status_table';
2324
import { useIsExperimentalFeatureEnabled } from '../../../common/experimental_features_context';
2425

@@ -32,7 +33,7 @@ const LiveQueryDetailsPageComponent = () => {
3233
useBreadcrumbs(isHistoryEnabled ? 'history_details' : 'live_query_details', {
3334
liveQueryId: actionId,
3435
});
35-
const backNavigationTarget = isHistoryEnabled ? 'history' : 'live_queries';
36+
const backNavigationTarget = isHistoryEnabled ? pagePathGetters.history() : 'live_queries';
3637
const handleGoBack = useGoBack(backNavigationTarget);
3738
const liveQueryListProps = useRouterNavigate(backNavigationTarget, handleGoBack);
3839
const [isLive, setIsLive] = useState(false);

x-pack/platform/plugins/shared/osquery/public/routes/live_queries/new/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { isArray } from 'lodash';
1515
import { WithHeaderLayout } from '../../../components/layouts';
1616
import { useRouterNavigate } from '../../../common/lib/kibana';
1717
import { useGoBack } from '../../../common/use_go_back';
18+
import { pagePathGetters } from '../../../common/page_paths';
1819
import type { LocationStateWithFromHistory } from '../../../common/use_go_back';
1920
import { LiveQuery } from '../../../live_queries';
2021
import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs';
@@ -29,7 +30,7 @@ const NewLiveQueryPageComponent = () => {
2930
useBreadcrumbs(isHistoryEnabled ? 'new_query' : 'live_query_new');
3031
const { replace } = useHistory();
3132
const location = useLocation<LocationState>();
32-
const backNavigationTarget = isHistoryEnabled ? 'history' : 'live_queries';
33+
const backNavigationTarget = isHistoryEnabled ? pagePathGetters.history() : 'live_queries';
3334
const handleGoBack = useGoBack(backNavigationTarget);
3435
const backNavigationProps = useRouterNavigate(backNavigationTarget, handleGoBack);
3536
const [initialFormData, setInitialFormData] = useState<Record<string, unknown> | undefined>({});

0 commit comments

Comments
 (0)