[ML] Adds editor for configuring detector rules (#20989)
* [ML] Adds editor for configuring detector rules * [ML] Edits to Rule Editor flyout following review
This commit is contained in:
parent
6ecc990274
commit
465ab78ef7
33
x-pack/plugins/ml/common/constants/detector_rule.js
Normal file
33
x-pack/plugins/ml/common/constants/detector_rule.js
Normal file
|
@ -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',
|
||||
};
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
<EuiInMemoryTable
|
||||
className="ml-anomalies-table eui-textBreakWord"
|
||||
items={tableData.anomalies}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSizeOptions: [10, 25, 100],
|
||||
initialPageSize: 25
|
||||
}}
|
||||
sorting={sorting}
|
||||
itemId="rowId"
|
||||
itemIdToExpandedRowMap={this.state.itemIdToExpandedRowMap}
|
||||
compressed={true}
|
||||
rowProps={getRowProps}
|
||||
/>
|
||||
<React.Fragment>
|
||||
<RuleEditorFlyout
|
||||
setShowFunction={this.setShowRuleEditorFlyoutFunction}
|
||||
unsetShowFunction={this.unsetShowRuleEditorFlyoutFunction}
|
||||
/>
|
||||
<EuiInMemoryTable
|
||||
className="ml-anomalies-table eui-textBreakWord"
|
||||
items={tableData.anomalies}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSizeOptions: [10, 25, 100],
|
||||
initialPageSize: 25
|
||||
}}
|
||||
sorting={sorting}
|
||||
itemId="rowId"
|
||||
itemIdToExpandedRowMap={this.state.itemIdToExpandedRowMap}
|
||||
compressed={true}
|
||||
rowProps={getRowProps}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = (
|
||||
<EuiButtonEmpty
|
||||
<EuiButtonIcon
|
||||
size="s"
|
||||
type="text"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
color="text"
|
||||
onClick={this.onButtonClick}
|
||||
>
|
||||
Open link
|
||||
</EuiButtonEmpty>
|
||||
iconType="gear"
|
||||
aria-label="Select action"
|
||||
/>
|
||||
);
|
||||
|
||||
const items = [];
|
||||
|
@ -387,6 +385,16 @@ export class LinksMenu extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
<EuiContextMenuItem
|
||||
key="create_rule"
|
||||
icon="controlsHorizontal"
|
||||
onClick={() => { this.closePopover(); this.props.showRuleEditorFlyout(anomaly); }}
|
||||
>
|
||||
Configure rules
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id="singlePanel"
|
||||
|
@ -409,5 +417,6 @@ LinksMenu.propTypes = {
|
|||
showViewSeriesLink: PropTypes.bool,
|
||||
isAggregatedData: PropTypes.bool,
|
||||
interval: PropTypes.string,
|
||||
timefilter: PropTypes.object.isRequired
|
||||
timefilter: PropTypes.object.isRequired,
|
||||
showRuleEditorFlyout: PropTypes.func
|
||||
};
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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 actions section of a rule.
|
||||
*/
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EuiCheckbox,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIconTip,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { ACTION } from '../../../common/constants/detector_rule';
|
||||
|
||||
export function ActionsSection({
|
||||
actions,
|
||||
onSkipResultChange,
|
||||
onSkipModelUpdateChange }) {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiText>
|
||||
<p>
|
||||
Choose the action(s) to take when the rule matches an anomaly.
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiCheckbox
|
||||
id="skip_result_cb"
|
||||
label="Skip result (recommended)"
|
||||
checked={actions.indexOf(ACTION.SKIP_RESULT) > -1}
|
||||
onChange={onSkipResultChange}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
content="Result will not be created but the model will be updated by the series value"
|
||||
size="s"
|
||||
position="right"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiCheckbox
|
||||
id="skip_model_update_cb"
|
||||
label="Skip model update"
|
||||
checked={actions.indexOf(ACTION.SKIP_MODEL_UPDATE) > -1}
|
||||
onChange={onSkipModelUpdateChange}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
content="The series value will not be used to update the model but anomalous records will be created"
|
||||
size="s"
|
||||
position="right"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
}
|
||||
ActionsSection.propTypes = {
|
||||
actions: PropTypes.array.isRequired,
|
||||
onSkipResultChange: PropTypes.func.isRequired,
|
||||
onSkipModelUpdateChange: PropTypes.func.isRequired
|
||||
};
|
|
@ -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 (
|
||||
<div style={POPOVER_STYLE}>
|
||||
<EuiPopoverTitle>When</EuiPopoverTitle>
|
||||
<EuiExpression style={{ width: 200 }}>
|
||||
<EuiSelect
|
||||
value={this.props.appliesTo}
|
||||
onChange={this.changeAppliesTo}
|
||||
options={[
|
||||
{ value: APPLIES_TO.ACTUAL, text: appliesToText(APPLIES_TO.ACTUAL) },
|
||||
{ value: APPLIES_TO.TYPICAL, text: appliesToText(APPLIES_TO.TYPICAL) },
|
||||
{ value: APPLIES_TO.DIFF_FROM_TYPICAL, text: appliesToText(APPLIES_TO.DIFF_FROM_TYPICAL) }
|
||||
]}
|
||||
/>
|
||||
</EuiExpression>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderOperatorValuePopover() {
|
||||
return (
|
||||
<div style={POPOVER_STYLE}>
|
||||
<EuiPopoverTitle>Is</EuiPopoverTitle>
|
||||
<EuiExpression>
|
||||
<EuiFlexGroup style={{ maxWidth: 450 }}>
|
||||
<EuiFlexItem grow={false} style={{ width: 250 }}>
|
||||
<EuiSelect
|
||||
value={this.props.operator}
|
||||
onChange={this.changeOperator}
|
||||
options={[
|
||||
{ value: OPERATOR.LESS_THAN, text: operatorToText(OPERATOR.LESS_THAN) },
|
||||
{ value: OPERATOR.LESS_THAN_OR_EQUAL, text: operatorToText(OPERATOR.LESS_THAN_OR_EQUAL) },
|
||||
{ value: OPERATOR.GREATER_THAN, text: operatorToText(OPERATOR.GREATER_THAN) },
|
||||
{ value: OPERATOR.GREATER_THAN_OR_EQUAL, text: operatorToText(OPERATOR.GREATER_THAN_OR_EQUAL) }
|
||||
]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false} style={{ width: 200 }}>
|
||||
<EuiFieldNumber
|
||||
value={+this.props.value}
|
||||
onChange={this.changeValue}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiExpression>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
index,
|
||||
appliesTo,
|
||||
operator,
|
||||
value,
|
||||
deleteCondition
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
id="appliesToPopover"
|
||||
button={(
|
||||
<EuiExpressionButton
|
||||
description="when"
|
||||
buttonValue={appliesToText(appliesTo)}
|
||||
isActive={this.state.isAppliesToOpen}
|
||||
onClick={this.openAppliesTo}
|
||||
/>
|
||||
)}
|
||||
isOpen={this.state.isAppliesToOpen}
|
||||
closePopover={this.closeAppliesTo}
|
||||
panelPaddingSize="none"
|
||||
ownFocus
|
||||
withTitle
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
{this.renderAppliesToPopover()}
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
id="operatorValuePopover"
|
||||
button={(
|
||||
<EuiExpressionButton
|
||||
description={`is ${operatorToText(operator)}`}
|
||||
buttonValue={`${value}`}
|
||||
isActive={this.state.isOperatorValueOpen}
|
||||
onClick={this.openOperatorValue}
|
||||
/>
|
||||
)}
|
||||
isOpen={this.state.isOperatorValueOpen}
|
||||
closePopover={this.closeOperatorValue}
|
||||
panelPaddingSize="none"
|
||||
ownFocus
|
||||
withTitle
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
{this.renderOperatorValuePopover()}
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
size="s"
|
||||
color="danger"
|
||||
onClick={() => deleteCondition(index)}
|
||||
iconType="trash"
|
||||
aria-label="Next"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
|
@ -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 (
|
||||
<ConditionExpression
|
||||
key={index}
|
||||
index={index}
|
||||
appliesTo={condition.applies_to}
|
||||
operator={condition.operator}
|
||||
value={condition.value}
|
||||
updateCondition={updateCondition}
|
||||
deleteCondition={deleteCondition}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{expressions}
|
||||
<EuiSpacer size="s" />
|
||||
<EuiButtonEmpty
|
||||
onClick={() => addCondition()}
|
||||
>
|
||||
Add new condition
|
||||
</EuiButtonEmpty>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
}
|
||||
ConditionsSection.propTypes = {
|
||||
isEnabled: PropTypes.bool.isRequired,
|
||||
conditions: PropTypes.array,
|
||||
addCondition: PropTypes.func.isRequired,
|
||||
updateCondition: PropTypes.func.isRequired,
|
||||
deleteCondition: PropTypes.func.isRequired
|
||||
};
|
8
x-pack/plugins/ml/public/components/rule_editor/index.js
Normal file
8
x-pack/plugins/ml/public/components/rule_editor/index.js
Normal 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 { RuleEditorFlyout } from './rule_editor_flyout';
|
|
@ -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 = (
|
||||
<EuiFlyout
|
||||
className="ml-rule-editor-flyout"
|
||||
onClose={this.closeFlyout}
|
||||
aria-labelledby="flyoutTitle"
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder={true}>
|
||||
<EuiTitle size="l">
|
||||
<h1 id="flyoutTitle">
|
||||
Edit Rules
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>
|
||||
<SelectRuleAction
|
||||
job={job}
|
||||
anomaly={anomaly}
|
||||
detectorIndex={anomaly.detectorIndex}
|
||||
setEditRuleIndex={this.setEditRuleIndex}
|
||||
deleteRuleAtIndex={this.deleteRuleAtIndex}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="cross"
|
||||
onClick={this.closeFlyout}
|
||||
flush="left"
|
||||
>
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
} 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 = (
|
||||
<EuiFlyout
|
||||
className="ml-rule-editor-flyout"
|
||||
onClose={this.closeFlyout}
|
||||
aria-labelledby="flyoutTitle"
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder={true}>
|
||||
<EuiTitle size="l">
|
||||
<h1 id="flyoutTitle">
|
||||
Create Rule
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>
|
||||
<EuiText>
|
||||
<p>
|
||||
Rules allow you to provide feedback in order to customize the analytics,
|
||||
skipping results for anomalies which though mathematically significant
|
||||
are not action worthy.
|
||||
</p>
|
||||
</EuiText>
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiTitle>
|
||||
<h2>Action</h2>
|
||||
</EuiTitle>
|
||||
<ActionsSection
|
||||
actions={rule.actions}
|
||||
onSkipResultChange={this.onSkipResultChange}
|
||||
onSkipModelUpdateChange={this.onSkipModelUpdateChange}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<EuiTitle>
|
||||
<h2>Conditions</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCheckbox
|
||||
id="enable_conditions_checkbox"
|
||||
className="scope-enable-checkbox"
|
||||
label={conditionsText}
|
||||
checked={isConditionsEnabled}
|
||||
onChange={this.onConditionsEnabledChange}
|
||||
disabled={!hasPartitioningFields}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<ConditionsSection
|
||||
isEnabled={isConditionsEnabled}
|
||||
conditions={rule.conditions}
|
||||
addCondition={this.addCondition}
|
||||
updateCondition={this.updateCondition}
|
||||
deleteCondition={this.deleteCondition}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<ScopeSection
|
||||
isEnabled={isScopeEnabled}
|
||||
onEnabledChange={this.onScopeEnabledChange}
|
||||
partitioningFieldNames={this.partitioningFieldNames}
|
||||
filterListIds={filterListIds}
|
||||
scope={rule.scope}
|
||||
updateScope={this.updateScope}
|
||||
/>
|
||||
|
||||
<EuiCallOut
|
||||
title="Rerun job"
|
||||
color="warning"
|
||||
iconType="help"
|
||||
>
|
||||
<p>
|
||||
Changes to rules take effect for new results only.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="cross"
|
||||
onClick={this.closeFlyout}
|
||||
flush="left"
|
||||
>
|
||||
Close
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={this.saveEdit}
|
||||
isDisabled={!isValidRule(rule)}
|
||||
fill
|
||||
>
|
||||
Save
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{flyout}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
RuleEditorFlyout.propTypes = {
|
||||
setShowFunction: PropTypes.func.isRequired,
|
||||
unsetShowFunction: PropTypes.func.isRequired,
|
||||
};
|
|
@ -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 (
|
||||
<div style={POPOVER_STYLE}>
|
||||
<EuiPopoverTitle>Is</EuiPopoverTitle>
|
||||
<EuiExpression>
|
||||
<EuiFlexGroup style={{ maxWidth: 450 }}>
|
||||
<EuiFlexItem grow={false} style={{ width: 150 }}>
|
||||
<EuiSelect
|
||||
value={filterType}
|
||||
onChange={this.onChangeFilterType}
|
||||
options={[
|
||||
{ value: FILTER_TYPE.INCLUDE, text: filterTypeToText(FILTER_TYPE.INCLUDE) },
|
||||
{ value: FILTER_TYPE.EXCLUDE, text: filterTypeToText(FILTER_TYPE.EXCLUDE) },
|
||||
]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false} style={{ width: 300 }}>
|
||||
<EuiSelect
|
||||
value={filterId}
|
||||
onChange={this.onChangeFilterId}
|
||||
options={getFilterListOptions(filterListIds)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiExpression>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
fieldName,
|
||||
filterId,
|
||||
filterType,
|
||||
enabled,
|
||||
filterListIds
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem grow={false} className="scope-field-checkbox">
|
||||
<EuiCheckbox
|
||||
id={`scope_cb_${fieldName}`}
|
||||
checked={enabled}
|
||||
onChange={this.onEnableChange}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiExpressionButton
|
||||
className={enabled ? 'scope-field-button' : 'scope-field-button disabled'}
|
||||
description="when"
|
||||
buttonValue={fieldName}
|
||||
isActive={false}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
{filterListIds !== undefined && filterListIds.length > 0 &&
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
id="operatorValuePopover"
|
||||
button={(
|
||||
<EuiExpressionButton
|
||||
className={enabled ? '' : 'disabled'}
|
||||
description={`is ${filterTypeToText(filterType)}`}
|
||||
buttonValue={filterId}
|
||||
isActive={this.state.isFilterListOpen}
|
||||
onClick={this.openFilterList}
|
||||
/>
|
||||
)}
|
||||
isOpen={this.state.isFilterListOpen}
|
||||
closePopover={this.closeFilterList}
|
||||
panelPaddingSize="none"
|
||||
ownFocus
|
||||
withTitle
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
{this.renderFilterListPopover()}
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
123
x-pack/plugins/ml/public/components/rule_editor/scope_section.js
Normal file
123
x-pack/plugins/ml/public/components/rule_editor/scope_section.js
Normal file
|
@ -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 (
|
||||
<EuiCallOut
|
||||
title="No filter lists configured"
|
||||
iconType="gear"
|
||||
>
|
||||
<p>
|
||||
To configure scope, you must first use the
|
||||
<EuiLink href="#/settings/filter_lists">Filter Lists</EuiLink> settings page
|
||||
to create the list of values you want to include or exclude in the rule.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
<ScopeExpression
|
||||
key={index}
|
||||
fieldName={fieldName}
|
||||
filterId={filterValues.filter_id}
|
||||
filterType={filterValues.filter_type}
|
||||
enabled={enabled}
|
||||
filterListIds={filterListIds}
|
||||
updateScope={updateScope}
|
||||
/>
|
||||
);
|
||||
});
|
||||
} else {
|
||||
content = <NoFilterListsCallOut />;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiTitle>
|
||||
<h2>Scope</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCheckbox
|
||||
id="enable_scope_checkbox"
|
||||
label={getScopeText(partitioningFieldNames)}
|
||||
checked={isEnabled}
|
||||
onChange={onEnabledChange}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
{isEnabled &&
|
||||
<React.Fragment>
|
||||
{content}
|
||||
</React.Fragment>
|
||||
}
|
||||
<EuiSpacer size="xxl" />
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
}
|
||||
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
|
||||
};
|
|
@ -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 = (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title="Delete rule"
|
||||
onCancel={this.closeModal}
|
||||
onConfirm={this.deleteRule}
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Delete"
|
||||
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
|
||||
>
|
||||
<p>Are you sure you want to delete this rule?</p>
|
||||
</EuiConfirmModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiLink
|
||||
color="danger"
|
||||
onClick={() => this.showModal()}
|
||||
>
|
||||
Delete rule
|
||||
</EuiLink>
|
||||
{modal}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
DeleteRuleModal.propTypes = {
|
||||
ruleIndex: PropTypes.number.isRequired,
|
||||
deleteRuleAtIndex: PropTypes.func.isRequired
|
||||
};
|
|
@ -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';
|
|
@ -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 (
|
||||
<EuiLink
|
||||
onClick={() => setEditRuleIndex(ruleIndex)}
|
||||
>
|
||||
Edit rule
|
||||
</EuiLink>
|
||||
);
|
||||
}
|
||||
|
||||
function getDeleteRuleLink(ruleIndex, deleteRuleAtIndex) {
|
||||
return (
|
||||
<DeleteRuleModal
|
||||
ruleIndex={ruleIndex}
|
||||
deleteRuleAtIndex={deleteRuleAtIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<EuiPanel paddingSize="m" className="select-rule-action-panel">
|
||||
<EuiDescriptionList
|
||||
type="column"
|
||||
listItems={descriptionListItems}
|
||||
/>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
RuleActionPanel.propTypes = {
|
||||
detectorIndex: PropTypes.number.isRequired,
|
||||
ruleIndex: PropTypes.number.isRequired,
|
||||
setEditRuleIndex: PropTypes.func.isRequired,
|
||||
deleteRuleAtIndex: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<React.Fragment key={`rule_panel_${index}`}>
|
||||
<RuleActionPanel
|
||||
job={job}
|
||||
detectorIndex={detectorIndex}
|
||||
ruleIndex={index}
|
||||
anomaly={anomaly}
|
||||
setEditRuleIndex={setEditRuleIndex}
|
||||
deleteRuleAtIndex={deleteRuleAtIndex}
|
||||
/>
|
||||
<EuiSpacer size="l"/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{rules.length > 0 &&
|
||||
<React.Fragment>
|
||||
<EuiDescriptionList
|
||||
className="select-rule-description-list"
|
||||
type="column"
|
||||
listItems={descriptionListItems}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
{ruleActionPanels}
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText style={{ display: 'inline' }}>
|
||||
or
|
||||
</EuiText>
|
||||
</React.Fragment>
|
||||
}
|
||||
<EuiLink
|
||||
onClick={() => setEditRuleIndex(rules.length)}
|
||||
>
|
||||
create a new rule
|
||||
</EuiLink>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
}
|
||||
SelectRuleAction.propTypes = {
|
||||
job: PropTypes.object.isRequired,
|
||||
anomaly: PropTypes.object.isRequired,
|
||||
detectorIndex: PropTypes.number.isRequired,
|
||||
setEditRuleIndex: PropTypes.func.isRequired,
|
||||
deleteRuleAtIndex: PropTypes.func.isRequired,
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
235
x-pack/plugins/ml/public/components/rule_editor/utils.js
Normal file
235
x-pack/plugins/ml/public/components/rule_editor/utils.js
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
.ml-edit-filter-lists {
|
||||
.ml-edit-filter-lists-content {
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue