mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
* 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:
parent
6a86b57242
commit
15880098cd
17 changed files with 650 additions and 224 deletions
|
@ -71,7 +71,7 @@ export interface EndpointResultList {
|
|||
}
|
||||
|
||||
export interface AlertData {
|
||||
'@timestamp': Date;
|
||||
'@timestamp': string;
|
||||
agent: {
|
||||
id: string;
|
||||
version: string;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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}</>;
|
||||
});
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue