[Endpoint] Add a flyout to alert list. (#57926) (#58275)

* Filter alert API so it shows only Alerts instead of all documents in the index
* Clicking an item in the alert list will open a flyout
This commit is contained in:
Robert Austin 2020-02-22 00:30:32 -05:00 committed by GitHub
parent 6a86b57242
commit 15880098cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 650 additions and 224 deletions

View file

@ -71,7 +71,7 @@ export interface EndpointResultList {
}
export interface AlertData {
'@timestamp': Date;
'@timestamp': string;
agent: {
id: string;
version: string;

View file

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

View file

@ -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<AlertListState, AppAction>;
@ -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;
});

View file

@ -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<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let history: History<never>;
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",
}
`);
});
});
});
});
});

View file

@ -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<AlertListState> = coreStart => {
return api => next => async (action: AppAction) => {
@ -16,7 +15,7 @@ export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = 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 });
}

View file

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

View file

@ -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
* Returns the query object received from parsing the browsers URL query params.
* Used to calculate urls for links and such.
*/
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
*/
export const urlFromNewPageSizeParam: (
export const uiQueryParams: (
state: AlertListState
) => (newPageSize: number) => string = state => {
return newPageSize => {
const urlPaginationData = paginationDataFromUrl(state);
urlPaginationData.page_size = newPageSize.toString();
) => Immutable<AlertingIndexUIQueryParams> = 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<keyof AlertingIndexUIQueryParams> = [
'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<AlertsAPIQueryParams> = createSelector(
uiQueryParams,
({ page_size, page_index }) => ({
page_size,
page_index,
})
);
export const hasSelectedAlert: (state: AlertListState) => boolean = createSelector(
uiQueryParams,
({ selected_alert: selectedAlert }) => selectedAlert !== undefined
);

View file

@ -48,25 +48,36 @@ export const substateMiddlewareFactory = <Substate>(
};
};
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;

View file

@ -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<S = GlobalState> = (
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<AlertResultList> & {
readonly location?: Immutable<EndpointAppLocation>;
};
/**
* 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;
}

View file

@ -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<never>;
let store: ReturnType<typeof appStoreFactory>;
/**
* @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<Element | null>;
beforeEach(async () => {
/**
* Create a 'history' instance that is only in-memory and causes no side effects to the testing environment.
*/
history = createMemoryHistory<never>();
/**
* 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(
<Provider store={store}>
<I18nProvider>
<Router history={history}>
<RouteCapture>
<AlertIndex />
</RouteCapture>
</Router>
</I18nProvider>
</Provider>
);
};
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');
});
});
});
});

View file

@ -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<AlertData, Date> = 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 (
<Link
data-testid="alertTypeCellLink"
to={urlFromQueryParams({ ...queryParams, selected_alert: 'TODO' })}
>
{i18n.translate(
'xpack.endpoint.application.endpoint.alerts.alertType.maliciousFileDescription',
{
defaultMessage: 'Malicious File',
}
)}
</Link>
);
} 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 (
<FormattedDate
value={date}
year="numeric"
month="2-digit"
day="2-digit"
hour="2-digit"
minute="2-digit"
second="2-digit"
/>
);
} else {
return (
<EuiBadge color="warning">
{i18n.translate(
'xpack.endpoint.application.endpoint.alerts.alertDate.timestampInvalidLabel',
{
defaultMessage: 'invalid',
}
)}
</EuiBadge>
);
}
} 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 (
<EuiPage data-test-subj="alertListPage">
<EuiPageBody>
<EuiPageContent>
<EuiDataGrid
aria-label="Alert List"
rowCount={total}
columns={columns}
columnVisibility={{
visibleColumns,
setVisibleColumns,
}}
renderCellValue={renderCellValue}
pagination={pagination}
data-test-subj="alertListGrid"
/>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
<>
{hasSelectedAlert && (
<EuiFlyout data-testid="alertDetailFlyout" size="l" onClose={handleFlyoutClose}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
{i18n.translate('xpack.endpoint.application.endpoint.alerts.detailsTitle', {
defaultMessage: 'Alert Details',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody />
</EuiFlyout>
)}
<EuiPage data-test-subj="alertListPage" data-testid="alertListPage">
<EuiPageBody>
<EuiPageContent>
<EuiDataGrid
aria-label="Alert List"
rowCount={total}
columns={columns}
columnVisibility={useMemo(
() => ({
visibleColumns,
setVisibleColumns,
}),
[setVisibleColumns, visibleColumns]
)}
renderCellValue={renderCellValue}
pagination={pagination}
data-test-subj="alertListGrid"
data-testid="alertListGrid"
/>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</>
);
});

View file

@ -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<EndpointAppLocation> {
const search = querystring.stringify(queryParams);
return {
search,
};
}

View file

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

View file

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

View file

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

View file

@ -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<string, any>);
"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<string, any>);
"sort": Array [
Object {
"@timestamp": Object {
"order": "desc",
},
},
],
"track_total_hits": 12000,
},
"from": 10000,
"index": "my-index",
"size": 1000,
}
`);
});
});
});

View file

@ -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<string, number>
): Promise<JSONish> => {
) => 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: [
{