diff --git a/x-pack/plugins/ml/common/constants/detector_rule.js b/x-pack/plugins/ml/common/constants/detector_rule.js new file mode 100644 index 000000000000..cb8c7a71d59e --- /dev/null +++ b/x-pack/plugins/ml/common/constants/detector_rule.js @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/* + * Contains values for ML job detector rules. + */ + + +export const ACTION = { + SKIP_MODEL_UPDATE: 'skip_model_update', + SKIP_RESULT: 'skip_result', +}; + +export const FILTER_TYPE = { + EXCLUDE: 'exclude', + INCLUDE: 'include', +}; + +export const APPLIES_TO = { + ACTUAL: 'actual', + DIFF_FROM_TYPICAL: 'diff_from_typical', + TYPICAL: 'typical', +}; + +export const OPERATOR = { + LESS_THAN: 'lt', + LESS_THAN_OR_EQUAL: 'lte', + GREATER_THAN: 'gt', + GREATER_THAN_OR_EQUAL: 'gte', +}; diff --git a/x-pack/plugins/ml/common/util/__tests__/job_utils.js b/x-pack/plugins/ml/common/util/__tests__/job_utils.js index d9e03dfe1f6c..685e76941ff3 100644 --- a/x-pack/plugins/ml/common/util/__tests__/job_utils.js +++ b/x-pack/plugins/ml/common/util/__tests__/job_utils.js @@ -12,6 +12,7 @@ import { isTimeSeriesViewJob, isTimeSeriesViewDetector, isTimeSeriesViewFunction, + getPartitioningFieldNames, isModelPlotEnabled, isJobVersionGte, mlFunctionToESAggregation, @@ -201,6 +202,68 @@ describe('ML - job utils', () => { }); }); + describe('getPartitioningFieldNames', () => { + const job = { + analysis_config: { + detectors: [ + { + function: 'count', + detector_description: 'count' + }, + { + function: 'count', + partition_field_name: 'clientip', + detector_description: 'Count by clientip' + }, + { + function: 'freq_rare', + by_field_name: 'uri', + over_field_name: 'clientip', + detector_description: 'Freq rare URI' + }, + { + function: 'sum', + field_name: 'bytes', + by_field_name: 'uri', + over_field_name: 'clientip', + partition_field_name: 'method', + detector_description: 'sum bytes' + }, + ] + } + }; + + it('returns empty array for a detector with no partitioning fields', () => { + const resp = getPartitioningFieldNames(job, 0); + expect(resp).to.be.an('array'); + expect(resp).to.be.empty(); + }); + + it('returns expected array for a detector with a partition field', () => { + const resp = getPartitioningFieldNames(job, 1); + expect(resp).to.be.an('array'); + expect(resp).to.have.length(1); + expect(resp).to.contain('clientip'); + }); + + it('returns expected array for a detector with by and over fields', () => { + const resp = getPartitioningFieldNames(job, 2); + expect(resp).to.be.an('array'); + expect(resp).to.have.length(2); + expect(resp).to.contain('uri'); + expect(resp).to.contain('clientip'); + }); + + it('returns expected array for a detector with partition, by and over fields', () => { + const resp = getPartitioningFieldNames(job, 3); + expect(resp).to.be.an('array'); + expect(resp).to.have.length(3); + expect(resp).to.contain('uri'); + expect(resp).to.contain('clientip'); + expect(resp).to.contain('method'); + }); + }); + describe('isModelPlotEnabled', () => { it('returns true for a job in which model plot has been enabled', () => { diff --git a/x-pack/plugins/ml/common/util/job_utils.js b/x-pack/plugins/ml/common/util/job_utils.js index 0cd006d8aed3..2319ced16edc 100644 --- a/x-pack/plugins/ml/common/util/job_utils.js +++ b/x-pack/plugins/ml/common/util/job_utils.js @@ -88,6 +88,24 @@ export function isTimeSeriesViewFunction(functionName) { return mlFunctionToESAggregation(functionName) !== null; } +// Returns the names of the partition, by, and over fields for the detector with the +// specified index from the supplied ML job configuration. +export function getPartitioningFieldNames(job, detectorIndex) { + const fieldNames = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (_.has(detector, 'partition_field_name')) { + fieldNames.push(detector.partition_field_name); + } + if (_.has(detector, 'by_field_name')) { + fieldNames.push(detector.by_field_name); + } + if (_.has(detector, 'over_field_name')) { + fieldNames.push(detector.over_field_name); + } + + return fieldNames; +} + // Returns a flag to indicate whether model plot has been enabled for a job. // If model plot is enabled for a job with a terms filter (comma separated // list of partition or by field names), performs additional checks that diff --git a/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js b/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js index 2842dc13ae64..e229cae32116 100644 --- a/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js +++ b/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table.js @@ -37,6 +37,7 @@ import { mlAnomaliesTableService } from './anomalies_table_service'; import { mlFieldFormatService } from 'plugins/ml/services/field_format_service'; import { getSeverityColor } from 'plugins/ml/../common/util/anomaly_utils'; import { formatValue } from 'plugins/ml/formatters/format_value'; +import { RuleEditorFlyout } from 'plugins/ml/components/rule_editor'; const INFLUENCERS_LIMIT = 5; // Maximum number of influencers to display before a 'show more' link is added. @@ -53,7 +54,10 @@ function renderTime(date, aggregationInterval) { } function showLinksMenuForItem(item) { - return item.isTimeSeriesViewDetector || + // TODO - add in checking of user privileges to see if they can view / edit rules. + const canViewRules = true; + return canViewRules || + item.isTimeSeriesViewDetector || item.entityName === 'mlcategory' || item.customUrls !== undefined; } @@ -65,9 +69,11 @@ function getColumns( interval, timefilter, showViewSeriesLink, + showRuleEditorFlyout, itemIdToExpandedRowMap, toggleRow, filter) { + const columns = [ { name: '', @@ -186,12 +192,11 @@ function getColumns( sortable: true }); - const showExamples = items.some(item => item.entityName === 'mlcategory'); const showLinks = (showViewSeriesLink === true) || items.some(item => showLinksMenuForItem(item)); if (showLinks === true) { columns.push({ - name: 'links', + name: 'actions', render: (item) => { if (showLinksMenuForItem(item) === true) { return ( @@ -201,6 +206,7 @@ function getColumns( isAggregatedData={isAggregatedData} interval={interval} timefilter={timefilter} + showRuleEditorFlyout={showRuleEditorFlyout} /> ); } else { @@ -211,6 +217,7 @@ function getColumns( }); } + const showExamples = items.some(item => item.entityName === 'mlcategory'); if (showExamples === true) { columns.push({ name: 'category examples', @@ -238,7 +245,8 @@ class AnomaliesTable extends Component { super(props); this.state = { - itemIdToExpandedRowMap: {} + itemIdToExpandedRowMap: {}, + showRuleEditorFlyout: () => {} }; } @@ -313,6 +321,19 @@ class AnomaliesTable extends Component { } }; + setShowRuleEditorFlyoutFunction = (func) => { + this.setState({ + showRuleEditorFlyout: func + }); + } + + unsetShowRuleEditorFlyoutFunction = () => { + const showRuleEditorFlyout = () => {}; + this.setState({ + showRuleEditorFlyout + }); + } + render() { const { timefilter, tableData, filter } = this.props; @@ -336,6 +357,7 @@ class AnomaliesTable extends Component { tableData.interval, timefilter, tableData.showViewSeriesLink, + this.state.showRuleEditorFlyout, this.state.itemIdToExpandedRowMap, this.toggleRow, filter); @@ -355,20 +377,26 @@ class AnomaliesTable extends Component { }; return ( - + + + + ); } } diff --git a/x-pack/plugins/ml/public/components/anomalies_table/links_menu.js b/x-pack/plugins/ml/public/components/anomalies_table/links_menu.js index a7e9c7e6efdd..1855b5b50297 100644 --- a/x-pack/plugins/ml/public/components/anomalies_table/links_menu.js +++ b/x-pack/plugins/ml/public/components/anomalies_table/links_menu.js @@ -12,7 +12,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { - EuiButtonEmpty, + EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover @@ -337,15 +337,13 @@ export class LinksMenu extends Component { const { anomaly, showViewSeriesLink } = this.props; const button = ( - - Open link - + iconType="gear" + aria-label="Select action" + /> ); const items = []; @@ -387,6 +385,16 @@ export class LinksMenu extends Component { ); } + items.push( + { this.closePopover(); this.props.showRuleEditorFlyout(anomaly); }} + > + Configure rules + + ); + return ( + +

+ Choose the action(s) to take when the rule matches an anomaly. +

+
+ + + + -1} + onChange={onSkipResultChange} + /> + + + + + + + + + + + + -1} + onChange={onSkipModelUpdateChange} + /> + + + + + + + + ); + +} +ActionsSection.propTypes = { + actions: PropTypes.array.isRequired, + onSkipResultChange: PropTypes.func.isRequired, + onSkipModelUpdateChange: PropTypes.func.isRequired +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/condition_expression.js b/x-pack/plugins/ml/public/components/rule_editor/condition_expression.js new file mode 100644 index 000000000000..be1ee6093b1f --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/condition_expression.js @@ -0,0 +1,232 @@ +/* + * 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 rendering a rule condition numerical expression. + */ + +import PropTypes from 'prop-types'; +import React, { + Component, +} from 'react'; + +import { + EuiButtonIcon, + EuiExpression, + EuiExpressionButton, + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, + EuiFieldNumber, +} from '@elastic/eui'; + +import { APPLIES_TO, OPERATOR } from '../../../common/constants/detector_rule'; +import { appliesToText, operatorToText } from './utils'; + +// Raise the popovers above GuidePageSideNav +const POPOVER_STYLE = { zIndex: '200' }; + + +export class ConditionExpression extends Component { + constructor(props) { + super(props); + + this.state = { + isAppliesToOpen: false, + isOperatorValueOpen: false + }; + } + + openAppliesTo = () => { + this.setState({ + isAppliesToOpen: true, + isOperatorValueOpen: false + }); + }; + + closeAppliesTo = () => { + this.setState({ + isAppliesToOpen: false + }); + }; + + openOperatorValue = () => { + this.setState({ + isAppliesToOpen: false, + isOperatorValueOpen: true + }); + }; + + closeOperatorValue = () => { + this.setState({ + isOperatorValueOpen: false + }); + }; + + changeAppliesTo = (event) => { + const { + index, + operator, + value, + updateCondition } = this.props; + updateCondition(index, event.target.value, operator, value); + } + + changeOperator = (event) => { + const { + index, + appliesTo, + value, + updateCondition } = this.props; + updateCondition(index, appliesTo, event.target.value, value); + } + + changeValue = (event) => { + const { + index, + appliesTo, + operator, + updateCondition } = this.props; + updateCondition(index, appliesTo, operator, +event.target.value); + } + + renderAppliesToPopover() { + return ( +
+ When + + + +
+ ); + } + + renderOperatorValuePopover() { + return ( +
+ Is + + + + + + + + + + + +
+ ); + } + + render() { + const { + index, + appliesTo, + operator, + value, + deleteCondition + } = this.props; + + return ( + + + + )} + isOpen={this.state.isAppliesToOpen} + closePopover={this.closeAppliesTo} + panelPaddingSize="none" + ownFocus + withTitle + anchorPosition="downLeft" + > + {this.renderAppliesToPopover()} + + + + + + )} + isOpen={this.state.isOperatorValueOpen} + closePopover={this.closeOperatorValue} + panelPaddingSize="none" + ownFocus + withTitle + anchorPosition="downLeft" + > + {this.renderOperatorValuePopover()} + + + + deleteCondition(index)} + iconType="trash" + aria-label="Next" + /> + + + ); + } +} +ConditionExpression.propTypes = { + index: PropTypes.number.isRequired, + appliesTo: PropTypes.oneOf([ + APPLIES_TO.ACTUAL, + APPLIES_TO.TYPICAL, + APPLIES_TO.DIFF_FROM_TYPICAL + ]), + operator: PropTypes.oneOf([ + OPERATOR.LESS_THAN, + OPERATOR.LESS_THAN_OR_EQUAL, + OPERATOR.GREATER_THAN, + OPERATOR.GREATER_THAN_OR_EQUAL + ]), + value: PropTypes.number.isRequired, + updateCondition: PropTypes.func.isRequired, + deleteCondition: PropTypes.func.isRequired +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/conditions_section.js b/x-pack/plugins/ml/public/components/rule_editor/conditions_section.js new file mode 100644 index 000000000000..d3972a2e29be --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/conditions_section.js @@ -0,0 +1,70 @@ +/* + * 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 rendering the form fields for editing the conditions section of a rule. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { + EuiButtonEmpty, + EuiSpacer, +} from '@elastic/eui'; + +import { ConditionExpression } from './condition_expression'; + + +export function ConditionsSection({ + isEnabled, + conditions, + addCondition, + updateCondition, + deleteCondition }) { + + if (isEnabled === false) { + return null; + } + + let expressions = []; + if (conditions !== undefined) { + expressions = conditions.map((condition, index) => { + return ( + + ); + }); + } + + return ( + + {expressions} + + addCondition()} + > + Add new condition + + + ); + +} +ConditionsSection.propTypes = { + isEnabled: PropTypes.bool.isRequired, + conditions: PropTypes.array, + addCondition: PropTypes.func.isRequired, + updateCondition: PropTypes.func.isRequired, + deleteCondition: PropTypes.func.isRequired +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/index.js b/x-pack/plugins/ml/public/components/rule_editor/index.js new file mode 100644 index 000000000000..6d4c6188c519 --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/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 { RuleEditorFlyout } from './rule_editor_flyout'; diff --git a/x-pack/plugins/ml/public/components/rule_editor/rule_editor_flyout.js b/x-pack/plugins/ml/public/components/rule_editor/rule_editor_flyout.js new file mode 100644 index 000000000000..1a719c6c9375 --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/rule_editor_flyout.js @@ -0,0 +1,536 @@ +/* + * 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. + */ + +/* + * Flyout component for viewing and editing job detector rules. + */ + +import PropTypes from 'prop-types'; +import React, { + Component, +} from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { toastNotifications } from 'ui/notify'; + +import { ActionsSection } from './actions_section'; +import { ConditionsSection } from './conditions_section'; +import { ScopeSection } from './scope_section'; +import { SelectRuleAction } from './select_rule_action'; +import { + getNewRuleDefaults, + getNewConditionDefaults, + isValidRule, + saveJobRule, + deleteJobRule +} from './utils'; + +import { ACTION } from '../../../common/constants/detector_rule'; +import { getPartitioningFieldNames } from 'plugins/ml/../common/util/job_utils'; +import { mlJobService } from 'plugins/ml/services/job_service'; +import { ml } from 'plugins/ml/services/ml_api_service'; + +import './styles/main.less'; + +export class RuleEditorFlyout extends Component { + constructor(props) { + super(props); + + this.state = { + anomaly: {}, + job: {}, + ruleIndex: -1, + rule: getNewRuleDefaults(), + skipModelUpdate: false, + isConditionsEnabled: false, + isScopeEnabled: false, + filterListIds: [], + isFlyoutVisible: false + }; + + this.partitioningFieldNames = []; + } + + componentDidMount() { + if (typeof this.props.setShowFunction === 'function') { + this.props.setShowFunction(this.showFlyout); + } + } + + componentWillUnmount() { + if (typeof this.props.unsetShowFunction === 'function') { + this.props.unsetShowFunction(); + } + } + + showFlyout = (anomaly) => { + let ruleIndex = -1; + const job = mlJobService.getJob(anomaly.jobId); + if (job === undefined) { + // No details found for this job, display an error and + // don't open the Flyout as no edits can be made without the job. + toastNotifications.addDanger( + `Unable to configure rules as an error occurred obtaining details for job ID ${anomaly.jobId}`); + this.setState({ + job, + isFlyoutVisible: false + }); + + return; + } + + this.partitioningFieldNames = getPartitioningFieldNames(job, anomaly.detectorIndex); + + // Check if any rules are configured for this detector. + const detectorIndex = anomaly.detectorIndex; + const detector = job.analysis_config.detectors[detectorIndex]; + if (detector.custom_rules === undefined) { + ruleIndex = 0; + } + + let isConditionsEnabled = false; + if (ruleIndex === 0) { + // Configuring the first rule for a detector. + isConditionsEnabled = (this.partitioningFieldNames.length === 0); + } + + this.setState({ + anomaly, + job, + ruleIndex, + isConditionsEnabled, + isScopeEnabled: false, + isFlyoutVisible: true + }); + + if (this.partitioningFieldNames.length > 0) { + // Load the current list of filters. + ml.filters.filters() + .then((filters) => { + const filterListIds = filters.map(filter => filter.filter_id); + this.setState({ + filterListIds + }); + }) + .catch((resp) => { + console.log('Error loading list of filters:', resp); + toastNotifications.addDanger('Error loading the filter lists used in the rule scope'); + }); + } + } + + closeFlyout = () => { + this.setState({ isFlyoutVisible: false }); + } + + setEditRuleIndex = (ruleIndex) => { + const detectorIndex = this.state.anomaly.detectorIndex; + const detector = this.state.job.analysis_config.detectors[detectorIndex]; + const rules = detector.custom_rules; + const rule = (rules === undefined || ruleIndex >= rules.length) ? + getNewRuleDefaults() : rules[ruleIndex]; + + const isConditionsEnabled = (this.partitioningFieldNames.length === 0) || + (rule.conditions !== undefined && rule.conditions.length > 0); + const isScopeEnabled = (rule.scope !== undefined) && (Object.keys(rule.scope).length > 0); + + this.setState({ + ruleIndex, + rule, + isConditionsEnabled, + isScopeEnabled + }); + } + + onSkipResultChange = (e) => { + const checked = e.target.checked; + this.setState((prevState) => { + const actions = [...prevState.rule.actions]; + const idx = actions.indexOf(ACTION.SKIP_RESULT); + if ((idx === -1) && checked) { + actions.push(ACTION.SKIP_RESULT); + } else if ((idx > -1) && !checked) { + actions.splice(idx, 1); + } + + return { + rule: { ...prevState.rule, actions } + }; + }); + } + + onSkipModelUpdateChange = (e) => { + const checked = e.target.checked; + this.setState((prevState) => { + const actions = [...prevState.rule.actions]; + const idx = actions.indexOf(ACTION.SKIP_MODEL_UPDATE); + if ((idx === -1) && checked) { + actions.push(ACTION.SKIP_MODEL_UPDATE); + } else if ((idx > -1) && !checked) { + actions.splice(idx, 1); + } + + return { + rule: { ...prevState.rule, actions } + }; + }); + } + + onConditionsEnabledChange = (e) => { + const isConditionsEnabled = e.target.checked; + this.setState((prevState) => { + let conditions; + if (isConditionsEnabled === false) { + // Clear any conditions that have been added. + conditions = []; + } else { + // Add a default new condition. + conditions = [getNewConditionDefaults()]; + } + + return { + rule: { ...prevState.rule, conditions }, + isConditionsEnabled + }; + }); + } + + addCondition = () => { + this.setState((prevState) => { + const conditions = [...prevState.rule.conditions]; + conditions.push(getNewConditionDefaults()); + + return { + rule: { ...prevState.rule, conditions } + }; + }); + } + + updateCondition = (index, appliesTo, operator, value) => { + this.setState((prevState) => { + const conditions = [...prevState.rule.conditions]; + if (index < conditions.length) { + conditions[index] = { + applies_to: appliesTo, + operator, + value + }; + } + + return { + rule: { ...prevState.rule, conditions } + }; + }); + } + + deleteCondition = (index) => { + this.setState((prevState) => { + const conditions = [...prevState.rule.conditions]; + if (index < conditions.length) { + conditions.splice(index, 1); + } + + return { + rule: { ...prevState.rule, conditions } + }; + }); + } + + onScopeEnabledChange = (e) => { + const isScopeEnabled = e.target.checked; + this.setState((prevState) => { + const rule = { ...prevState.rule }; + if (isScopeEnabled === false) { + // Clear scope property. + delete rule.scope; + } + + return { + rule, + isScopeEnabled + }; + }); + } + + updateScope = (fieldName, filterId, filterType, enabled) => { + this.setState((prevState) => { + let scope = { ...prevState.rule.scope }; + if (enabled === true) { + if (scope === undefined) { + scope = {}; + } + + scope[fieldName] = { + filter_id: filterId, + filter_type: filterType + }; + } else { + if (scope !== undefined) { + delete scope[fieldName]; + } + } + + return { + rule: { ...prevState.rule, scope } + }; + }); + } + + saveEdit = () => { + const { + job, + anomaly, + rule, + ruleIndex + } = this.state; + + const jobId = job.job_id; + const detectorIndex = anomaly.detectorIndex; + + saveJobRule(job, detectorIndex, ruleIndex, rule) + .then((resp) => { + if (resp.success) { + toastNotifications.addSuccess(`Changes to ${jobId} detector rules saved`); + this.closeFlyout(); + } else { + toastNotifications.addDanger(`Error saving changes to ${jobId} detector rules`); + } + }) + .catch((error) => { + console.error(error); + toastNotifications.addDanger(`Error saving changes to ${jobId} detector rules`); + }); + } + + deleteRuleAtIndex = (index) => { + const { + job, + anomaly + } = this.state; + const jobId = job.job_id; + const detectorIndex = anomaly.detectorIndex; + + deleteJobRule(job, detectorIndex, index) + .then((resp) => { + if (resp.success) { + toastNotifications.addSuccess(`Rule deleted from ${jobId} detector`); + this.closeFlyout(); + } else { + toastNotifications.addDanger(`Error deleting rule from ${jobId} detector`); + } + }) + .catch((error) => { + console.error(error); + let errorMessage = `Error deleting rule from ${jobId} detector`; + if (error.message) { + errorMessage += ` : ${error.message}`; + } + toastNotifications.addDanger(errorMessage); + }); + } + + render() { + const { + isFlyoutVisible, + job, + anomaly, + ruleIndex, + rule, + filterListIds, + isConditionsEnabled, + isScopeEnabled } = this.state; + + if (isFlyoutVisible === false) { + return null; + } + + let flyout; + + const hasPartitioningFields = (this.partitioningFieldNames && this.partitioningFieldNames.length > 0); + + if (ruleIndex === -1) { + flyout = ( + + + +

+ Edit Rules +

+
+
+ + + + + + + + + + Close + + + + +
+ ); + } else { + const conditionsText = 'Add numeric conditions to take action according ' + + 'to the actual or typical values of the anomaly. Multiple conditions are ' + + 'combined using AND.'; + flyout = ( + + + +

+ Create Rule +

+
+
+ + + +

+ Rules allow you to provide feedback in order to customize the analytics, + skipping results for anomalies which though mathematically significant + are not action worthy. +

+
+ + + + +

Action

+
+ + + + + +

Conditions

+
+ + + + + + + + + + +

+ Changes to rules take effect for new results only. +

+

+ To apply these changes to existing results you must clone and rerun the job. + Note rerunning the job may take some time and should only be done once + you have completed all your changes to the rules for this job. +

+
+ +
+ + + + + + Close + + + + + Save + + + + +
+ ); + + } + + return ( + + {flyout} + + ); + + } +} +RuleEditorFlyout.propTypes = { + setShowFunction: PropTypes.func.isRequired, + unsetShowFunction: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/scope_expression.js b/x-pack/plugins/ml/public/components/rule_editor/scope_expression.js new file mode 100644 index 000000000000..28c97c11180c --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/scope_expression.js @@ -0,0 +1,191 @@ +/* + * 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 rendering a rule scope expression. + */ + +import PropTypes from 'prop-types'; +import React, { + Component, +} from 'react'; + +import { + EuiCheckbox, + EuiExpression, + EuiExpressionButton, + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, +} from '@elastic/eui'; + +import { FILTER_TYPE } from '../../../common/constants/detector_rule'; +import { filterTypeToText } from './utils'; + +// Raise the popovers above GuidePageSideNav +const POPOVER_STYLE = { zIndex: '200' }; + +function getFilterListOptions(filterListIds) { + return filterListIds.map(filterId => ({ value: filterId, text: filterId })); +} + +export class ScopeExpression extends Component { + constructor(props) { + super(props); + + this.state = { + isFilterListOpen: false + }; + } + + openFilterList = () => { + this.setState({ + isFilterListOpen: true + }); + } + + closeFilterList = () => { + this.setState({ + isFilterListOpen: false + }); + } + + onChangeFilterType = (event) => { + const { + fieldName, + filterId, + enabled, + updateScope } = this.props; + + updateScope(fieldName, filterId, event.target.value, enabled); + } + + onChangeFilterId = (event) => { + const { + fieldName, + filterType, + enabled, + updateScope } = this.props; + + updateScope(fieldName, event.target.value, filterType, enabled); + } + + onEnableChange = (event) => { + const { + fieldName, + filterId, + filterType, + updateScope } = this.props; + + updateScope(fieldName, filterId, filterType, event.target.checked); + } + + renderFilterListPopover() { + const { + filterId, + filterType, + filterListIds + } = this.props; + + return ( +
+ Is + + + + + + + + + + + +
+ ); + } + + render() { + const { + fieldName, + filterId, + filterType, + enabled, + filterListIds + } = this.props; + + return ( + + + + + + event.preventDefault()} + /> + + + {filterListIds !== undefined && filterListIds.length > 0 && + + + )} + isOpen={this.state.isFilterListOpen} + closePopover={this.closeFilterList} + panelPaddingSize="none" + ownFocus + withTitle + anchorPosition="downLeft" + > + {this.renderFilterListPopover()} + + + } + + ); + } +} +ScopeExpression.propTypes = { + fieldName: PropTypes.string.isRequired, + filterId: PropTypes.string, + filterType: PropTypes.oneOf([ + FILTER_TYPE.INCLUDE, + FILTER_TYPE.EXCLUDE + ]), + enabled: PropTypes.bool.isRequired, + filterListIds: PropTypes.array.isRequired, + updateScope: PropTypes.func.isRequired +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/scope_section.js b/x-pack/plugins/ml/public/components/rule_editor/scope_section.js new file mode 100644 index 000000000000..d7c52fab492b --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/scope_section.js @@ -0,0 +1,123 @@ +/* + * 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 rendering the form fields for editing the scope section of a rule. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { + EuiCallOut, + EuiCheckbox, + EuiLink, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { ScopeExpression } from './scope_expression'; +import { getScopeFieldDefaults } from './utils'; + + +function getScopeText(partitioningFieldNames) { + if (partitioningFieldNames.length === 1) { + return `Specify whether the rule should only apply if the ${partitioningFieldNames[0]} is ` + + `in a chosen list of values.`; + } else { + return `Specify whether the rule should only apply if the ${partitioningFieldNames.join(' or ')} are ` + + `in a chosen list of values.`; + } +} + +function NoFilterListsCallOut() { + return ( + +

+ To configure scope, you must first use the  + Filter Lists settings page + to create the list of values you want to include or exclude in the rule. +

+
+ ); +} + + +export function ScopeSection({ + isEnabled, + onEnabledChange, + partitioningFieldNames, + filterListIds, + scope, + updateScope }) { + + if (partitioningFieldNames === null || partitioningFieldNames.length === 0) { + return null; + } + + let content; + if (filterListIds.length > 0) { + content = partitioningFieldNames.map((fieldName, index) => { + let filterValues; + let enabled = false; + if (scope !== undefined && scope[fieldName] !== undefined) { + filterValues = scope[fieldName]; + enabled = true; + } else { + filterValues = getScopeFieldDefaults(filterListIds); + } + + return ( + + ); + }); + } else { + content = ; + } + + return ( + + +

Scope

+
+ + + + {isEnabled && + + {content} + + } + +
+ ); + +} +ScopeSection.propTypes = { + isEnabled: PropTypes.bool.isRequired, + onEnabledChange: PropTypes.func.isRequired, + partitioningFieldNames: PropTypes.array.isRequired, + filterListIds: PropTypes.array.isRequired, + scope: PropTypes.object, + updateScope: PropTypes.func.isRequired +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/delete_rule_modal.js b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/delete_rule_modal.js new file mode 100644 index 000000000000..4892b9d474c7 --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/delete_rule_modal.js @@ -0,0 +1,83 @@ +/* + * 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 rendering a modal to confirm deletion of a rule. + */ + +import PropTypes from 'prop-types'; +import React, { + Component, +} from 'react'; + +import { + EuiConfirmModal, + EuiLink, + EuiOverlayMask, + EUI_MODAL_CONFIRM_BUTTON, +} from '@elastic/eui'; + +export class DeleteRuleModal extends Component { + constructor(props) { + super(props); + + this.state = { + isModalVisible: false, + }; + } + + deleteRule = () => { + const { ruleIndex, deleteRuleAtIndex } = this.props; + deleteRuleAtIndex(ruleIndex); + this.closeModal(); + } + + closeModal = () => { + this.setState({ isModalVisible: false }); + } + + showModal = () => { + this.setState({ isModalVisible: true }); + } + + render() { + let modal; + + if (this.state.isModalVisible) { + modal = ( + + +

Are you sure you want to delete this rule?

+
+
+ ); + } + + return ( + + this.showModal()} + > + Delete rule + + {modal} + + ); + } +} +DeleteRuleModal.propTypes = { + ruleIndex: PropTypes.number.isRequired, + deleteRuleAtIndex: PropTypes.func.isRequired +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/index.js b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/index.js new file mode 100644 index 000000000000..60aae9ac50c9 --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/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 { SelectRuleAction } from './select_rule_action'; diff --git a/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/rule_action_panel.js b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/rule_action_panel.js new file mode 100644 index 000000000000..8116f207d417 --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/rule_action_panel.js @@ -0,0 +1,88 @@ +/* + * 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. + */ + + +/* + * Panel with a description of a rule and a list of actions that can be performed on the rule. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { + EuiDescriptionList, + EuiLink, + EuiPanel, +} from '@elastic/eui'; + +import { DeleteRuleModal } from './delete_rule_modal'; +import { buildRuleDescription } from '../utils'; + +function getEditRuleLink(ruleIndex, setEditRuleIndex) { + return ( + setEditRuleIndex(ruleIndex)} + > + Edit rule + + ); +} + +function getDeleteRuleLink(ruleIndex, deleteRuleAtIndex) { + return ( + + ); +} + +export function RuleActionPanel({ + job, + detectorIndex, + ruleIndex, + setEditRuleIndex, + deleteRuleAtIndex, +}) { + const detector = job.analysis_config.detectors[detectorIndex]; + const rules = detector.custom_rules; + if (rules === undefined || ruleIndex >= rules.length) { + return null; + } + + const rule = rules[ruleIndex]; + + const descriptionListItems = [ + { + title: 'rule', + description: buildRuleDescription(rule), + }, + { + title: 'actions', + description: getEditRuleLink(ruleIndex, setEditRuleIndex), + }, + { + title: '', + description: getDeleteRuleLink(ruleIndex, deleteRuleAtIndex) + } + ]; + + return ( + + + + ); +} +RuleActionPanel.propTypes = { + detectorIndex: PropTypes.number.isRequired, + ruleIndex: PropTypes.number.isRequired, + setEditRuleIndex: PropTypes.func.isRequired, + deleteRuleAtIndex: PropTypes.func.isRequired, +}; + diff --git a/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/select_rule_action.js b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/select_rule_action.js new file mode 100644 index 000000000000..303887b5e592 --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/select_rule_action/select_rule_action.js @@ -0,0 +1,96 @@ +/* + * 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 selecting the rule to edit, create or delete. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { + EuiDescriptionList, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { RuleActionPanel } from './rule_action_panel'; + + +export function SelectRuleAction({ + job, + anomaly, + detectorIndex, + setEditRuleIndex, + deleteRuleAtIndex }) { + + const detector = job.analysis_config.detectors[detectorIndex]; + const descriptionListItems = [ + { + title: 'job ID', + description: job.job_id, + }, + { + title: 'detector', + description: detector.detector_description, + } + ]; + + const rules = detector.custom_rules || []; + let ruleActionPanels; + if (rules.length > 0) { + ruleActionPanels = rules.map((rule, index) => { + return ( + + + + + ); + }); + } + + return ( + + {rules.length > 0 && + + + + {ruleActionPanels} + + + or  + + + } + setEditRuleIndex(rules.length)} + > + create a new rule + + + ); + +} +SelectRuleAction.propTypes = { + job: PropTypes.object.isRequired, + anomaly: PropTypes.object.isRequired, + detectorIndex: PropTypes.number.isRequired, + setEditRuleIndex: PropTypes.func.isRequired, + deleteRuleAtIndex: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/ml/public/components/rule_editor/styles/main.less b/x-pack/plugins/ml/public/components/rule_editor/styles/main.less new file mode 100644 index 000000000000..1cebe4004d6e --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/styles/main.less @@ -0,0 +1,80 @@ +.ml-rule-editor-flyout { + font-size: 14px; + + .select-rule-description-list { + padding-left: 16px; + + .euiDescriptionList__title { + flex-basis: 15%; + } + + .euiDescriptionList__description { + flex-basis: 85%; + } + } + + .euiDescriptionList.select-rule-description-list.euiDescriptionList--column > * { + margin-top: 5px; + } + + .select-rule-action-panel { + padding-top:10px; + + .euiDescriptionList { + .euiDescriptionList__title { + flex-basis: 15%; + } + + .euiDescriptionList__description { + flex-basis: 85%; + } + + .euiDescriptionList__description:nth-child(2) { + color: #1a1a1a; + font-weight: 600; + } + } + + .euiDescriptionList.euiDescriptionList--column > * { + margin-top: 5px; + } + } + + .scope-enable-checkbox { + .euiCheckbox__input[disabled] ~ .euiCheckbox__label { + color: inherit; + } + } + + .scope-field-checkbox { + margin-right: 2px; + + .euiCheckbox { + margin-top: 6px; + } + } + + .scope-field-button { + pointer-events: none; + border-bottom: none; + } + + .scope-edit-filter-link { + line-height: 32px; + font-size: 12px; + } + + .euiExpressionButton.disabled { + pointer-events: none; + + .euiExpressionButton__value, + .euiExpressionButton__description { + color: #c5c5c5; + } + } + + .text-highlight { + font-weight: bold; + } + +} diff --git a/x-pack/plugins/ml/public/components/rule_editor/utils.js b/x-pack/plugins/ml/public/components/rule_editor/utils.js new file mode 100644 index 000000000000..14a95caeed6d --- /dev/null +++ b/x-pack/plugins/ml/public/components/rule_editor/utils.js @@ -0,0 +1,235 @@ +/* + * 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 { + ACTION, + FILTER_TYPE, + APPLIES_TO, + OPERATOR +} from '../../../common/constants/detector_rule'; + +import { cloneDeep } from 'lodash'; +import { mlJobService } from 'plugins/ml/services/job_service'; + +export function getNewConditionDefaults() { + return { + applies_to: APPLIES_TO.ACTUAL, + operator: OPERATOR.LESS_THAN, + value: 1 + }; +} + +export function getNewRuleDefaults() { + return { + actions: [ACTION.SKIP_RESULT], + conditions: [] + }; +} + +export function getScopeFieldDefaults(filterListIds) { + const defaults = { + filter_type: FILTER_TYPE.INCLUDE, + }; + + if (filterListIds !== undefined && filterListIds.length > 0) { + defaults.filter_id = filterListIds[0]; + } + + return defaults; +} + +export function isValidRule(rule) { + // Runs simple checks to make sure the minimum set of + // properties have values in the edited rule. + let isValid = false; + + // Check an action has been supplied. + const actions = rule.actions; + if (actions.length > 0) { + // Check either a condition or a scope property has been set. + const conditions = rule.conditions; + if (conditions !== undefined && conditions.length > 0) { + isValid = true; + } else { + const scope = rule.scope; + if (scope !== undefined && Object.keys(scope).length > 0) { + isValid = true; + } + } + } + + return isValid; +} + +export function saveJobRule(job, detectorIndex, ruleIndex, editedRule) { + const detector = job.analysis_config.detectors[detectorIndex]; + + let rules = []; + if (detector.custom_rules === undefined) { + rules = [editedRule]; + } else { + rules = cloneDeep(detector.custom_rules); + if (ruleIndex < rules.length) { + // Edit to an existing rule. + rules[ruleIndex] = editedRule; + } else { + // Add a new rule. + rules.push(editedRule); + } + } + + return updateJobRules(job, detectorIndex, rules); +} + +export function deleteJobRule(job, detectorIndex, ruleIndex) { + const detector = job.analysis_config.detectors[detectorIndex]; + let customRules = []; + if (detector.custom_rules !== undefined && ruleIndex < detector.custom_rules.length) { + customRules = cloneDeep(detector.custom_rules); + customRules.splice(ruleIndex, 1); + return updateJobRules(job, detectorIndex, customRules); + } else { + return Promise.reject(new Error( + `Rule no longer exists for detector index ${detectorIndex} in job ${job.job_id}`)); + } +} + +export function updateJobRules(job, detectorIndex, rules) { + // Pass just the detector with the edited rule to the updateJob endpoint. + const jobId = job.job_id; + const jobData = { + detectors: [ + { + detector_index: detectorIndex, + custom_rules: rules + } + ] + }; + + // If created_by is set in the job's custom_settings, remove it as the rules + // cannot currently be edited in the job wizards and so would be lost in a clone. + let customSettings = {}; + if (job.custom_settings !== undefined) { + customSettings = { ...job.custom_settings }; + delete customSettings.created_by; + jobData.custom_settings = customSettings; + } + + return new Promise((resolve, reject) => { + mlJobService.updateJob(jobId, jobData) + .then((resp) => { + if (resp.success) { + // Refresh the job data in the job service before resolving. + mlJobService.refreshJob(jobId) + .then(() => { + resolve({ success: true }); + }) + .catch((refreshResp) => { + reject(refreshResp); + }); + } else { + reject(resp); + } + }) + .catch((resp) => { + reject(resp); + }); + }); +} + +export function buildRuleDescription(rule) { + const { actions, conditions, scope } = rule; + let description = 'skip '; + actions.forEach((action, i) => { + if (i > 0) { + description += ' AND '; + } + switch (action) { + case ACTION.SKIP_RESULT: + description += 'result'; + break; + case ACTION.SKIP_MODEL_UPDATE: + description += 'model update'; + break; + } + }); + + description += ' when '; + if (conditions !== undefined) { + conditions.forEach((condition, i) => { + if (i > 0) { + description += ' AND '; + } + + description += `${condition.applies_to} is ${operatorToText(condition.operator)} ${condition.value}`; + }); + } + + if (scope !== undefined) { + if (conditions !== undefined && conditions.length > 0) { + description += ' AND '; + } + const fieldNames = Object.keys(scope); + fieldNames.forEach((fieldName, i) => { + if (i > 0) { + description += ' AND '; + } + + const filter = scope[fieldName]; + description += `${fieldName} is ${filterTypeToText(filter.filter_type)} ${filter.filter_id}`; + }); + } + + return description; +} + +export function filterTypeToText(filterType) { + switch (filterType) { + case FILTER_TYPE.INCLUDE: + return 'in'; + + case FILTER_TYPE.EXCLUDE: + return 'not in'; + + default: + return filterType; + } +} + +export function appliesToText(appliesTo) { + switch (appliesTo) { + case APPLIES_TO.ACTUAL: + return 'actual'; + + case APPLIES_TO.TYPICAL: + return 'typical'; + + case APPLIES_TO.DIFF_FROM_TYPICAL: + return 'diff from typical'; + + default: + return appliesTo; + } +} + +export function operatorToText(operator) { + switch (operator) { + case OPERATOR.LESS_THAN: + return 'less than'; + + case OPERATOR.LESS_THAN_OR_EQUAL: + return 'less than or equal to'; + + case OPERATOR.GREATER_THAN: + return 'greater than'; + + case OPERATOR.GREATER_THAN_OR_EQUAL: + return 'greater than or equal to'; + + default: + return operator; + } +} 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 index fa55f9a4371c..1fc0c0e190b1 100644 --- 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 @@ -1,6 +1,7 @@ .ml-edit-filter-lists { .ml-edit-filter-lists-content { max-width: 1100px; + width: 100%; margin-top: 16px; margin-bottom: 16px; }