diff --git a/x-pack/plugins/ml/public/components/items_grid/index.js b/x-pack/plugins/ml/public/components/items_grid/index.js
new file mode 100644
index 000000000000..4f491aa9eb9d
--- /dev/null
+++ b/x-pack/plugins/ml/public/components/items_grid/index.js
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+
+export { ItemsGrid } from './items_grid';
diff --git a/x-pack/plugins/ml/public/components/items_grid/items_grid.js b/x-pack/plugins/ml/public/components/items_grid/items_grid.js
new file mode 100644
index 000000000000..c653479a125b
--- /dev/null
+++ b/x-pack/plugins/ml/public/components/items_grid/items_grid.js
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React component for a paged grid of items.
+ */
+
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {
+ EuiCheckbox,
+ EuiFlexGrid,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText
+} from '@elastic/eui';
+
+import { ItemsGridPagination } from './items_grid_pagination';
+
+import './styles/main.less';
+
+export function ItemsGrid({
+ numberColumns,
+ totalItemCount,
+ items,
+ selectedItems,
+ itemsPerPage,
+ itemsPerPageOptions,
+ setItemsPerPage,
+ setItemSelected,
+ activePage,
+ setActivePage }) {
+
+ if (items === undefined || items.length === 0) {
+ return (
+
+
+
+ {(totalItemCount === 0) ? 'No items have been added' : 'No matching items'}
+
+
+
+ );
+ }
+
+ const startIndex = activePage * itemsPerPage;
+ const pageItems = items.slice(startIndex, startIndex + itemsPerPage);
+ const gridItems = pageItems.map((item, index) => {
+ return (
+
+ = 0)}
+ onChange={(e) => { setItemSelected(item, e.target.checked); }}
+ />
+
+ );
+ });
+
+ return (
+
+
+ {gridItems}
+
+
+
+ );
+
+}
+ItemsGrid.propTypes = {
+ numberColumns: PropTypes.oneOf([2, 3, 4]), // In line with EuiFlexGrid which supports 2, 3 or 4 columns.
+ totalItemCount: PropTypes.number.isRequired,
+ items: PropTypes.array,
+ selectedItems: PropTypes.array,
+ itemsPerPage: PropTypes.number,
+ itemsPerPageOptions: PropTypes.arrayOf(PropTypes.number),
+ setItemsPerPage: PropTypes.func.isRequired,
+ setItemSelected: PropTypes.func.isRequired,
+ activePage: PropTypes.number.isRequired,
+ setActivePage: PropTypes.func.isRequired
+};
+
+ItemsGrid.defaultProps = {
+ numberColumns: 4,
+ itemsPerPage: 50,
+ itemsPerPageOptions: [50, 100, 500, 1000],
+};
diff --git a/x-pack/plugins/ml/public/components/items_grid/items_grid_pagination.js b/x-pack/plugins/ml/public/components/items_grid/items_grid_pagination.js
new file mode 100644
index 000000000000..1f29eea757f8
--- /dev/null
+++ b/x-pack/plugins/ml/public/components/items_grid/items_grid_pagination.js
@@ -0,0 +1,135 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React component for the pagination controls of the items grid.
+ */
+
+import PropTypes from 'prop-types';
+import React, {
+ Component,
+} from 'react';
+
+import {
+ EuiButtonEmpty,
+ EuiContextMenuItem,
+ EuiContextMenuPanel,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPagination,
+ EuiPopover,
+} from '@elastic/eui';
+
+
+function getContextMenuItemIcon(menuItemSetting, itemsPerPage) {
+ return (menuItemSetting === itemsPerPage) ? 'check' : 'empty';
+}
+
+
+export class ItemsGridPagination extends Component {
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPopoverOpen: false
+ };
+ }
+
+ onButtonClick = () => {
+ this.setState({
+ isPopoverOpen: !this.state.isPopoverOpen,
+ });
+ }
+
+ closePopover = () => {
+ this.setState({
+ isPopoverOpen: false,
+ });
+ }
+
+ onPageClick = (pageNumber) => {
+ this.props.setActivePage(pageNumber);
+ }
+
+ onChangeItemsPerPage = (pageSize) => {
+ this.closePopover();
+ this.props.setItemsPerPage(pageSize);
+ }
+
+ render() {
+
+ const {
+ itemCount,
+ itemsPerPage,
+ itemsPerPageOptions,
+ activePage } = this.props;
+
+ const button = (
+
+ Items per page: {itemsPerPage}
+
+ );
+
+ const pageCount = Math.ceil(itemCount / itemsPerPage);
+
+ const items = itemsPerPageOptions.map((pageSize) => {
+ return (
+ {this.onChangeItemsPerPage(pageSize);}}
+ >
+ {pageSize} items
+
+ );
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+ItemsGridPagination.propTypes = {
+ itemCount: PropTypes.number.isRequired,
+ itemsPerPage: PropTypes.number.isRequired,
+ itemsPerPageOptions: PropTypes.arrayOf(PropTypes.number).isRequired,
+ setItemsPerPage: PropTypes.func.isRequired,
+ activePage: PropTypes.number.isRequired,
+ setActivePage: PropTypes.func.isRequired,
+};
+
diff --git a/x-pack/plugins/ml/public/components/items_grid/styles/main.less b/x-pack/plugins/ml/public/components/items_grid/styles/main.less
new file mode 100644
index 000000000000..f38cd62e9854
--- /dev/null
+++ b/x-pack/plugins/ml/public/components/items_grid/styles/main.less
@@ -0,0 +1,3 @@
+.ml-items-grid-page-size-menu {
+ width: 140px;
+}
diff --git a/x-pack/plugins/ml/public/components/nav_menu/nav_menu.js b/x-pack/plugins/ml/public/components/nav_menu/nav_menu.js
index 6e0eb3d533d8..72686c4a1354 100644
--- a/x-pack/plugins/ml/public/components/nav_menu/nav_menu.js
+++ b/x-pack/plugins/ml/public/components/nav_menu/nav_menu.js
@@ -50,6 +50,9 @@ module.directive('mlNavMenu', function () {
calendars_list: { label: 'Calendar Management', url: '#/settings/calendars_list' },
new_calendar: { label: 'New Calendar', url: '#/settings/calendars_list/new_calendar' },
edit_calendar: { label: 'Edit Calendar', url: '#/settings/calendars_list/edit_calendar' },
+ filter_lists: { label: 'Filter Lists', url: '#/settings/filter_lists' },
+ new_filter_list: { label: 'New Filter List', url: '#/settings/filter_lists/new' },
+ edit_filter_list: { label: 'Edit Filter List', url: '#/settings/filter_lists/edit' },
};
const breadcrumbs = [{ label: 'Machine Learning', url: '#/' }];
diff --git a/x-pack/plugins/ml/public/services/ml_api_service/filters.js b/x-pack/plugins/ml/public/services/ml_api_service/filters.js
index bd78e85bbe8d..f603b3491630 100644
--- a/x-pack/plugins/ml/public/services/ml_api_service/filters.js
+++ b/x-pack/plugins/ml/public/services/ml_api_service/filters.js
@@ -22,6 +22,13 @@ export const filters = {
});
},
+ filtersStats() {
+ return http({
+ url: `${basePath}/filters/_stats`,
+ method: 'GET'
+ });
+ },
+
addFilter(
filterId,
description,
@@ -41,7 +48,7 @@ export const filters = {
filterId,
description,
addItems,
- deleteItems
+ removeItems
) {
return http({
url: `${basePath}/filters/${filterId}`,
@@ -49,7 +56,7 @@ export const filters = {
data: {
description,
addItems,
- deleteItems
+ removeItems
}
});
},
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/add_item_popover.js b/x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/add_item_popover.js
new file mode 100644
index 000000000000..8f99368170a5
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/add_item_popover.js
@@ -0,0 +1,130 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React popover for adding items to a filter list.
+ */
+
+import PropTypes from 'prop-types';
+import React, {
+ Component,
+} from 'react';
+
+import {
+ EuiButton,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiForm,
+ EuiFormRow,
+ EuiPopover,
+ EuiSpacer,
+ EuiText,
+ EuiTextArea
+} from '@elastic/eui';
+
+
+export class AddItemPopover extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPopoverOpen: false,
+ itemsText: ''
+ };
+ }
+
+ onItemsTextChange = (e) => {
+ this.setState({
+ itemsText: e.target.value,
+ });
+ };
+
+ onButtonClick = () => {
+ this.setState({
+ isPopoverOpen: !this.state.isPopoverOpen,
+ });
+ }
+
+ closePopover = () => {
+ this.setState({
+ isPopoverOpen: false,
+ });
+ }
+
+ onAddButtonClick = () => {
+ const items = this.state.itemsText.split('\n');
+ const addItems = [];
+ // Remove duplicates.
+ items.forEach((item) => {
+ if ((addItems.indexOf(item) === -1 && item.length > 0)) {
+ addItems.push(item);
+ }
+ });
+
+ this.props.addItems(addItems);
+ this.setState({
+ isPopoverOpen: false,
+ itemsText: ''
+ });
+ }
+
+ render() {
+ const button = (
+
+ Add item
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ Enter one item per line
+
+
+
+
+
+ Add
+
+
+
+
+
+ );
+ }
+}
+AddItemPopover.propTypes = {
+ addItems: PropTypes.func.isRequired
+};
+
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/index.js b/x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/index.js
new file mode 100644
index 000000000000..b7bfbe1a1492
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/index.js
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+
+export { AddItemPopover } from './add_item_popover';
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js b/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js
new file mode 100644
index 000000000000..25511700558c
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js
@@ -0,0 +1,102 @@
+/*
+ * 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 PropTypes from 'prop-types';
+import React, {
+ Component,
+} from 'react';
+
+import {
+ EuiButton,
+ EuiConfirmModal,
+ EuiOverlayMask,
+ EUI_MODAL_CONFIRM_BUTTON,
+} from '@elastic/eui';
+
+import { deleteFilterLists } from './delete_filter_lists';
+
+/*
+ * React modal for confirming deletion of filter lists.
+ */
+export class DeleteFilterListModal extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isModalVisible: false
+ };
+ }
+
+ closeModal = () => {
+ this.setState({ isModalVisible: false });
+ }
+
+ showModal = () => {
+ this.setState({ isModalVisible: true });
+ }
+
+ onConfirmDelete = () => {
+ this.doDelete();
+ }
+
+ async doDelete() {
+ const { selectedFilterLists, refreshFilterLists } = this.props;
+ await deleteFilterLists(selectedFilterLists);
+
+ refreshFilterLists();
+ this.closeModal();
+ }
+
+ render() {
+ const { selectedFilterLists } = this.props;
+ let modal;
+
+ if (this.state.isModalVisible) {
+ const title = `Delete ${(selectedFilterLists.length > 1) ?
+ `${selectedFilterLists.length} filter lists` : selectedFilterLists[0].filter_id}`;
+ modal = (
+
+
+
+ Are you sure you want to delete {(selectedFilterLists.length > 1) ?
+ 'these filter lists' : 'this filter list'}?
+
+
+
+ );
+ }
+
+ return (
+
+
+ Delete
+
+
+ {modal}
+
+ );
+ }
+}
+DeleteFilterListModal.propTypes = {
+ selectedFilterLists: PropTypes.array.isRequired,
+};
+
+
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_lists.js b/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_lists.js
new file mode 100644
index 000000000000..b07bf002328e
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_lists.js
@@ -0,0 +1,37 @@
+/*
+ * 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 { toastNotifications } from 'ui/notify';
+import { ml } from 'plugins/ml/services/ml_api_service';
+
+
+export async function deleteFilterLists(filterListsToDelete) {
+ if (filterListsToDelete === undefined || filterListsToDelete.length === 0) {
+ return;
+ }
+
+ // Delete each of the specified filter lists in turn, waiting for each response
+ // before deleting the next to minimize load on the cluster.
+ const messageId = `${(filterListsToDelete.length > 1) ?
+ `${filterListsToDelete.length} filter lists` : filterListsToDelete[0].filter_id}`;
+ toastNotifications.add(`Deleting ${messageId}`);
+
+ for(const filterList of filterListsToDelete) {
+ const filterId = filterList.filter_id;
+ try {
+ await ml.filters.deleteFilter(filterId);
+ } catch (resp) {
+ console.log('Error deleting filter list:', resp);
+ let errorMessage = `An error occurred deleting filter list ${filterList.filter_id}`;
+ if (resp.message) {
+ errorMessage += ` : ${resp.message}`;
+ }
+ toastNotifications.addDanger(errorMessage);
+ }
+ }
+
+ toastNotifications.addSuccess(`${messageId} deleted`);
+}
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/index.js b/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/index.js
new file mode 100644
index 000000000000..be8ac77c30b5
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/index.js
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+
+export { DeleteFilterListModal } from './delete_filter_list_modal';
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/edit_description_popover.js b/x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/edit_description_popover.js
new file mode 100644
index 000000000000..e2b2f157741b
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/edit_description_popover.js
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React popover for editing the description of a filter list.
+ */
+
+import PropTypes from 'prop-types';
+import React, {
+ Component,
+} from 'react';
+
+import {
+ EuiButtonIcon,
+ EuiPopover,
+ EuiForm,
+ EuiFormRow,
+ EuiFieldText,
+} from '@elastic/eui';
+
+
+export class EditDescriptionPopover extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPopoverOpen: false,
+ value: props.description
+ };
+ }
+
+ onChange = (e) => {
+ this.setState({
+ value: e.target.value,
+ });
+ };
+
+ onButtonClick = () => {
+ if (this.state.isPopoverOpen === false) {
+ this.setState({
+ isPopoverOpen: !this.state.isPopoverOpen,
+ value: this.props.description
+ });
+ } else {
+ this.closePopover();
+ }
+ }
+
+ closePopover = () => {
+ if (this.state.isPopoverOpen === true) {
+ this.setState({
+ isPopoverOpen: false,
+ });
+ this.props.updateDescription(this.state.value);
+ }
+ }
+
+ render() {
+ const { isPopoverOpen, value } = this.state;
+
+ const button = (
+
+ );
+
+ return (
+
+ );
+ }
+}
+EditDescriptionPopover.propTypes = {
+ description: PropTypes.string,
+ updateDescription: PropTypes.func.isRequired
+};
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/index.js b/x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/index.js
new file mode 100644
index 000000000000..692215b89b54
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/index.js
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+
+export { EditDescriptionPopover } from './edit_description_popover';
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/filter_list_usage_popover.js b/x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/filter_list_usage_popover.js
new file mode 100644
index 000000000000..fcaf05fe3964
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/filter_list_usage_popover.js
@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React popover listing the jobs or detectors using a particular filter list in a custom rule.
+ */
+
+import PropTypes from 'prop-types';
+import React, {
+ Component,
+} from 'react';
+
+import {
+ EuiButtonEmpty,
+ EuiPopover,
+} from '@elastic/eui';
+
+
+export class FilterListUsagePopover extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPopoverOpen: false,
+ };
+ }
+
+ onButtonClick = () => {
+ this.setState({
+ isPopoverOpen: !this.state.isPopoverOpen,
+ });
+ }
+
+ closePopover = () => {
+ this.setState({
+ isPopoverOpen: false,
+ });
+ }
+
+ render() {
+ const {
+ entityType,
+ entityValues } = this.props;
+
+ const linkText = `${entityValues.length} ${entityType}${(entityValues.length !== 1) ? 's' : ''}`;
+
+ const listItems = entityValues.map(value => ({value}));
+
+ const button = (
+
+ {linkText}
+
+ );
+
+ return (
+
+ );
+ }
+}
+FilterListUsagePopover.propTypes = {
+ entityType: PropTypes.oneOf(['job', 'detector']),
+ entityValues: PropTypes.array.isRequired
+};
+
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/index.js b/x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/index.js
new file mode 100644
index 000000000000..006e18eab875
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/index.js
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+
+export { FilterListUsagePopover } from './filter_list_usage_popover';
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/edit/directive.js b/x-pack/plugins/ml/public/settings/filter_lists/edit/directive.js
new file mode 100644
index 000000000000..4376a09b459d
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/edit/directive.js
@@ -0,0 +1,68 @@
+/*
+ * 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 'ngreact';
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import { uiModules } from 'ui/modules';
+const module = uiModules.get('apps/ml', ['react']);
+
+import { checkLicense } from 'plugins/ml/license/check_license';
+import { checkGetJobsPrivilege } from 'plugins/ml/privilege/check_privilege';
+import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes';
+import { initPromise } from 'plugins/ml/util/promise';
+
+import uiRoutes from 'ui/routes';
+
+const template = `
+
+
+
+
+`;
+
+uiRoutes
+ .when('/settings/filter_lists/new_filter_list', {
+ template,
+ resolve: {
+ CheckLicense: checkLicense,
+ privileges: checkGetJobsPrivilege,
+ mlNodeCount: getMlNodeCount,
+ initPromise: initPromise(false)
+ }
+ })
+ .when('/settings/filter_lists/edit_filter_list/:filterId', {
+ template,
+ resolve: {
+ CheckLicense: checkLicense,
+ privileges: checkGetJobsPrivilege,
+ mlNodeCount: getMlNodeCount,
+ initPromise: initPromise(false)
+ }
+ });
+
+
+import { EditFilterList } from './edit_filter_list';
+
+module.directive('mlEditFilterList', function ($route) {
+ return {
+ restrict: 'E',
+ replace: false,
+ scope: {},
+ link: function (scope, element) {
+ const props = {
+ filterId: $route.current.params.filterId
+ };
+
+ ReactDOM.render(
+ React.createElement(EditFilterList, props),
+ element[0]
+ );
+ }
+ };
+});
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/edit/edit_filter_list.js b/x-pack/plugins/ml/public/settings/filter_lists/edit/edit_filter_list.js
new file mode 100644
index 000000000000..8a7bd87f003c
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/edit/edit_filter_list.js
@@ -0,0 +1,337 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React component for viewing and editing a filter list, a list of items
+ * used for example to safe list items via a job detector rule.
+ */
+
+import PropTypes from 'prop-types';
+import React, {
+ Component
+} from 'react';
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPage,
+ EuiPageContent,
+ EuiSearchBar,
+ EuiSpacer,
+} from '@elastic/eui';
+
+import { toastNotifications } from 'ui/notify';
+
+import { EditFilterListHeader } from './header';
+import { EditFilterListToolbar } from './toolbar';
+import { ItemsGrid } from 'plugins/ml/components/items_grid';
+import {
+ isValidFilterListId,
+ saveFilterList
+} from './utils';
+import { ml } from 'plugins/ml/services/ml_api_service';
+
+const DEFAULT_ITEMS_PER_PAGE = 50;
+
+// Returns the list of items that match the query entered in the EuiSearchBar.
+function getMatchingFilterItems(searchBarQuery, items) {
+ if (searchBarQuery === undefined) {
+ return [...items];
+ }
+
+ // Convert the list of Strings into a list of Objects suitable for running through
+ // the search bar query.
+ const allItems = items.map(item => ({ value: item }));
+ const matchingObjects =
+ EuiSearchBar.Query.execute(searchBarQuery, allItems, { defaultFields: ['value'] });
+ return matchingObjects.map(item => item.value);
+}
+
+function getActivePage(activePageState, itemsPerPage, numMatchingItems) {
+ // Checks if supplied active page number from state is applicable for the number
+ // of matching items in the grid, and if not returns the last applicable page number.
+ let activePage = activePageState;
+ const activePageStartIndex = itemsPerPage * activePageState;
+ if (activePageStartIndex > numMatchingItems) {
+ activePage = Math.max((Math.ceil(numMatchingItems / itemsPerPage)) - 1, 0); // Sets to 0 for 0 matches.
+ }
+ return activePage;
+}
+
+function returnToFiltersList() {
+ window.location.href = `#/settings/filter_lists`;
+}
+
+export class EditFilterList extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ description: '',
+ items: [],
+ matchingItems: [],
+ selectedItems: [],
+ loadedFilter: {},
+ newFilterId: '',
+ isNewFilterIdInvalid: true,
+ activePage: 0,
+ itemsPerPage: DEFAULT_ITEMS_PER_PAGE,
+ saveInProgress: false,
+ };
+ }
+
+ componentDidMount() {
+ const filterId = this.props.filterId;
+ if (filterId !== undefined) {
+ this.loadFilterList(filterId);
+ } else {
+ this.setState({ newFilterId: '' });
+ }
+ }
+
+ loadFilterList = (filterId) => {
+ ml.filters.filters({ filterId })
+ .then((filter) => {
+ this.setLoadedFilterState(filter);
+ })
+ .catch((resp) => {
+ console.log(`Error loading filter ${filterId}:`, resp);
+ toastNotifications.addDanger(`An error occurred loading details of filter ${filterId}`);
+ });
+ }
+
+ setLoadedFilterState = (loadedFilter) => {
+ // Store the loaded filter so we can diff changes to the items when saving updates.
+ this.setState((prevState) => {
+ const { itemsPerPage, searchQuery } = prevState;
+
+ const matchingItems = getMatchingFilterItems(searchQuery, loadedFilter.items);
+ const activePage = getActivePage(prevState.activePage, itemsPerPage, matchingItems.length);
+
+ return {
+ description: loadedFilter.description,
+ items: [...loadedFilter.items],
+ matchingItems,
+ selectedItems: [],
+ loadedFilter,
+ isNewFilterIdInvalid: false,
+ activePage,
+ searchQuery,
+ saveInProgress: false
+ };
+ });
+ }
+
+ updateNewFilterId = (newFilterId) => {
+ this.setState({
+ newFilterId,
+ isNewFilterIdInvalid: !isValidFilterListId(newFilterId)
+ });
+ }
+
+ updateDescription = (description) => {
+ this.setState({ description });
+ }
+
+ addItems = (itemsToAdd) => {
+ this.setState((prevState) => {
+ const { itemsPerPage, searchQuery } = prevState;
+ const items = [...prevState.items];
+ const alreadyInFilter = [];
+ itemsToAdd.forEach((item) => {
+ if (items.indexOf(item) === -1) {
+ items.push(item);
+ } else {
+ alreadyInFilter.push(item);
+ }
+ });
+ items.sort((str1, str2) => {
+ return str1.localeCompare(str2);
+ });
+
+ if (alreadyInFilter.length > 0) {
+ toastNotifications.addWarning(`The following items were already in the filter list: ${alreadyInFilter}`);
+ }
+
+ const matchingItems = getMatchingFilterItems(searchQuery, items);
+ const activePage = getActivePage(prevState.activePage, itemsPerPage, matchingItems.length);
+
+ return {
+ items,
+ matchingItems,
+ activePage,
+ searchQuery
+ };
+ });
+ };
+
+ deleteSelectedItems = () => {
+ this.setState((prevState) => {
+ const { selectedItems, itemsPerPage, searchQuery } = prevState;
+ const items = [...prevState.items];
+ selectedItems.forEach((item) => {
+ const index = items.indexOf(item);
+ if (index !== -1) {
+ items.splice(index, 1);
+ }
+ });
+
+ const matchingItems = getMatchingFilterItems(searchQuery, items);
+ const activePage = getActivePage(prevState.activePage, itemsPerPage, matchingItems.length);
+
+ return {
+ items,
+ matchingItems,
+ selectedItems: [],
+ activePage,
+ searchQuery
+ };
+ });
+ }
+
+ onSearchChange = ({ query }) => {
+ this.setState((prevState) => {
+ const { items, itemsPerPage } = prevState;
+
+ const matchingItems = getMatchingFilterItems(query, items);
+ const activePage = getActivePage(prevState.activePage, itemsPerPage, matchingItems.length);
+
+ return {
+ matchingItems,
+ activePage,
+ searchQuery: query
+ };
+ });
+ };
+
+ setItemSelected = (item, isSelected) => {
+ this.setState((prevState) => {
+ const selectedItems = [...prevState.selectedItems];
+ const index = selectedItems.indexOf(item);
+ if (isSelected === true && index === -1) {
+ selectedItems.push(item);
+ } else if (isSelected === false && index !== -1) {
+ selectedItems.splice(index, 1);
+ }
+
+ return {
+ selectedItems
+ };
+ });
+ };
+
+ setActivePage = (activePage) => {
+ this.setState({ activePage });
+ }
+
+ setItemsPerPage = (itemsPerPage) => {
+ this.setState({
+ itemsPerPage,
+ activePage: 0
+ });
+ }
+
+ save = () => {
+ this.setState({ saveInProgress: true });
+
+ const { loadedFilter, newFilterId, description, items } = this.state;
+ const filterId = (this.props.filterId !== undefined) ? this.props.filterId : newFilterId;
+ saveFilterList(
+ filterId,
+ description,
+ items,
+ loadedFilter
+ )
+ .then((savedFilter) => {
+ this.setLoadedFilterState(savedFilter);
+ returnToFiltersList();
+ })
+ .catch((resp) => {
+ console.log(`Error saving filter ${filterId}:`, resp);
+ toastNotifications.addDanger(`An error occurred saving filter ${filterId}`);
+ this.setState({ saveInProgress: false });
+ });
+ }
+
+ render() {
+ const {
+ loadedFilter,
+ newFilterId,
+ isNewFilterIdInvalid,
+ description,
+ items,
+ matchingItems,
+ selectedItems,
+ itemsPerPage,
+ activePage,
+ saveInProgress } = this.state;
+
+ const totalItemCount = (items !== undefined) ? items.length : 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Save
+
+
+
+
+
+ );
+ }
+}
+EditFilterList.propTypes = {
+ filterId: PropTypes.string
+};
+
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/edit/header.js b/x-pack/plugins/ml/public/settings/filter_lists/edit/header.js
new file mode 100644
index 000000000000..9550b2ad2c4f
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/edit/header.js
@@ -0,0 +1,170 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React component for the header section of the edit filter list page, showing the
+ * filter ID, description, number of items, and the jobs and detectors using the filter list.
+ */
+
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {
+ EuiFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiSpacer,
+ EuiText,
+ EuiTextColor,
+ EuiTitle,
+} from '@elastic/eui';
+
+import { EditDescriptionPopover } from '../components/edit_description_popover';
+import { FilterListUsagePopover } from '../components/filter_list_usage_popover';
+
+export function EditFilterListHeader({
+ filterId,
+ totalItemCount,
+ description,
+ updateDescription,
+ newFilterId,
+ isNewFilterIdInvalid,
+ updateNewFilterId,
+ usedBy }) {
+
+ const title = (filterId !== undefined) ? `Filter list ${filterId}` : 'Create new filter list';
+
+ let idField;
+ let descriptionField;
+ let usedByElement;
+
+ if (filterId === undefined) {
+ const msg = 'Use lowercase alphanumerics (a-z and 0-9), hyphens or underscores;' +
+ ' must start and end with an alphanumeric character';
+ const helpText = (isNewFilterIdInvalid === false) ? msg : undefined;
+ const error = (isNewFilterIdInvalid === true) ? [msg] : undefined;
+
+ idField = (
+
+ updateNewFilterId(e.target.value)}
+ />
+
+ );
+ }
+
+ if (description !== undefined && description.length > 0) {
+ descriptionField = (
+
+
+ {description}
+
+
+ );
+ } else {
+ descriptionField = (
+
+
+ Add a description
+
+
+ );
+ }
+
+ if (filterId !== undefined) {
+ if (usedBy !== undefined && usedBy.jobs.length > 0) {
+ usedByElement = (
+
+
+
+ This filter list is used in
+
+
+
+ across
+
+
+
+
+
+ );
+ } else {
+ usedByElement = (
+
+
+
+ This filter list is not being used by any jobs.
+
+
+
+
+ );
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {title}
+
+
+
+
+ {totalItemCount} {(totalItemCount !== 1) ? 'items' : 'item'} in total
+
+
+
+
+
+
+ {idField}
+
+
+ {descriptionField}
+
+
+
+
+
+
+ {usedByElement}
+
+ );
+
+}
+EditFilterListHeader.propTypes = {
+ filterId: PropTypes.string,
+ newFilterId: PropTypes.string,
+ isNewFilterIdInvalid: PropTypes.bool,
+ updateNewFilterId: PropTypes.func.isRequired,
+ totalItemCount: PropTypes.number.isRequired,
+ description: PropTypes.string,
+ updateDescription: PropTypes.func.isRequired,
+ usedBy: PropTypes.object
+};
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/edit/index.js b/x-pack/plugins/ml/public/settings/filter_lists/edit/index.js
new file mode 100644
index 000000000000..bdbb6120391f
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/edit/index.js
@@ -0,0 +1,9 @@
+/*
+ * 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 './directive';
+import './styles/main.less';
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/edit/styles/main.less b/x-pack/plugins/ml/public/settings/filter_lists/edit/styles/main.less
new file mode 100644
index 000000000000..fa55f9a4371c
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/edit/styles/main.less
@@ -0,0 +1,34 @@
+.ml-edit-filter-lists {
+ .ml-edit-filter-lists-content {
+ max-width: 1100px;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ }
+
+ .ml-filter-list-usage > div {
+ display: inline;
+ }
+
+ .ml-filter-list-usage {
+ .euiButtonEmpty.euiButtonEmpty--small {
+ padding-bottom: 3px;
+ }
+
+ .euiButtonEmpty .euiButtonEmpty__content {
+ padding: 0px 4px;
+ }
+ }
+}
+
+.ml-add-filter-item-popover {
+ .euiFormRow {
+ width: 300px;
+ padding-bottom: 4px;
+ }
+}
+
+.ml-filter-list-usage-popover {
+ li {
+ line-height: 24px;
+ }
+}
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/edit/toolbar.js b/x-pack/plugins/ml/public/settings/filter_lists/edit/toolbar.js
new file mode 100644
index 000000000000..16cdd2654a7b
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/edit/toolbar.js
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React component for the toolbar section of the edit filter list page,
+ * holding a search bar,, and buttons for adding and deleting items from the list.
+ */
+
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {
+ EuiButton,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSearchBar,
+} from '@elastic/eui';
+
+import { AddItemPopover } from '../components/add_item_popover';
+
+export function EditFilterListToolbar({
+ onSearchChange,
+ addItems,
+ deleteSelectedItems,
+ selectedItemCount }) {
+
+ return (
+
+
+
+
+
+
+
+
+
+ Delete item
+
+
+
+
+
+
+
+
+ );
+}
+EditFilterListToolbar.propTypes = {
+ onSearchChange: PropTypes.func.isRequired,
+ addItems: PropTypes.func.isRequired,
+ deleteSelectedItems: PropTypes.func.isRequired,
+ selectedItemCount: PropTypes.number.isRequired
+};
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/edit/utils.js b/x-pack/plugins/ml/public/settings/filter_lists/edit/utils.js
new file mode 100644
index 000000000000..f9bba504c404
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/edit/utils.js
@@ -0,0 +1,105 @@
+/*
+ * 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 { toastNotifications } from 'ui/notify';
+import { isJobIdValid } from 'plugins/ml/../common/util/job_utils';
+import { ml } from 'plugins/ml/services/ml_api_service';
+
+export function isValidFilterListId(id) {
+ // Filter List ID requires the same format as a Job ID, therefore isJobIdValid can be used
+ return (id !== undefined) && (id.length > 0) && isJobIdValid(id);
+}
+
+
+// Saves a filter list, running an update if the supplied loadedFilterList, holding the
+// original filter list to which edits are being applied, is defined with a filter_id property.
+export function saveFilterList(filterId, description, items, loadedFilterList) {
+ return new Promise((resolve, reject) => {
+ if (loadedFilterList === undefined || loadedFilterList.filter_id === undefined) {
+ // Create a new filter.
+ addFilterList(filterId,
+ description,
+ items
+ )
+ .then((newFilter) => {
+ resolve(newFilter);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ } else {
+ // Edit to existing filter.
+ updateFilterList(
+ loadedFilterList,
+ description,
+ items)
+ .then((updatedFilter) => {
+ resolve(updatedFilter);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+
+ }
+ });
+}
+
+export function addFilterList(filterId, description, items) {
+ return new Promise((resolve, reject) => {
+
+ // First check the filterId isn't already in use by loading the current list of filters.
+ ml.filters.filtersStats()
+ .then((filterLists) => {
+ const savedFilterIds = filterLists.map(filterList => filterList.filter_id);
+ if (savedFilterIds.indexOf(filterId) === -1) {
+ // Save the new filter.
+ ml.filters.addFilter(
+ filterId,
+ description,
+ items
+ )
+ .then((newFilter) => {
+ resolve(newFilter);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ } else {
+ toastNotifications.addDanger(`A filter with id ${filterId} already exists`);
+ reject(new Error(`A filter with id ${filterId} already exists`));
+ }
+ })
+ .catch((error) => {
+ reject(error);
+ });
+
+ });
+
+}
+
+export function updateFilterList(loadedFilterList, description, items) {
+
+ return new Promise((resolve, reject) => {
+
+ // Get items added and removed from loaded filter.
+ const loadedItems = loadedFilterList.items;
+ const addItems = items.filter(item => (loadedItems.includes(item) === false));
+ const removeItems = loadedItems.filter(item => (items.includes(item) === false));
+
+ ml.filters.updateFilter(
+ loadedFilterList.filter_id,
+ description,
+ addItems,
+ removeItems
+ )
+ .then((updatedFilter) => {
+ resolve(updatedFilter);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ });
+}
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/index.js b/x-pack/plugins/ml/public/settings/filter_lists/index.js
new file mode 100644
index 000000000000..5fb5e06aa5aa
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/index.js
@@ -0,0 +1,10 @@
+/*
+ * 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 './edit';
+import './list';
+import './styles/main.less';
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/list/directive.js b/x-pack/plugins/ml/public/settings/filter_lists/list/directive.js
new file mode 100644
index 000000000000..d0edbc4cbc9f
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/list/directive.js
@@ -0,0 +1,55 @@
+/*
+ * 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 'ngreact';
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import { uiModules } from 'ui/modules';
+const module = uiModules.get('apps/ml', ['react']);
+
+import { checkLicense } from 'plugins/ml/license/check_license';
+import { checkGetJobsPrivilege } from 'plugins/ml/privilege/check_privilege';
+import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes';
+import { initPromise } from 'plugins/ml/util/promise';
+
+import uiRoutes from 'ui/routes';
+
+const template = `
+
+
+
+
+`;
+
+uiRoutes
+ .when('/settings/filter_lists', {
+ template,
+ resolve: {
+ CheckLicense: checkLicense,
+ privileges: checkGetJobsPrivilege,
+ mlNodeCount: getMlNodeCount,
+ initPromise: initPromise(false)
+ }
+ });
+
+
+import { FilterLists } from './filter_lists';
+
+module.directive('mlFilterLists', function () {
+ return {
+ restrict: 'E',
+ replace: false,
+ scope: {},
+ link: function (scope, element) {
+ ReactDOM.render(
+ React.createElement(FilterLists),
+ element[0]
+ );
+ }
+ };
+});
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/list/filter_lists.js b/x-pack/plugins/ml/public/settings/filter_lists/list/filter_lists.js
new file mode 100644
index 000000000000..2c2047da36ab
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/list/filter_lists.js
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React table for displaying a table of filter lists.
+ */
+
+import React, {
+ Component
+} from 'react';
+
+import {
+ EuiPage,
+ EuiPageContent,
+} from '@elastic/eui';
+
+import { toastNotifications } from 'ui/notify';
+
+import { FilterListsHeader } from './header';
+import { FilterListsTable } from './table';
+import { ml } from 'plugins/ml/services/ml_api_service';
+
+
+export class FilterLists extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ filterLists: [],
+ selectedFilterLists: []
+ };
+ }
+
+ componentDidMount() {
+ this.refreshFilterLists();
+ }
+
+ setSelectedFilterLists = (selectedFilterLists) => {
+ this.setState({ selectedFilterLists });
+ }
+
+ refreshFilterLists = () => {
+ // Load the list of filters.
+ ml.filters.filtersStats()
+ .then((filterLists) => {
+ // Check selected filter lists still exist.
+ this.setState((prevState) => {
+ const loadedFilterIds = filterLists.map(filterList => filterList.filter_id);
+ const selectedFilterLists = prevState.selectedFilterLists.filter((filterList) => {
+ return (loadedFilterIds.indexOf(filterList.filter_id) !== -1);
+ });
+
+ return {
+ filterLists,
+ selectedFilterLists
+ };
+ });
+ })
+ .catch((resp) => {
+ console.log('Error loading list of filters:', resp);
+ toastNotifications.addDanger('An error occurred loading the filter lists');
+ });
+ }
+
+ render() {
+ const { filterLists, selectedFilterLists } = this.state;
+
+ return (
+
+
+
+
+
+
+ );
+ }
+}
+
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/list/header.js b/x-pack/plugins/ml/public/settings/filter_lists/list/header.js
new file mode 100644
index 000000000000..187f3b8e0005
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/list/header.js
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React component for the header section of the filter lists page.
+ */
+
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {
+ EuiSpacer,
+ EuiTitle,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText,
+ EuiTextColor,
+ EuiButtonEmpty,
+} from '@elastic/eui';
+
+export function FilterListsHeader({ totalCount, refreshFilterLists }) {
+ return (
+
+
+
+
+
+
+ Filter Lists
+
+
+
+
+ {totalCount} in total
+
+
+
+
+
+
+
+ refreshFilterLists()}
+ >
+ Refresh
+
+
+
+
+
+
+
+
+
+ From here you can create and edit filter lists for use in detector rules for scoping whether the rule should
+ apply to a known set of values.
+
+
+
+
+
+ );
+
+}
+FilterListsHeader.propTypes = {
+ totalCount: PropTypes.number.isRequired,
+ refreshFilterLists: PropTypes.func.isRequired
+};
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/list/index.js b/x-pack/plugins/ml/public/settings/filter_lists/list/index.js
new file mode 100644
index 000000000000..4bbbfcae5dc2
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/list/index.js
@@ -0,0 +1,10 @@
+/*
+ * 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 './directive';
+import './styles/main.less';
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/list/styles/main.less b/x-pack/plugins/ml/public/settings/filter_lists/list/styles/main.less
new file mode 100644
index 000000000000..961b748d54f1
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/list/styles/main.less
@@ -0,0 +1,16 @@
+.ml-list-filter-lists {
+
+ .ml-list-filter-lists-content {
+ max-width: 1100px;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ }
+
+ .ml-filter-lists-table {
+ th:last-child, td:last-child {
+ width: 70px;
+ }
+ }
+
+}
+
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/list/table.js b/x-pack/plugins/ml/public/settings/filter_lists/list/table.js
new file mode 100644
index 000000000000..f80e8c53958e
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/list/table.js
@@ -0,0 +1,181 @@
+/*
+ * 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.
+ */
+
+
+/*
+ * React table for displaying a table of filter lists.
+ */
+
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {
+ EuiButton,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiIcon,
+ EuiInMemoryTable,
+ EuiLink,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+
+import chrome from 'ui/chrome';
+import { DeleteFilterListModal } from '../components/delete_filter_list_modal';
+
+
+function UsedByIcon({ usedBy }) {
+ // Renders a tick or cross in the 'usedBy' column to indicate whether
+ // the filter list is in use in a detectors in any jobs.
+ let icon;
+ if (usedBy !== undefined && usedBy.jobs.length > 0) {
+ icon = ;
+ } else {
+ icon = ;
+ }
+
+ return icon;
+}
+UsedByIcon.propTypes = {
+ usedBy: PropTypes.object
+};
+
+function NewFilterButton() {
+ return (
+
+ New
+
+ );
+}
+
+function getColumns() {
+
+ const columns = [
+ {
+ field: 'filter_id',
+ name: 'ID',
+ render: (id) => (
+
+ {id}
+
+ ),
+ sortable: true
+ },
+ {
+ field: 'description',
+ name: 'Description',
+ sortable: true
+ },
+ {
+ field: 'item_count',
+ name: 'Item count',
+ sortable: true
+ },
+ {
+ field: 'used_by',
+ name: 'In use',
+ render: (usedBy) => (
+
+ ),
+ sortable: true
+ }
+ ];
+
+ return columns;
+}
+
+function renderToolsRight(selectedFilterLists, refreshFilterLists) {
+ return [
+ (
+
+ ),
+ (
+
+ )];
+}
+
+
+export function FilterListsTable({
+ filterLists,
+ selectedFilterLists,
+ setSelectedFilterLists,
+ refreshFilterLists
+}) {
+
+ const sorting = {
+ sort: {
+ field: 'filter_id',
+ direction: 'asc',
+ }
+ };
+
+ const search = {
+ toolsRight: renderToolsRight(selectedFilterLists, refreshFilterLists),
+ box: {
+ incremental: true,
+ },
+ filters: []
+ };
+
+ const tableSelection = {
+ selectable: (filterList) => (filterList.used_by === undefined || filterList.used_by.jobs.length === 0),
+ selectableMessage: () => undefined,
+ onSelectionChange: (selection) => setSelectedFilterLists(selection)
+ };
+
+ return (
+
+ {filterLists === undefined || filterLists.length === 0 ? (
+
+
+
+
+
+
+
+
+
+
+ No filters have been created
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ );
+
+}
+FilterListsTable.propTypes = {
+ filterLists: PropTypes.array,
+ selectedFilterLists: PropTypes.array,
+ setSelectedFilterLists: PropTypes.func.isRequired,
+ refreshFilterLists: PropTypes.func.isRequired
+};
diff --git a/x-pack/plugins/ml/public/settings/filter_lists/styles/main.less b/x-pack/plugins/ml/public/settings/filter_lists/styles/main.less
new file mode 100644
index 000000000000..b4a491815298
--- /dev/null
+++ b/x-pack/plugins/ml/public/settings/filter_lists/styles/main.less
@@ -0,0 +1,4 @@
+.ml-filter-lists {
+ background: #F5F5F5;
+ min-height: 100vh;
+}
diff --git a/x-pack/plugins/ml/public/settings/index.js b/x-pack/plugins/ml/public/settings/index.js
index 77766a9aaa7d..dda643fce713 100644
--- a/x-pack/plugins/ml/public/settings/index.js
+++ b/x-pack/plugins/ml/public/settings/index.js
@@ -9,3 +9,4 @@
import './styles/main.less';
import './settings_controller';
import './scheduled_events';
+import './filter_lists';
diff --git a/x-pack/plugins/ml/public/settings/settings.html b/x-pack/plugins/ml/public/settings/settings.html
index 71f4ee3f54b4..4ec6ab99ced7 100644
--- a/x-pack/plugins/ml/public/settings/settings.html
+++ b/x-pack/plugins/ml/public/settings/settings.html
@@ -28,9 +28,22 @@
Calendar management
+
+
+ Filter Lists
+
+
+
diff --git a/x-pack/plugins/ml/public/settings/styles/main.less b/x-pack/plugins/ml/public/settings/styles/main.less
index ffc4b86ebc6e..80de8028b3b6 100644
--- a/x-pack/plugins/ml/public/settings/styles/main.less
+++ b/x-pack/plugins/ml/public/settings/styles/main.less
@@ -5,3 +5,13 @@
color: #999;
cursor: pointer;
}
+
+ml-settings {
+ .management-panel .management-panel__link {
+ font-size: 17px;
+ line-height: 32px;
+ margin-left: 6px;
+ }
+}
+
+
diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/plugins/ml/server/client/elasticsearch_ml.js
index ba21b2e78d85..954349ef166e 100644
--- a/x-pack/plugins/ml/server/client/elasticsearch_ml.js
+++ b/x-pack/plugins/ml/server/client/elasticsearch_ml.js
@@ -537,7 +537,7 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
{
fmt: '/_xpack/ml/filters/<%=filterId%>/_update',
req: {
- jobId: {
+ filterId: {
type: 'string'
}
}
diff --git a/x-pack/plugins/ml/server/models/filter/filter_manager.js b/x-pack/plugins/ml/server/models/filter/filter_manager.js
index a7ba4d400091..2395a22393ea 100644
--- a/x-pack/plugins/ml/server/models/filter/filter_manager.js
+++ b/x-pack/plugins/ml/server/models/filter/filter_manager.js
@@ -15,10 +15,21 @@ export class FilterManager {
async getFilter(filterId) {
try {
- const resp = await this.callWithRequest('ml.filters', { filterId });
- const filters = resp.filters;
- if (filters.length) {
- return filters[0];
+ const [ JOBS, FILTERS ] = [0, 1];
+ const results = await Promise.all([
+ this.callWithRequest('ml.jobs'),
+ this.callWithRequest('ml.filters', { filterId })
+ ]);
+
+ if (results[FILTERS] && results[FILTERS].filters.length) {
+ let filtersInUse = {};
+ if (results[JOBS] && results[JOBS].jobs) {
+ filtersInUse = this.buildFiltersInUse(results[JOBS].jobs);
+ }
+
+ const filter = results[FILTERS].filters[0];
+ filter.used_by = filtersInUse[filter.filter_id];
+ return filter;
} else {
return Boom.notFound(`Filter with the id "${filterId}" not found`);
}
@@ -36,14 +47,50 @@ export class FilterManager {
}
}
+ async getAllFilterStats() {
+ try {
+ const [ JOBS, FILTERS ] = [0, 1];
+ const results = await Promise.all([
+ this.callWithRequest('ml.jobs'),
+ this.callWithRequest('ml.filters')
+ ]);
+
+ // Build a map of filter_ids against jobs and detectors using that filter.
+ let filtersInUse = {};
+ if (results[JOBS] && results[JOBS].jobs) {
+ filtersInUse = this.buildFiltersInUse(results[JOBS].jobs);
+ }
+
+ // For each filter, return just
+ // filter_id
+ // description
+ // item_count
+ // jobs using the filter
+ const filterStats = [];
+ if (results[FILTERS] && results[FILTERS].filters) {
+ results[FILTERS].filters.forEach((filter) => {
+ const stats = {
+ filter_id: filter.filter_id,
+ description: filter.description,
+ item_count: filter.items.length,
+ used_by: filtersInUse[filter.filter_id]
+ };
+ filterStats.push(stats);
+ });
+ }
+
+ return filterStats;
+ } catch (error) {
+ throw Boom.badRequest(error);
+ }
+ }
+
async newFilter(filter) {
const filterId = filter.filterId;
delete filter.filterId;
try {
- await this.callWithRequest('ml.addFilter', { filterId, body: filter });
-
- // Return the newly created filter.
- return await this.getFilter(filterId);
+ // Returns the newly created filter.
+ return await this.callWithRequest('ml.addFilter', { filterId, body: filter });
} catch (error) {
return Boom.badRequest(error);
}
@@ -52,19 +99,17 @@ export class FilterManager {
async updateFilter(filterId,
description,
addItems,
- deleteItems) {
+ removeItems) {
try {
- await this.callWithRequest('ml.updateFilter', {
+ // Returns the newly updated filter.
+ return await this.callWithRequest('ml.updateFilter', {
filterId,
body: {
description,
add_items: addItems,
- delete_items: deleteItems
+ remove_items: removeItems
}
});
-
- // Return the newly updated filter.
- return await this.getFilter(filterId);
} catch (error) {
return Boom.badRequest(error);
}
@@ -75,4 +120,45 @@ export class FilterManager {
return this.callWithRequest('ml.deleteFilter', { filterId });
}
+ buildFiltersInUse(jobsList) {
+ // Build a map of filter_ids against jobs and detectors using that filter.
+ const filtersInUse = {};
+ jobsList.forEach((job) => {
+ const detectors = job.analysis_config.detectors;
+ detectors.forEach((detector) => {
+ if (detector.custom_rules) {
+ const rules = detector.custom_rules;
+ rules.forEach((rule) => {
+ if (rule.scope) {
+ const scopeFields = Object.keys(rule.scope);
+ scopeFields.forEach((scopeField) => {
+ const filter = rule.scope[scopeField];
+ const filterId = filter.filter_id;
+ if (filtersInUse[filterId] === undefined) {
+ filtersInUse[filterId] = { jobs: [], detectors: [] };
+ }
+
+ const jobs = filtersInUse[filterId].jobs;
+ const dtrs = filtersInUse[filterId].detectors;
+ const jobId = job.job_id;
+
+ // Label the detector with the job it comes from.
+ const detectorLabel = `${detector.detector_description} (${jobId})`;
+ if (jobs.indexOf(jobId) === -1) {
+ jobs.push(jobId);
+ }
+
+ if (dtrs.indexOf(detectorLabel) === -1) {
+ dtrs.push(detectorLabel);
+ }
+ });
+ }
+ });
+ }
+ });
+ });
+
+ return filtersInUse;
+ }
+
}
diff --git a/x-pack/plugins/ml/server/routes/filters.js b/x-pack/plugins/ml/server/routes/filters.js
index e3dbc3a46635..5aedaf334d84 100644
--- a/x-pack/plugins/ml/server/routes/filters.js
+++ b/x-pack/plugins/ml/server/routes/filters.js
@@ -18,6 +18,11 @@ function getAllFilters(callWithRequest) {
return mgr.getAllFilters();
}
+function getAllFilterStats(callWithRequest) {
+ const mgr = new FilterManager(callWithRequest);
+ return mgr.getAllFilterStats();
+}
+
function getFilter(callWithRequest, filterId) {
const mgr = new FilterManager(callWithRequest);
return mgr.getFilter(filterId);
@@ -33,9 +38,9 @@ function updateFilter(
filterId,
description,
addItems,
- deleteItems) {
+ removeItems) {
const mgr = new FilterManager(callWithRequest);
- return mgr.updateFilter(filterId, description, addItems, deleteItems);
+ return mgr.updateFilter(filterId, description, addItems, removeItems);
}
function deleteFilter(callWithRequest, filterId) {
@@ -59,6 +64,20 @@ export function filtersRoutes(server, commonRouteConfig) {
}
});
+ server.route({
+ method: 'GET',
+ path: '/api/ml/filters/_stats',
+ handler(request, reply) {
+ const callWithRequest = callWithRequestFactory(server, request);
+ return getAllFilterStats(callWithRequest)
+ .then(resp => reply(resp))
+ .catch(resp => reply(wrapError(resp)));
+ },
+ config: {
+ ...commonRouteConfig
+ }
+ });
+
server.route({
method: 'GET',
path: '/api/ml/filters/{filterId}',
@@ -101,7 +120,7 @@ export function filtersRoutes(server, commonRouteConfig) {
filterId,
payload.description,
payload.addItems,
- payload.deleteItems)
+ payload.removeItems)
.then(resp => reply(resp))
.catch(resp => reply(wrapError(resp)));
},