Skip to content

Commit 96ffcd7

Browse files
authored
[UII] Save agent list table state using session storage (elastic#228875)
## Summary Resolves elastic/ingest-dev#5213. This PR makes Fleet's agent list table state be saved using session storage. The state of the table includes: - Pagination (rows per page and page #) - Sort field and direction - Text search bar - All other filters (status, tags, agent policy, has upgrade) Using session storage means that this state will be saved _within_ a window/tab session. Saving the state this way suffices to manage agents without losing context and clears up the state for new tabs/sessions. When some state is declared using URL query params (i.e. `&kuery=` and `&showInactive=`), the session storage state is overridden with defaults *plus* state from query params. This is so that referral pages that apply filters (like from Agent policy > click into count of Unprivileged agents) do not run into unexpected filtering from the user's session storage. Because the filter state is now persistent, I changed `Clear filters` button to `Reset filters` and updated the behavior accordingly: clicking it resets back to initial filter state rather than clearing every filter (i.e. clicking it resets back to 5 active agent statuses, rather than clearing all statuses which causes inactive agents to be shown). ## Testing I recommend using `node scripts/create_agents` (from Fleet directory) to create large number of agents and playing around with filtering the list UI and navigating between pages. ## Release note Fleet agent list table now persists state of filters while navigating within a session. ## To-do - [x] Make this behave nicely with kuery & other URL query params ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels.
1 parent 2281fc3 commit 96ffcd7

File tree

9 files changed

+778
-132
lines changed

9 files changed

+778
-132
lines changed

x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export const AgentListTable: React.FC<Props> = (props: Props) => {
139139
<EuiLink onClick={() => clearFilters()}>
140140
<FormattedMessage
141141
id="xpack.fleet.agentList.clearFiltersLinkText"
142-
defaultMessage="Clear filters"
142+
defaultMessage="Reset filters"
143143
/>
144144
</EuiLink>
145145
),

x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ export const SearchAndFilterBar: React.FunctionComponent<SearchAndFilterBarProps
205205
agentPolicies={agentPolicies}
206206
/>
207207
<EuiFilterButton
208+
isToggle
209+
isSelected={showUpgradeable}
208210
hasActiveFilters={showUpgradeable}
209211
onClick={() => {
210212
onShowUpgradeableChange(!showUpgradeable);

x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ export const AgentTableHeader: React.FunctionComponent<{
4444
}) => {
4545
return (
4646
<>
47-
<EuiFlexGroup justifyContent="spaceBetween">
48-
<EuiFlexGroup justifyContent="flexStart" alignItems="center">
47+
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="m">
48+
<EuiFlexGroup justifyContent="flexStart" alignItems="center" gutterSize="s">
4949
<EuiFlexItem grow={false}>
5050
<AgentsSelectionStatus
5151
totalAgents={totalAgents}
@@ -63,7 +63,7 @@ export const AgentTableHeader: React.FunctionComponent<{
6363
<EuiLink onClick={() => clearFilters()}>
6464
<FormattedMessage
6565
id="xpack.fleet.agentList.header.clearFiltersLinkText"
66-
defaultMessage="Clear filters"
66+
defaultMessage="Reset filters"
6767
/>
6868
</EuiLink>
6969
</EuiFlexItem>

x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.test.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,40 @@ import { useFetchAgentsData } from './use_fetch_agents_data';
1717
jest.mock('../../../../../../services/experimental_features');
1818
const mockedExperimentalFeaturesService = jest.mocked(ExperimentalFeaturesService);
1919

20+
const defaultState = {
21+
search: '',
22+
selectedAgentPolicies: [],
23+
selectedStatus: ['healthy', 'unhealthy', 'orphaned', 'updating', 'offline'],
24+
selectedTags: [],
25+
showUpgradeable: false,
26+
sort: { field: 'enrolled_at', direction: 'desc' },
27+
page: { index: 0, size: 20 },
28+
};
29+
30+
jest.mock('./use_session_agent_list_state', () => {
31+
let currentMockState = { ...defaultState };
32+
33+
const mockUseSessionAgentListState = jest.fn(() => {
34+
const mockUpdateTableState = jest.fn((updates: any) => {
35+
currentMockState = { ...currentMockState, ...updates };
36+
});
37+
38+
return {
39+
...currentMockState,
40+
updateTableState: mockUpdateTableState,
41+
onTableChange: jest.fn(),
42+
clearFilters: jest.fn(),
43+
resetToDefaults: jest.fn(),
44+
};
45+
});
46+
47+
return {
48+
useSessionAgentListState: mockUseSessionAgentListState,
49+
getDefaultAgentListState: jest.fn(() => defaultState),
50+
defaultAgentListState: defaultState,
51+
};
52+
});
53+
2054
jest.mock('../../../../hooks', () => ({
2155
...jest.requireActual('../../../../hooks'),
2256
sendGetAgentsForRq: jest.fn().mockResolvedValue({
@@ -87,14 +121,6 @@ jest.mock('../../../../hooks', () => ({
87121
cloud: {},
88122
data: { dataViews: { getFieldsForWildcard: jest.fn() } },
89123
}),
90-
usePagination: jest.fn().mockReturnValue({
91-
pagination: {
92-
currentPage: 1,
93-
pageSize: 5,
94-
},
95-
pageSizeOptions: [5, 20, 50],
96-
setPagination: jest.fn(),
97-
}),
98124
}));
99125

100126
describe('useFetchAgentsData', () => {
@@ -149,7 +175,7 @@ describe('useFetchAgentsData', () => {
149175
'status:online or (status:error or status:degraded) or status:orphaned or (status:updating or status:unenrolling or status:enrolling) or status:offline'
150176
);
151177

152-
expect(result?.current.pagination).toEqual({ currentPage: 1, pageSize: 5 });
178+
expect(result?.current.page).toEqual({ index: 0, size: 20 });
153179
expect(result?.current.pageSizeOptions).toEqual([5, 20, 50]);
154180
});
155181

x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_fetch_agents_data.tsx

Lines changed: 107 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ import { useQuery } from '@kbn/react-query';
1212

1313
import { agentStatusesToSummary } from '../../../../../../../common/services';
1414

15-
import type { Agent, AgentPolicy } from '../../../../types';
15+
import type { AgentPolicy } from '../../../../types';
1616
import {
17-
usePagination,
1817
useGetAgentPolicies,
1918
sendGetAgentStatus,
2019
useUrlParams,
@@ -32,6 +31,8 @@ import { LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../..
3231

3332
import { getKuery } from '../utils/get_kuery';
3433

34+
import { useSessionAgentListState, defaultAgentListState } from './use_session_agent_list_state';
35+
3536
const REFRESH_INTERVAL_MS = 30000;
3637
const MAX_AGENT_ACTIONS = 100;
3738
/** Allow to fetch full agent policy using a cache */
@@ -102,29 +103,91 @@ export function useFetchAgentsData() {
102103
const { showAgentless } = useAgentlessResources();
103104
const defaultKuery: string = (urlParams.kuery as string) || '';
104105
const urlHasInactive = (urlParams.showInactive as string) === 'true';
106+
const isUsingParams = defaultKuery || urlHasInactive;
107+
108+
// Extract state from session storage hook
109+
const sessionState = useSessionAgentListState();
110+
const {
111+
search,
112+
selectedAgentPolicies,
113+
selectedStatus,
114+
selectedTags,
115+
showUpgradeable,
116+
sort,
117+
page,
118+
updateTableState,
119+
} = sessionState;
120+
121+
// If URL params are used, reset the table state to defaults with the param options
122+
useEffect(() => {
123+
if (isUsingParams) {
124+
updateTableState({
125+
...defaultAgentListState,
126+
search: defaultKuery,
127+
selectedStatus: [...new Set([...selectedStatus, ...(urlHasInactive ? ['inactive'] : [])])],
128+
});
129+
}
130+
// Empty array so that this only runs once on mount
131+
// eslint-disable-next-line react-hooks/exhaustive-deps
132+
}, []);
133+
134+
// Sync URL kuery param with session storage search to maintain shareable state
135+
useEffect(() => {
136+
const currentUrlKuery = (urlParams.kuery as string) || '';
137+
// If search is empty and URL has kuery, or search differs from URL, update URL
138+
if ((search === '' && currentUrlKuery !== '') || (search && search !== currentUrlKuery)) {
139+
const { kuery: _, ...restParams } = urlParams;
140+
const newParams = search === '' ? restParams : { ...restParams, kuery: search };
141+
history.replace({
142+
search: toUrlParams(newParams),
143+
});
144+
}
145+
}, [search, urlParams, history, toUrlParams]);
146+
147+
// Flag to indicate if filters differ from default state
148+
const isUsingFilter = useMemo(() => {
149+
return (
150+
search !== defaultAgentListState.search ||
151+
!isEqual(selectedAgentPolicies, defaultAgentListState.selectedAgentPolicies) ||
152+
!isEqual(selectedStatus, defaultAgentListState.selectedStatus) ||
153+
!isEqual(selectedTags, defaultAgentListState.selectedTags) ||
154+
showUpgradeable !== defaultAgentListState.showUpgradeable
155+
);
156+
}, [search, selectedAgentPolicies, selectedStatus, selectedTags, showUpgradeable]);
157+
158+
// Create individual setters using updateTableState
159+
const setSearchState = useCallback(
160+
(value: string) => updateTableState({ search: value }),
161+
[updateTableState]
162+
);
163+
164+
const setSelectedAgentPolicies = useCallback(
165+
(value: string[]) => updateTableState({ selectedAgentPolicies: value }),
166+
[updateTableState]
167+
);
168+
169+
const setSelectedStatus = useCallback(
170+
(value: string[]) => updateTableState({ selectedStatus: value }),
171+
[updateTableState]
172+
);
105173

106-
// Table and search states
107-
const [showUpgradeable, setShowUpgradeable] = useState<boolean>(false);
108-
const [draftKuery, setDraftKuery] = useState<string>(defaultKuery);
109-
const [search, setSearchState] = useState<string>(defaultKuery);
110-
const { pagination, pageSizeOptions, setPagination } = usePagination();
111-
const [sortField, setSortField] = useState<keyof Agent>('enrolled_at');
112-
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
113-
114-
// Policies state for filtering
115-
const [selectedAgentPolicies, setSelectedAgentPolicies] = useState<string[]>([]);
116-
117-
// Status for filtering
118-
const [selectedStatus, setSelectedStatus] = useState<string[]>([
119-
'healthy',
120-
'unhealthy',
121-
'orphaned',
122-
'updating',
123-
'offline',
124-
...(urlHasInactive ? ['inactive'] : []),
125-
]);
126-
127-
const [selectedTags, setSelectedTags] = useState<string[]>([]);
174+
const setSelectedTags = useCallback(
175+
(value: string[]) => updateTableState({ selectedTags: value }),
176+
[updateTableState]
177+
);
178+
179+
const setShowUpgradeable = useCallback(
180+
(value: boolean) => updateTableState({ showUpgradeable: value }),
181+
[updateTableState]
182+
);
183+
184+
const pageSizeOptions = [5, 20, 50];
185+
186+
// Sync draftKuery with session storage search
187+
const [draftKuery, setDraftKuery] = useState<string>(search);
188+
useEffect(() => {
189+
setDraftKuery(search);
190+
}, [search]);
128191

129192
const showInactive = useMemo(() => {
130193
return selectedStatus.some((status) => status === 'inactive') || selectedStatus.length === 0;
@@ -142,13 +205,14 @@ export function useFetchAgentsData() {
142205
}
143206

144207
if (urlParams.kuery !== newVal) {
208+
const { kuery: _, ...restParams } = urlParams;
209+
const newParams = newVal === '' ? restParams : { ...restParams, kuery: newVal };
145210
history.replace({
146-
// @ts-expect-error - kuery can't be undefined
147-
search: toUrlParams({ ...urlParams, kuery: newVal === '' ? undefined : newVal }),
211+
search: toUrlParams(newParams),
148212
});
149213
}
150214
},
151-
[urlParams, history, toUrlParams]
215+
[setSearchState, urlParams, history, toUrlParams]
152216
);
153217

154218
// filters kuery
@@ -192,7 +256,12 @@ export function useFetchAgentsData() {
192256
}
193257
}, [latestAgentActionErrors, actionErrors]);
194258

195-
const queryKeyPagination = JSON.stringify({ pagination, sortField, sortOrder });
259+
// Use session storage state for pagination and sort
260+
const queryKeyPagination = JSON.stringify({
261+
pagination: { currentPage: page.index + 1, pageSize: page.size },
262+
sortField: sort.field,
263+
sortOrder: sort.direction,
264+
});
196265
const queryKeyFilters = JSON.stringify({
197266
kuery,
198267
showAgentless,
@@ -210,7 +279,7 @@ export function useFetchAgentsData() {
210279
refetch,
211280
} = useQuery({
212281
queryKey: ['get-agents-list', queryKeyFilters, queryKeyPagination],
213-
keepPreviousData: true, // Keep previous data to avoid flashing when going through pages coulse
282+
keepPreviousData: true, // Keep previous data to avoid flashing when going through pages
214283
queryFn: async () => {
215284
try {
216285
const [
@@ -220,11 +289,11 @@ export function useFetchAgentsData() {
220289
agentTagsResponse,
221290
] = await Promise.all([
222291
sendGetAgentsForRq({
223-
page: pagination.currentPage,
224-
perPage: pagination.pageSize,
292+
page: page.index + 1,
293+
perPage: page.size,
225294
kuery: kuery && kuery !== '' ? kuery : undefined,
226-
sortField: getSortFieldForAPI(sortField),
227-
sortOrder,
295+
sortField: getSortFieldForAPI(sort.field),
296+
sortOrder: sort.direction,
228297
showAgentless,
229298
showInactive,
230299
showUpgradeable,
@@ -377,26 +446,25 @@ export function useFetchAgentsData() {
377446
setSearch,
378447
selectedAgentPolicies,
379448
setSelectedAgentPolicies,
380-
sortField,
381-
setSortField,
382-
sortOrder,
383-
setSortOrder,
449+
sort,
384450
selectedStatus,
385451
setSelectedStatus,
386452
selectedTags,
387453
setSelectedTags,
388454
allAgentPolicies,
389455
agentPoliciesRequest,
390456
agentPoliciesIndexedById,
391-
pagination,
457+
page,
392458
pageSizeOptions,
393-
setPagination,
394459
kuery,
395460
draftKuery,
396461
setDraftKuery,
397462
fetchData,
398463
queryHasChanged,
399464
latestAgentActionErrors,
400465
setLatestAgentActionErrors,
466+
isUsingFilter,
467+
clearFilters: sessionState.clearFilters,
468+
onTableChange: sessionState.onTableChange,
401469
};
402470
}

0 commit comments

Comments
 (0)