[Security Solution][Detections] - Add skeleton exceptions list tab to all rules page (#85465)

## Summary

This PR is the first of 2 to complete the addition of a table displaying all exception lists on the all rules page. This PR focuses on the following:

- all exception lists displayed
- 'number of rules assigned to' displayed
- names and links of rules assigned to displayed
- refresh action button working
- no trusted apps list show
- search by `name`, `created_by`, `list_id`
  - just searching a word will search by list name
  - to search by `created_by` type `created_by:ytercero`
  - to search by `list_id` type `list_id:some-list-id`

#### TO DO (follow up PR)
- [ ] add tests
- [ ] wire up export of exception list
- [ ] wire up deletion of exception list

<img width="1121" alt="Screen Shot 2020-12-09 at 2 10 59 PM" src="https://user-images.githubusercontent.com/10927944/101676548-50498e00-3a29-11eb-90cb-5f56fc8c0a1b.png">

### Checklist
- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials
- [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Yara Tercero 2020-12-16 00:45:18 -05:00 committed by GitHub
parent 4dccbcad33
commit be055b85b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2268 additions and 613 deletions

View file

@ -48,7 +48,7 @@ pageLoadAssetSize:
lensOss: 19341
licenseManagement: 41817
licensing: 39008
lists: 183665
lists: 202261
logstash: 53548
management: 46112
maps: 183610

View file

@ -22,7 +22,7 @@ export const getFindExceptionListSchemaMock = (): FindExceptionListSchema => ({
export const getFindExceptionListSchemaDecodedMock = (): FindExceptionListSchemaDecoded => ({
filter: FILTER,
namespace_type: NAMESPACE_TYPE,
namespace_type: [NAMESPACE_TYPE],
page: 1,
per_page: 25,
sort_field: undefined,

View file

@ -37,7 +37,7 @@ describe('find_exception_list_schema', () => {
expect(getPaths(left(message.errors))).toEqual([]);
const expected: FindExceptionListSchemaDecoded = {
filter: undefined,
namespace_type: 'single',
namespace_type: ['single'],
page: undefined,
per_page: undefined,
sort_field: undefined,

View file

@ -6,15 +6,15 @@
import * as t from 'io-ts';
import { filter, namespace_type, sort_field, sort_order } from '../common/schemas';
import { filter, sort_field, sort_order } from '../common/schemas';
import { RequiredKeepUndefined } from '../../types';
import { StringToPositiveNumber } from '../types/string_to_positive_number';
import { NamespaceType } from '../types';
import { DefaultNamespaceArray, NamespaceTypeArray } from '../types/default_namespace_array';
export const findExceptionListSchema = t.exact(
t.partial({
filter, // defaults to undefined if not set during decode
namespace_type, // defaults to 'single' if not set during decode
namespace_type: DefaultNamespaceArray, // defaults to 'single' if not set during decode
page: StringToPositiveNumber, // defaults to undefined if not set during decode
per_page: StringToPositiveNumber, // defaults to undefined if not set during decode
sort_field, // defaults to undefined if not set during decode
@ -29,5 +29,5 @@ export type FindExceptionListSchemaDecoded = Omit<
RequiredKeepUndefined<t.TypeOf<typeof findExceptionListSchema>>,
'namespace_type'
> & {
namespace_type: NamespaceType;
namespace_type: NamespaceTypeArray;
};

View file

@ -4,6 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const exceptionListSavedObjectType = 'exception-list';
export const exceptionListAgnosticSavedObjectType = 'exception-list-agnostic';
export type SavedObjectType = 'exception-list' | 'exception-list-agnostic';
/**
* This makes any optional property the same as Required<T> would but also has the
* added benefit of keeping your undefined.

View file

@ -17,6 +17,7 @@ import {
ExceptionListItemSchema,
ExceptionListSchema,
} from '../../common/schemas';
import { getFoundExceptionListSchemaMock } from '../../common/schemas/response/found_exception_list_schema.mock';
import {
addEndpointExceptionList,
@ -26,11 +27,12 @@ import {
deleteExceptionListItemById,
fetchExceptionListById,
fetchExceptionListItemById,
fetchExceptionLists,
fetchExceptionListsItemsByListIds,
updateExceptionList,
updateExceptionListItem,
} from './api';
import { ApiCallByIdProps, ApiCallByListIdProps } from './types';
import { ApiCallByIdProps, ApiCallByListIdProps, ApiCallFetchExceptionListsProps } from './types';
const abortCtrl = new AbortController();
@ -289,6 +291,87 @@ describe('Exceptions Lists API', () => {
});
});
describe('#fetchExceptionLists', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getFoundExceptionListSchemaMock());
});
test('it invokes "fetchExceptionLists" with expected url and body values', async () => {
await fetchExceptionLists({
filters: 'exception-list.attributes.name: Sample Endpoint',
http: httpMock,
namespaceTypes: 'single,agnostic',
pagination: {
page: 1,
perPage: 20,
},
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith('/api/exception_lists/_find', {
method: 'GET',
query: {
filter: 'exception-list.attributes.name: Sample Endpoint',
namespace_type: 'single,agnostic',
page: '1',
per_page: '20',
sort_field: 'exception-list.created_at',
sort_order: 'desc',
},
signal: abortCtrl.signal,
});
});
test('it returns expected exception list on success', async () => {
const exceptionResponse = await fetchExceptionLists({
filters: 'exception-list.attributes.name: Sample Endpoint',
http: httpMock,
namespaceTypes: 'single,agnostic',
pagination: {
page: 1,
perPage: 20,
},
signal: abortCtrl.signal,
});
expect(exceptionResponse.data).toEqual([getExceptionListSchemaMock()]);
});
test('it returns error and does not make request if request payload fails decode', async () => {
const payload = ({
filters: 'exception-list.attributes.name: Sample Endpoint',
http: httpMock,
namespaceTypes: 'notANamespaceType',
pagination: {
page: 1,
perPage: 20,
},
signal: abortCtrl.signal,
} as unknown) as ApiCallFetchExceptionListsProps & { namespaceTypes: string[] };
await expect(fetchExceptionLists(payload)).rejects.toEqual(
'Invalid value "notANamespaceType" supplied to "namespace_type"'
);
});
test('it returns error if response payload fails decode', async () => {
const badPayload = getExceptionListSchemaMock();
// @ts-expect-error
delete badPayload.id;
httpMock.fetch.mockResolvedValue({ data: [badPayload], page: 1, per_page: 20, total: 1 });
await expect(
fetchExceptionLists({
filters: 'exception-list.attributes.name: Sample Endpoint',
http: httpMock,
namespaceTypes: 'single,agnostic',
pagination: {
page: 1,
perPage: 20,
},
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "undefined" supplied to "data,id"');
});
});
describe('#fetchExceptionListById', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getExceptionListSchemaMock());

View file

@ -15,6 +15,7 @@ import {
ExceptionListItemSchema,
ExceptionListSchema,
FoundExceptionListItemSchema,
FoundExceptionListSchema,
createEndpointListSchema,
createExceptionListItemSchema,
createExceptionListSchema,
@ -23,7 +24,9 @@ import {
exceptionListItemSchema,
exceptionListSchema,
findExceptionListItemSchema,
findExceptionListSchema,
foundExceptionListItemSchema,
foundExceptionListSchema,
readExceptionListItemSchema,
readExceptionListSchema,
updateExceptionListItemSchema,
@ -37,6 +40,7 @@ import {
AddExceptionListProps,
ApiCallByIdProps,
ApiCallByListIdProps,
ApiCallFetchExceptionListsProps,
UpdateExceptionListItemProps,
UpdateExceptionListProps,
} from './types';
@ -201,6 +205,58 @@ export const updateExceptionListItem = async ({
}
};
/**
* Fetch all ExceptionLists (optionally by namespaceType)
*
* @param http Kibana http service
* @param namespaceTypes ExceptionList namespace_types of lists to find
* @param filters search bar filters
* @param pagination optional
* @param signal to cancel request
*
* @throws An error if request params or response is not OK
*/
export const fetchExceptionLists = async ({
http,
filters,
namespaceTypes,
pagination,
signal,
}: ApiCallFetchExceptionListsProps): Promise<FoundExceptionListSchema> => {
const query = {
filter: filters,
namespace_type: namespaceTypes,
page: pagination.page ? `${pagination.page}` : '1',
per_page: pagination.perPage ? `${pagination.perPage}` : '20',
sort_field: 'exception-list.created_at',
sort_order: 'desc',
};
const [validatedRequest, errorsRequest] = validate(query, findExceptionListSchema);
if (validatedRequest != null) {
try {
const response = await http.fetch<ExceptionListSchema>(`${EXCEPTION_LIST_URL}/_find`, {
method: 'GET',
query,
signal,
});
const [validatedResponse, errorsResponse] = validate(response, foundExceptionListSchema);
if (errorsResponse != null || validatedResponse == null) {
return Promise.reject(errorsResponse);
} else {
return Promise.resolve(validatedResponse);
}
} catch (error) {
return Promise.reject(error);
}
} else {
return Promise.reject(errorsRequest);
}
};
/**
* Fetch an ExceptionList by providing a ExceptionList ID
*

View file

@ -10,13 +10,13 @@ import { coreMock } from '../../../../../../src/core/public/mocks';
import * as api from '../api';
import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock';
import { ExceptionListItemSchema } from '../../../common/schemas';
import { UseExceptionListProps, UseExceptionListSuccess } from '../types';
import { UseExceptionListItemsSuccess, UseExceptionListProps } from '../types';
import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list';
import { ReturnExceptionListAndItems, useExceptionListItems } from './use_exception_list_items';
const mockKibanaHttpService = coreMock.createStart().http;
describe('useExceptionList', () => {
describe('useExceptionListItems', () => {
const onErrorMock = jest.fn();
beforeEach(() => {
@ -36,7 +36,7 @@ describe('useExceptionList', () => {
UseExceptionListProps,
ReturnExceptionListAndItems
>(() =>
useExceptionList({
useExceptionListItems({
filterOptions: [],
http: mockKibanaHttpService,
lists: [
@ -75,7 +75,7 @@ describe('useExceptionList', () => {
UseExceptionListProps,
ReturnExceptionListAndItems
>(() =>
useExceptionList({
useExceptionListItems({
filterOptions: [],
http: mockKibanaHttpService,
lists: [
@ -100,7 +100,7 @@ describe('useExceptionList', () => {
const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock()
.data;
const expectedResult: UseExceptionListSuccess = {
const expectedResult: UseExceptionListItemsSuccess = {
exceptions: expectedListItemsResult,
pagination: { page: 1, perPage: 1, total: 1 },
};
@ -129,7 +129,7 @@ describe('useExceptionList', () => {
const onSuccessMock = jest.fn();
const { waitForNextUpdate } = renderHook<UseExceptionListProps, ReturnExceptionListAndItems>(
() =>
useExceptionList({
useExceptionListItems({
filterOptions: [],
http: mockKibanaHttpService,
lists: [
@ -179,7 +179,7 @@ describe('useExceptionList', () => {
const onSuccessMock = jest.fn();
const { waitForNextUpdate } = renderHook<UseExceptionListProps, ReturnExceptionListAndItems>(
() =>
useExceptionList({
useExceptionListItems({
filterOptions: [],
http: mockKibanaHttpService,
lists: [
@ -231,7 +231,7 @@ describe('useExceptionList', () => {
UseExceptionListProps,
ReturnExceptionListAndItems
>(() =>
useExceptionList({
useExceptionListItems({
filterOptions: [],
http: mockKibanaHttpService,
lists: [
@ -278,7 +278,7 @@ describe('useExceptionList', () => {
const onSuccessMock = jest.fn();
const { waitForNextUpdate } = renderHook<UseExceptionListProps, ReturnExceptionListAndItems>(
() =>
useExceptionList({
useExceptionListItems({
filterOptions: [{ filter: 'host.name', tags: [] }],
http: mockKibanaHttpService,
lists: [
@ -343,7 +343,7 @@ describe('useExceptionList', () => {
showDetectionsListsOnly,
showEndpointListsOnly,
}) =>
useExceptionList({
useExceptionListItems({
filterOptions,
http,
lists,
@ -413,7 +413,7 @@ describe('useExceptionList', () => {
UseExceptionListProps,
ReturnExceptionListAndItems
>(() =>
useExceptionList({
useExceptionListItems({
filterOptions: [],
http: mockKibanaHttpService,
lists: [
@ -455,7 +455,7 @@ describe('useExceptionList', () => {
await act(async () => {
const { waitForNextUpdate } = renderHook<UseExceptionListProps, ReturnExceptionListAndItems>(
() =>
useExceptionList({
useExceptionListItems({
filterOptions: [],
http: mockKibanaHttpService,
lists: [

View file

@ -34,7 +34,7 @@ export type ReturnExceptionListAndItems = [
* @param pagination optional
*
*/
export const useExceptionList = ({
export const useExceptionListItems = ({
http,
lists,
pagination = {

View file

@ -0,0 +1,348 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { coreMock } from '../../../../../../src/core/public/mocks';
import * as api from '../api';
import { getFoundExceptionListSchemaMock } from '../../../common/schemas/response/found_exception_list_schema.mock';
import { ExceptionListSchema } from '../../../common/schemas';
import { UseExceptionListsProps } from '../types';
import { ReturnExceptionLists, useExceptionLists } from './use_exception_lists';
const mockKibanaHttpService = coreMock.createStart().http;
const mockKibanaNotificationsService = coreMock.createStart().notifications;
describe('useExceptionLists', () => {
beforeEach(() => {
jest.spyOn(api, 'fetchExceptionLists').mockResolvedValue(getFoundExceptionListSchemaMock());
});
afterEach(() => {
jest.clearAllMocks();
});
test('initializes hook', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseExceptionListsProps,
ReturnExceptionLists
>(() =>
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
})
);
await waitForNextUpdate();
expect(result.current).toEqual([
true,
[],
{
page: 1,
perPage: 20,
total: 0,
},
null,
]);
});
});
test('fetches exception lists', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseExceptionListsProps,
ReturnExceptionLists
>(() =>
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
// Second call applies the params
await waitForNextUpdate();
await waitForNextUpdate();
const expectedListItemsResult: ExceptionListSchema[] = getFoundExceptionListSchemaMock().data;
expect(result.current).toEqual([
false,
expectedListItemsResult,
{
page: 1,
perPage: 1,
total: 1,
},
result.current[3],
]);
});
});
test('fetches trusted apps lists if "showTrustedApps" is true', async () => {
const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
await act(async () => {
const { waitForNextUpdate } = renderHook<UseExceptionListsProps, ReturnExceptionLists>(() =>
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: true,
})
);
// NOTE: First `waitForNextUpdate` is initialization
// Second call applies the params
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
filters:
'(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)',
http: mockKibanaHttpService,
namespaceTypes: 'single,agnostic',
pagination: { page: 1, perPage: 20 },
signal: new AbortController().signal,
});
});
});
test('does not fetch trusted apps lists if "showTrustedApps" is false', async () => {
const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
await act(async () => {
const { waitForNextUpdate } = renderHook<UseExceptionListsProps, ReturnExceptionLists>(() =>
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
// Second call applies the params
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
filters:
'(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)',
http: mockKibanaHttpService,
namespaceTypes: 'single,agnostic',
pagination: { page: 1, perPage: 20 },
signal: new AbortController().signal,
});
});
});
test('applies filters to query', async () => {
const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
await act(async () => {
const { waitForNextUpdate } = renderHook<UseExceptionListsProps, ReturnExceptionLists>(() =>
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {
created_by: 'Moi',
name: 'Sample Endpoint',
},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
// Second call applies the params
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({
filters:
'(exception-list.attributes.created_by:Moi* OR exception-list-agnostic.attributes.created_by:Moi*) AND (exception-list.attributes.name:Sample Endpoint* OR exception-list-agnostic.attributes.name:Sample Endpoint*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)',
http: mockKibanaHttpService,
namespaceTypes: 'single,agnostic',
pagination: { page: 1, perPage: 20 },
signal: new AbortController().signal,
});
});
});
test('fetches a new exception list and its items when props change', async () => {
const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
await act(async () => {
const { rerender, waitForNextUpdate } = renderHook<
UseExceptionListsProps,
ReturnExceptionLists
>(
({
errorMessage,
filterOptions,
http,
namespaceTypes,
notifications,
pagination,
showTrustedApps,
}) =>
useExceptionLists({
errorMessage,
filterOptions,
http,
namespaceTypes,
notifications,
pagination,
showTrustedApps,
}),
{
initialProps: {
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
},
}
);
// NOTE: First `waitForNextUpdate` is initialization
// Second call applies the params
await waitForNextUpdate();
await waitForNextUpdate();
rerender({
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
});
// NOTE: Only need one call here because hook already initilaized
await waitForNextUpdate();
expect(spyOnfetchExceptionLists).toHaveBeenCalledTimes(2);
});
});
test('fetches list when refreshExceptionList callback invoked', async () => {
const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseExceptionListsProps,
ReturnExceptionLists
>(() =>
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
// Second call applies the params
await waitForNextUpdate();
await waitForNextUpdate();
expect(typeof result.current[3]).toEqual('function');
if (result.current[3] != null) {
result.current[3]();
}
// NOTE: Only need one call here because hook already initilaized
await waitForNextUpdate();
expect(spyOnfetchExceptionLists).toHaveBeenCalledTimes(2);
});
});
test('invokes notifications service if "fetchExceptionLists" fails', async () => {
const mockError = new Error('failed to fetches list items');
const spyOnfetchExceptionLists = jest
.spyOn(api, 'fetchExceptionLists')
.mockRejectedValue(mockError);
await act(async () => {
const { waitForNextUpdate } = renderHook<UseExceptionListsProps, ReturnExceptionLists>(() =>
useExceptionLists({
errorMessage: 'Uh oh',
filterOptions: {},
http: mockKibanaHttpService,
namespaceTypes: ['single', 'agnostic'],
notifications: mockKibanaNotificationsService,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
showTrustedApps: false,
})
);
// NOTE: First `waitForNextUpdate` is initialization
// Second call applies the params
await waitForNextUpdate();
await waitForNextUpdate();
expect(mockKibanaNotificationsService.toasts.addError).toHaveBeenCalledWith(mockError, {
title: 'Uh oh',
});
expect(spyOnfetchExceptionLists).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,115 @@
/*
* 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 { useEffect, useMemo, useRef, useState } from 'react';
import { fetchExceptionLists } from '../api';
import { Pagination, UseExceptionListsProps } from '../types';
import { ExceptionListSchema } from '../../../common/schemas';
import { getFilters } from '../utils';
export type Func = () => void;
export type ReturnExceptionLists = [boolean, ExceptionListSchema[], Pagination, Func | null];
/**
* Hook for fetching ExceptionLists
*
* @param http Kibana http service
* @param errorMessage message shown to user if error occurs
* @param filterOptions filter by certain fields
* @param namespaceTypes spaces to be searched
* @param notifications kibana service for displaying toasters
* @param showTrustedApps boolean - include/exclude trusted app lists
* @param pagination
*
*/
export const useExceptionLists = ({
errorMessage,
http,
pagination = {
page: 1,
perPage: 20,
total: 0,
},
filterOptions = {},
namespaceTypes,
notifications,
showTrustedApps = false,
}: UseExceptionListsProps): ReturnExceptionLists => {
const [exceptionLists, setExceptionLists] = useState<ExceptionListSchema[]>([]);
const [paginationInfo, setPagination] = useState<Pagination>(pagination);
const [loading, setLoading] = useState(true);
const fetchExceptionListsRef = useRef<Func | null>(null);
const namespaceTypesAsString = useMemo(() => namespaceTypes.join(','), [namespaceTypes]);
const filters = useMemo(
(): string => getFilters(filterOptions, namespaceTypes, showTrustedApps),
[namespaceTypes, filterOptions, showTrustedApps]
);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const fetchData = async (): Promise<void> => {
try {
setLoading(true);
const { page, per_page: perPage, total, data } = await fetchExceptionLists({
filters,
http,
namespaceTypes: namespaceTypesAsString,
pagination: {
page: pagination.page,
perPage: pagination.perPage,
},
signal: abortCtrl.signal,
});
if (isSubscribed) {
setPagination({
page,
perPage,
total,
});
setExceptionLists(data);
setLoading(false);
}
} catch (error) {
if (isSubscribed) {
notifications.toasts.addError(error, {
title: errorMessage,
});
setExceptionLists([]);
setPagination({
page: 1,
perPage: 20,
total: 0,
});
setLoading(false);
}
}
};
fetchData();
fetchExceptionListsRef.current = fetchData;
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, [
errorMessage,
notifications,
pagination.page,
pagination.perPage,
filters,
namespaceTypesAsString,
http,
]);
return [loading, exceptionLists, paginationInfo, fetchExceptionListsRef.current];
};

View file

@ -17,7 +17,7 @@ import {
UpdateExceptionListItemSchema,
UpdateExceptionListSchema,
} from '../../common/schemas';
import { HttpStart } from '../../../../../src/core/public';
import { HttpStart, NotificationsStart } from '../../../../../src/core/public';
export interface FilterExceptionsOptions {
filter: string;
@ -43,7 +43,7 @@ export interface ExceptionList extends ExceptionListSchema {
totalItems: number;
}
export interface UseExceptionListSuccess {
export interface UseExceptionListItemsSuccess {
exceptions: ExceptionListItemSchema[];
pagination: Pagination;
}
@ -57,7 +57,7 @@ export interface UseExceptionListProps {
showDetectionsListsOnly: boolean;
showEndpointListsOnly: boolean;
matchFilters: boolean;
onSuccess?: (arg: UseExceptionListSuccess) => void;
onSuccess?: (arg: UseExceptionListItemsSuccess) => void;
}
export interface ExceptionListIdentifiers {
@ -97,7 +97,35 @@ export interface ApiCallFindListsItemsMemoProps {
showDetectionsListsOnly: boolean;
showEndpointListsOnly: boolean;
onError: (arg: string[]) => void;
onSuccess: (arg: UseExceptionListSuccess) => void;
onSuccess: (arg: UseExceptionListItemsSuccess) => void;
}
export interface ApiCallFetchExceptionListsProps {
http: HttpStart;
namespaceTypes: string;
pagination: Partial<Pagination>;
filters: string;
signal: AbortSignal;
}
export interface UseExceptionListsSuccess {
exceptions: ExceptionListSchema[];
pagination: Pagination;
}
export interface ExceptionListFilter {
name?: string | null;
list_id?: string | null;
created_by?: string | null;
}
export interface UseExceptionListsProps {
errorMessage: string;
filterOptions?: ExceptionListFilter;
http: HttpStart;
namespaceTypes: NamespaceType[];
notifications: NotificationsStart;
pagination?: Pagination;
showTrustedApps: boolean;
}
export interface AddExceptionListProps {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getIdsAndNamespaces } from './utils';
import { getFilters, getGeneralFilters, getIdsAndNamespaces, getTrustedAppsFilter } from './utils';
describe('Exceptions utils', () => {
describe('#getIdsAndNamespaces', () => {
@ -102,4 +102,169 @@ describe('Exceptions utils', () => {
});
});
});
describe('getGeneralFilters', () => {
test('it returns empty string if no filters', () => {
const filters = getGeneralFilters({}, ['exception-list']);
expect(filters).toEqual('');
});
test('it properly formats filters when one namespace type passed in', () => {
const filters = getGeneralFilters({ created_by: 'moi', name: 'Sample' }, ['exception-list']);
expect(filters).toEqual(
'(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*)'
);
});
test('it properly formats filters when two namespace types passed in', () => {
const filters = getGeneralFilters({ created_by: 'moi', name: 'Sample' }, [
'exception-list',
'exception-list-agnostic',
]);
expect(filters).toEqual(
'(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*)'
);
});
});
describe('getTrustedAppsFilter', () => {
test('it returns filter to search for "exception-list" namespace trusted apps', () => {
const filter = getTrustedAppsFilter(true, ['exception-list']);
expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)');
});
test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => {
const filter = getTrustedAppsFilter(true, ['exception-list', 'exception-list-agnostic']);
expect(filter).toEqual(
'(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it returns filter to exclude "exception-list" namespace trusted apps', () => {
const filter = getTrustedAppsFilter(false, ['exception-list']);
expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)');
});
test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => {
const filter = getTrustedAppsFilter(false, ['exception-list', 'exception-list-agnostic']);
expect(filter).toEqual(
'(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
});
describe('getFilters', () => {
describe('single', () => {
test('it properly formats when no filters passed and "showTrustedApps" is false', () => {
const filter = getFilters({}, ['single'], false);
expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)');
});
test('it properly formats when no filters passed and "showTrustedApps" is true', () => {
const filter = getFilters({}, ['single'], true);
expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)');
});
test('it properly formats when filters passed and "showTrustedApps" is false', () => {
const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], false);
expect(filter).toEqual(
'(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it if filters passed and "showTrustedApps" is true', () => {
const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], true);
expect(filter).toEqual(
'(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)'
);
});
});
describe('agnostic', () => {
test('it properly formats when no filters passed and "showTrustedApps" is false', () => {
const filter = getFilters({}, ['agnostic'], false);
expect(filter).toEqual(
'(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it properly formats when no filters passed and "showTrustedApps" is true', () => {
const filter = getFilters({}, ['agnostic'], true);
expect(filter).toEqual(
'(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it properly formats when filters passed and "showTrustedApps" is false', () => {
const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], false);
expect(filter).toEqual(
'(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it if filters passed and "showTrustedApps" is true', () => {
const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], true);
expect(filter).toEqual(
'(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
});
describe('single, agnostic', () => {
test('it properly formats when no filters passed and "showTrustedApps" is false', () => {
const filter = getFilters({}, ['single', 'agnostic'], false);
expect(filter).toEqual(
'(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it properly formats when no filters passed and "showTrustedApps" is true', () => {
const filter = getFilters({}, ['single', 'agnostic'], true);
expect(filter).toEqual(
'(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it properly formats when filters passed and "showTrustedApps" is false', () => {
const filter = getFilters(
{ created_by: 'moi', name: 'Sample' },
['single', 'agnostic'],
false
);
expect(filter).toEqual(
'(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
test('it properly formats when filters passed and "showTrustedApps" is true', () => {
const filter = getFilters(
{ created_by: 'moi', name: 'Sample' },
['single', 'agnostic'],
true
);
expect(filter).toEqual(
'(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)'
);
});
});
});
});

View file

@ -4,9 +4,40 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { NamespaceType } from '../../common/schemas';
import { get } from 'lodash/fp';
import { ExceptionListIdentifiers } from './types';
import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../common/constants';
import { NamespaceType } from '../../common/schemas';
import { NamespaceTypeArray } from '../../common/schemas/types/default_namespace_array';
import {
SavedObjectType,
exceptionListAgnosticSavedObjectType,
exceptionListSavedObjectType,
} from '../../common/types';
import { ExceptionListFilter, ExceptionListIdentifiers } from './types';
export const getSavedObjectType = ({
namespaceType,
}: {
namespaceType: NamespaceType;
}): SavedObjectType => {
if (namespaceType === 'agnostic') {
return exceptionListAgnosticSavedObjectType;
} else {
return exceptionListSavedObjectType;
}
};
export const getSavedObjectTypes = ({
namespaceType,
}: {
namespaceType: NamespaceTypeArray;
}): SavedObjectType[] => {
return namespaceType.map((singleNamespaceType) =>
getSavedObjectType({ namespaceType: singleNamespaceType })
);
};
export const getIdsAndNamespaces = ({
lists,
@ -34,3 +65,51 @@ export const getIdsAndNamespaces = ({
}),
{ ids: [], namespaces: [] }
);
export const getGeneralFilters = (
filters: ExceptionListFilter,
namespaceTypes: SavedObjectType[]
): string => {
return Object.keys(filters)
.map((filterKey) => {
const value = get(filterKey, filters);
if (value != null) {
const filtersByNamespace = namespaceTypes
.map((namespace) => {
return `${namespace}.attributes.${filterKey}:${value}*`;
})
.join(' OR ');
return `(${filtersByNamespace})`;
} else return null;
})
.filter((item) => item != null)
.join(' AND ');
};
export const getTrustedAppsFilter = (
showTrustedApps: boolean,
namespaceTypes: SavedObjectType[]
): string => {
if (showTrustedApps) {
const filters = namespaceTypes.map((namespace) => {
return `${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`;
});
return `(${filters.join(' OR ')})`;
} else {
const filters = namespaceTypes.map((namespace) => {
return `not ${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`;
});
return `(${filters.join(' AND ')})`;
}
};
export const getFilters = (
filters: ExceptionListFilter,
namespaceTypes: NamespaceType[],
showTrustedApps: boolean
): string => {
const namespaces = getSavedObjectTypes({ namespaceType: namespaceTypes });
const generalFilters = getGeneralFilters(filters, namespaces);
const trustedAppsFilter = getTrustedAppsFilter(showTrustedApps, namespaces);
return [generalFilters, trustedAppsFilter].filter((filter) => filter.trim() !== '').join(' AND ');
};

View file

@ -11,7 +11,8 @@ export { useAsync } from './common/hooks/use_async';
export { useApi } from './exceptions/hooks/use_api';
export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item';
export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list';
export { useExceptionList } from './exceptions/hooks/use_exception_list';
export { useExceptionListItems } from './exceptions/hooks/use_exception_list_items';
export { useExceptionLists } from './exceptions/hooks/use_exception_lists';
export { useFindLists } from './lists/hooks/use_find_lists';
export { useImportList } from './lists/hooks/use_import_list';
export { useDeleteList } from './lists/hooks/use_delete_list';
@ -32,5 +33,6 @@ export {
ExceptionList,
ExceptionListIdentifiers,
Pagination,
UseExceptionListSuccess,
UseExceptionListItemsSuccess,
UseExceptionListsSuccess,
} from './exceptions/types';

View file

@ -147,6 +147,7 @@ const getReferencedExceptionLists = async (
.join(' OR ');
return exceptionLists.findExceptionList({
filter: `(${filter})`,
namespaceType: ['agnostic', 'single'],
page: 1,
perPage: 10000,
sortField: undefined,

View file

@ -6,11 +6,12 @@
import { SavedObjectsType } from 'kibana/server';
import { migrations } from './migrations';
import {
exceptionListAgnosticSavedObjectType,
exceptionListSavedObjectType,
} from '../../common/types';
export const exceptionListSavedObjectType = 'exception-list';
export const exceptionListAgnosticSavedObjectType = 'exception-list-agnostic';
export type SavedObjectType = 'exception-list' | 'exception-list-agnostic';
import { migrations } from './migrations';
/**
* This is a super set of exception list and exception list items. The switch

View file

@ -205,7 +205,7 @@ export interface FindValueListExceptionListsItems {
}
export interface FindExceptionListOptions {
namespaceType?: NamespaceType;
namespaceType: NamespaceTypeArray;
filter: FilterOrUndefined;
perPage: PerPageOrUndefined;
page: PageOrUndefined;

View file

@ -0,0 +1,66 @@
/*
* 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 { getExceptionListFilter } from './find_exception_list';
describe('find_exception_list', () => {
describe('getExceptionListFilter', () => {
test('it should create a filter for agnostic lists if only searching for agnostic lists', () => {
const filter = getExceptionListFilter({
filter: undefined,
savedObjectTypes: ['exception-list-agnostic'],
});
expect(filter).toEqual('(exception-list-agnostic.attributes.list_type: list)');
});
test('it should create a filter for agnostic lists with additional filters if only searching for agnostic lists', () => {
const filter = getExceptionListFilter({
filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"',
savedObjectTypes: ['exception-list-agnostic'],
});
expect(filter).toEqual(
'(exception-list-agnostic.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"'
);
});
test('it should create a filter for single lists if only searching for single lists', () => {
const filter = getExceptionListFilter({
filter: undefined,
savedObjectTypes: ['exception-list'],
});
expect(filter).toEqual('(exception-list.attributes.list_type: list)');
});
test('it should create a filter for single lists with additional filters if only searching for single lists', () => {
const filter = getExceptionListFilter({
filter: 'exception-list.attributes.name: "Sample Endpoint Exception List"',
savedObjectTypes: ['exception-list'],
});
expect(filter).toEqual(
'(exception-list.attributes.list_type: list) AND exception-list.attributes.name: "Sample Endpoint Exception List"'
);
});
test('it should create a filter that searches for both agnostic and single lists', () => {
const filter = getExceptionListFilter({
filter: undefined,
savedObjectTypes: ['exception-list-agnostic', 'exception-list'],
});
expect(filter).toEqual(
'(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list)'
);
});
test('it should create a filter that searches for both agnostic and single lists with additional filters if only searching for agnostic lists', () => {
const filter = getExceptionListFilter({
filter: 'exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"',
savedObjectTypes: ['exception-list-agnostic', 'exception-list'],
});
expect(filter).toEqual(
'(exception-list-agnostic.attributes.list_type: list OR exception-list.attributes.list_type: list) AND exception-list-agnostic.attributes.name: "Sample Endpoint Exception List"'
);
});
});
});

View file

@ -6,26 +6,22 @@
import { SavedObjectsClientContract } from 'kibana/server';
import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array';
import { SavedObjectType } from '../../../common/types';
import {
ExceptionListSoSchema,
FilterOrUndefined,
FoundExceptionListSchema,
NamespaceType,
PageOrUndefined,
PerPageOrUndefined,
SortFieldOrUndefined,
SortOrderOrUndefined,
} from '../../../common/schemas';
import {
SavedObjectType,
exceptionListAgnosticSavedObjectType,
exceptionListSavedObjectType,
} from '../../saved_objects';
import { getSavedObjectType, transformSavedObjectsToFoundExceptionList } from './utils';
import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionList } from './utils';
interface FindExceptionListOptions {
namespaceType?: NamespaceType;
namespaceType: NamespaceTypeArray;
savedObjectsClient: SavedObjectsClientContract;
filter: FilterOrUndefined;
perPage: PerPageOrUndefined;
@ -43,37 +39,31 @@ export const findExceptionList = async ({
sortField,
sortOrder,
}: FindExceptionListOptions): Promise<FoundExceptionListSchema> => {
const savedObjectType: SavedObjectType[] = namespaceType
? [getSavedObjectType({ namespaceType })]
: [exceptionListSavedObjectType, exceptionListAgnosticSavedObjectType];
const savedObjectTypes = getSavedObjectTypes({ namespaceType });
const savedObjectsFindResponse = await savedObjectsClient.find<ExceptionListSoSchema>({
filter: getExceptionListFilter({ filter, savedObjectType }),
filter: getExceptionListFilter({ filter, savedObjectTypes }),
page,
perPage,
sortField,
sortOrder,
type: savedObjectType,
type: savedObjectTypes,
});
return transformSavedObjectsToFoundExceptionList({ savedObjectsFindResponse });
};
export const getExceptionListFilter = ({
filter,
savedObjectType,
savedObjectTypes,
}: {
filter: FilterOrUndefined;
savedObjectType: SavedObjectType[];
savedObjectTypes: SavedObjectType[];
}): string => {
const savedObjectTypeFilter = `(${savedObjectType
.map((sot) => `${sot}.attributes.list_type: list`)
.join(' OR ')})`;
if (filter == null) {
return savedObjectTypeFilter;
} else {
if (Array.isArray(savedObjectType)) {
return `${savedObjectTypeFilter} AND ${filter}`;
} else {
return `${savedObjectType}.attributes.list_type: list AND ${filter}`;
}
}
const listTypesFilter = savedObjectTypes
.map((type) => `${type}.attributes.list_type: list`)
.join(' OR ');
if (filter != null) {
return `(${listTypesFilter}) AND ${filter}`;
} else return `(${listTypesFilter})`;
};

View file

@ -5,6 +5,11 @@
*/
import { SavedObjectsClientContract } from 'kibana/server';
import {
SavedObjectType,
exceptionListAgnosticSavedObjectType,
exceptionListSavedObjectType,
} from '../../../common/types';
import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array';
import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array';
import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array';
@ -17,11 +22,6 @@ import {
SortFieldOrUndefined,
SortOrderOrUndefined,
} from '../../../common/schemas';
import {
SavedObjectType,
exceptionListAgnosticSavedObjectType,
exceptionListSavedObjectType,
} from '../../saved_objects';
import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionListItem } from './utils';
import { getExceptionList } from './get_exception_list';

View file

@ -6,6 +6,11 @@
import uuid from 'uuid';
import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server';
import {
SavedObjectType,
exceptionListAgnosticSavedObjectType,
exceptionListSavedObjectType,
} from '../../../common/types';
import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array';
import {
CommentsArray,
@ -21,11 +26,6 @@ import {
exceptionListItemType,
exceptionListType,
} from '../../../common/schemas';
import {
SavedObjectType,
exceptionListAgnosticSavedObjectType,
exceptionListSavedObjectType,
} from '../../saved_objects';
export const getSavedObjectType = ({
namespaceType,

View file

@ -13,7 +13,7 @@ import { ExceptionsViewer } from './';
import { useKibana } from '../../../../common/lib/kibana';
import {
ExceptionListTypeEnum,
useExceptionList,
useExceptionListItems,
useApi,
} from '../../../../../public/lists_plugin_deps';
import { getExceptionListSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_schema.mock';
@ -40,7 +40,7 @@ describe('ExceptionsViewer', () => {
getExceptionListsItems: jest.fn().mockResolvedValue(getFoundExceptionListItemSchemaMock()),
});
(useExceptionList as jest.Mock).mockReturnValue([
(useExceptionListItems as jest.Mock).mockReturnValue([
false,
[],
[],
@ -54,7 +54,7 @@ describe('ExceptionsViewer', () => {
});
it('it renders loader if "loadingList" is true', () => {
(useExceptionList as jest.Mock).mockReturnValue([
(useExceptionListItems as jest.Mock).mockReturnValue([
true,
[],
[],
@ -106,7 +106,7 @@ describe('ExceptionsViewer', () => {
});
it('it renders empty prompt if no exception items exist', () => {
(useExceptionList as jest.Mock).mockReturnValue([
(useExceptionListItems as jest.Mock).mockReturnValue([
false,
[getExceptionListSchemaMock()],
[],

View file

@ -17,11 +17,11 @@ import { ExceptionsViewerHeader } from './exceptions_viewer_header';
import { ExceptionListItemIdentifiers, Filter } from '../types';
import { allExceptionItemsReducer, State, ViewerModalName } from './reducer';
import {
useExceptionList,
useExceptionListItems,
ExceptionListIdentifiers,
ExceptionListTypeEnum,
ExceptionListItemSchema,
UseExceptionListSuccess,
UseExceptionListItemsSuccess,
useApi,
} from '../../../../../public/lists_plugin_deps';
import { ExceptionsViewerPagination } from './exceptions_pagination';
@ -105,7 +105,10 @@ const ExceptionsViewerComponent = ({
const { deleteExceptionItem, getExceptionListsItems } = useApi(services.http);
const setExceptions = useCallback(
({ exceptions: newExceptions, pagination: newPagination }: UseExceptionListSuccess): void => {
({
exceptions: newExceptions,
pagination: newPagination,
}: UseExceptionListItemsSuccess): void => {
dispatch({
type: 'setExceptions',
lists: exceptionListsMeta,
@ -115,7 +118,7 @@ const ExceptionsViewerComponent = ({
},
[dispatch, exceptionListsMeta]
);
const [loadingList, , , fetchListItems] = useExceptionList({
const [loadingList, , , fetchListItems] = useExceptionListItems({
http: services.http,
lists: exceptionListsMeta,
filterOptions:

View file

@ -30,7 +30,7 @@ interface FormatUrlOptions {
skipSearch: boolean;
}
type FormatUrl = (path: string, options?: Partial<FormatUrlOptions>) => string;
export type FormatUrl = (path: string, options?: Partial<FormatUrlOptions>) => string;
export const useFormatUrl = (page: SecurityPageName) => {
const { getUrlForApp } = useKibana().services.application;

View file

@ -108,14 +108,16 @@ export const fetchRules = async ({
},
signal,
}: FetchRulesProps): Promise<FetchRulesResponse> => {
const showCustomRuleFilter = filterOptions.showCustomRules
? [`alert.attributes.tags: "__internal_immutable:false"`]
: [];
const showElasticRuleFilter = filterOptions.showElasticRules
? [`alert.attributes.tags: "__internal_immutable:true"`]
: [];
const filtersWithoutTags = [
...(filterOptions.filter.length ? [`alert.attributes.name: ${filterOptions.filter}`] : []),
...(filterOptions.showCustomRules
? [`alert.attributes.tags: "__internal_immutable:false"`]
: []),
...(filterOptions.showElasticRules
? [`alert.attributes.tags: "__internal_immutable:true"`]
: []),
...showCustomRuleFilter,
...showElasticRuleFilter,
].join(' AND ');
const tags = [

View file

@ -0,0 +1,120 @@
/*
* 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.
*/
/* eslint-disable react/display-name */
import React from 'react';
import { EuiButtonIcon, EuiBasicTableColumn, EuiToolTip } from '@elastic/eui';
import { History } from 'history';
import { FormatUrl } from '../../../../../../common/components/link_to';
import { LinkAnchor } from '../../../../../../common/components/links';
import * as i18n from './translations';
import { ExceptionListInfo } from './use_all_exception_lists';
import { getRuleDetailsUrl } from '../../../../../../common/components/link_to/redirect_to_detection_engine';
export type AllExceptionListsColumns = EuiBasicTableColumn<ExceptionListInfo>;
export type Func = (listId: string) => () => void;
export const getAllExceptionListsColumns = (
onExport: Func,
onDelete: Func,
history: History,
formatUrl: FormatUrl
): AllExceptionListsColumns[] => [
{
align: 'left',
field: 'list_id',
name: i18n.EXCEPTION_LIST_ID_TITLE,
truncateText: true,
dataType: 'string',
width: '15%',
render: (value: ExceptionListInfo['list_id']) => (
<EuiToolTip position="left" content={value}>
<>{value}</>
</EuiToolTip>
),
},
{
align: 'center',
field: 'rules',
name: i18n.NUMBER_RULES_ASSIGNED_TO_TITLE,
truncateText: true,
dataType: 'number',
width: '10%',
render: (value: ExceptionListInfo['rules']) => {
return <p>{value.length}</p>;
},
},
{
align: 'left',
field: 'rules',
name: i18n.RULES_ASSIGNED_TO_TITLE,
truncateText: true,
dataType: 'string',
width: '20%',
render: (value: ExceptionListInfo['rules']) => {
return (
<>
{value.map(({ id, name }, index) => (
<>
<LinkAnchor
data-test-subj="ruleName"
onClick={(ev: { preventDefault: () => void }) => {
ev.preventDefault();
history.push(getRuleDetailsUrl(id));
}}
href={formatUrl(getRuleDetailsUrl(id))}
>
{name}
</LinkAnchor>
{index !== value.length - 1 ? ', ' : ''}
</>
))}
</>
);
},
},
{
align: 'left',
field: 'created_at',
name: i18n.LIST_DATE_CREATED_TITLE,
truncateText: true,
dataType: 'date',
width: '14%',
},
{
align: 'left',
field: 'updated_at',
name: i18n.LIST_DATE_UPDATED_TITLE,
truncateText: true,
width: '14%',
},
{
align: 'center',
isExpander: false,
width: '25px',
render: (list: ExceptionListInfo) => (
<EuiButtonIcon
onClick={onExport(list.id)}
aria-label="Export exception list"
iconType="exportAction"
/>
),
},
{
align: 'center',
width: '25px',
isExpander: false,
render: (list: ExceptionListInfo) => (
<EuiButtonIcon
color="danger"
onClick={onDelete(list.id)}
aria-label="Delete exception list"
iconType="trash"
/>
),
},
];

View file

@ -0,0 +1,205 @@
/*
* 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, { useMemo, useEffect, useCallback, useState, ChangeEvent } from 'react';
import {
EuiBasicTable,
EuiEmptyPrompt,
EuiLoadingContent,
EuiProgress,
EuiFieldSearch,
} from '@elastic/eui';
import styled from 'styled-components';
import { History } from 'history';
import { set } from 'lodash/fp';
import { useKibana } from '../../../../../../common/lib/kibana';
import { useExceptionLists } from '../../../../../../shared_imports';
import { FormatUrl } from '../../../../../../common/components/link_to';
import { HeaderSection } from '../../../../../../common/components/header_section';
import { Loader } from '../../../../../../common/components/loader';
import { Panel } from '../../../../../../common/components/panel';
import * as i18n from './translations';
import { AllRulesUtilityBar } from '../utility_bar';
import { LastUpdatedAt } from '../../../../../../common/components/last_updated';
import { AllExceptionListsColumns, getAllExceptionListsColumns } from './columns';
import { useAllExceptionLists } from './use_all_exception_lists';
// Known lost battle with Eui :(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any;
export type Func = () => void;
export interface ExceptionListFilter {
name?: string | null;
list_id?: string | null;
created_by?: string | null;
}
interface ExceptionListsTableProps {
history: History;
hasNoPermissions: boolean;
loading: boolean;
formatUrl: FormatUrl;
}
export const ExceptionListsTable = React.memo<ExceptionListsTableProps>(
({ formatUrl, history, hasNoPermissions, loading }) => {
const {
services: { http, notifications },
} = useKibana();
const [filters, setFilters] = useState<ExceptionListFilter>({
name: null,
list_id: null,
created_by: null,
});
const [loadingExceptions, exceptions, pagination, refreshExceptions] = useExceptionLists({
errorMessage: i18n.ERROR_EXCEPTION_LISTS,
filterOptions: filters,
http,
namespaceTypes: ['single', 'agnostic'],
notifications,
showTrustedApps: false,
});
const [loadingTableInfo, data] = useAllExceptionLists({
exceptionLists: exceptions ?? [],
});
const [initLoading, setInitLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState(Date.now());
const handleDelete = useCallback((id: string) => () => {}, []);
const handleExport = useCallback((id: string) => () => {}, []);
const exceptionsColumns = useMemo((): AllExceptionListsColumns[] => {
return getAllExceptionListsColumns(handleExport, handleDelete, history, formatUrl);
}, [handleExport, handleDelete, history, formatUrl]);
const handleRefresh = useCallback((): void => {
if (refreshExceptions != null) {
setLastUpdated(Date.now());
refreshExceptions();
}
}, [refreshExceptions]);
useEffect(() => {
if (initLoading && !loading && !loadingExceptions && !loadingTableInfo) {
setInitLoading(false);
}
}, [initLoading, loading, loadingExceptions, loadingTableInfo]);
const emptyPrompt = useMemo((): JSX.Element => {
return (
<EuiEmptyPrompt
title={<h3>{i18n.NO_EXCEPTION_LISTS}</h3>}
titleSize="xs"
body={i18n.NO_LISTS_BODY}
/>
);
}, []);
const handleSearch = useCallback((search: string) => {
const regex = search.split(/\s+(?=([^"]*"[^"]*")*[^"]*$)/);
const formattedFilter = regex
.filter((c) => c != null)
.reduce<ExceptionListFilter>(
(filter, term) => {
const [qualifier, value] = term.split(':');
if (qualifier == null) {
filter.name = search;
} else if (value != null && Object.keys(filter).includes(qualifier)) {
return set(qualifier, value, filter);
}
return filter;
},
{ name: null, list_id: null, created_by: null }
);
setFilters(formattedFilter);
}, []);
const handleSearchChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const val = event.target.value;
handleSearch(val);
},
[handleSearch]
);
const paginationMemo = useMemo(
() => ({
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
totalItemCount: pagination.total,
pageSizeOptions: [5, 10, 20, 50, 100, 200, 300],
}),
[pagination]
);
return (
<>
<Panel loading={!initLoading && loadingTableInfo} data-test-subj="allExceptionListsPanel">
<>
{loadingTableInfo && (
<EuiProgress
data-test-subj="loadingRulesInfoProgress"
size="xs"
position="absolute"
color="accent"
/>
)}
<HeaderSection
split
title={i18n.ALL_EXCEPTIONS}
subtitle={<LastUpdatedAt showUpdating={loading} updatedAt={lastUpdated} />}
>
<EuiFieldSearch
data-test-subj="exceptionsHeaderSearch"
aria-label={i18n.EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER}
placeholder={i18n.EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER}
onSearch={handleSearch}
onChange={handleSearchChange}
disabled={initLoading}
incremental={false}
fullWidth
/>
</HeaderSection>
{loadingTableInfo && !initLoading && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{initLoading ? (
<EuiLoadingContent data-test-subj="initialLoadingPanelAllRulesTable" lines={10} />
) : (
<>
<AllRulesUtilityBar
showBulkActions={false}
userHasNoPermissions={hasNoPermissions}
paginationTotal={data.length ?? 0}
numberSelectedItems={0}
onRefresh={handleRefresh}
/>
<MyEuiBasicTable
data-test-subj="exceptions-table"
columns={exceptionsColumns}
isSelectable={!hasNoPermissions ?? false}
itemId="id"
items={data ?? []}
noItemsMessage={emptyPrompt}
onChange={() => {}}
pagination={paginationMemo}
/>
</>
)}
</>
</Panel>
</>
);
}
);
ExceptionListsTable.displayName = 'ExceptionListsTable';

View file

@ -0,0 +1,77 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const EXCEPTION_LIST_ID_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.idTitle',
{
defaultMessage: 'List ID',
}
);
export const NUMBER_RULES_ASSIGNED_TO_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.numberRulesAssignedTitle',
{
defaultMessage: 'Number of rules assigned to',
}
);
export const RULES_ASSIGNED_TO_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.rulesAssignedTitle',
{
defaultMessage: 'Rules assigned to',
}
);
export const LIST_DATE_CREATED_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.dateCreatedTitle',
{
defaultMessage: 'Date created',
}
);
export const LIST_DATE_UPDATED_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.dateUPdatedTitle',
{
defaultMessage: 'Last edited',
}
);
export const ERROR_EXCEPTION_LISTS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.all.exceptions.errorFetching',
{
defaultMessage: 'Error fetching exception lists',
}
);
export const NO_EXCEPTION_LISTS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allExceptionLists.filters.noExceptionsTitle',
{
defaultMessage: 'No exception lists found',
}
);
export const EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder',
{
defaultMessage: 'Search exception lists',
}
);
export const ALL_EXCEPTIONS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle',
{
defaultMessage: 'Exception Lists',
}
);
export const NO_LISTS_BODY = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody',
{
defaultMessage: "We weren't able to find any exception lists.",
}
);

View file

@ -0,0 +1,113 @@
/*
* 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 { useEffect, useState } from 'react';
import { ExceptionListSchema } from '../../../../../../../../lists/common';
import { fetchRules } from '../../../../../containers/detection_engine/rules/api';
export interface ExceptionListInfo extends ExceptionListSchema {
rules: Array<{ name: string; id: string }>;
}
export type UseAllExceptionListsReturn = [boolean, ExceptionListInfo[]];
/**
* Hook for preparing exception lists table info. For now, we need to do a table scan
* of all rules to figure out which exception lists are used in what rules. This is very
* slow, however, there is an issue open that would push all this work to Kiaban/ES and
* speed things up a ton - https://github.com/elastic/kibana/issues/85173
*
* @param exceptionLists ExceptionListSchema(s) to evaluate
*
*/
export const useAllExceptionLists = ({
exceptionLists,
}: {
exceptionLists: ExceptionListSchema[];
}): UseAllExceptionListsReturn => {
const [loading, setLoading] = useState(true);
const [exceptionsListInfo, setExceptionsListInfo] = useState<ExceptionListInfo[]>([]);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const fetchData = async (): Promise<void> => {
if (exceptionLists.length === 0 && isSubscribed) {
setLoading(false);
return;
}
try {
setLoading(true);
const listsSkeleton = exceptionLists.reduce<Record<string, ExceptionListInfo>>(
(acc, { id, ...rest }) => {
acc[id] = {
...rest,
id,
rules: [],
};
return acc;
},
{}
);
const { data: rules } = await fetchRules({
pagination: {
page: 1,
perPage: 500,
total: 0,
},
signal: abortCtrl.signal,
});
const updatedLists = rules.reduce<Record<string, ExceptionListInfo>>((acc, rule) => {
const exceptions = rule.exceptions_list;
if (exceptions != null && exceptions.length > 0) {
exceptions.forEach((ex) => {
const list = acc[ex.id];
if (list != null) {
acc[ex.id] = {
...list,
rules: [...list.rules, { id: rule.id, name: rule.name }],
};
}
});
}
return acc;
}, listsSkeleton);
const lists = Object.keys(updatedLists).map<ExceptionListInfo>(
(listKey) => updatedLists[listKey]
);
setExceptionsListInfo(lists);
if (isSubscribed) {
setLoading(false);
}
} catch (error) {
if (isSubscribed) {
setLoading(false);
}
}
};
fetchData();
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, [exceptionLists.length, exceptionLists]);
return [loading, exceptionsListInfo];
};

View file

@ -158,7 +158,7 @@ describe('AllRules', () => {
/>
);
expect(wrapper.find('[title="All rules"]')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="allRulesTableTab-rules"]')).toHaveLength(1);
});
it('it pulls from uiSettings to determine default refresh values', async () => {
@ -230,7 +230,7 @@ describe('AllRules', () => {
});
});
describe('rules tab', () => {
describe('tabs', () => {
it('renders all rules tab by default', async () => {
const wrapper = mount(
<TestProviders>
@ -283,4 +283,33 @@ describe('AllRules', () => {
expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy();
});
});
it('renders exceptions lists tab when tab clicked', async () => {
const wrapper = mount(
<TestProviders>
<AllRules
createPrePackagedRules={jest.fn()}
hasNoPermissions={false}
loading={false}
loadingCreatePrePackagedRules={false}
refetchPrePackagedRulesStatus={jest.fn()}
rulesCustomInstalled={1}
rulesInstalled={0}
rulesNotInstalled={0}
rulesNotUpdated={0}
setRefreshRulesData={jest.fn()}
/>
</TestProviders>
);
await waitFor(() => {
const exceptionsTab = wrapper.find('[data-test-subj="allRulesTableTab-exceptions"] button');
exceptionsTab.simulate('click');
wrapper.update();
expect(wrapper.exists('[data-test-subj="allExceptionListsPanel"]')).toBeTruthy();
expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy();
expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy();
});
});
});

View file

@ -4,79 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiBasicTable,
EuiLoadingContent,
EuiSpacer,
EuiTab,
EuiTabs,
EuiProgress,
EuiOverlayMask,
EuiConfirmModal,
EuiWindowEvent,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
import React, { useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import uuid from 'uuid';
import { debounce } from 'lodash/fp';
import {
useRules,
useRulesStatuses,
CreatePreBuiltRules,
FilterOptions,
Rule,
PaginationOptions,
exportRules,
RulesSortingFields,
} from '../../../../containers/detection_engine/rules';
import { HeaderSection } from '../../../../../common/components/header_section';
import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana';
import { useStateToaster } from '../../../../../common/components/toasters';
import { Loader } from '../../../../../common/components/loader';
import { Panel } from '../../../../../common/components/panel';
import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt';
import { GenericDownloader } from '../../../../../common/components/generic_downloader';
import { AllRulesTables, SortingType } from '../../../../components/rules/all_rules_tables';
import { getPrePackagedRuleStatus } from '../helpers';
import * as i18n from '../translations';
import { EuiBasicTableOnChange } from '../types';
import { getBatchItems } from './batch_actions';
import { getColumns, getMonitoringColumns } from './columns';
import { showRulesTable } from './helpers';
import { allRulesReducer, State } from './reducer';
import { RulesTableFilters } from './rules_table_filters/rules_table_filters';
import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities';
import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions';
import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license';
import { SecurityPageName } from '../../../../../app/types';
import { useFormatUrl } from '../../../../../common/components/link_to';
import { isBoolean } from '../../../../../common/utils/privileges';
import { AllRulesUtilityBar } from './utility_bar';
import { LastUpdatedAt } from '../../../../../common/components/last_updated';
import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants';
const INITIAL_SORT_FIELD = 'enabled';
const initialState: State = {
exportRuleIds: [],
filterOptions: {
filter: '',
sortField: INITIAL_SORT_FIELD,
sortOrder: 'desc',
},
loadingRuleIds: [],
loadingRulesAction: null,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
rules: [],
selectedRuleIds: [],
lastUpdated: 0,
showIdleModal: false,
isRefreshOn: true,
};
import { CreatePreBuiltRules } from '../../../../containers/detection_engine/rules';
import { RulesTables } from './rules_tables';
import * as i18n from '../translations';
import { ExceptionListsTable } from './exceptions/exceptions_table';
interface AllRulesProps {
createPrePackagedRules: CreatePreBuiltRules | null;
@ -94,6 +31,7 @@ interface AllRulesProps {
export enum AllRulesTabs {
rules = 'rules',
monitoring = 'monitoring',
exceptions = 'exceptions',
}
const allRulesTabs = [
@ -107,6 +45,11 @@ const allRulesTabs = [
name: i18n.MONITORING_TAB,
disabled: false,
},
{
id: AllRulesTabs.exceptions,
name: i18n.EXCEPTIONS_TAB,
disabled: false,
},
];
/**
@ -130,312 +73,9 @@ export const AllRules = React.memo<AllRulesProps>(
rulesNotUpdated,
setRefreshRulesData,
}) => {
const [initLoading, setInitLoading] = useState(true);
const tableRef = useRef<EuiBasicTable>();
const {
services: {
application: {
capabilities: { actions },
},
},
} = useKibana();
const [defaultAutoRefreshSetting] = useUiSetting$<{
on: boolean;
value: number;
idleTimeout: number;
}>(DEFAULT_RULES_TABLE_REFRESH_SETTING);
const [
{
exportRuleIds,
filterOptions,
loadingRuleIds,
loadingRulesAction,
pagination,
rules,
selectedRuleIds,
lastUpdated,
showIdleModal,
isRefreshOn,
},
dispatch,
] = useReducer(allRulesReducer(tableRef), {
...initialState,
lastUpdated: Date.now(),
isRefreshOn: defaultAutoRefreshSetting.on,
});
const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules);
const history = useHistory();
const [, dispatchToaster] = useStateToaster();
const mlCapabilities = useMlCapabilities();
const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules);
const { formatUrl } = useFormatUrl(SecurityPageName.detections);
// TODO: Refactor license check + hasMlAdminPermissions to common check
const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities);
const setRules = useCallback((newRules: Rule[], newPagination: Partial<PaginationOptions>) => {
dispatch({
type: 'setRules',
rules: newRules,
pagination: newPagination,
});
}, []);
const setShowIdleModal = useCallback((show: boolean) => {
dispatch({
type: 'setShowIdleModal',
show,
});
}, []);
const setLastRefreshDate = useCallback(() => {
dispatch({
type: 'setLastRefreshDate',
});
}, []);
const setAutoRefreshOn = useCallback((on: boolean) => {
dispatch({
type: 'setAutoRefreshOn',
on,
});
}, []);
const [isLoadingRules, , reFetchRulesData] = useRules({
pagination,
filterOptions,
refetchPrePackagedRulesStatus,
dispatchRulesInReducer: setRules,
});
const sorting = useMemo(
(): SortingType => ({
sort: {
field: filterOptions.sortField,
direction: filterOptions.sortOrder,
},
}),
[filterOptions]
);
const prePackagedRuleStatus = getPrePackagedRuleStatus(
rulesInstalled,
rulesNotInstalled,
rulesNotUpdated
);
const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [
actions,
]);
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void): JSX.Element[] => {
return getBatchItems({
closePopover,
dispatch,
dispatchToaster,
hasMlPermissions,
hasActionsPrivileges,
loadingRuleIds,
selectedRuleIds,
reFetchRules: reFetchRulesData,
rules,
});
},
[
dispatch,
dispatchToaster,
hasMlPermissions,
loadingRuleIds,
reFetchRulesData,
rules,
selectedRuleIds,
hasActionsPrivileges,
]
);
const paginationMemo = useMemo(
() => ({
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
totalItemCount: pagination.total,
pageSizeOptions: [5, 10, 20, 50, 100, 200, 300],
}),
[pagination]
);
const tableOnChangeCallback = useCallback(
({ page, sort }: EuiBasicTableOnChange) => {
dispatch({
type: 'updateFilterOptions',
filterOptions: {
sortField: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types
sortOrder: sort?.direction ?? 'desc',
},
pagination: { page: page.index + 1, perPage: page.size },
});
},
[dispatch]
);
const rulesColumns = useMemo(() => {
return getColumns({
dispatch,
dispatchToaster,
formatUrl,
history,
hasMlPermissions,
hasNoPermissions,
loadingRuleIds:
loadingRulesAction != null &&
(loadingRulesAction === 'enable' || loadingRulesAction === 'disable')
? loadingRuleIds
: [],
reFetchRules: reFetchRulesData,
hasReadActionsPrivileges: hasActionsPrivileges,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dispatch,
dispatchToaster,
formatUrl,
hasMlPermissions,
history,
loadingRuleIds,
loadingRulesAction,
reFetchRulesData,
]);
const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [
history,
formatUrl,
]);
useEffect(() => {
if (reFetchRulesData != null) {
setRefreshRulesData(reFetchRulesData);
}
}, [reFetchRulesData, setRefreshRulesData]);
useEffect(() => {
if (initLoading && !loading && !isLoadingRules && !isLoadingRulesStatuses) {
setInitLoading(false);
}
}, [initLoading, loading, isLoadingRules, isLoadingRulesStatuses]);
const handleCreatePrePackagedRules = useCallback(async () => {
if (createPrePackagedRules != null && reFetchRulesData != null) {
await createPrePackagedRules();
reFetchRulesData(true);
}
}, [createPrePackagedRules, reFetchRulesData]);
const euiBasicTableSelectionProps = useMemo(
() => ({
selectable: (item: Rule) => !loadingRuleIds.includes(item.id),
onSelectionChange: (selected: Rule[]) =>
dispatch({ type: 'selectedRuleIds', ids: selected.map((r) => r.id) }),
}),
[loadingRuleIds]
);
const onFilterChangedCallback = useCallback((newFilterOptions: Partial<FilterOptions>) => {
dispatch({
type: 'updateFilterOptions',
filterOptions: {
...newFilterOptions,
},
pagination: { page: 1 },
});
}, []);
const isLoadingAnActionOnRule = useMemo(() => {
if (
loadingRuleIds.length > 0 &&
(loadingRulesAction === 'disable' || loadingRulesAction === 'enable')
) {
return false;
} else if (loadingRuleIds.length > 0) {
return true;
}
return false;
}, [loadingRuleIds, loadingRulesAction]);
const handleRefreshData = useCallback((): void => {
if (reFetchRulesData != null && !isLoadingAnActionOnRule) {
reFetchRulesData(true);
setLastRefreshDate();
}
}, [reFetchRulesData, isLoadingAnActionOnRule, setLastRefreshDate]);
const handleResetIdleTimer = useCallback((): void => {
if (isRefreshOn) {
setShowIdleModal(true);
setAutoRefreshOn(false);
}
}, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]);
const debounceResetIdleTimer = useMemo(() => {
return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer);
}, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]);
useEffect(() => {
const interval = setInterval(() => {
if (isRefreshOn) {
handleRefreshData();
}
}, defaultAutoRefreshSetting.value);
return () => {
clearInterval(interval);
};
}, [isRefreshOn, handleRefreshData, defaultAutoRefreshSetting.value]);
const handleIdleModalContinue = useCallback((): void => {
setShowIdleModal(false);
handleRefreshData();
setAutoRefreshOn(true);
}, [setShowIdleModal, setAutoRefreshOn, handleRefreshData]);
const handleAutoRefreshSwitch = useCallback(
(refreshOn: boolean) => {
if (refreshOn) {
handleRefreshData();
}
setAutoRefreshOn(refreshOn);
},
[setAutoRefreshOn, handleRefreshData]
);
const shouldShowRulesTable = useMemo(
(): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading,
[initLoading, rulesCustomInstalled, rulesInstalled]
);
const shouldShowPrepackagedRulesPrompt = useMemo(
(): boolean =>
rulesCustomInstalled != null &&
rulesCustomInstalled === 0 &&
prePackagedRuleStatus === 'ruleNotInstalled' &&
!initLoading,
[initLoading, prePackagedRuleStatus, rulesCustomInstalled]
);
const handleGenericDownloaderSuccess = useCallback(
(exportCount) => {
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
dispatchToaster({
type: 'addToaster',
toast: {
id: uuid.v4(),
title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount),
color: 'success',
iconType: 'check',
},
});
},
[dispatchToaster]
);
const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules);
const tabs = useMemo(
() => (
@ -459,109 +99,35 @@ export const AllRules = React.memo<AllRulesProps>(
return (
<>
<EuiWindowEvent event="mousemove" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="mousedown" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="click" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="keydown" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="scroll" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="load" handler={debounceResetIdleTimer} />
<GenericDownloader
filename={`${i18n.EXPORT_FILENAME}.ndjson`}
ids={exportRuleIds}
onExportSuccess={handleGenericDownloaderSuccess}
exportSelectedData={exportRules}
/>
<EuiSpacer />
{tabs}
<EuiSpacer />
<Panel
loading={loading || isLoadingRules || isLoadingRulesStatuses}
data-test-subj="allRulesPanel"
>
<>
{(isLoadingRules || isLoadingRulesStatuses) && (
<EuiProgress
data-test-subj="loadingRulesInfoProgress"
size="xs"
position="absolute"
color="accent"
/>
)}
<HeaderSection
split
growLeftSplit={false}
title={i18n.ALL_RULES}
subtitle={
<LastUpdatedAt
showUpdating={loading || isLoadingRules || isLoadingRulesStatuses}
updatedAt={lastUpdated}
/>
}
>
<RulesTableFilters
onFilterChanged={onFilterChangedCallback}
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}
currentFilterTags={filterOptions.tags ?? []}
/>
</HeaderSection>
{isLoadingAnActionOnRule && !initLoading && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{shouldShowPrepackagedRulesPrompt && (
<PrePackagedRulesPrompt
createPrePackagedRules={handleCreatePrePackagedRules}
loading={loadingCreatePrePackagedRules}
userHasNoPermissions={hasNoPermissions}
/>
)}
{initLoading && (
<EuiLoadingContent data-test-subj="initialLoadingPanelAllRulesTable" lines={10} />
)}
{showIdleModal && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.REFRESH_PROMPT_TITLE}
onCancel={handleIdleModalContinue}
onConfirm={handleIdleModalContinue}
confirmButtonText={i18n.REFRESH_PROMPT_CONFIRM}
defaultFocusedButton="confirm"
data-test-subj="allRulesIdleModal"
>
<p>{i18n.REFRESH_PROMPT_BODY}</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
{shouldShowRulesTable && (
<>
<AllRulesUtilityBar
userHasNoPermissions={hasNoPermissions}
paginationTotal={pagination.total ?? 0}
numberSelectedRules={selectedRuleIds.length}
onGetBatchItemsPopoverContent={getBatchItemsPopoverContent}
onRefresh={handleRefreshData}
isAutoRefreshOn={isRefreshOn}
onRefreshSwitch={handleAutoRefreshSwitch}
/>
<AllRulesTables
selectedTab={allRulesTab}
euiBasicTableSelectionProps={euiBasicTableSelectionProps}
hasNoPermissions={hasNoPermissions}
monitoringColumns={monitoringColumns}
pagination={paginationMemo}
rules={rules}
rulesColumns={rulesColumns}
rulesStatuses={rulesStatuses}
sorting={sorting}
tableOnChangeCallback={tableOnChangeCallback}
tableRef={tableRef}
/>
</>
)}
</>
</Panel>
{(allRulesTab === AllRulesTabs.rules || allRulesTab === AllRulesTabs.monitoring) && (
<RulesTables
history={history}
formatUrl={formatUrl}
selectedTab={allRulesTab}
createPrePackagedRules={createPrePackagedRules}
hasNoPermissions={hasNoPermissions}
loading={loading}
loadingCreatePrePackagedRules={loadingCreatePrePackagedRules}
refetchPrePackagedRulesStatus={refetchPrePackagedRulesStatus}
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}
rulesNotInstalled={rulesNotInstalled}
rulesNotUpdated={rulesNotUpdated}
setRefreshRulesData={setRefreshRulesData}
/>
)}
{allRulesTab === AllRulesTabs.exceptions && (
<ExceptionListsTable
formatUrl={formatUrl}
history={history}
hasNoPermissions={hasNoPermissions}
loading={loading}
/>
)}
</>
);
}

View file

@ -0,0 +1,530 @@
/*
* 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 {
EuiBasicTable,
EuiLoadingContent,
EuiProgress,
EuiOverlayMask,
EuiConfirmModal,
EuiWindowEvent,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import uuid from 'uuid';
import { debounce } from 'lodash/fp';
import { History } from 'history';
import {
useRules,
useRulesStatuses,
CreatePreBuiltRules,
FilterOptions,
Rule,
PaginationOptions,
exportRules,
RulesSortingFields,
} from '../../../../containers/detection_engine/rules';
import { FormatUrl } from '../../../../../common/components/link_to';
import { HeaderSection } from '../../../../../common/components/header_section';
import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana';
import { useStateToaster } from '../../../../../common/components/toasters';
import { Loader } from '../../../../../common/components/loader';
import { Panel } from '../../../../../common/components/panel';
import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt';
import { GenericDownloader } from '../../../../../common/components/generic_downloader';
import { AllRulesTables, SortingType } from '../../../../components/rules/all_rules_tables';
import { getPrePackagedRuleStatus } from '../helpers';
import * as i18n from '../translations';
import { EuiBasicTableOnChange } from '../types';
import { getBatchItems } from './batch_actions';
import { getColumns, getMonitoringColumns } from './columns';
import { showRulesTable } from './helpers';
import { allRulesReducer, State } from './reducer';
import { RulesTableFilters } from './rules_table_filters/rules_table_filters';
import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities';
import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions';
import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license';
import { isBoolean } from '../../../../../common/utils/privileges';
import { AllRulesUtilityBar } from './utility_bar';
import { LastUpdatedAt } from '../../../../../common/components/last_updated';
import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants';
import { AllRulesTabs } from '.';
const INITIAL_SORT_FIELD = 'enabled';
const initialState: State = {
exportRuleIds: [],
filterOptions: {
filter: '',
sortField: INITIAL_SORT_FIELD,
sortOrder: 'desc',
},
loadingRuleIds: [],
loadingRulesAction: null,
pagination: {
page: 1,
perPage: 20,
total: 0,
},
rules: [],
selectedRuleIds: [],
lastUpdated: 0,
showIdleModal: false,
isRefreshOn: true,
};
interface RulesTableProps {
history: History;
formatUrl: FormatUrl;
createPrePackagedRules: CreatePreBuiltRules | null;
hasNoPermissions: boolean;
loading: boolean;
loadingCreatePrePackagedRules: boolean;
refetchPrePackagedRulesStatus: () => void;
rulesCustomInstalled: number | null;
rulesInstalled: number | null;
rulesNotInstalled: number | null;
rulesNotUpdated: number | null;
setRefreshRulesData: (refreshRule: (refreshPrePackagedRule?: boolean) => void) => void;
selectedTab: AllRulesTabs;
}
/**
* Table Component for displaying all Rules for a given cluster. Provides the ability to filter
* by name, sort by enabled, and perform the following actions:
* * Enable/Disable
* * Duplicate
* * Delete
* * Import/Export
*/
export const RulesTables = React.memo<RulesTableProps>(
({
history,
formatUrl,
createPrePackagedRules,
hasNoPermissions,
loading,
loadingCreatePrePackagedRules,
refetchPrePackagedRulesStatus,
rulesCustomInstalled,
rulesInstalled,
rulesNotInstalled,
rulesNotUpdated,
setRefreshRulesData,
selectedTab,
}) => {
const [initLoading, setInitLoading] = useState(true);
const tableRef = useRef<EuiBasicTable>();
const {
services: {
application: {
capabilities: { actions },
},
},
} = useKibana();
const [defaultAutoRefreshSetting] = useUiSetting$<{
on: boolean;
value: number;
idleTimeout: number;
}>(DEFAULT_RULES_TABLE_REFRESH_SETTING);
const [
{
exportRuleIds,
filterOptions,
loadingRuleIds,
loadingRulesAction,
pagination,
rules,
selectedRuleIds,
lastUpdated,
showIdleModal,
isRefreshOn,
},
dispatch,
] = useReducer(allRulesReducer(tableRef), {
...initialState,
lastUpdated: Date.now(),
isRefreshOn: defaultAutoRefreshSetting.on,
});
const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules);
const [, dispatchToaster] = useStateToaster();
const mlCapabilities = useMlCapabilities();
// TODO: Refactor license check + hasMlAdminPermissions to common check
const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities);
const setRules = useCallback((newRules: Rule[], newPagination: Partial<PaginationOptions>) => {
dispatch({
type: 'setRules',
rules: newRules,
pagination: newPagination,
});
}, []);
const setShowIdleModal = useCallback((show: boolean) => {
dispatch({
type: 'setShowIdleModal',
show,
});
}, []);
const setLastRefreshDate = useCallback(() => {
dispatch({
type: 'setLastRefreshDate',
});
}, []);
const setAutoRefreshOn = useCallback((on: boolean) => {
dispatch({
type: 'setAutoRefreshOn',
on,
});
}, []);
const [isLoadingRules, , reFetchRulesData] = useRules({
pagination,
filterOptions,
refetchPrePackagedRulesStatus,
dispatchRulesInReducer: setRules,
});
const sorting = useMemo(
(): SortingType => ({
sort: {
field: filterOptions.sortField,
direction: filterOptions.sortOrder,
},
}),
[filterOptions]
);
const prePackagedRuleStatus = getPrePackagedRuleStatus(
rulesInstalled,
rulesNotInstalled,
rulesNotUpdated
);
const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [
actions,
]);
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void): JSX.Element[] => {
return getBatchItems({
closePopover,
dispatch,
dispatchToaster,
hasMlPermissions,
hasActionsPrivileges,
loadingRuleIds,
selectedRuleIds,
reFetchRules: reFetchRulesData,
rules,
});
},
[
dispatch,
dispatchToaster,
hasMlPermissions,
loadingRuleIds,
reFetchRulesData,
rules,
selectedRuleIds,
hasActionsPrivileges,
]
);
const paginationMemo = useMemo(
() => ({
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
totalItemCount: pagination.total,
pageSizeOptions: [5, 10, 20, 50, 100, 200, 300],
}),
[pagination]
);
const tableOnChangeCallback = useCallback(
({ page, sort }: EuiBasicTableOnChange) => {
dispatch({
type: 'updateFilterOptions',
filterOptions: {
sortField: (sort?.field as RulesSortingFields) ?? INITIAL_SORT_FIELD, // Narrowing EuiBasicTable sorting types
sortOrder: sort?.direction ?? 'desc',
},
pagination: { page: page.index + 1, perPage: page.size },
});
},
[dispatch]
);
const rulesColumns = useMemo(() => {
return getColumns({
dispatch,
dispatchToaster,
formatUrl,
history,
hasMlPermissions,
hasNoPermissions,
loadingRuleIds:
loadingRulesAction != null &&
(loadingRulesAction === 'enable' || loadingRulesAction === 'disable')
? loadingRuleIds
: [],
reFetchRules: reFetchRulesData,
hasReadActionsPrivileges: hasActionsPrivileges,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dispatch,
dispatchToaster,
formatUrl,
hasMlPermissions,
history,
loadingRuleIds,
loadingRulesAction,
reFetchRulesData,
]);
const monitoringColumns = useMemo(() => getMonitoringColumns(history, formatUrl), [
history,
formatUrl,
]);
useEffect(() => {
if (reFetchRulesData != null) {
setRefreshRulesData(reFetchRulesData);
}
}, [reFetchRulesData, setRefreshRulesData]);
useEffect(() => {
if (initLoading && !loading && !isLoadingRules && !isLoadingRulesStatuses) {
setInitLoading(false);
}
}, [initLoading, loading, isLoadingRules, isLoadingRulesStatuses]);
const handleCreatePrePackagedRules = useCallback(async () => {
if (createPrePackagedRules != null && reFetchRulesData != null) {
await createPrePackagedRules();
reFetchRulesData(true);
}
}, [createPrePackagedRules, reFetchRulesData]);
const euiBasicTableSelectionProps = useMemo(
() => ({
selectable: (item: Rule) => !loadingRuleIds.includes(item.id),
onSelectionChange: (selected: Rule[]) =>
dispatch({ type: 'selectedRuleIds', ids: selected.map((r) => r.id) }),
}),
[loadingRuleIds]
);
const onFilterChangedCallback = useCallback((newFilterOptions: Partial<FilterOptions>) => {
dispatch({
type: 'updateFilterOptions',
filterOptions: {
...newFilterOptions,
},
pagination: { page: 1 },
});
}, []);
const isLoadingAnActionOnRule = useMemo(() => {
if (
loadingRuleIds.length > 0 &&
(loadingRulesAction === 'disable' || loadingRulesAction === 'enable')
) {
return false;
} else if (loadingRuleIds.length > 0) {
return true;
}
return false;
}, [loadingRuleIds, loadingRulesAction]);
const handleRefreshData = useCallback((): void => {
if (reFetchRulesData != null && !isLoadingAnActionOnRule) {
reFetchRulesData(true);
setLastRefreshDate();
}
}, [reFetchRulesData, isLoadingAnActionOnRule, setLastRefreshDate]);
const handleResetIdleTimer = useCallback((): void => {
if (isRefreshOn) {
setShowIdleModal(true);
setAutoRefreshOn(false);
}
}, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]);
const debounceResetIdleTimer = useMemo(() => {
return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer);
}, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]);
useEffect(() => {
const interval = setInterval(() => {
if (isRefreshOn) {
handleRefreshData();
}
}, defaultAutoRefreshSetting.value);
return () => {
clearInterval(interval);
};
}, [isRefreshOn, handleRefreshData, defaultAutoRefreshSetting.value]);
const handleIdleModalContinue = useCallback((): void => {
setShowIdleModal(false);
handleRefreshData();
setAutoRefreshOn(true);
}, [setShowIdleModal, setAutoRefreshOn, handleRefreshData]);
const handleAutoRefreshSwitch = useCallback(
(refreshOn: boolean) => {
if (refreshOn) {
handleRefreshData();
}
setAutoRefreshOn(refreshOn);
},
[setAutoRefreshOn, handleRefreshData]
);
const shouldShowRulesTable = useMemo(
(): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading,
[initLoading, rulesCustomInstalled, rulesInstalled]
);
const shouldShowPrepackagedRulesPrompt = useMemo(
(): boolean =>
rulesCustomInstalled != null &&
rulesCustomInstalled === 0 &&
prePackagedRuleStatus === 'ruleNotInstalled' &&
!initLoading,
[initLoading, prePackagedRuleStatus, rulesCustomInstalled]
);
const handleGenericDownloaderSuccess = useCallback(
(exportCount) => {
dispatch({ type: 'loadingRuleIds', ids: [], actionType: null });
dispatchToaster({
type: 'addToaster',
toast: {
id: uuid.v4(),
title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount),
color: 'success',
iconType: 'check',
},
});
},
[dispatchToaster]
);
return (
<>
<EuiWindowEvent event="mousemove" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="mousedown" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="click" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="keydown" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="scroll" handler={debounceResetIdleTimer} />
<EuiWindowEvent event="load" handler={debounceResetIdleTimer} />
<GenericDownloader
filename={`${i18n.EXPORT_FILENAME}.ndjson`}
ids={exportRuleIds}
onExportSuccess={handleGenericDownloaderSuccess}
exportSelectedData={exportRules}
/>
<Panel
loading={loading || isLoadingRules || isLoadingRulesStatuses}
data-test-subj="allRulesPanel"
>
<>
{(isLoadingRules || isLoadingRulesStatuses) && (
<EuiProgress
data-test-subj="loadingRulesInfoProgress"
size="xs"
position="absolute"
color="accent"
/>
)}
<HeaderSection
split
growLeftSplit={false}
title={i18n.ALL_RULES}
subtitle={
<LastUpdatedAt
showUpdating={loading || isLoadingRules || isLoadingRulesStatuses}
updatedAt={lastUpdated}
/>
}
>
<RulesTableFilters
onFilterChanged={onFilterChangedCallback}
rulesCustomInstalled={rulesCustomInstalled}
rulesInstalled={rulesInstalled}
currentFilterTags={filterOptions.tags ?? []}
/>
</HeaderSection>
{isLoadingAnActionOnRule && !initLoading && (
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
)}
{shouldShowPrepackagedRulesPrompt && (
<PrePackagedRulesPrompt
createPrePackagedRules={handleCreatePrePackagedRules}
loading={loadingCreatePrePackagedRules}
userHasNoPermissions={hasNoPermissions}
/>
)}
{initLoading && (
<EuiLoadingContent data-test-subj="initialLoadingPanelAllRulesTable" lines={10} />
)}
{showIdleModal && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.REFRESH_PROMPT_TITLE}
onCancel={handleIdleModalContinue}
onConfirm={handleIdleModalContinue}
confirmButtonText={i18n.REFRESH_PROMPT_CONFIRM}
defaultFocusedButton="confirm"
data-test-subj="allRulesIdleModal"
>
<p>{i18n.REFRESH_PROMPT_BODY}</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
{shouldShowRulesTable && (
<>
<AllRulesUtilityBar
userHasNoPermissions={hasNoPermissions}
paginationTotal={pagination.total ?? 0}
numberSelectedItems={selectedRuleIds.length}
onGetBatchItemsPopoverContent={getBatchItemsPopoverContent}
onRefresh={handleRefreshData}
isAutoRefreshOn={isRefreshOn}
onRefreshSwitch={handleAutoRefreshSwitch}
showBulkActions
/>
<AllRulesTables
selectedTab={selectedTab}
euiBasicTableSelectionProps={euiBasicTableSelectionProps}
hasNoPermissions={hasNoPermissions}
monitoringColumns={monitoringColumns}
pagination={paginationMemo}
rules={rules}
rulesColumns={rulesColumns}
rulesStatuses={rulesStatuses}
sorting={sorting}
tableOnChangeCallback={tableOnChangeCallback}
tableRef={tableRef}
/>
</>
)}
</>
</Panel>
</>
);
}
);
RulesTables.displayName = 'RulesTables';

View file

@ -22,10 +22,11 @@ describe('AllRules', () => {
userHasNoPermissions={false}
onRefresh={jest.fn()}
paginationTotal={4}
numberSelectedRules={1}
numberSelectedItems={1}
onGetBatchItemsPopoverContent={jest.fn()}
isAutoRefreshOn={true}
onRefreshSwitch={jest.fn()}
showBulkActions
/>
</ThemeProvider>
);
@ -36,6 +37,29 @@ describe('AllRules', () => {
);
});
it('does not render total selected and bulk actions when "showBulkActions" is false', () => {
const wrapper = mount(
<ThemeProvider theme={theme}>
<AllRulesUtilityBar
userHasNoPermissions={false}
onRefresh={jest.fn()}
paginationTotal={4}
numberSelectedItems={1}
onGetBatchItemsPopoverContent={jest.fn()}
isAutoRefreshOn={true}
onRefreshSwitch={jest.fn()}
showBulkActions={false}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="showingRules"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="tableBulkActions"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="showingExceptionLists"]').at(0).text()).toEqual(
'Showing 4 lists'
);
});
it('renders utility actions if user has permissions', () => {
const wrapper = mount(
<ThemeProvider theme={theme}>
@ -43,10 +67,11 @@ describe('AllRules', () => {
userHasNoPermissions={false}
onRefresh={jest.fn()}
paginationTotal={4}
numberSelectedRules={1}
numberSelectedItems={1}
onGetBatchItemsPopoverContent={jest.fn()}
isAutoRefreshOn={true}
onRefreshSwitch={jest.fn()}
showBulkActions
/>
</ThemeProvider>
);
@ -61,10 +86,11 @@ describe('AllRules', () => {
userHasNoPermissions
onRefresh={jest.fn()}
paginationTotal={4}
numberSelectedRules={1}
numberSelectedItems={1}
onGetBatchItemsPopoverContent={jest.fn()}
isAutoRefreshOn={true}
onRefreshSwitch={jest.fn()}
showBulkActions
/>
</ThemeProvider>
);
@ -80,10 +106,11 @@ describe('AllRules', () => {
userHasNoPermissions={false}
onRefresh={mockRefresh}
paginationTotal={4}
numberSelectedRules={1}
numberSelectedItems={1}
onGetBatchItemsPopoverContent={jest.fn()}
isAutoRefreshOn={true}
onRefreshSwitch={jest.fn()}
showBulkActions
/>
</ThemeProvider>
);
@ -101,10 +128,11 @@ describe('AllRules', () => {
userHasNoPermissions={false}
onRefresh={jest.fn()}
paginationTotal={4}
numberSelectedRules={1}
numberSelectedItems={1}
onGetBatchItemsPopoverContent={jest.fn()}
isAutoRefreshOn={true}
onRefreshSwitch={mockSwitch}
showBulkActions
/>
</ThemeProvider>
);

View file

@ -18,12 +18,13 @@ import * as i18n from '../translations';
interface AllRulesUtilityBarProps {
userHasNoPermissions: boolean;
numberSelectedRules: number;
numberSelectedItems: number;
paginationTotal: number;
isAutoRefreshOn: boolean;
onRefresh: (refreshRule: boolean) => void;
onGetBatchItemsPopoverContent: (closePopover: () => void) => JSX.Element[];
onRefreshSwitch: (checked: boolean) => void;
isAutoRefreshOn?: boolean;
showBulkActions: boolean;
onRefresh?: (refreshRule: boolean) => void;
onGetBatchItemsPopoverContent?: (closePopover: () => void) => JSX.Element[];
onRefreshSwitch?: (checked: boolean) => void;
}
export const AllRulesUtilityBar = React.memo<AllRulesUtilityBarProps>(
@ -31,22 +32,29 @@ export const AllRulesUtilityBar = React.memo<AllRulesUtilityBarProps>(
userHasNoPermissions,
onRefresh,
paginationTotal,
numberSelectedRules,
numberSelectedItems,
onGetBatchItemsPopoverContent,
isAutoRefreshOn,
showBulkActions = true,
onRefreshSwitch,
}) => {
const handleGetBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => (
<EuiContextMenuPanel items={onGetBatchItemsPopoverContent(closePopover)} />
),
(closePopover: () => void): JSX.Element | null => {
if (onGetBatchItemsPopoverContent != null) {
return <EuiContextMenuPanel items={onGetBatchItemsPopoverContent(closePopover)} />;
} else {
return null;
}
},
[onGetBatchItemsPopoverContent]
);
const handleAutoRefreshSwitch = useCallback(
(closePopover: () => void) => (e: EuiSwitchEvent) => {
onRefreshSwitch(e.target.checked);
closePopover();
if (onRefreshSwitch != null) {
onRefreshSwitch(e.target.checked);
closePopover();
}
},
[onRefreshSwitch]
);
@ -58,7 +66,7 @@ export const AllRulesUtilityBar = React.memo<AllRulesUtilityBarProps>(
<EuiSwitch
key="allRulesAutoRefreshSwitch"
label={i18n.REFRESH_RULE_POPOVER_DESCRIPTION}
checked={isAutoRefreshOn}
checked={isAutoRefreshOn ?? false}
onChange={handleAutoRefreshSwitch(closePopover)}
compressed
data-test-subj="refreshSettingsSwitch"
@ -73,42 +81,64 @@ export const AllRulesUtilityBar = React.memo<AllRulesUtilityBarProps>(
<UtilityBar>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText dataTestSubj="showingRules">
{i18n.SHOWING_RULES(paginationTotal)}
</UtilityBarText>
{showBulkActions ? (
<UtilityBarText dataTestSubj="showingRules">
{i18n.SHOWING_RULES(paginationTotal)}
</UtilityBarText>
) : (
<UtilityBarText dataTestSubj="showingExceptionLists">
{i18n.SHOWING_EXCEPTION_LISTS(paginationTotal)}
</UtilityBarText>
)}
</UtilityBarGroup>
<UtilityBarGroup>
<UtilityBarText dataTestSubj="selectedRules">
{i18n.SELECTED_RULES(numberSelectedRules)}
</UtilityBarText>
{!userHasNoPermissions && (
{showBulkActions ? (
<>
<UtilityBarGroup data-test-subj="tableBulkActions">
<UtilityBarText dataTestSubj="selectedRules">
{i18n.SELECTED_RULES(numberSelectedItems)}
</UtilityBarText>
{!userHasNoPermissions && (
<UtilityBarAction
dataTestSubj="bulkActions"
iconSide="right"
iconType="arrowDown"
popoverContent={handleGetBatchItemsPopoverContent}
>
{i18n.BATCH_ACTIONS}
</UtilityBarAction>
)}
<UtilityBarAction
dataTestSubj="refreshRulesAction"
iconSide="left"
iconType="refresh"
onClick={onRefresh}
>
{i18n.REFRESH}
</UtilityBarAction>
<UtilityBarAction
dataTestSubj="refreshSettings"
iconSide="right"
iconType="arrowDown"
popoverContent={handleGetRefreshSettingsPopoverContent}
>
{i18n.REFRESH_RULE_POPOVER_LABEL}
</UtilityBarAction>
</UtilityBarGroup>
</>
) : (
<UtilityBarGroup>
<UtilityBarAction
dataTestSubj="bulkActions"
iconSide="right"
iconType="arrowDown"
popoverContent={handleGetBatchItemsPopoverContent}
dataTestSubj="refreshRulesAction"
iconSide="left"
iconType="refresh"
onClick={onRefresh}
>
{i18n.BATCH_ACTIONS}
{i18n.REFRESH}
</UtilityBarAction>
)}
<UtilityBarAction
dataTestSubj="refreshRulesAction"
iconSide="left"
iconType="refresh"
onClick={onRefresh}
>
{i18n.REFRESH}
</UtilityBarAction>
<UtilityBarAction
dataTestSubj="refreshSettings"
iconSide="right"
iconType="arrowDown"
popoverContent={handleGetRefreshSettingsPopoverContent}
>
{i18n.REFRESH_RULE_POPOVER_LABEL}
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarGroup>
)}
</UtilityBarSection>
</UtilityBar>
);

View file

@ -374,7 +374,14 @@ export const RULES_TAB = i18n.translate(
export const MONITORING_TAB = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring',
{
defaultMessage: 'Monitoring',
defaultMessage: 'Rule Monitoring',
}
);
export const EXCEPTIONS_TAB = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions',
{
defaultMessage: 'Exception Lists',
}
);
@ -589,3 +596,9 @@ export const REFRESH_RULE_POPOVER_LABEL = i18n.translate(
defaultMessage: 'Refresh settings',
}
);
export const SHOWING_EXCEPTION_LISTS = (totalLists: number) =>
i18n.translate('xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists', {
values: { totalLists },
defaultMessage: 'Showing {totalLists} {totalLists, plural, =1 {list} other {lists}}',
});

View file

@ -36,7 +36,8 @@ export {
useCursor,
useApi,
useAsync,
useExceptionList,
useExceptionListItems,
useExceptionLists,
usePersistExceptionItem,
usePersistExceptionList,
useFindLists,
@ -52,7 +53,7 @@ export {
ExceptionListIdentifiers,
ExceptionList,
Pagination,
UseExceptionListSuccess,
UseExceptionListItemsSuccess,
addEndpointExceptionList,
withOptionalSignal,
} from '../../lists/public';