[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
This commit is contained in:
Pete Harverson 2018-07-17 09:47:26 +01:00 committed by GitHub
parent ac1a922124
commit 6939f5073c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 2132 additions and 20 deletions

View file

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

View file

@ -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 (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiText>
<h4>{(totalItemCount === 0) ? 'No items have been added' : 'No matching items'}</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
const startIndex = activePage * itemsPerPage;
const pageItems = items.slice(startIndex, startIndex + itemsPerPage);
const gridItems = pageItems.map((item, index) => {
return (
<EuiFlexItem key={`ml_grid_item_${index}`}>
<EuiCheckbox
id={`ml_grid_item_${index}`}
label={item}
checked={(selectedItems.indexOf(item) >= 0)}
onChange={(e) => { setItemSelected(item, e.target.checked); }}
/>
</EuiFlexItem>
);
});
return (
<div>
<EuiFlexGrid
columns={numberColumns}
className="eui-textBreakWord"
gutterSize="m"
>
{gridItems}
</EuiFlexGrid>
<ItemsGridPagination
itemCount={items.length}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
setItemsPerPage={setItemsPerPage}
activePage={activePage}
setActivePage={setActivePage}
/>
</div>
);
}
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],
};

View file

@ -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 = (
<EuiButtonEmpty
size="s"
color="text"
iconType="arrowDown"
iconSide="right"
onClick={this.onButtonClick}
>
Items per page: {itemsPerPage}
</EuiButtonEmpty>
);
const pageCount = Math.ceil(itemCount / itemsPerPage);
const items = itemsPerPageOptions.map((pageSize) => {
return (
<EuiContextMenuItem
key={`${pageSize} items`}
icon={getContextMenuItemIcon(pageSize, itemsPerPage)}
onClick={() => {this.onChangeItemsPerPage(pageSize);}}
>
{pageSize} items
</EuiContextMenuItem>
);
});
return (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiPopover
id="customizablePagination"
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
>
<EuiContextMenuPanel
items={items}
className="ml-items-grid-page-size-menu"
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPagination
pageCount={pageCount}
activePage={activePage}
onPageClick={this.onPageClick}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
}
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,
};

View file

@ -0,0 +1,3 @@
.ml-items-grid-page-size-menu {
width: 140px;
}

View file

@ -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: '#/' }];

View file

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

View file

@ -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 = (
<EuiButton
size="s"
color="primary"
iconType="arrowDown"
iconSide="right"
onClick={this.onButtonClick}
>
Add item
</EuiButton>
);
return (
<div>
<EuiPopover
id="add_item_popover"
panelClassName="ml-add-filter-item-popover"
ownFocus
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
>
<EuiForm>
<EuiFormRow
label="Items"
>
<EuiTextArea
value={this.state.itemsText}
onChange={this.onItemsTextChange}
/>
</EuiFormRow>
</EuiForm>
<EuiText size="xs">
Enter one item per line
</EuiText>
<EuiSpacer size="s"/>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
onClick={this.onAddButtonClick}
disabled={(this.state.itemsText.length === 0)}
>
Add
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopover>
</div>
);
}
}
AddItemPopover.propTypes = {
addItems: PropTypes.func.isRequired
};

View file

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

View file

@ -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 = (
<EuiOverlayMask>
<EuiConfirmModal
title={title}
className="eui-textBreakWord"
onCancel={this.closeModal}
onConfirm={this.onConfirmDelete}
cancelButtonText="Cancel"
confirmButtonText="Delete"
buttonColor="danger"
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
>
<p>
Are you sure you want to delete {(selectedFilterLists.length > 1) ?
'these filter lists' : 'this filter list'}?
</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
}
return (
<div>
<EuiButton
key="delete_filter_list"
iconType="trash"
color="danger"
onClick={this.showModal}
isDisabled={selectedFilterLists.length === 0}
>
Delete
</EuiButton>
{modal}
</div>
);
}
}
DeleteFilterListModal.propTypes = {
selectedFilterLists: PropTypes.array.isRequired,
};

View file

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

View file

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

View file

@ -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 = (
<EuiButtonIcon
size="s"
color="primary"
onClick={this.onButtonClick}
iconType="pencil"
aria-label="Edit description"
/>
);
return (
<div>
<EuiPopover
id="filter_list_description_popover"
ownFocus
button={button}
isOpen={isPopoverOpen}
closePopover={this.closePopover}
>
<div style={{ width: '300px' }}>
<EuiForm>
<EuiFormRow
label="Filter list description"
>
<EuiFieldText
name="filter_list_description"
value={value}
onChange={this.onChange}
/>
</EuiFormRow>
</EuiForm>
</div>
</EuiPopover>
</div>
);
}
}
EditDescriptionPopover.propTypes = {
description: PropTypes.string,
updateDescription: PropTypes.func.isRequired
};

View file

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

View file

@ -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 => (<li key={value}>{value}</li>));
const button = (
<EuiButtonEmpty
size="s"
color="primary"
onClick={this.onButtonClick}
>
{linkText}
</EuiButtonEmpty>
);
return (
<div>
<EuiPopover
id={`${entityType}_filter_list_usage`}
panelClassName="ml-filter-list-usage-popover"
ownFocus
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
>
<ul>
{listItems}
</ul>
</EuiPopover>
</div>
);
}
}
FilterListUsagePopover.propTypes = {
entityType: PropTypes.oneOf(['job', 'detector']),
entityValues: PropTypes.array.isRequired
};

View file

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

View file

@ -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 = `
<ml-nav-menu name="settings"></ml-nav-menu>
<div class="ml-filter-lists">
<ml-edit-filter-list></ml-edit-filter-list>
</div>
`;
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]
);
}
};
});

View file

@ -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 (
<EuiPage className="ml-edit-filter-lists">
<EuiPageContent
className="ml-edit-filter-lists-content"
verticalPosition="center"
horizontalPosition="center"
>
<EditFilterListHeader
filterId={this.props.filterId}
newFilterId={newFilterId}
isNewFilterIdInvalid={isNewFilterIdInvalid}
updateNewFilterId={this.updateNewFilterId}
description={description}
updateDescription={this.updateDescription}
totalItemCount={totalItemCount}
usedBy={loadedFilter.used_by}
/>
<EditFilterListToolbar
onSearchChange={this.onSearchChange}
addItems={this.addItems}
deleteSelectedItems={this.deleteSelectedItems}
selectedItemCount={selectedItems.length}
/>
<EuiSpacer size="xl" />
<ItemsGrid
totalItemCount={totalItemCount}
items={matchingItems}
selectedItems={selectedItems}
itemsPerPage={itemsPerPage}
setItemsPerPage={this.setItemsPerPage}
setItemSelected={this.setItemSelected}
activePage={activePage}
setActivePage={this.setActivePage}
/>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={returnToFiltersList}
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={this.save}
disabled={(saveInProgress === true) || (isNewFilterIdInvalid === true)}
fill
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContent>
</EuiPage>
);
}
}
EditFilterList.propTypes = {
filterId: PropTypes.string
};

View file

@ -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 = (
<EuiFormRow
label="Filter list ID"
helpText={helpText}
error={error}
isInvalid={isNewFilterIdInvalid}
>
<EuiFieldText
name="new_filter_id"
value={newFilterId}
isInvalid={isNewFilterIdInvalid}
onChange={(e) => updateNewFilterId(e.target.value)}
/>
</EuiFormRow>
);
}
if (description !== undefined && description.length > 0) {
descriptionField = (
<EuiText>
<p>
{description}
</p>
</EuiText>
);
} else {
descriptionField = (
<EuiText>
<EuiTextColor color="subdued">
Add a description
</EuiTextColor>
</EuiText>
);
}
if (filterId !== undefined) {
if (usedBy !== undefined && usedBy.jobs.length > 0) {
usedByElement = (
<React.Fragment>
<div className="ml-filter-list-usage">
<EuiText>
This filter list is used in
</EuiText>
<FilterListUsagePopover
entityType="detector"
entityValues={usedBy.detectors}
/>
<EuiText>
across
</EuiText>
<FilterListUsagePopover
entityType="job"
entityValues={usedBy.jobs}
/>
</div>
<EuiSpacer size="s"/>
</React.Fragment>
);
} else {
usedByElement = (
<React.Fragment>
<EuiText>
<p>
This filter list is not being used by any jobs.
</p>
</EuiText>
<EuiSpacer size="s"/>
</React.Fragment>
);
}
}
return (
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="baseline" gutterSize="m" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>{title}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiTextColor color="subdued">
<p>{totalItemCount} {(totalItemCount !== 1) ? 'items' : 'item'} in total</p>
</EuiTextColor>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m"/>
{idField}
<EuiFlexGroup alignItems="baseline" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
{descriptionField}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EditDescriptionPopover
description={description}
updateDescription={updateDescription}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s"/>
{usedByElement}
</React.Fragment>
);
}
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
};

View file

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

View file

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

View file

@ -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 (
<React.Fragment>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<AddItemPopover
addItems={addItems}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup alignItems="center" gutterSize="xl">
<EuiFlexItem grow={false}>
<EuiButton
color="danger"
disabled={(selectedItemCount === 0)}
onClick={deleteSelectedItems}
>
Delete item
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiSearchBar
onChange={onSearchChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>
);
}
EditFilterListToolbar.propTypes = {
onSearchChange: PropTypes.func.isRequired,
addItems: PropTypes.func.isRequired,
deleteSelectedItems: PropTypes.func.isRequired,
selectedItemCount: PropTypes.number.isRequired
};

View file

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

View file

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

View file

@ -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 = `
<ml-nav-menu name="settings"></ml-nav-menu>
<div class="ml-filter-lists">
<ml-filter-lists></ml-filter-lists>
</div>
`;
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]
);
}
};
});

View file

@ -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 (
<EuiPage className="ml-list-filter-lists">
<EuiPageContent
className="ml-list-filter-lists-content"
verticalPosition="center"
horizontalPosition="center"
>
<FilterListsHeader
totalCount={filterLists.length}
refreshFilterLists={this.refreshFilterLists}
/>
<FilterListsTable
filterLists={filterLists}
selectedFilterLists={selectedFilterLists}
setSelectedFilterLists={this.setSelectedFilterLists}
refreshFilterLists={this.refreshFilterLists}
/>
</EuiPageContent>
</EuiPage>
);
}
}

View file

@ -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 (
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="baseline" gutterSize="m" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>Filter Lists</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiTextColor color="subdued">
<p>{totalCount} in total</p>
</EuiTextColor>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="baseline" gutterSize="m" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
iconType="refresh"
onClick={() => refreshFilterLists()}
>
Refresh
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m"/>
<EuiText>
<p>
<EuiTextColor color="subdued">
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.
</EuiTextColor>
</p>
</EuiText>
<EuiSpacer size="m"/>
</React.Fragment>
);
}
FilterListsHeader.propTypes = {
totalCount: PropTypes.number.isRequired,
refreshFilterLists: PropTypes.func.isRequired
};

View file

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

View file

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

View file

@ -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 = <EuiIcon type="check" aria-label="In use"/>;
} else {
icon = <EuiIcon type="cross" aria-label="Not in use"/>;
}
return icon;
}
UsedByIcon.propTypes = {
usedBy: PropTypes.object
};
function NewFilterButton() {
return (
<EuiButton
key="new_filter_list"
href={`${chrome.getBasePath()}/app/ml#/settings/filter_lists/new_filter_list`}
>
New
</EuiButton>
);
}
function getColumns() {
const columns = [
{
field: 'filter_id',
name: 'ID',
render: (id) => (
<EuiLink href={`${chrome.getBasePath()}/app/ml#/settings/filter_lists/edit_filter_list/${id}`} >
{id}
</EuiLink>
),
sortable: true
},
{
field: 'description',
name: 'Description',
sortable: true
},
{
field: 'item_count',
name: 'Item count',
sortable: true
},
{
field: 'used_by',
name: 'In use',
render: (usedBy) => (
<UsedByIcon
usedBy={usedBy}
/>
),
sortable: true
}
];
return columns;
}
function renderToolsRight(selectedFilterLists, refreshFilterLists) {
return [
(
<NewFilterButton
key="new_filter_list"
/>
),
(
<DeleteFilterListModal
selectedFilterLists={selectedFilterLists}
refreshFilterLists={refreshFilterLists}
/>
)];
}
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 (
<React.Fragment>
{filterLists === undefined || filterLists.length === 0 ? (
<React.Fragment>
<EuiFlexGroup alignItems="flexEnd" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<NewFilterButton />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiText>
<h4>No filters have been created</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>
) : (
<React.Fragment>
<EuiInMemoryTable
className="ml-filter-lists-table"
items={filterLists}
itemId="filter_id"
columns={getColumns()}
search={search}
pagination={true}
sorting={sorting}
selection={tableSelection}
isSelectable={true}
/>
</React.Fragment>
)}
</React.Fragment>
);
}
FilterListsTable.propTypes = {
filterLists: PropTypes.array,
selectedFilterLists: PropTypes.array,
setSelectedFilterLists: PropTypes.func.isRequired,
refreshFilterLists: PropTypes.func.isRequired
};

View file

@ -0,0 +1,4 @@
.ml-filter-lists {
background: #F5F5F5;
min-height: 100vh;
}

View file

@ -9,3 +9,4 @@
import './styles/main.less';
import './settings_controller';
import './scheduled_events';
import './filter_lists';

View file

@ -28,9 +28,22 @@
Calendar management
</a>
</li>
<li class="col-xs-4 col-md-3 ng-scope">
<a
data-test-subj=""
class="management-panel__link ng-binding"
tooltip=""
tooltip-placement="bottom"
tooltip-popup-delay="400"
tooltip-append-to-body="1"
href="ml#/settings/filter_lists">
Filter Lists
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</ml-settings>

View file

@ -5,3 +5,13 @@
color: #999;
cursor: pointer;
}
ml-settings {
.management-panel .management-panel__link {
font-size: 17px;
line-height: 32px;
margin-left: 6px;
}
}

View file

@ -537,7 +537,7 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
{
fmt: '/_xpack/ml/filters/<%=filterId%>/_update',
req: {
jobId: {
filterId: {
type: 'string'
}
}

View file

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

View file

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