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 0000000000000..cb8c7a71d59ef
--- /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 d9e03dfe1f6c6..685e76941ff37 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 0cd006d8aed3b..2319ced16edcb 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 2842dc13ae64f..e229cae321168 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 a7e9c7e6efddf..1855b5b50297e 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 0000000000000..be1ee6093b1f7
--- /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 0000000000000..d3972a2e29be7
--- /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 0000000000000..6d4c6188c519a
--- /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 0000000000000..1a719c6c93756
--- /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 0000000000000..28c97c11180c2
--- /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 0000000000000..d7c52fab492be
--- /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 0000000000000..4892b9d474c7a
--- /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 0000000000000..60aae9ac50c92
--- /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 0000000000000..8116f207d417d
--- /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 0000000000000..303887b5e5925
--- /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 0000000000000..1cebe4004d6ec
--- /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 0000000000000..14a95caeed6d3
--- /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 fa55f9a4371c1..1fc0c0e190b13 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;
}