Task/hostlist pagination (#63722)

* hostlist pagination for endpoint security
This commit is contained in:
Candace Park 2020-04-24 10:01:06 -04:00 committed by GitHub
parent 2ace269a26
commit fb70f01eff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 418 additions and 170 deletions

View file

@ -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;

View file

@ -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<CoreStart>;
let depsStart: DepsStartMock;
let fakeHttpServices: jest.Mocked<HttpSetup>;
let history: History<never>;
let store: Store<Immutable<HostState>, Immutable<AppAction>>;
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<HttpSetup>;
history = createBrowserHistory();
const middleware = hostMiddlewareFactory(fakeCoreStart, depsStart);
({ actionSpyMiddleware, waitForAction } = createSpyMiddleware<HostState>());
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' });
});
});
});

View file

@ -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<HostListState>;
let store: Store<HostState>;
let dispatch: Dispatch<HostAction>;
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,
});
});

View file

@ -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<CoreStart>;
let depsStart: DepsStartMock;
let fakeHttpServices: jest.Mocked<HttpSetup>;
type HostListStore = Store<Immutable<HostListState>, Immutable<AppAction>>;
type HostListStore = Store<Immutable<HostState>, Immutable<AppAction>>;
let store: HostListStore;
let getState: HostListStore['getState'];
let dispatch: HostListStore['dispatch'];
let waitForAction: MiddlewareActionSpyHelper['waitForAction'];
let actionSpyMiddleware;
let history: History<never>;
const getEndpointListApiResponse = (): HostResultList => {
@ -33,15 +35,16 @@ describe('host list middleware', () => {
fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
depsStart = depsStartMock();
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>;
({ actionSpyMiddleware, waitForAction } = createSpyMiddleware<HostState>());
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));

View file

@ -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<HostListState> = coreStart => {
export const hostMiddlewareFactory: ImmutableMiddlewareFactory<HostState> = 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<HostResultList>('/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}`);

View file

@ -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<HostListState, AppAction> = (
export const hostListReducer: ImmutableReducer<HostState, AppAction> = (
state = initialState(),
action
) => {
@ -38,30 +42,77 @@ export const hostListReducer: ImmutableReducer<HostListState, AppAction> = (
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,
};
} else if (action.type === 'userPaginatedHostList') {
return {
...state,
...action.payload,
loading: true,
detailsLoading: false,
};
} else if (action.type === 'userChangedUrl') {
const newState: Immutable<HostState> = {
...state,
location: action.payload,
};
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;
};

View file

@ -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<HostListState>) => state.hosts;
const PAGE_SIZES = Object.freeze([10, 20, 50]);
export const pageIndex = (state: Immutable<HostListState>) => state.pageIndex;
export const listData = (state: Immutable<HostState>) => state.hosts;
export const pageSize = (state: Immutable<HostListState>) => state.pageSize;
export const pageIndex = (state: Immutable<HostState>): number => state.pageIndex;
export const totalHits = (state: Immutable<HostListState>) => state.total;
export const pageSize = (state: Immutable<HostState>): number => state.pageSize;
export const isLoading = (state: Immutable<HostListState>) => state.loading;
export const totalHits = (state: Immutable<HostState>): number => state.total;
export const detailsError = (state: Immutable<HostListState>) => state.detailsError;
export const listLoading = (state: Immutable<HostState>): boolean => state.loading;
export const detailsData = (state: Immutable<HostListState>) => {
return state.details;
};
export const listError = (state: Immutable<HostState>) => state.error;
export const isOnHostPage = (state: Immutable<HostListState>) =>
export const detailsData = (state: Immutable<HostState>) => state.details;
export const detailsLoading = (state: Immutable<HostState>): boolean => state.detailsLoading;
export const detailsError = (state: Immutable<HostState>) => state.detailsError;
export const isOnHostPage = (state: Immutable<HostState>) =>
state.location ? state.location.pathname === '/hosts' : false;
export const uiQueryParams: (
state: Immutable<HostListState>
state: Immutable<HostState>
) => Immutable<HostIndexUIQueryParams> = createSelector(
(state: Immutable<HostListState>) => state.location,
(location: Immutable<HostListState>['location']) => {
const data: HostIndexUIQueryParams = {};
(state: Immutable<HostState>) => state.location,
(location: Immutable<HostState>['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<keyof HostIndexUIQueryParams> = ['selected_host', 'show'];
const keys: Array<keyof HostIndexUIQueryParams> = [
'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<HostListState>) => boolean = createSelector(
export const hasSelectedHost: (state: Immutable<HostState>) => boolean = createSelector(
uiQueryParams,
({ selected_host: selectedHost }) => {
return selectedHost !== undefined;
@ -60,7 +80,7 @@ export const hasSelectedHost: (state: Immutable<HostListState>) => 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';

View file

@ -88,23 +88,40 @@ export type SubstateMiddlewareFactory = <Substate>(
middleware: ImmutableMiddleware<Substate, AppAction>
) => Middleware<{}, GlobalState, Dispatch<AppAction | Immutable<AppAction>>>;
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<HostMetadata>;
/** details page is retrieving data */
detailsLoading: boolean;
/** api error from retrieving host details */
detailsError?: ServerApiError;
/** current location info */
location?: Immutable<EndpointAppLocation>;
}
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<O, Criteria> = {
export type MalwareProtectionOSes = KeysByValueCriteria<UIPolicyConfig, { malware: MalwareFields }>;
export interface GlobalState {
readonly hostList: HostListState;
readonly hostList: HostState;
readonly alertList: AlertListState;
readonly policyList: PolicyListState;
readonly policyDetails: PolicyDetailsState;

View file

@ -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 [
{

View file

@ -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(() => {
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 data-test-subj="hostDetailsFlyoutTitle">
{details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname}
{loading ? <EuiLoadingContent lines={1} /> : details?.host?.hostname}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
@ -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({

View file

@ -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<TSelected>(selector: (state: HostListState) => TSelected) {
export function useHostSelector<TSelected>(selector: (state: HostState) => TSelected) {
return useSelector(function(state: GlobalState) {
return selector(state.hostList);
});

View file

@ -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'
);
});
});
});

View file

@ -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<EuiBasicTableColumn<Immutable<HostMetadata>>> = 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 (
<HostPage>
<PageView
viewType="list"
data-test-subj="hostPage"
headerLeft={i18n.translate('xpack.endpoint.host.hosts', { defaultMessage: 'Hosts' })}
>
{hasSelectedHost && <HostDetailsFlyout />}
<EuiPage className="hostPage">
<EuiPageBody>
<EuiPageHeader className="hostHeader">
<EuiTitle size="l">
<h1 data-test-subj="hostListTitle">
<FormattedMessage id="xpack.endpoint.host.hosts" defaultMessage="Hosts" />
</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent className="hostPageContent">
<EuiText color="subdued" size="xs">
<FormattedMessage
id="xpack.endpoint.host.list.totalCount"
defaultMessage="Showing: {totalItemCount, plural, one {# Host} other {# Hosts}}"
values={{ totalItemCount }}
/>
</EuiText>
<EuiHorizontalRule margin="xs" />
<EuiBasicTable
data-test-subj="hostListTable"
items={useMemo(() => [...listData], [listData])}
columns={columns}
loading={isLoading}
pagination={paginationSetup}
onChange={onTableChange}
/>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</HostPage>
<EuiText color="subdued" size="xs">
<FormattedMessage
id="xpack.endpoint.host.list.totalCount"
defaultMessage="{totalItemCount, plural, one {# Host} other {# Hosts}}"
values={{ totalItemCount }}
/>
</EuiText>
<EuiHorizontalRule margin="xs" />
<EuiBasicTable
data-test-subj="hostListTable"
items={useMemo(() => [...listData], [listData])}
columns={columns}
loading={loading}
error={listError?.message}
pagination={paginationSetup}
onChange={onTableChange}
/>
</PageView>
);
};
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;
}
`;

View file

@ -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');
});
});

View file

@ -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');