From 54243c8cca98050db7486cff044d8c9529c82a4e Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Fri, 24 Apr 2020 10:01:06 -0400 Subject: [PATCH] Task/hostlist pagination (#63722) * hostlist pagination for endpoint security --- .../endpoint/store/hosts/action.ts | 16 +- .../store/hosts/host_pagination.test.ts | 145 ++++++++++++++++++ .../endpoint/store/hosts/index.test.ts | 9 +- .../endpoint/store/hosts/middleware.test.ts | 17 +- .../endpoint/store/hosts/middleware.ts | 68 +++++--- .../endpoint/store/hosts/reducer.ts | 71 +++++++-- .../endpoint/store/hosts/selectors.ts | 56 ++++--- .../public/applications/endpoint/types.ts | 31 +++- .../view/hosts/details/host_details.tsx | 4 +- .../endpoint/view/hosts/details/index.tsx | 23 ++- .../applications/endpoint/view/hosts/hooks.ts | 4 +- .../endpoint/view/hosts/index.test.tsx | 16 +- .../endpoint/view/hosts/index.tsx | 118 +++++--------- .../feature_controls/endpoint_spaces.ts | 4 +- .../apps/endpoint/header_nav.ts | 4 +- 15 files changed, 417 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/host_pagination.test.ts diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts index 21871ec8ca849..56a49df3bdab4 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HostListPagination, ServerApiError } from '../../types'; +import { ServerApiError } from '../../types'; import { HostResultList, HostInfo } from '../../../../../common/types'; interface ServerReturnedHostList { @@ -12,6 +12,11 @@ interface ServerReturnedHostList { payload: HostResultList; } +interface ServerFailedToReturnHostList { + type: 'serverFailedToReturnHostList'; + payload: ServerApiError; +} + interface ServerReturnedHostDetails { type: 'serverReturnedHostDetails'; payload: HostInfo; @@ -22,13 +27,8 @@ interface ServerFailedToReturnHostDetails { payload: ServerApiError; } -interface UserPaginatedHostList { - type: 'userPaginatedHostList'; - payload: HostListPagination; -} - export type HostAction = | ServerReturnedHostList + | ServerFailedToReturnHostList | ServerReturnedHostDetails - | ServerFailedToReturnHostDetails - | UserPaginatedHostList; + | ServerFailedToReturnHostDetails; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/host_pagination.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/host_pagination.test.ts new file mode 100644 index 0000000000000..d2e1985d055c6 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/host_pagination.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart, HttpSetup } from 'kibana/public'; +import { DepsStartMock, depsStartMock } from '../../mocks'; +import { AppAction, HostState, HostIndexUIQueryParams } from '../../types'; +import { Immutable, HostResultList } from '../../../../../common/types'; +import { History, createBrowserHistory } from 'history'; +import { hostMiddlewareFactory } from './middleware'; +import { applyMiddleware, Store, createStore } from 'redux'; +import { hostListReducer } from './reducer'; +import { coreMock } from 'src/core/public/mocks'; +import { urlFromQueryParams } from '../../view/hosts/url_from_query_params'; +import { uiQueryParams } from './selectors'; +import { mockHostResultList } from './mock_host_result_list'; +import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../test_utils'; + +describe('host list pagination: ', () => { + let fakeCoreStart: jest.Mocked; + let depsStart: DepsStartMock; + let fakeHttpServices: jest.Mocked; + let history: History; + let store: Store, Immutable>; + let queryParams: () => HostIndexUIQueryParams; + let waitForAction: MiddlewareActionSpyHelper['waitForAction']; + let actionSpyMiddleware; + const getEndpointListApiResponse = (): HostResultList => { + return mockHostResultList({ request_page_size: 1, request_page_index: 1, total: 10 }); + }; + + let historyPush: (params: HostIndexUIQueryParams) => void; + beforeEach(() => { + fakeCoreStart = coreMock.createStart(); + depsStart = depsStartMock(); + fakeHttpServices = fakeCoreStart.http as jest.Mocked; + history = createBrowserHistory(); + const middleware = hostMiddlewareFactory(fakeCoreStart, depsStart); + ({ actionSpyMiddleware, waitForAction } = createSpyMiddleware()); + store = createStore(hostListReducer, applyMiddleware(middleware, actionSpyMiddleware)); + + history.listen(location => { + store.dispatch({ type: 'userChangedUrl', payload: location }); + }); + + queryParams = () => uiQueryParams(store.getState()); + + historyPush = (nextQueryParams: HostIndexUIQueryParams): void => { + return history.push(urlFromQueryParams(nextQueryParams)); + }; + }); + + describe('when the user enteres the host list for the first time', () => { + it('the api is called with page_index and page_size defaulting to 0 and 10 respectively', async () => { + const apiResponse = getEndpointListApiResponse(); + fakeHttpServices.post.mockResolvedValue(apiResponse); + expect(fakeHttpServices.post).not.toHaveBeenCalled(); + + store.dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/hosts', + }, + }); + await waitForAction('serverReturnedHostList'); + expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', { + body: JSON.stringify({ + paging_properties: [{ page_index: '0' }, { page_size: '10' }], + }), + }); + }); + }); + describe('when a new page size is passed', () => { + it('should modify the url correctly', () => { + historyPush({ ...queryParams(), page_size: '20' }); + expect(queryParams()).toMatchInlineSnapshot(` + Object { + "page_index": "0", + "page_size": "20", + } + `); + }); + }); + describe('when an invalid page size is passed', () => { + it('should modify the page size in the url to the default page size', () => { + historyPush({ ...queryParams(), page_size: '1' }); + expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); + }); + }); + + describe('when a negative page size is passed', () => { + it('should modify the page size in the url to the default page size', () => { + historyPush({ ...queryParams(), page_size: '-1' }); + expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); + }); + }); + + describe('when a new page index is passed', () => { + it('should modify the page index in the url correctly', () => { + historyPush({ ...queryParams(), page_index: '2' }); + expect(queryParams()).toEqual({ page_index: '2', page_size: '10' }); + }); + }); + + describe('when a negative page index is passed', () => { + it('should modify the page index in the url to the default page index', () => { + historyPush({ ...queryParams(), page_index: '-2' }); + expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); + }); + }); + + describe('when invalid params are passed in the url', () => { + it('ignores non-numeric values for page_index and page_size', () => { + historyPush({ ...queryParams, page_index: 'one', page_size: 'fifty' }); + expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); + }); + + it('ignores unknown url search params', () => { + store.dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/hosts', + search: '?foo=bar', + }, + }); + expect(queryParams()).toEqual({ page_index: '0', page_size: '10' }); + }); + + it('ignores multiple values of the same query params except the last value', () => { + store.dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/hosts', + search: '?page_index=2&page_index=3&page_size=20&page_size=50', + }, + }); + expect(queryParams()).toEqual({ page_index: '3', page_size: '50' }); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts index 6148934343635..515c54eab3280 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/index.test.ts @@ -6,12 +6,12 @@ import { createStore, Dispatch, Store } from 'redux'; import { HostAction, hostListReducer } from './index'; -import { HostListState } from '../../types'; +import { HostState } from '../../types'; import { listData } from './selectors'; import { mockHostResultList } from './mock_host_result_list'; describe('HostList store concerns', () => { - let store: Store; + let store: Store; let dispatch: Dispatch; const createTestStore = () => { store = createStore(hostListReducer); @@ -37,6 +37,11 @@ describe('HostList store concerns', () => { pageIndex: 0, total: 0, loading: false, + error: undefined, + details: undefined, + detailsLoading: false, + detailsError: undefined, + location: undefined, }); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts index 8f39baddda00e..1af83a975d1d8 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.test.ts @@ -9,21 +9,23 @@ import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { History, createBrowserHistory } from 'history'; import { hostListReducer, hostMiddlewareFactory } from './index'; import { HostResultList, Immutable } from '../../../../../common/types'; -import { HostListState } from '../../types'; +import { HostState } from '../../types'; import { AppAction } from '../action'; import { listData } from './selectors'; import { DepsStartMock, depsStartMock } from '../../mocks'; import { mockHostResultList } from './mock_host_result_list'; +import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../test_utils'; describe('host list middleware', () => { - const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms)); let fakeCoreStart: jest.Mocked; let depsStart: DepsStartMock; let fakeHttpServices: jest.Mocked; - type HostListStore = Store, Immutable>; + type HostListStore = Store, Immutable>; let store: HostListStore; let getState: HostListStore['getState']; let dispatch: HostListStore['dispatch']; + let waitForAction: MiddlewareActionSpyHelper['waitForAction']; + let actionSpyMiddleware; let history: History; const getEndpointListApiResponse = (): HostResultList => { @@ -33,15 +35,16 @@ describe('host list middleware', () => { fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); depsStart = depsStartMock(); fakeHttpServices = fakeCoreStart.http as jest.Mocked; + ({ actionSpyMiddleware, waitForAction } = createSpyMiddleware()); store = createStore( hostListReducer, - applyMiddleware(hostMiddlewareFactory(fakeCoreStart, depsStart)) + applyMiddleware(hostMiddlewareFactory(fakeCoreStart, depsStart), actionSpyMiddleware) ); getState = store.getState; dispatch = store.dispatch; history = createBrowserHistory(); }); - test('handles `userChangedUrl`', async () => { + it('handles `userChangedUrl`', async () => { const apiResponse = getEndpointListApiResponse(); fakeHttpServices.post.mockResolvedValue(apiResponse); expect(fakeHttpServices.post).not.toHaveBeenCalled(); @@ -53,10 +56,10 @@ describe('host list middleware', () => { pathname: '/hosts', }, }); - await sleep(); + await waitForAction('serverReturnedHostList'); expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', { body: JSON.stringify({ - paging_properties: [{ page_index: 0 }, { page_size: 10 }], + paging_properties: [{ page_index: '0' }, { page_size: '10' }], }), }); expect(listData(getState())).toEqual(apiResponse.hosts.map(hostInfo => hostInfo.metadata)); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts index 83e11f5408bcd..bb1cfc4dd10af 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts @@ -4,34 +4,64 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HostResultList } from '../../../../../common/types'; +import { isOnHostPage, hasSelectedHost, uiQueryParams, listData } from './selectors'; +import { HostState } from '../../types'; import { ImmutableMiddlewareFactory } from '../../types'; -import { pageIndex, pageSize, isOnHostPage, hasSelectedHost, uiQueryParams } from './selectors'; -import { HostListState } from '../../types'; -export const hostMiddlewareFactory: ImmutableMiddlewareFactory = coreStart => { +export const hostMiddlewareFactory: ImmutableMiddlewareFactory = coreStart => { return ({ getState, dispatch }) => next => async action => { next(action); const state = getState(); if ( - (action.type === 'userChangedUrl' && - isOnHostPage(state) && - hasSelectedHost(state) !== true) || - action.type === 'userPaginatedHostList' + action.type === 'userChangedUrl' && + isOnHostPage(state) && + hasSelectedHost(state) !== true ) { - const hostPageIndex = pageIndex(state); - const hostPageSize = pageSize(state); - const response = await coreStart.http.post('/api/endpoint/metadata', { - body: JSON.stringify({ - paging_properties: [{ page_index: hostPageIndex }, { page_size: hostPageSize }], - }), - }); - response.request_page_index = hostPageIndex; - dispatch({ - type: 'serverReturnedHostList', - payload: response, - }); + const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state); + try { + const response = await coreStart.http.post('/api/endpoint/metadata', { + body: JSON.stringify({ + paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], + }), + }); + response.request_page_index = Number(pageIndex); + dispatch({ + type: 'serverReturnedHostList', + payload: response, + }); + } catch (error) { + dispatch({ + type: 'serverFailedToReturnHostList', + payload: error, + }); + } } if (action.type === 'userChangedUrl' && hasSelectedHost(state) !== false) { + // If user navigated directly to a host details page, load the host list + if (listData(state).length === 0) { + const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state); + try { + const response = await coreStart.http.post('/api/endpoint/metadata', { + body: JSON.stringify({ + paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], + }), + }); + response.request_page_index = Number(pageIndex); + dispatch({ + type: 'serverReturnedHostList', + payload: response, + }); + } catch (error) { + dispatch({ + type: 'serverFailedToReturnHostList', + payload: error, + }); + return; + } + } + + // call the host details api const { selected_host: selectedHost } = uiQueryParams(state); try { const response = await coreStart.http.get(`/api/endpoint/metadata/${selectedHost}`); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts index 298e819645dbe..adf18fa50c24f 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts @@ -4,23 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HostListState, ImmutableReducer } from '../../types'; +import { Immutable } from '../../../../../common/types'; +import { HostState, ImmutableReducer } from '../../types'; import { AppAction } from '../action'; +import { isOnHostPage, hasSelectedHost } from './selectors'; -const initialState = (): HostListState => { +const initialState = (): HostState => { return { hosts: [], pageSize: 10, pageIndex: 0, total: 0, loading: false, - detailsError: undefined, + error: undefined, details: undefined, + detailsLoading: false, + detailsError: undefined, location: undefined, }; }; -export const hostListReducer: ImmutableReducer = ( +export const hostListReducer: ImmutableReducer = ( state = initialState(), action ) => { @@ -38,30 +42,77 @@ export const hostListReducer: ImmutableReducer = ( pageSize, pageIndex, loading: false, + error: undefined, + }; + } else if (action.type === 'serverFailedToReturnHostList') { + return { + ...state, + error: action.payload, + loading: false, }; } else if (action.type === 'serverReturnedHostDetails') { return { ...state, details: action.payload.metadata, + detailsLoading: false, + detailsError: undefined, }; } else if (action.type === 'serverFailedToReturnHostDetails') { return { ...state, detailsError: action.payload, + detailsLoading: false, }; - } else if (action.type === 'userPaginatedHostList') { - return { + } else if (action.type === 'userChangedUrl') { + const newState: Immutable = { ...state, - ...action.payload, - loading: true, + location: action.payload, }; - } else if (action.type === 'userChangedUrl') { + const isCurrentlyOnListPage = isOnHostPage(newState) && !hasSelectedHost(newState); + const wasPreviouslyOnListPage = isOnHostPage(state) && !hasSelectedHost(state); + const isCurrentlyOnDetailsPage = isOnHostPage(newState) && hasSelectedHost(newState); + const wasPreviouslyOnDetailsPage = isOnHostPage(state) && hasSelectedHost(state); + + // if on the host list page for the first time, return new location and load list + if (isCurrentlyOnListPage) { + if (!wasPreviouslyOnListPage) { + return { + ...state, + location: action.payload, + loading: true, + error: undefined, + detailsError: undefined, + }; + } + } else if (isCurrentlyOnDetailsPage) { + // if previous page was the list or another host details page, load host details only + if (wasPreviouslyOnDetailsPage || wasPreviouslyOnListPage) { + return { + ...state, + location: action.payload, + detailsLoading: true, + error: undefined, + detailsError: undefined, + }; + } else { + // if previous page was not host list or host details, load both list and details + return { + ...state, + location: action.payload, + loading: true, + detailsLoading: true, + error: undefined, + detailsError: undefined, + }; + } + } + // otherwise we are not on a host list or details page return { ...state, location: action.payload, + error: undefined, detailsError: undefined, }; } - return state; }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts index 03cdba8505800..b0f949ebbe757 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts @@ -6,38 +6,47 @@ import querystring from 'querystring'; import { createSelector } from 'reselect'; import { Immutable } from '../../../../../common/types'; -import { HostListState, HostIndexUIQueryParams } from '../../types'; +import { HostState, HostIndexUIQueryParams } from '../../types'; -export const listData = (state: Immutable) => state.hosts; +const PAGE_SIZES = Object.freeze([10, 20, 50]); -export const pageIndex = (state: Immutable) => state.pageIndex; +export const listData = (state: Immutable) => state.hosts; -export const pageSize = (state: Immutable) => state.pageSize; +export const pageIndex = (state: Immutable): number => state.pageIndex; -export const totalHits = (state: Immutable) => state.total; +export const pageSize = (state: Immutable): number => state.pageSize; -export const isLoading = (state: Immutable) => state.loading; +export const totalHits = (state: Immutable): number => state.total; -export const detailsError = (state: Immutable) => state.detailsError; +export const listLoading = (state: Immutable): boolean => state.loading; -export const detailsData = (state: Immutable) => { - return state.details; -}; +export const listError = (state: Immutable) => state.error; -export const isOnHostPage = (state: Immutable) => +export const detailsData = (state: Immutable) => state.details; + +export const detailsLoading = (state: Immutable): boolean => state.detailsLoading; + +export const detailsError = (state: Immutable) => state.detailsError; + +export const isOnHostPage = (state: Immutable) => state.location ? state.location.pathname === '/hosts' : false; export const uiQueryParams: ( - state: Immutable + state: Immutable ) => Immutable = createSelector( - (state: Immutable) => state.location, - (location: Immutable['location']) => { - const data: HostIndexUIQueryParams = {}; + (state: Immutable) => state.location, + (location: Immutable['location']) => { + const data: HostIndexUIQueryParams = { page_index: '0', page_size: '10' }; if (location) { // Removes the `?` from the beginning of query string if it exists const query = querystring.parse(location.search.slice(1)); - const keys: Array = ['selected_host', 'show']; + const keys: Array = [ + 'selected_host', + 'page_size', + 'page_index', + 'show', + ]; for (const key of keys) { const value = query[key]; @@ -47,12 +56,23 @@ export const uiQueryParams: ( data[key] = value[value.length - 1]; } } + + // Check if page size is an expected size, otherwise default to 10 + if (!PAGE_SIZES.includes(Number(data.page_size))) { + data.page_size = '10'; + } + + // Check if page index is a valid positive integer, otherwise default to 0 + const pageIndexAsNumber = Number(data.page_index); + if (!Number.isFinite(pageIndexAsNumber) || pageIndexAsNumber < 0) { + data.page_index = '0'; + } } return data; } ); -export const hasSelectedHost: (state: Immutable) => boolean = createSelector( +export const hasSelectedHost: (state: Immutable) => boolean = createSelector( uiQueryParams, ({ selected_host: selectedHost }) => { return selectedHost !== undefined; @@ -60,7 +80,7 @@ export const hasSelectedHost: (state: Immutable) => boolean = cre ); /** What policy details panel view to show */ -export const showView: (state: HostListState) => 'policy_response' | 'details' = createSelector( +export const showView: (state: HostState) => 'policy_response' | 'details' = createSelector( uiQueryParams, searchParams => { return searchParams.show === 'policy_response' ? 'policy_response' : 'details'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index f407d32cb3b42..e5e600f6c6288 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -88,23 +88,40 @@ export type SubstateMiddlewareFactory = ( middleware: ImmutableMiddleware ) => Middleware<{}, GlobalState, Dispatch>>; -export interface HostListState { +export interface HostState { + /** list of host **/ hosts: HostMetadata[]; + /** number of items per page */ pageSize: number; + /** which page to show */ pageIndex: number; + /** total number of hosts returned */ total: number; + /** list page is retrieving data */ loading: boolean; - detailsError?: ServerApiError; + /** api error from retrieving host list */ + error?: ServerApiError; + /** details data for a specific host */ details?: Immutable; + /** details page is retrieving data */ + detailsLoading: boolean; + /** api error from retrieving host details */ + detailsError?: ServerApiError; + /** current location info */ location?: Immutable; } -export interface HostListPagination { - pageIndex: number; - pageSize: number; -} +/** + * Query params on the host page parsed from the URL + */ export interface HostIndexUIQueryParams { + /** Selected host id shows host details flyout */ selected_host?: string; + /** How many items to show in list */ + page_size?: string; + /** Which page to show */ + page_index?: string; + /** show the policy response or host details */ show?: string; } @@ -257,7 +274,7 @@ export type KeysByValueCriteria = { export type MalwareProtectionOSes = KeysByValueCriteria; export interface GlobalState { - readonly hostList: HostListState; + readonly hostList: HostState; readonly alertList: AlertListState; readonly policyList: PolicyListState; readonly policyDetails: PolicyDetailsState; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx index 336308b2ee271..e4da39d50304a 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { HostMetadata } from '../../../../../../common/types'; import { FormattedDateAndTime } from '../../formatted_date_time'; import { LinkToApp } from '../../components/link_to_app'; -import { useHostListSelector, useHostLogsUrl } from '../hooks'; +import { useHostSelector, useHostLogsUrl } from '../hooks'; import { urlFromQueryParams } from '../url_from_query_params'; import { uiQueryParams } from '../../../store/hosts/selectors'; import { useNavigateByRouterEventHandler } from '../../hooks/use_navigate_by_router_event_handler'; @@ -33,7 +33,7 @@ const HostIds = styled(EuiListGroupItem)` export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const { appId, appPath, url } = useHostLogsUrl(details.host.id); - const queryParams = useHostListSelector(uiQueryParams); + const queryParams = useHostSelector(uiQueryParams); const detailsResultsUpper = useMemo(() => { return [ { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx index 0c43e18822508..e44a45f300daa 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/index.tsx @@ -17,9 +17,15 @@ import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; -import { useHostListSelector } from '../hooks'; +import { useHostSelector } from '../hooks'; import { urlFromQueryParams } from '../url_from_query_params'; -import { uiQueryParams, detailsData, detailsError, showView } from '../../../store/hosts/selectors'; +import { + uiQueryParams, + detailsData, + detailsError, + showView, + detailsLoading, +} from '../../../store/hosts/selectors'; import { HostDetails } from './host_details'; import { PolicyResponse } from './policy_response'; import { HostMetadata } from '../../../../../../common/types'; @@ -29,11 +35,12 @@ import { useNavigateByRouterEventHandler } from '../../hooks/use_navigate_by_rou export const HostDetailsFlyout = memo(() => { const history = useHistory(); const { notifications } = useKibana(); - const queryParams = useHostListSelector(uiQueryParams); + const queryParams = useHostSelector(uiQueryParams); const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams; - const details = useHostListSelector(detailsData); - const error = useHostListSelector(detailsError); - const show = useHostListSelector(showView); + const details = useHostSelector(detailsData); + const loading = useHostSelector(detailsLoading); + const error = useHostSelector(detailsError); + const show = useHostSelector(showView); const handleFlyoutClose = useCallback(() => { history.push(urlFromQueryParams(queryParamsWithoutSelectedHost)); @@ -64,7 +71,7 @@ export const HostDetailsFlyout = memo(() => {

- {details === undefined ? : details.host.hostname} + {loading ? : details?.host?.hostname}

@@ -93,7 +100,7 @@ export const HostDetailsFlyout = memo(() => { const PolicyResponseFlyoutPanel = memo<{ hostMeta: HostMetadata; }>(({ hostMeta }) => { - const { show, ...queryParams } = useHostListSelector(uiQueryParams); + const { show, ...queryParams } = useHostSelector(uiQueryParams); const detailsUri = useMemo( () => urlFromQueryParams({ diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts index 7eb51f3a7b294..eb242f5c535f4 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/hooks.ts @@ -6,10 +6,10 @@ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; -import { GlobalState, HostListState } from '../../types'; +import { GlobalState, HostState } from '../../types'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -export function useHostListSelector(selector: (state: HostListState) => TSelected) { +export function useHostSelector(selector: (state: HostState) => TSelected) { return useSelector(function(state: GlobalState) { return selector(state.hostList); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx index 88416b577ed0c..11dbed716c527 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx @@ -139,7 +139,7 @@ describe('when on the hosts page', () => { expect(policyStatusLink).not.toBeNull(); expect(policyStatusLink.textContent).toEqual('Successful'); expect(policyStatusLink.getAttribute('href')).toEqual( - '?selected_host=1&show=policy_response' + '?page_index=0&page_size=10&selected_host=1&show=policy_response' ); }); it('should update the URL when policy status link is clicked', async () => { @@ -150,7 +150,9 @@ describe('when on the hosts page', () => { fireEvent.click(policyStatusLink); }); const changedUrlAction = await userChangedUrlChecker; - expect(changedUrlAction.payload.search).toEqual('?selected_host=1&show=policy_response'); + expect(changedUrlAction.payload.search).toEqual( + '?page_index=0&page_size=10&selected_host=1&show=policy_response' + ); }); it('should include the link to logs', async () => { const renderResult = render(); @@ -170,7 +172,7 @@ describe('when on the hosts page', () => { }); }); - it('should navigate to logs without full page refresh', async () => { + it('should navigate to logs without full page refresh', () => { expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1); }); }); @@ -205,7 +207,9 @@ describe('when on the hosts page', () => { it('should include the back to details link', async () => { const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton'); expect(subHeaderBackLink.textContent).toBe('Endpoint Details'); - expect(subHeaderBackLink.getAttribute('href')).toBe('?selected_host=1'); + expect(subHeaderBackLink.getAttribute('href')).toBe( + '?page_index=0&page_size=10&selected_host=1' + ); }); it('should update URL when back to details link is clicked', async () => { const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton'); @@ -214,7 +218,9 @@ describe('when on the hosts page', () => { fireEvent.click(subHeaderBackLink); }); const changedUrlAction = await userChangedUrlChecker; - expect(changedUrlAction.payload.search).toEqual('?selected_host=1'); + expect(changedUrlAction.payload.search).toEqual( + '?page_index=0&page_size=10&selected_host=1' + ); }); }); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx index e662bafed6492..5c2922162ce0c 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.tsx @@ -5,31 +5,19 @@ */ import React, { useMemo, useCallback, memo } from 'react'; -import { useDispatch } from 'react-redux'; -import { - EuiPage, - EuiPageBody, - EuiPageHeader, - EuiPageContent, - EuiHorizontalRule, - EuiTitle, - EuiBasicTable, - EuiText, - EuiLink, - EuiHealth, -} from '@elastic/eui'; +import { EuiHorizontalRule, EuiBasicTable, EuiText, EuiLink, EuiHealth } from '@elastic/eui'; +import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import { createStructuredSelector } from 'reselect'; import { EuiBasicTableColumn } from '@elastic/eui'; import { HostDetailsFlyout } from './details'; import * as selectors from '../../store/hosts/selectors'; -import { HostAction } from '../../store/hosts/action'; -import { useHostListSelector } from './hooks'; +import { useHostSelector } from './hooks'; import { CreateStructuredSelector } from '../../types'; import { urlFromQueryParams } from './url_from_query_params'; import { HostMetadata, Immutable } from '../../../../../common/types'; +import { PageView } from '../components/page_view'; import { useNavigateByRouterEventHandler } from '../hooks/use_navigate_by_router_event_handler'; const HostLink = memo<{ @@ -49,16 +37,17 @@ const HostLink = memo<{ const selector = (createStructuredSelector as CreateStructuredSelector)(selectors); export const HostList = () => { - const dispatch = useDispatch<(a: HostAction) => void>(); + const history = useHistory(); const { listData, pageIndex, pageSize, totalHits: totalItemCount, - isLoading, + listLoading: loading, + listError, uiQueryParams: queryParams, hasSelectedHost, - } = useHostListSelector(selector); + } = useHostSelector(selector); const paginationSetup = useMemo(() => { return { @@ -73,12 +62,15 @@ export const HostList = () => { const onTableChange = useCallback( ({ page }: { page: { index: number; size: number } }) => { const { index, size } = page; - dispatch({ - type: 'userPaginatedHostList', - payload: { pageIndex: index, pageSize: size }, - }); + history.push( + urlFromQueryParams({ + ...queryParams, + page_index: JSON.stringify(index), + page_size: JSON.stringify(size), + }) + ); }, - [dispatch] + [history, queryParams] ); const columns: Array>> = useMemo(() => { @@ -100,6 +92,7 @@ export const HostList = () => { name: i18n.translate('xpack.endpoint.host.list.policy', { defaultMessage: 'Policy', }), + truncateText: true, render: () => { return 'Policy Name'; }, @@ -134,6 +127,7 @@ export const HostList = () => { name: i18n.translate('xpack.endpoint.host.list.ip', { defaultMessage: 'IP Address', }), + truncateText: true, }, { field: '', @@ -158,59 +152,29 @@ export const HostList = () => { }, [queryParams]); return ( - + {hasSelectedHost && } - - - - -

- -

-
-
- - - - - - - [...listData], [listData])} - columns={columns} - loading={isLoading} - pagination={paginationSetup} - onChange={onTableChange} - /> - -
-
-
+ + + + + [...listData], [listData])} + columns={columns} + loading={loading} + error={listError?.message} + pagination={paginationSetup} + onChange={onTableChange} + /> + ); }; - -const HostPage = styled.div` - .hostPage { - padding: 0; - } - .hostHeader { - background-color: ${props => props.theme.eui.euiColorLightestShade}; - border-bottom: ${props => props.theme.eui.euiBorderThin}; - padding: ${props => - props.theme.eui.euiSizeXL + - ' ' + - 0 + - props.theme.eui.euiSizeXL + - ' ' + - props.theme.eui.euiSizeL}; - margin-bottom: 0; - } - .hostPageContent { - border: none; - } -`; diff --git a/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/endpoint_spaces.ts b/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/endpoint_spaces.ts index c543046031e9f..fdebdae9e5d0e 100644 --- a/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/endpoint_spaces.ts +++ b/x-pack/test/functional_endpoint/apps/endpoint/feature_controls/endpoint_spaces.ts @@ -41,13 +41,13 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.existOrFail('welcomeTitle'); }); - it(`endpoint management shows 'Hosts'`, async () => { + it(`endpoint hosts shows hosts lists page`, async () => { await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/hosts', undefined, { basePath: '/s/custom_space', ensureCurrentUrl: false, shouldLoginIfPrompted: false, }); - await testSubjects.existOrFail('hostListTitle'); + await testSubjects.existOrFail('hostPage'); }); }); diff --git a/x-pack/test/functional_endpoint/apps/endpoint/header_nav.ts b/x-pack/test/functional_endpoint/apps/endpoint/header_nav.ts index c2c4068212484..b944056e00911 100644 --- a/x-pack/test/functional_endpoint/apps/endpoint/header_nav.ts +++ b/x-pack/test/functional_endpoint/apps/endpoint/header_nav.ts @@ -31,7 +31,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('renders the hosts page when the Hosts tab is selected', async () => { await (await testSubjects.find('hostsEndpointTab')).click(); - await testSubjects.existOrFail('hostListTitle'); + await testSubjects.existOrFail('hostPage'); }); it('renders the alerts page when the Alerts tab is selected', async () => { @@ -46,7 +46,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('renders the home page when Home tab is selected after selecting another tab', async () => { await (await testSubjects.find('hostsEndpointTab')).click(); - await testSubjects.existOrFail('hostListTitle'); + await testSubjects.existOrFail('hostPage'); await (await testSubjects.find('homeEndpointTab')).click(); await testSubjects.existOrFail('welcomeTitle');