diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index f0fd9dc610e4e1..78be98b7805ba5 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -71,7 +71,7 @@ export interface EndpointResultList { } export interface AlertData { - '@timestamp': Date; + '@timestamp': string; agent: { id: string; version: string; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx index 8530d6206d3987..c6c032c2735435 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx @@ -8,16 +8,14 @@ import * as React from 'react'; import ReactDOM from 'react-dom'; import { CoreStart, AppMountParameters } from 'kibana/public'; import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; -import { Route, Switch, BrowserRouter, useLocation } from 'react-router-dom'; -import { Provider, useDispatch } from 'react-redux'; +import { Route, Switch, BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; import { Store } from 'redux'; -import { memo } from 'react'; +import { RouteCapture } from './view/route_capture'; import { appStoreFactory } from './store'; import { AlertIndex } from './view/alerts'; import { ManagementList } from './view/managing'; import { PolicyList } from './view/policy'; -import { AppAction } from './store/action'; -import { EndpointAppLocation } from './types'; /** * This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle. @@ -33,13 +31,6 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou }; } -const RouteCapture = memo(({ children }) => { - const location: EndpointAppLocation = useLocation(); - const dispatch: (action: AppAction) => unknown = useDispatch(); - dispatch({ type: 'userChangedUrl', payload: location }); - return <>{children}; -}); - interface RouterProps { basename: string; store: Store; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts index 6ba7a34ae81d1b..0aeeb6881ad96e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts @@ -14,6 +14,7 @@ import { coreMock } from 'src/core/public/mocks'; import { AlertResultList } from '../../../../../common/types'; import { isOnAlertPage } from './selectors'; import { createBrowserHistory } from 'history'; +import { mockAlertResultList } from './mock_alert_result_list'; describe('alert list tests', () => { let store: Store; @@ -28,37 +29,7 @@ describe('alert list tests', () => { describe('when the user navigates to the alert list page', () => { beforeEach(() => { coreStart.http.get.mockImplementation(async () => { - const response: AlertResultList = { - alerts: [ - { - '@timestamp': new Date(1542341895000), - agent: { - id: 'ced9c68e-b94a-4d66-bb4c-6106514f0a2f', - version: '3.0.0', - }, - event: { - action: 'open', - }, - file_classification: { - malware_classification: { - score: 3, - }, - }, - host: { - hostname: 'HD-c15-bc09190a', - ip: '10.179.244.14', - os: { - name: 'Windows', - }, - }, - thread: {}, - }, - ], - total: 1, - request_page_size: 10, - request_page_index: 0, - result_from_index: 0, - }; + const response: AlertResultList = mockAlertResultList(); return response; }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts index 77708a3c77e2b7..5c257c3d65fdc5 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts @@ -7,37 +7,47 @@ import { Store, createStore, applyMiddleware } from 'redux'; import { History } from 'history'; import { alertListReducer } from './reducer'; -import { AlertListState } from '../../types'; +import { AlertListState, AlertingIndexUIQueryParams } from '../../types'; import { alertMiddlewareFactory } from './middleware'; import { AppAction } from '../action'; import { coreMock } from 'src/core/public/mocks'; import { createBrowserHistory } from 'history'; -import { - urlFromNewPageSizeParam, - paginationDataFromUrl, - urlFromNewPageIndexParam, -} from './selectors'; +import { uiQueryParams } from './selectors'; +import { urlFromQueryParams } from '../../view/alerts/url_from_query_params'; describe('alert list pagination', () => { let store: Store; let coreStart: ReturnType; let history: History; + let queryParams: () => AlertingIndexUIQueryParams; + /** + * Update the history with a new `AlertingIndexUIQueryParams` + */ + let historyPush: (params: AlertingIndexUIQueryParams) => void; beforeEach(() => { coreStart = coreMock.createStart(); history = createBrowserHistory(); + const middleware = alertMiddlewareFactory(coreStart); store = createStore(alertListReducer, applyMiddleware(middleware)); + + history.listen(location => { + store.dispatch({ type: 'userChangedUrl', payload: location }); + }); + + queryParams = () => uiQueryParams(store.getState()); + + historyPush = (nextQueryParams: AlertingIndexUIQueryParams): void => { + return history.push(urlFromQueryParams(nextQueryParams)); + }; }); describe('when the user navigates to the alert list page', () => { describe('when a new page size is passed', () => { beforeEach(() => { - const urlPageSizeSelector = urlFromNewPageSizeParam(store.getState()); - history.push(urlPageSizeSelector(1)); - store.dispatch({ type: 'userChangedUrl', payload: history.location }); + historyPush({ ...queryParams(), page_size: '1' }); }); it('should modify the url correctly', () => { - const actualPaginationQuery = paginationDataFromUrl(store.getState()); - expect(actualPaginationQuery).toMatchInlineSnapshot(` + expect(queryParams()).toMatchInlineSnapshot(` Object { "page_size": "1", } @@ -46,13 +56,10 @@ describe('alert list pagination', () => { describe('and then a new page index is passed', () => { beforeEach(() => { - const urlPageIndexSelector = urlFromNewPageIndexParam(store.getState()); - history.push(urlPageIndexSelector(1)); - store.dispatch({ type: 'userChangedUrl', payload: history.location }); + historyPush({ ...queryParams(), page_index: '1' }); }); it('should modify the url in the correct order', () => { - const actualPaginationQuery = paginationDataFromUrl(store.getState()); - expect(actualPaginationQuery).toMatchInlineSnapshot(` + expect(queryParams()).toMatchInlineSnapshot(` Object { "page_index": "1", "page_size": "1", @@ -64,35 +71,15 @@ describe('alert list pagination', () => { describe('when a new page index is passed', () => { beforeEach(() => { - const urlPageIndexSelector = urlFromNewPageIndexParam(store.getState()); - history.push(urlPageIndexSelector(1)); - store.dispatch({ type: 'userChangedUrl', payload: history.location }); + historyPush({ ...queryParams(), page_index: '1' }); }); it('should modify the url correctly', () => { - const actualPaginationQuery = paginationDataFromUrl(store.getState()); - expect(actualPaginationQuery).toMatchInlineSnapshot(` + expect(queryParams()).toMatchInlineSnapshot(` Object { "page_index": "1", } `); }); - - describe('and then a new page size is passed', () => { - beforeEach(() => { - const urlPageSizeSelector = urlFromNewPageSizeParam(store.getState()); - history.push(urlPageSizeSelector(1)); - store.dispatch({ type: 'userChangedUrl', payload: history.location }); - }); - it('should modify the url correctly and reset index to `0`', () => { - const actualPaginationQuery = paginationDataFromUrl(store.getState()); - expect(actualPaginationQuery).toMatchInlineSnapshot(` - Object { - "page_index": "0", - "page_size": "1", - } - `); - }); - }); }); }); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts index 059507c8f06581..76a6867418bd86 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpFetchQuery } from 'kibana/public'; import { AlertResultList } from '../../../../../common/types'; import { AppAction } from '../action'; import { MiddlewareFactory, AlertListState } from '../../types'; -import { isOnAlertPage, paginationDataFromUrl } from './selectors'; +import { isOnAlertPage, apiQueryParams } from './selectors'; export const alertMiddlewareFactory: MiddlewareFactory = coreStart => { return api => next => async (action: AppAction) => { @@ -16,7 +15,7 @@ export const alertMiddlewareFactory: MiddlewareFactory = coreSta const state = api.getState(); if (action.type === 'userChangedUrl' && isOnAlertPage(state)) { const response: AlertResultList = await coreStart.http.get(`/api/endpoint/alerts`, { - query: paginationDataFromUrl(state) as HttpFetchQuery, + query: apiQueryParams(state), }); api.dispatch({ type: 'serverReturnedAlertsData', payload: response }); } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts new file mode 100644 index 00000000000000..338a1077b58a29 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts @@ -0,0 +1,60 @@ +/* + * 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 { AlertResultList } from '../../../../../common/types'; + +export const mockAlertResultList: (options?: { + total?: number; + request_page_size?: number; + request_page_index?: number; +}) => AlertResultList = (options = {}) => { + const { + total = 1, + request_page_size: requestPageSize = 10, + request_page_index: requestPageIndex = 0, + } = options; + + // Skip any that are before the page we're on + const numberToSkip = requestPageSize * requestPageIndex; + + // total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0 + const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0); + + const alerts = []; + for (let index = 0; index < actualCountToReturn; index++) { + alerts.push({ + '@timestamp': new Date(1542341895000).toString(), + agent: { + id: 'ced9c68e-b94a-4d66-bb4c-6106514f0a2f', + version: '3.0.0', + }, + event: { + action: 'open', + }, + file_classification: { + malware_classification: { + score: 3, + }, + }, + host: { + hostname: 'HD-c15-bc09190a', + ip: '10.179.244.14', + os: { + name: 'Windows', + }, + }, + thread: {}, + }); + } + const mock: AlertResultList = { + alerts, + total, + request_page_size: requestPageSize, + request_page_index: requestPageIndex, + result_from_index: 0, + }; + return mock; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts index 6ad053888729c3..3a0461e06538fc 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts @@ -4,9 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import qs from 'querystring'; -import { AlertListState } from '../../types'; +import querystring from 'querystring'; +import { + createSelector, + createStructuredSelector as createStructuredSelectorWithBadType, +} from 'reselect'; +import { Immutable } from '../../../../../common/types'; +import { + AlertListState, + AlertingIndexUIQueryParams, + AlertsAPIQueryParams, + CreateStructuredSelector, +} from '../../types'; +const createStructuredSelector: CreateStructuredSelector = createStructuredSelectorWithBadType; /** * Returns the Alert Data array from state */ @@ -15,14 +26,12 @@ export const alertListData = (state: AlertListState) => state.alerts; /** * Returns the alert list pagination data from state */ -export const alertListPagination = (state: AlertListState) => { - return { - pageIndex: state.request_page_index, - pageSize: state.request_page_size, - resultFromIndex: state.result_from_index, - total: state.total, - }; -}; +export const alertListPagination = createStructuredSelector({ + pageIndex: (state: AlertListState) => state.request_page_index, + pageSize: (state: AlertListState) => state.request_page_size, + resultFromIndex: (state: AlertListState) => state.result_from_index, + total: (state: AlertListState) => state.total, +}); /** * Returns a boolean based on whether or not the user is on the alerts page @@ -32,48 +41,55 @@ export const isOnAlertPage = (state: AlertListState): boolean => { }; /** - * Returns the query object received from parsing the URL query params - */ -export const paginationDataFromUrl = (state: AlertListState): qs.ParsedUrlQuery => { - if (state.location) { - // Removes the `?` from the beginning of query string if it exists - const query = qs.parse(state.location.search.slice(1)); - return { - ...(query.page_size ? { page_size: query.page_size } : {}), - ...(query.page_index ? { page_index: query.page_index } : {}), - }; - } else { - return {}; - } -}; - -/** - * Returns a function that takes in a new page size and returns a new query param string + * Returns the query object received from parsing the browsers URL query params. + * Used to calculate urls for links and such. */ -export const urlFromNewPageSizeParam: ( +export const uiQueryParams: ( state: AlertListState -) => (newPageSize: number) => string = state => { - return newPageSize => { - const urlPaginationData = paginationDataFromUrl(state); - urlPaginationData.page_size = newPageSize.toString(); +) => Immutable = createSelector( + (state: AlertListState) => state.location, + (location: AlertListState['location']) => { + const data: AlertingIndexUIQueryParams = {}; + if (location) { + // Removes the `?` from the beginning of query string if it exists + const query = querystring.parse(location.search.slice(1)); - // Only set the url back to page zero if the user has changed the page index already - if (urlPaginationData.page_index !== undefined) { - urlPaginationData.page_index = '0'; + /** + * Build an AlertingIndexUIQueryParams object with keys from the query. + * If more than one value exists for a key, use the last. + */ + const keys: Array = [ + 'page_size', + 'page_index', + 'selected_alert', + ]; + for (const key of keys) { + const value = query[key]; + if (typeof value === 'string') { + data[key] = value; + } else if (Array.isArray(value)) { + data[key] = value[value.length - 1]; + } + } } - return '?' + qs.stringify(urlPaginationData); - }; -}; + return data; + } +); /** - * Returns a function that takes in a new page index and returns a new query param string + * query params to use when requesting alert data. */ -export const urlFromNewPageIndexParam: ( +export const apiQueryParams: ( state: AlertListState -) => (newPageIndex: number) => string = state => { - return newPageIndex => { - const urlPaginationData = paginationDataFromUrl(state); - urlPaginationData.page_index = newPageIndex.toString(); - return '?' + qs.stringify(urlPaginationData); - }; -}; +) => Immutable = createSelector( + uiQueryParams, + ({ page_size, page_index }) => ({ + page_size, + page_index, + }) +); + +export const hasSelectedAlert: (state: AlertListState) => boolean = createSelector( + uiQueryParams, + ({ selected_alert: selectedAlert }) => selectedAlert !== undefined +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts index 3aeeeaf1c09e26..b95ff7cb2d45ca 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts @@ -48,25 +48,36 @@ export const substateMiddlewareFactory = ( }; }; -export const appStoreFactory = (coreStart: CoreStart): Store => { +export const appStoreFactory: ( + /** + * Allow middleware to communicate with Kibana core. + */ + coreStart: CoreStart, + /** + * Create the store without any middleware. This is useful for testing the store w/o side effects. + */ + disableMiddleware?: boolean +) => Store = (coreStart, disableMiddleware = false) => { const store = createStore( appReducer, - composeWithReduxDevTools( - applyMiddleware( - substateMiddlewareFactory( - globalState => globalState.managementList, - managementMiddlewareFactory(coreStart) - ), - substateMiddlewareFactory( - globalState => globalState.policyList, - policyListMiddlewareFactory(coreStart) - ), - substateMiddlewareFactory( - globalState => globalState.alertList, - alertMiddlewareFactory(coreStart) + disableMiddleware + ? undefined + : composeWithReduxDevTools( + applyMiddleware( + substateMiddlewareFactory( + globalState => globalState.managementList, + managementMiddlewareFactory(coreStart) + ), + substateMiddlewareFactory( + globalState => globalState.policyList, + policyListMiddlewareFactory(coreStart) + ), + substateMiddlewareFactory( + globalState => globalState.alertList, + alertMiddlewareFactory(coreStart) + ) + ) ) - ) - ) ); return store; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index d07521d09a119f..bd4838419891d5 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -10,6 +10,7 @@ import { EndpointMetadata } from '../../../common/types'; import { AppAction } from './store/action'; import { AlertResultList, Immutable } from '../../../common/types'; +export { AppAction }; export type MiddlewareFactory = ( coreStart: CoreStart ) => ( @@ -63,6 +64,9 @@ export interface GlobalState { readonly policyList: PolicyListState; } +/** + * A better type for createStructuredSelector. This doesn't support the options object. + */ export type CreateStructuredSelector = < SelectorMap extends { [key: string]: (...args: never[]) => unknown } >( @@ -76,7 +80,6 @@ export type CreateStructuredSelector = < export interface EndpointAppLocation { pathname: string; search: string; - state: never; hash: string; key?: string; } @@ -85,3 +88,35 @@ export type AlertListData = AlertResultList; export type AlertListState = Immutable & { readonly location?: Immutable; }; + +/** + * Gotten by parsing the URL from the browser. Used to calculate the new URL when changing views. + */ +export interface AlertingIndexUIQueryParams { + /** + * How many items to show in list. + */ + page_size?: string; + /** + * Which page to show. If `page_index` is 1, show page 2. + */ + page_index?: string; + /** + * If any value is present, show the alert detail view for the selected alert. Should be an ID for an alert event. + */ + selected_alert?: string; +} + +/** + * Query params to pass to the alert API when fetching new data. + */ +export interface AlertsAPIQueryParams { + /** + * Number of results to return. + */ + page_size?: string; + /** + * 0-based index of 'page' to return. + */ + page_index?: string; +} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx new file mode 100644 index 00000000000000..37847553d512ad --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx @@ -0,0 +1,189 @@ +/* + * 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 React from 'react'; +import * as reactTestingLibrary from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { I18nProvider } from '@kbn/i18n/react'; +import { AlertIndex } from './index'; +import { appStoreFactory } from '../../store'; +import { coreMock } from 'src/core/public/mocks'; +import { fireEvent, waitForElement, act } from '@testing-library/react'; +import { RouteCapture } from '../route_capture'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { AppAction } from '../../types'; +import { mockAlertResultList } from '../../store/alerts/mock_alert_result_list'; + +describe('when on the alerting page', () => { + let render: () => reactTestingLibrary.RenderResult; + let history: MemoryHistory; + let store: ReturnType; + + /** + * @testing-library/react provides `queryByTestId`, but that uses the data attribute + * 'data-testid' whereas our FTR and EUI's tests all use 'data-test-subj'. While @testing-library/react + * could be configured to use 'data-test-subj', it is not currently configured that way. + * + * This provides an equivalent function to `queryByTestId` but that uses our 'data-test-subj' attribute. + */ + let queryByTestSubjId: ( + renderResult: reactTestingLibrary.RenderResult, + testSubjId: string + ) => Promise; + + beforeEach(async () => { + /** + * Create a 'history' instance that is only in-memory and causes no side effects to the testing environment. + */ + history = createMemoryHistory(); + /** + * Create a store, with the middleware disabled. We don't want side effects being created by our code in this test. + */ + store = appStoreFactory(coreMock.createStart(), true); + /** + * Render the test component, use this after setting up anything in `beforeEach`. + */ + render = () => { + /** + * Provide the store via `Provider`, and i18n APIs via `I18nProvider`. + * Use react-router via `Router`, passing our in-memory `history` instance. + * Use `RouteCapture` to emit url-change actions when the URL is changed. + * Finally, render the `AlertIndex` component which we are testing. + */ + return reactTestingLibrary.render( + + + + + + + + + + ); + }; + queryByTestSubjId = async (renderResult, testSubjId) => { + return await waitForElement( + /** + * Use document.body instead of container because EUI renders things like popover out of the DOM heirarchy. + */ + () => document.body.querySelector(`[data-test-subj="${testSubjId}"]`), + { + container: renderResult.container, + } + ); + }; + }); + it('should show a data grid', async () => { + await render().findByTestId('alertListGrid'); + }); + describe('when there is no selected alert in the url', () => { + it('should not show the flyout', () => { + expect(render().queryByTestId('alertDetailFlyout')).toBeNull(); + }); + describe('when data loads', () => { + beforeEach(() => { + /** + * Dispatch the `serverReturnedAlertsData` action, which is normally dispatched by the middleware + * after interacting with the server. + */ + reactTestingLibrary.act(() => { + const action: AppAction = { + type: 'serverReturnedAlertsData', + payload: mockAlertResultList(), + }; + store.dispatch(action); + }); + }); + it('should render the alert summary row in the grid', async () => { + const renderResult = render(); + const rows = await renderResult.findAllByRole('row'); + + /** + * There should be a 'row' which is the header, and + * row which is the alert item. + */ + expect(rows).toHaveLength(2); + }); + describe('when the user has clicked the alert type in the grid', () => { + let renderResult: reactTestingLibrary.RenderResult; + beforeEach(async () => { + renderResult = render(); + /** + * This is the cell with the alert type, it has a link. + */ + fireEvent.click(await renderResult.findByTestId('alertTypeCellLink')); + }); + it('should show the flyout', async () => { + await renderResult.findByTestId('alertDetailFlyout'); + }); + }); + }); + }); + describe('when there is a selected alert in the url', () => { + beforeEach(() => { + reactTestingLibrary.act(() => { + history.push({ + ...history.location, + search: '?selected_alert=1', + }); + }); + }); + it('should show the flyout', async () => { + await render().findByTestId('alertDetailFlyout'); + }); + describe('when the user clicks the close button on the flyout', () => { + let renderResult: reactTestingLibrary.RenderResult; + beforeEach(async () => { + renderResult = render(); + /** + * Use our helper function to find the flyout's close button, as it uses a different test ID attribute. + */ + const closeButton = await queryByTestSubjId(renderResult, 'euiFlyoutCloseButton'); + if (closeButton) { + fireEvent.click(closeButton); + } + }); + it('should no longer show the flyout', () => { + expect(render().queryByTestId('alertDetailFlyout')).toBeNull(); + }); + }); + }); + describe('when the url has page_size=1 and a page_index=1', () => { + beforeEach(() => { + reactTestingLibrary.act(() => { + history.push({ + ...history.location, + search: '?page_size=1&page_index=1', + }); + }); + }); + describe('when the user changes page size to 10', () => { + beforeEach(async () => { + const renderResult = render(); + const paginationButton = await queryByTestSubjId( + renderResult, + 'tablePaginationPopoverButton' + ); + if (paginationButton) { + act(() => { + fireEvent.click(paginationButton); + }); + } + const show10RowsButton = await queryByTestSubjId(renderResult, 'tablePagination-10-rows'); + if (show10RowsButton) { + act(() => { + fireEvent.click(show10RowsButton); + }); + } + }); + it('should have a page_index of 0', () => { + expect(history.location.search).toBe('?page_size=10'); + }); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx index 045b82200b11b8..6f88727575557d 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx @@ -6,16 +6,30 @@ import { memo, useState, useMemo, useCallback } from 'react'; import React from 'react'; -import { EuiDataGrid, EuiDataGridColumn, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; +import { + EuiDataGrid, + EuiDataGridColumn, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiBadge, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useHistory } from 'react-router-dom'; +import { useHistory, Link } from 'react-router-dom'; +import { FormattedDate } from 'react-intl'; +import { urlFromQueryParams } from './url_from_query_params'; +import { AlertData } from '../../../../../common/types'; import * as selectors from '../../store/alerts/selectors'; import { useAlertListSelector } from './hooks/use_alerts_selector'; export const AlertIndex = memo(() => { const history = useHistory(); - const columns: EuiDataGridColumn[] = useMemo(() => { + const columns = useMemo((): EuiDataGridColumn[] => { return [ { id: 'alert_type', @@ -69,22 +83,48 @@ export const AlertIndex = memo(() => { }, []); const { pageIndex, pageSize, total } = useAlertListSelector(selectors.alertListPagination); - const urlFromNewPageSizeParam = useAlertListSelector(selectors.urlFromNewPageSizeParam); - const urlFromNewPageIndexParam = useAlertListSelector(selectors.urlFromNewPageIndexParam); const alertListData = useAlertListSelector(selectors.alertListData); + const hasSelectedAlert = useAlertListSelector(selectors.hasSelectedAlert); + const queryParams = useAlertListSelector(selectors.uiQueryParams); const onChangeItemsPerPage = useCallback( - newPageSize => history.push(urlFromNewPageSizeParam(newPageSize)), - [history, urlFromNewPageSizeParam] + newPageSize => { + const newQueryParms = { ...queryParams }; + newQueryParms.page_size = newPageSize; + delete newQueryParms.page_index; + const relativeURL = urlFromQueryParams(newQueryParms); + return history.push(relativeURL); + }, + [history, queryParams] ); const onChangePage = useCallback( - newPageIndex => history.push(urlFromNewPageIndexParam(newPageIndex)), - [history, urlFromNewPageIndexParam] + newPageIndex => { + return history.push( + urlFromQueryParams({ + ...queryParams, + page_index: newPageIndex, + }) + ); + }, + [history, queryParams] ); const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }) => id)); + const handleFlyoutClose = useCallback(() => { + const { selected_alert, ...paramsWithoutSelectedAlert } = queryParams; + history.push(urlFromQueryParams(paramsWithoutSelectedAlert)); + }, [history, queryParams]); + + const datesForRows: Map = useMemo(() => { + return new Map( + alertListData.map(alertData => { + return [alertData, new Date(alertData['@timestamp'])]; + }) + ); + }, [alertListData]); + const renderCellValue = useMemo(() => { return ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { if (rowIndex > total) { @@ -94,11 +134,18 @@ export const AlertIndex = memo(() => { const row = alertListData[rowIndex % pageSize]; if (columnId === 'alert_type') { - return i18n.translate( - 'xpack.endpoint.application.endpoint.alerts.alertType.maliciousFileDescription', - { - defaultMessage: 'Malicious File', - } + return ( + + {i18n.translate( + 'xpack.endpoint.application.endpoint.alerts.alertType.maliciousFileDescription', + { + defaultMessage: 'Malicious File', + } + )} + ); } else if (columnId === 'event_type') { return row.event.action; @@ -109,7 +156,31 @@ export const AlertIndex = memo(() => { } else if (columnId === 'host_name') { return row.host.hostname; } else if (columnId === 'timestamp') { - return row['@timestamp']; + const date = datesForRows.get(row)!; + if (date && isFinite(date.getTime())) { + return ( + + ); + } else { + return ( + + {i18n.translate( + 'xpack.endpoint.application.endpoint.alerts.alertDate.timestampInvalidLabel', + { + defaultMessage: 'invalid', + } + )} + + ); + } } else if (columnId === 'archived') { return null; } else if (columnId === 'malware_score') { @@ -117,7 +188,7 @@ export const AlertIndex = memo(() => { } return null; }; - }, [alertListData, pageSize, total]); + }, [alertListData, datesForRows, pageSize, queryParams, total]); const pagination = useMemo(() => { return { @@ -130,23 +201,43 @@ export const AlertIndex = memo(() => { }, [onChangeItemsPerPage, onChangePage, pageIndex, pageSize]); return ( - - - - - - - + <> + {hasSelectedAlert && ( + + + +

+ {i18n.translate('xpack.endpoint.application.endpoint.alerts.detailsTitle', { + defaultMessage: 'Alert Details', + })} +

+
+
+ +
+ )} + + + + ({ + visibleColumns, + setVisibleColumns, + }), + [setVisibleColumns, visibleColumns] + )} + renderCellValue={renderCellValue} + pagination={pagination} + data-test-subj="alertListGrid" + data-testid="alertListGrid" + /> + + + + ); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/url_from_query_params.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/url_from_query_params.ts new file mode 100644 index 00000000000000..e037d000e6e8f6 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/url_from_query_params.ts @@ -0,0 +1,31 @@ +/* + * 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 querystring from 'querystring'; +import { AlertingIndexUIQueryParams, EndpointAppLocation } from '../../types'; + +/** + * Return a relative URL for `AlertingIndexUIQueryParams`. + * usage: + * + * ```ts + * // Replace this with however you get state, e.g. useSelector in react + * const queryParams = selectors.uiQueryParams(store.getState()) + * + * // same as current url, but page_index is now 3 + * const relativeURL = urlFromQueryParams({ ...queryParams, page_index: 3 }) + * + * // now use relativeURL in the 'href' of a link, the 'to' of a react-router-dom 'Link' or history.push, history.replace + * ``` + */ +export function urlFromQueryParams( + queryParams: AlertingIndexUIQueryParams +): Partial { + const search = querystring.stringify(queryParams); + return { + search, + }; +} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/route_capture.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/route_capture.tsx new file mode 100644 index 00000000000000..28d2019b568881 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/route_capture.tsx @@ -0,0 +1,21 @@ +/* + * 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 React, { memo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { EndpointAppLocation, AppAction } from '../types'; + +/** + * This component should be used above all routes, but below the Provider. + * It dispatches actions when the URL is changed. + */ +export const RouteCapture = memo(({ children }) => { + const location: EndpointAppLocation = useLocation(); + const dispatch: (action: AppAction) => unknown = useDispatch(); + dispatch({ type: 'userChangedUrl', payload: location }); + return <>{children}; +}); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts index 4d12e656205fae..25d08a8c347ed5 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -31,7 +31,6 @@ export const inverseProjectionMatrix = composeSelectors( /** * The scale by which world values are scaled when rendered. - * TODO make it a number */ export const scale = composeSelectors(cameraStateSelector, cameraSelectors.scale); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx index 85e1d4e694b150..f4abb51f062f2a 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -/** - * This import must be hoisted as it uses `jest.mock`. Is there a better way? Mocking is not good. - */ import React from 'react'; import { render, act, RenderResult, fireEvent } from '@testing-library/react'; import { useCamera } from './use_camera'; diff --git a/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.test.ts b/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.test.ts index a4d7de8fdcfdb1..3ef1142b9ce465 100644 --- a/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.test.ts +++ b/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.test.ts @@ -16,26 +16,36 @@ describe('test query builder', () => { config: () => Promise.resolve(EndpointConfigSchema.validate({})), }; const queryParams = await getPagingProperties(mockRequest, mockCtx); - const query = await buildAlertListESQuery(queryParams); + const query = buildAlertListESQuery(queryParams); - expect(query).toEqual({ - body: { - query: { - match_all: {}, - }, - sort: [ - { - '@timestamp': { - order: 'desc', + expect(query).toMatchInlineSnapshot(` + Object { + "body": Object { + "query": Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "event.kind": "alert", + }, + }, + ], }, }, - ], - track_total_hits: 10000, - }, - from: 0, - size: 10, - index: 'my-index', - } as Record); + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + "track_total_hits": 10000, + }, + "from": 0, + "index": "my-index", + "size": 10, + } + `); }); it('should adjust track_total_hits for deep pagination', async () => { const mockRequest = httpServerMock.createKibanaRequest({ @@ -49,26 +59,36 @@ describe('test query builder', () => { config: () => Promise.resolve(EndpointConfigSchema.validate({})), }; const queryParams = await getPagingProperties(mockRequest, mockCtx); - const query = await buildAlertListESQuery(queryParams); + const query = buildAlertListESQuery(queryParams); - expect(query).toEqual({ - body: { - query: { - match_all: {}, - }, - sort: [ - { - '@timestamp': { - order: 'desc', + expect(query).toMatchInlineSnapshot(` + Object { + "body": Object { + "query": Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "event.kind": "alert", + }, + }, + ], }, }, - ], - track_total_hits: 12000, - }, - from: 10000, - size: 1000, - index: 'my-index', - } as Record); + "sort": Array [ + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + "track_total_hits": 12000, + }, + "from": 10000, + "index": "my-index", + "size": 1000, + } + `); }); }); }); diff --git a/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.ts b/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.ts index a20f2ae1cdecd5..e56ae43ef5c765 100644 --- a/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.ts +++ b/x-pack/plugins/endpoint/server/services/endpoint/alert_query_builders.ts @@ -7,9 +7,9 @@ import { KibanaRequest } from 'kibana/server'; import { EndpointAppConstants } from '../../../common/types'; import { EndpointAppContext, AlertRequestParams, JSONish } from '../../types'; -export const buildAlertListESQuery = async ( +export const buildAlertListESQuery: ( pagingProperties: Record -): Promise => { +) => JSONish = pagingProperties => { const DEFAULT_TOTAL_HITS = 10000; // Calculate minimum total hits set to indicate there's a next page @@ -22,7 +22,15 @@ export const buildAlertListESQuery = async ( body: { track_total_hits: totalHitsMin, query: { - match_all: {}, + bool: { + must: [ + { + match: { + 'event.kind': 'alert', + }, + }, + ], + }, }, sort: [ {