[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:
Pete Harverson 2018-07-22 14:01:45 +01:00 committed by GitHub
parent 6ecc990274
commit 465ab78ef7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 2015 additions and 27 deletions

View 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',
};

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RuleEditorFlyout } from './rule_editor_flyout';

View file

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

View file

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

View 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&nbsp;
<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
};

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SelectRuleAction } from './select_rule_action';

View file

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

View file

@ -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&nbsp;
</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,
};

View file

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

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

View file

@ -1,6 +1,7 @@
.ml-edit-filter-lists {
.ml-edit-filter-lists-content {
max-width: 1100px;
width: 100%;
margin-top: 16px;
margin-bottom: 16px;
}