Task/hostlist pagination (#63722)
* hostlist pagination for endpoint security
This commit is contained in:
parent
2ace269a26
commit
fb70f01eff
15 changed files with 418 additions and 170 deletions
|
@ -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;
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Reference in a new issue