From 6939f5073cf4b520fc25b87adc60e45ee0d1fcd7 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Tue, 17 Jul 2018 09:47:26 +0100 Subject: [PATCH] [ML] Adds page to ML Settings for viewing and editing filter lists (#20769) * [ML] Add page to ML Settings for viewing and editing filter lists * [ML] Edits to Filter Lists Settings page following review --- .../ml/public/components/items_grid/index.js | 8 + .../components/items_grid/items_grid.js | 104 ++++++ .../items_grid/items_grid_pagination.js | 135 +++++++ .../components/items_grid/styles/main.less | 3 + .../ml/public/components/nav_menu/nav_menu.js | 3 + .../public/services/ml_api_service/filters.js | 11 +- .../add_item_popover/add_item_popover.js | 130 +++++++ .../components/add_item_popover/index.js | 8 + .../delete_filter_list_modal.js | 102 ++++++ .../delete_filter_lists.js | 37 ++ .../delete_filter_list_modal/index.js | 8 + .../edit_description_popover.js | 105 ++++++ .../edit_description_popover/index.js | 8 + .../filter_list_usage_popover.js | 85 +++++ .../filter_list_usage_popover/index.js | 8 + .../settings/filter_lists/edit/directive.js | 68 ++++ .../filter_lists/edit/edit_filter_list.js | 337 ++++++++++++++++++ .../settings/filter_lists/edit/header.js | 170 +++++++++ .../settings/filter_lists/edit/index.js | 9 + .../filter_lists/edit/styles/main.less | 34 ++ .../settings/filter_lists/edit/toolbar.js | 65 ++++ .../settings/filter_lists/edit/utils.js | 105 ++++++ .../ml/public/settings/filter_lists/index.js | 10 + .../settings/filter_lists/list/directive.js | 55 +++ .../filter_lists/list/filter_lists.js | 94 +++++ .../settings/filter_lists/list/header.js | 74 ++++ .../settings/filter_lists/list/index.js | 10 + .../filter_lists/list/styles/main.less | 16 + .../settings/filter_lists/list/table.js | 181 ++++++++++ .../settings/filter_lists/styles/main.less | 4 + x-pack/plugins/ml/public/settings/index.js | 1 + .../plugins/ml/public/settings/settings.html | 13 + .../ml/public/settings/styles/main.less | 10 + .../ml/server/client/elasticsearch_ml.js | 2 +- .../ml/server/models/filter/filter_manager.js | 114 +++++- x-pack/plugins/ml/server/routes/filters.js | 25 +- 36 files changed, 2132 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/ml/public/components/items_grid/index.js create mode 100644 x-pack/plugins/ml/public/components/items_grid/items_grid.js create mode 100644 x-pack/plugins/ml/public/components/items_grid/items_grid_pagination.js create mode 100644 x-pack/plugins/ml/public/components/items_grid/styles/main.less create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/add_item_popover.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/add_item_popover/index.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/delete_filter_lists.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/delete_filter_list_modal/index.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/edit_description_popover.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/edit_description_popover/index.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/filter_list_usage_popover.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/components/filter_list_usage_popover/index.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/edit/directive.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/edit/edit_filter_list.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/edit/header.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/edit/index.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/edit/styles/main.less create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/edit/toolbar.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/edit/utils.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/index.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/list/directive.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/list/filter_lists.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/list/header.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/list/index.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/list/styles/main.less create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/list/table.js create mode 100644 x-pack/plugins/ml/public/settings/filter_lists/styles/main.less 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 ( +
    + +
      + {listItems} +
    +
    +
    + ); + } +} +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))); },