[ML] Add links to rule editor for quick edit of value or filter (#22990)

* [ML] Add links to rule editor for quick edit of value or filter

* [ML] Updates to rule editor quick links following review
This commit is contained in:
Pete Harverson 2018-09-13 15:54:11 +01:00 committed by GitHub
parent ff2c377271
commit c272a1b7dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1159 additions and 162 deletions

View file

@ -31,6 +31,15 @@ exports[`RuleEditorFlyout renders the flyout after adding a condition to a rule
</EuiFlyoutHeader>
<EuiFlyoutBody>
<DetectorDescriptionList
anomaly={
Object {
"detectorIndex": 0,
"jobId": "farequote_no_by",
"source": Object {
"function": "mean",
},
}
}
detector={
Object {
"detector_description": "mean(responsetime)",
@ -252,6 +261,15 @@ exports[`RuleEditorFlyout renders the flyout after setting the rule to edit 1`]
</EuiFlyoutHeader>
<EuiFlyoutBody>
<DetectorDescriptionList
anomaly={
Object {
"detectorIndex": 1,
"jobId": "farequote_no_by",
"source": Object {
"function": "max",
},
}
}
detector={
Object {
"custom_rules": Array [
@ -487,6 +505,15 @@ exports[`RuleEditorFlyout renders the flyout for creating a rule with conditions
</EuiFlyoutHeader>
<EuiFlyoutBody>
<DetectorDescriptionList
anomaly={
Object {
"detectorIndex": 0,
"jobId": "farequote_no_by",
"source": Object {
"function": "mean",
},
}
}
detector={
Object {
"detector_description": "mean(responsetime)",
@ -700,6 +727,7 @@ exports[`RuleEditorFlyout renders the select action component for a detector wit
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SelectRuleAction
addItemToFilterList={[Function]}
anomaly={
Object {
"detectorIndex": 1,
@ -710,7 +738,6 @@ exports[`RuleEditorFlyout renders the select action component for a detector wit
}
}
deleteRuleAtIndex={[Function]}
detectorIndex={1}
job={
Object {
"analysis_config": Object {
@ -749,6 +776,7 @@ exports[`RuleEditorFlyout renders the select action component for a detector wit
}
}
setEditRuleIndex={[Function]}
updateRuleAtIndex={[Function]}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -0,0 +1,134 @@
/*
* 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 expect from 'expect.js';
import {
isValidRule,
buildRuleDescription,
getAppliesToValueFromAnomaly,
} from '../utils';
import {
ACTION,
APPLIES_TO,
OPERATOR,
FILTER_TYPE,
} from '../../../../common/constants/detector_rule';
describe('ML - rule editor utils', () => {
const ruleWithCondition = {
actions: [ACTION.SKIP_RESULT],
conditions: [
{
applies_to: APPLIES_TO.ACTUAL,
operator: OPERATOR.GREATER_THAN,
value: 10
}
]
};
const ruleWithScope = {
actions: [ACTION.SKIP_RESULT],
scope: {
instance: {
filter_id: 'test_aws_instances',
filter_type: FILTER_TYPE.INCLUDE,
enabled: true
}
}
};
const ruleWithConditionAndScope = {
actions: [ACTION.SKIP_RESULT],
conditions: [
{
applies_to: APPLIES_TO.TYPICAL,
operator: OPERATOR.LESS_THAN,
value: 100
}
],
scope: {
instance: {
filter_id: 'test_aws_instances',
filter_type: FILTER_TYPE.EXCLUDE,
enabled: true
}
}
};
describe('isValidRule', () => {
it('returns true for a rule with an action and a condition', () => {
expect(isValidRule(ruleWithCondition)).to.be(true);
});
it('returns true for a rule with an action and scope', () => {
expect(isValidRule(ruleWithScope)).to.be(true);
});
it('returns true for a rule with an action, scope and condition', () => {
expect(isValidRule(ruleWithConditionAndScope)).to.be(true);
});
it('returns false for a rule with no action', () => {
const ruleWithNoAction = {
actions: [],
conditions: [
{
applies_to: APPLIES_TO.TYPICAL,
operator: OPERATOR.LESS_THAN,
value: 100
}
],
};
expect(isValidRule(ruleWithNoAction)).to.be(false);
});
it('returns false for a rule with no scope or conditions', () => {
const ruleWithNoScopeOrCondition = {
actions: [ACTION.SKIP_RESULT],
};
expect(isValidRule(ruleWithNoScopeOrCondition)).to.be(false);
});
});
describe('buildRuleDescription', () => {
it('returns expected rule descriptions', () => {
expect(buildRuleDescription(ruleWithCondition)).to.be(
'skip result when actual is greater than 10');
expect(buildRuleDescription(ruleWithScope)).to.be(
'skip result when instance is in test_aws_instances');
expect(buildRuleDescription(ruleWithConditionAndScope)).to.be(
'skip result when typical is less than 100 AND instance is not in test_aws_instances');
});
});
describe('getAppliesToValueFromAnomaly', () => {
const anomaly = {
actual: [210],
typical: [1.23],
};
it('returns expected actual value from an anomaly', () => {
expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.ACTUAL)).to.be(210);
});
it('returns expected typical value from an anomaly', () => {
expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.TYPICAL)).to.be(1.23);
});
it('returns expected diff from typical value from an anomaly', () => {
expect(getAppliesToValueFromAnomaly(anomaly, APPLIES_TO.DIFF_FROM_TYPICAL)).to.be(208.77);
});
});
});

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DetectorDescriptionList render for farequote detector 1`] = `
exports[`DetectorDescriptionList render for detector with anomaly values 1`] = `
<EuiDescriptionList
align="left"
className="rule-detector-description-list"
@ -8,12 +8,38 @@ exports[`DetectorDescriptionList render for farequote detector 1`] = `
listItems={
Array [
Object {
"description": "farequote",
"title": "job ID",
"description": "responsetimes",
"title": "Job ID",
},
Object {
"description": "mean response time",
"title": "detector",
"title": "Detector",
},
Object {
"description": "actual 50, typical 1.23",
"title": "Selected anomaly",
},
]
}
textStyle="normal"
type="column"
/>
`;
exports[`DetectorDescriptionList render for population detector with no anomaly values 1`] = `
<EuiDescriptionList
align="left"
className="rule-detector-description-list"
compressed={false}
listItems={
Array [
Object {
"description": "population",
"title": "Job ID",
},
Object {
"description": "count by status over clientip",
"title": "Detector",
},
]
}

View file

@ -17,23 +17,41 @@ import {
EuiDescriptionList,
} from '@elastic/eui';
import { formatValue } from '../../../../formatters/format_value';
import './styles/main.less';
export function DetectorDescriptionList({
job,
detector }) {
detector,
anomaly, }) {
const listItems = [
{
title: 'job ID',
title: 'Job ID',
description: job.job_id,
},
{
title: 'detector',
title: 'Detector',
description: detector.detector_description,
}
];
if (anomaly.actual !== undefined) {
// Format based on magnitude of value at this stage, rather than using the
// Kibana field formatter (if set) which would add complexity converting
// the entered value to / from e.g. bytes.
const actual = formatValue(anomaly.actual, anomaly.source.function);
const typical = formatValue(anomaly.typical, anomaly.source.function);
listItems.push(
{
title: 'Selected anomaly',
description: `actual ${actual}, typical ${typical}`,
}
);
}
return (
<EuiDescriptionList
className="rule-detector-description-list"
@ -45,5 +63,6 @@ export function DetectorDescriptionList({
DetectorDescriptionList.propTypes = {
job: PropTypes.object.isRequired,
detector: PropTypes.object.isRequired,
anomaly: PropTypes.object.isRequired,
};

View file

@ -12,15 +12,52 @@ import { DetectorDescriptionList } from './detector_description_list';
describe('DetectorDescriptionList', () => {
test('render for farequote detector', () => {
test('render for detector with anomaly values', () => {
const props = {
job: {
job_id: 'farequote'
job_id: 'responsetimes'
},
detector: {
detector_description: 'mean response time'
}
},
anomaly: {
actual: [50],
typical: [1.23],
source: { function: 'mean' },
},
};
const component = shallow(
<DetectorDescriptionList {...props} />
);
expect(component).toMatchSnapshot();
});
test('render for population detector with no anomaly values', () => {
const props = {
job: {
job_id: 'population'
},
detector: {
detector_description: 'count by status over clientip'
},
anomaly: {
source: { function: 'count' },
causes: [
{
actual: [50],
typical: [1.01]
},
{
actual: [60],
typical: [1.2]
},
],
},
};
const component = shallow(

View file

@ -43,7 +43,8 @@ import {
getNewConditionDefaults,
isValidRule,
saveJobRule,
deleteJobRule
deleteJobRule,
addItemToFilter,
} from './utils';
import { ACTION, CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../../../common/constants/detector_rule';
@ -130,7 +131,7 @@ export class RuleEditorFlyout extends Component {
});
if (this.partitioningFieldNames.length > 0 && this.canGetFilters) {
// Load the current list of filters.
// Load the current list of filters. These are used for configuring rule scope.
ml.filters.filters()
.then((filters) => {
const filterListIds = filters.map(filter => filter.filter_id);
@ -305,19 +306,33 @@ export class RuleEditorFlyout extends Component {
saveEdit = () => {
const {
job,
anomaly,
rule,
ruleIndex
} = this.state;
this.updateRuleAtIndex(ruleIndex, rule);
}
updateRuleAtIndex = (ruleIndex, editedRule) => {
const {
job,
anomaly,
} = this.state;
const jobId = job.job_id;
const detectorIndex = anomaly.detectorIndex;
saveJobRule(job, detectorIndex, ruleIndex, rule)
saveJobRule(job, detectorIndex, ruleIndex, editedRule)
.then((resp) => {
if (resp.success) {
toastNotifications.addSuccess(`Changes to ${jobId} detector rules saved`);
toastNotifications.add(
{
title: `Changes to ${jobId} detector rules saved`,
color: 'success',
iconType: 'check',
text: 'Note that changes will take effect for new results only.'
}
);
this.closeFlyout();
} else {
toastNotifications.addDanger(`Error saving changes to ${jobId} detector rules`);
@ -356,6 +371,27 @@ export class RuleEditorFlyout extends Component {
});
}
addItemToFilterList = (item, filterId, closeFlyoutOnAdd) => {
addItemToFilter(item, filterId)
.then(() => {
if (closeFlyoutOnAdd === true) {
toastNotifications.add(
{
title: `Added ${item} to ${filterId}`,
color: 'success',
iconType: 'check',
text: 'Note that changes will take effect for new results only.'
}
);
this.closeFlyout();
}
})
.catch((error) => {
console.log(`Error adding ${item} to filter ${filterId}:`, error);
toastNotifications.addDanger(`An error occurred adding ${item} to filter ${filterId}`);
});
}
render() {
const {
isFlyoutVisible,
@ -392,9 +428,10 @@ export class RuleEditorFlyout extends Component {
<SelectRuleAction
job={job}
anomaly={anomaly}
detectorIndex={anomaly.detectorIndex}
setEditRuleIndex={this.setEditRuleIndex}
updateRuleAtIndex={this.updateRuleAtIndex}
deleteRuleAtIndex={this.deleteRuleAtIndex}
addItemToFilterList={this.addItemToFilterList}
/>
</EuiFlyoutBody>
@ -442,6 +479,7 @@ export class RuleEditorFlyout extends Component {
<DetectorDescriptionList
job={job}
detector={detector}
anomaly={anomaly}
/>
<EuiSpacer size="m" />
<EuiText>

View file

@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddToFilterListLink renders the add to filter list link for a value 1`] = `
<EuiLink
color="primary"
onClick={[Function]}
type="button"
>
Add
elastic.co
to
safe_domains
</EuiLink>
`;

View file

@ -0,0 +1,160 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditConditionLink renders for a condition using actual 1`] = `
<EuiFlexGroup
alignItems="center"
component="div"
direction="row"
gutterSize="s"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText
grow={true}
>
Update rule condition from
5
to
</EuiText>
</EuiFlexItem>
<EuiFlexItem
className="condition-edit-value-field"
component="div"
grow={false}
>
<EuiFieldNumber
aria-label="Enter numeric value for condition"
compressed={true}
fullWidth={false}
isLoading={false}
onChange={[Function]}
placeholder="Enter value"
value={210}
/>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiLink
color="primary"
onClick={[Function]}
size="s"
type="button"
>
Update
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`EditConditionLink renders for a condition using diff from typical 1`] = `
<EuiFlexGroup
alignItems="center"
component="div"
direction="row"
gutterSize="s"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText
grow={true}
>
Update rule condition from
5
to
</EuiText>
</EuiFlexItem>
<EuiFlexItem
className="condition-edit-value-field"
component="div"
grow={false}
>
<EuiFieldNumber
aria-label="Enter numeric value for condition"
compressed={true}
fullWidth={false}
isLoading={false}
onChange={[Function]}
placeholder="Enter value"
value={208.8}
/>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiLink
color="primary"
onClick={[Function]}
size="s"
type="button"
>
Update
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`EditConditionLink renders for a condition using typical 1`] = `
<EuiFlexGroup
alignItems="center"
component="div"
direction="row"
gutterSize="s"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<EuiText
grow={true}
>
Update rule condition from
5
to
</EuiText>
</EuiFlexItem>
<EuiFlexItem
className="condition-edit-value-field"
component="div"
grow={false}
>
<EuiFieldNumber
aria-label="Enter numeric value for condition"
compressed={true}
fullWidth={false}
isLoading={false}
onChange={[Function]}
placeholder="Enter value"
value={1.23}
/>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiLink
color="primary"
onClick={[Function]}
size="s"
type="button"
>
Update
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -16,6 +16,32 @@ exports[`RuleActionPanel renders panel for rule with a condition 1`] = `
"description": "skip result when actual is less than 1",
"title": "rule",
},
Object {
"description": <EditConditionLink
anomaly={
Object {
"actual": Array [
50,
],
"detectorIndex": 0,
"source": Object {
"airline": Array [
"AAL",
],
"function": "mean",
},
"typical": Array [
1.23,
],
}
}
appliesTo="actual"
conditionIndex={0}
conditionValue={1}
updateConditionValue={[Function]}
/>,
"title": "actions",
},
Object {
"description": <EuiLink
color="primary"
@ -24,11 +50,11 @@ exports[`RuleActionPanel renders panel for rule with a condition 1`] = `
>
Edit rule
</EuiLink>,
"title": "actions",
"title": "",
},
Object {
"description": <DeleteRuleModal
deleteRuleAtIndex={[Function]}
deleteRuleAtIndex={[MockFunction]}
ruleIndex={0}
/>,
"title": "",
@ -41,7 +67,7 @@ exports[`RuleActionPanel renders panel for rule with a condition 1`] = `
</EuiPanel>
`;
exports[`RuleActionPanel renders panel for rule with a condition and scope 1`] = `
exports[`RuleActionPanel renders panel for rule with a condition and scope, value not in filter list 1`] = `
<EuiPanel
className="select-rule-action-panel"
grow={true}
@ -54,9 +80,17 @@ exports[`RuleActionPanel renders panel for rule with a condition and scope 1`]
listItems={
Array [
Object {
"description": "skip model update when actual is greater than 500 AND instance is not in eu-airlines",
"description": "skip model update when airline is not in eu-airlines",
"title": "rule",
},
Object {
"description": <AddToFilterListLink
addItemToFilterList={[MockFunction]}
fieldValue="AAL"
filterId="eu-airlines"
/>,
"title": "actions",
},
Object {
"description": <EuiLink
color="primary"
@ -65,52 +99,52 @@ exports[`RuleActionPanel renders panel for rule with a condition and scope 1`]
>
Edit rule
</EuiLink>,
"title": "actions",
},
Object {
"description": <DeleteRuleModal
deleteRuleAtIndex={[Function]}
ruleIndex={2}
/>,
"title": "",
},
]
}
textStyle="normal"
type="column"
/>
</EuiPanel>
`;
exports[`RuleActionPanel renders panel for rule with scope 1`] = `
<EuiPanel
className="select-rule-action-panel"
grow={true}
hasShadow={false}
paddingSize="m"
>
<EuiDescriptionList
align="left"
compressed={false}
listItems={
Array [
Object {
"description": "skip model update when instance is not in eu-airlines",
"title": "rule",
},
Object {
"description": <EuiLink
color="primary"
onClick={[Function]}
type="button"
>
Edit rule
</EuiLink>,
"title": "actions",
},
Object {
"description": <DeleteRuleModal
deleteRuleAtIndex={[Function]}
deleteRuleAtIndex={[MockFunction]}
ruleIndex={1}
/>,
"title": "",
},
]
}
textStyle="normal"
type="column"
/>
</EuiPanel>
`;
exports[`RuleActionPanel renders panel for rule with scope, value in filter list 1`] = `
<EuiPanel
className="select-rule-action-panel"
grow={true}
hasShadow={false}
paddingSize="m"
>
<EuiDescriptionList
align="left"
compressed={false}
listItems={
Array [
Object {
"description": "skip model update when airline is not in eu-airlines",
"title": "rule",
},
Object {
"description": <EuiLink
color="primary"
onClick={[Function]}
type="button"
>
Edit rule
</EuiLink>,
"title": "actions",
},
Object {
"description": <DeleteRuleModal
deleteRuleAtIndex={[MockFunction]}
ruleIndex={1}
/>,
"title": "",

View file

@ -0,0 +1,38 @@
/*
* 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 quick addition of a partitioning field value
* to a filter list used in the scope part of a rule.
*/
import PropTypes from 'prop-types';
import React from 'react';
import {
EuiLink,
} from '@elastic/eui';
export function AddToFilterListLink({
fieldValue,
filterId,
addItemToFilterList,
}) {
return (
<EuiLink
onClick={() => addItemToFilterList(fieldValue, filterId, true)}
>
Add {fieldValue} to {filterId}
</EuiLink>
);
}
AddToFilterListLink.propTypes = {
fieldValue: PropTypes.string.isRequired,
filterId: PropTypes.string.isRequired,
addItemToFilterList: PropTypes.func.isRequired,
};

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.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { AddToFilterListLink } from './add_to_filter_list_link';
describe('AddToFilterListLink', () => {
test(`renders the add to filter list link for a value`, () => {
const addItemToFilterList = jest.fn(() => {});
const wrapper = shallow(
<AddToFilterListLink
fieldValue="elastic.co"
filterId="safe_domains"
addItemToFilterList={addItemToFilterList}
/>
);
expect(wrapper).toMatchSnapshot();
wrapper.find('EuiLink').simulate('click');
wrapper.update();
expect(addItemToFilterList).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,106 @@
/*
* 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 quick edit of the numeric condition part of a rule,
* containing a number field input for editing the condition value.
*/
import PropTypes from 'prop-types';
import React, {
Component,
} from 'react';
import {
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiText,
} from '@elastic/eui';
import { APPLIES_TO } from '../../../../common/constants/detector_rule';
import { formatValue } from '../../../formatters/format_value';
import {
getAppliesToValueFromAnomaly,
} from '../utils';
export class EditConditionLink extends Component {
constructor(props) {
super(props);
// Initialize value to anomaly value, if it exists.
// Do rounding at this initialization stage. Then if the user
// really wants to define to higher precision they can.
// Format based on magnitude of value at this stage, rather than using the
// Kibana field formatter (if set) which would add complexity converting
// the entered value to / from e.g. bytes.
let value = '';
const anomaly = this.props.anomaly;
const anomalyValue = getAppliesToValueFromAnomaly(anomaly, props.appliesTo);
if (anomalyValue !== undefined) {
value = +formatValue(anomalyValue, anomaly.source.function);
}
this.state = { value };
}
onChangeValue = (event) => {
const enteredValue = event.target.value;
this.setState({
value: (enteredValue !== '') ? +enteredValue : '',
});
}
onUpdateClick = () => {
const { conditionIndex, updateConditionValue } = this.props;
updateConditionValue(conditionIndex, this.state.value);
}
render() {
const value = this.state.value;
return (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText>
Update rule condition from {this.props.conditionValue} to
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} className="condition-edit-value-field">
<EuiFieldNumber
placeholder="Enter value"
compressed={true}
value={value}
onChange={this.onChangeValue}
aria-label="Enter numeric value for condition"
/>
</EuiFlexItem>
{value !== '' &&
<EuiFlexItem grow={false}>
<EuiLink
size="s"
onClick={() => this.onUpdateClick()}
>
Update
</EuiLink>
</EuiFlexItem>
}
</EuiFlexGroup>
);
}
}
EditConditionLink.propTypes = {
conditionIndex: PropTypes.number.isRequired,
conditionValue: PropTypes.number.isRequired,
appliesTo: PropTypes.oneOf([
APPLIES_TO.ACTUAL,
APPLIES_TO.TYPICAL,
APPLIES_TO.DIFF_FROM_TYPICAL
]),
anomaly: PropTypes.object.isRequired,
updateConditionValue: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../services/job_service.js', () => 'mlJobService');
import { shallow } from 'enzyme';
import React from 'react';
import { EditConditionLink } from './edit_condition_link';
import { APPLIES_TO } from '../../../../common/constants/detector_rule';
function prepareTest(updateConditionValueFn, appliesTo) {
const anomaly = {
actual: [210],
typical: [1.23],
detectorIndex: 0,
source: {
function: 'mean',
airline: ['AAL'],
},
};
const props = {
conditionIndex: 0,
conditionValue: 5,
appliesTo,
anomaly,
updateConditionValue: updateConditionValueFn,
};
const wrapper = shallow(
<EditConditionLink {...props} />
);
return wrapper;
}
describe('EditConditionLink', () => {
const updateConditionValue = jest.fn(() => {});
test(`renders for a condition using actual`, () => {
const wrapper = prepareTest(updateConditionValue, APPLIES_TO.ACTUAL);
expect(wrapper).toMatchSnapshot();
});
test(`renders for a condition using typical`, () => {
const wrapper = prepareTest(updateConditionValue, APPLIES_TO.TYPICAL);
expect(wrapper).toMatchSnapshot();
});
test(`renders for a condition using diff from typical`, () => {
const wrapper = prepareTest(updateConditionValue, APPLIES_TO.DIFF_FROM_TYPICAL);
expect(wrapper).toMatchSnapshot();
});
test('calls updateConditionValue on clicking update link', () => {
const wrapper = prepareTest(updateConditionValue, APPLIES_TO.ACTUAL);
const instance = wrapper.instance();
instance.onUpdateClick();
wrapper.update();
expect(updateConditionValue).toHaveBeenCalledWith(0, 210);
});
});

View file

@ -10,7 +10,9 @@
*/
import PropTypes from 'prop-types';
import React from 'react';
import React, {
Component,
} from 'react';
import {
EuiDescriptionList,
@ -18,72 +20,202 @@ import {
EuiPanel,
} from '@elastic/eui';
import { cloneDeep } from 'lodash';
import { AddToFilterListLink } from './add_to_filter_list_link';
import { DeleteRuleModal } from './delete_rule_modal';
import { EditConditionLink } from './edit_condition_link';
import { buildRuleDescription } from '../utils';
import { ml } from '../../../services/ml_api_service';
function getEditRuleLink(ruleIndex, setEditRuleIndex) {
return (
<EuiLink
onClick={() => setEditRuleIndex(ruleIndex)}
>
Edit rule
</EuiLink>
);
}
function getDeleteRuleLink(ruleIndex, deleteRuleAtIndex) {
return (
<DeleteRuleModal
ruleIndex={ruleIndex}
deleteRuleAtIndex={deleteRuleAtIndex}
/>
);
}
export class RuleActionPanel extends Component {
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;
constructor(props) {
super(props);
const {
job,
anomaly,
ruleIndex } = this.props;
const detector = job.analysis_config.detectors[anomaly.detectorIndex];
const rules = detector.custom_rules;
if (rules !== undefined && ruleIndex < rules.length) {
this.rule = rules[ruleIndex];
}
this.state = {
showAddToFilterListLink: false,
};
}
const rule = rules[ruleIndex];
componentDidMount() {
// If the rule has a scope section with a single partitioning field key,
// load the filter list to check whether to add a link to add the
// anomaly partitioning field value to the filter list.
const scope = this.rule.scope;
if (scope !== undefined && Object.keys(scope).length === 1) {
const partitionFieldName = Object.keys(scope)[0];
const partitionFieldValue = this.props.anomaly.source[partitionFieldName];
if (scope[partitionFieldName] !== undefined &&
partitionFieldValue !== undefined &&
partitionFieldValue.length === 1 &&
partitionFieldValue[0].length > 0) {
const filterId = scope[partitionFieldName].filter_id;
ml.filters.filters({ filterId })
.then((filter) => {
const filterItems = filter.items;
if (filterItems.indexOf(partitionFieldValue[0]) === -1) {
this.setState({ showAddToFilterListLink: true });
}
})
.catch((resp) => {
console.log(`Error loading filter ${filterId}:`, resp);
});
}
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}
getEditRuleLink = () => {
const { ruleIndex, setEditRuleIndex } = this.props;
return (
<EuiLink
onClick={() => setEditRuleIndex(ruleIndex)}
>
Edit rule
</EuiLink>
);
}
getDeleteRuleLink = () => {
const { ruleIndex, deleteRuleAtIndex } = this.props;
return (
<DeleteRuleModal
ruleIndex={ruleIndex}
deleteRuleAtIndex={deleteRuleAtIndex}
/>
</EuiPanel>
);
);
}
getQuickEditConditionLink = () => {
// Returns the link to adjust the numeric value of a condition
// if the rule has a single numeric condition.
const conditions = this.rule.conditions;
let link = null;
if (this.rule.conditions !== undefined && conditions.length === 1) {
link = (
<EditConditionLink
conditionIndex={0}
conditionValue={conditions[0].value}
appliesTo={conditions[0].applies_to}
anomaly={this.props.anomaly}
updateConditionValue={this.updateConditionValue}
/>
);
}
return link;
}
getQuickAddToFilterListLink = () => {
// Returns the link to add the partitioning field value of the anomaly to the filter
// list used in the scope part of the rule.
// Note componentDidMount performs the checks for the existence of scope and partitioning fields.
const { anomaly, addItemToFilterList } = this.props;
const scope = this.rule.scope;
const partitionFieldName = Object.keys(scope)[0];
const partitionFieldValue = anomaly.source[partitionFieldName];
const filterId = scope[partitionFieldName].filter_id;
// Partitioning field values stored under named field in anomaly record will be an array.
return (
<AddToFilterListLink
fieldValue={partitionFieldValue[0]}
filterId={filterId}
addItemToFilterList={addItemToFilterList}
/>
);
}
updateConditionValue = (conditionIndex, value) => {
const {
job,
anomaly,
ruleIndex,
updateRuleAtIndex } = this.props;
const detector = job.analysis_config.detectors[anomaly.detectorIndex];
const editedRule = cloneDeep(detector.custom_rules[ruleIndex]);
const conditions = editedRule.conditions;
if (conditionIndex < conditions.length) {
conditions[conditionIndex].value = value;
}
updateRuleAtIndex(ruleIndex, editedRule);
}
render() {
if (this.rule === undefined) {
return null;
}
// Add items for the standard Edit and Delete links.
const descriptionListItems = [
{
title: 'rule',
description: buildRuleDescription(this.rule, this.props.anomaly),
},
{
title: '',
description: this.getEditRuleLink(),
},
{
title: '',
description: this.getDeleteRuleLink()
}
];
// Insert links if applicable for quick edits to a numeric condition
// or to the safe list used by the scope.
const quickConditionLink = this.getQuickEditConditionLink();
if (quickConditionLink !== null) {
descriptionListItems.splice(1, 0, {
title: '', description: quickConditionLink
});
}
if (this.state.showAddToFilterListLink === true) {
const quickAddToFilterListLink = this.getQuickAddToFilterListLink();
descriptionListItems.splice(descriptionListItems.length - 2, 0, {
title: '', description: quickAddToFilterListLink
});
}
descriptionListItems[1].title = 'actions';
return (
<EuiPanel paddingSize="m" className="select-rule-action-panel">
<EuiDescriptionList
type="column"
listItems={descriptionListItems}
/>
</EuiPanel>
);
}
}
RuleActionPanel.propTypes = {
job: PropTypes.object.isRequired,
detectorIndex: PropTypes.number.isRequired,
anomaly: PropTypes.object.isRequired,
ruleIndex: PropTypes.number.isRequired,
setEditRuleIndex: PropTypes.func.isRequired,
updateRuleAtIndex: PropTypes.func.isRequired,
deleteRuleAtIndex: PropTypes.func.isRequired,
addItemToFilterList: PropTypes.func.isRequired,
};

View file

@ -6,6 +6,28 @@
jest.mock('../../../services/job_service.js', () => 'mlJobService');
// Mock the call for loading a filter.
// The mock is hoisted to the top, so need to prefix the filter variable
// with 'mock' so it can be used lazily.
const mockTestFilter = {
filter_id: 'eu-airlines',
description: 'List of European airlines',
items: ['ABA', 'AEL'],
used_by: {
detectors: ['mean response time'],
jobs: ['farequote']
},
};
jest.mock('../../../services/ml_api_service', () => ({
ml: {
filters: {
filters: () => {
return Promise.resolve(mockTestFilter);
}
}
}
}));
import { shallow } from 'enzyme';
import React from 'react';
@ -38,7 +60,7 @@ describe('RuleActionPanel', () => {
ACTION.SKIP_MODEL_UPDATE
],
scope: {
instance: {
airline: {
filter_id: 'eu-airlines',
filter_type: 'exclude'
}
@ -49,7 +71,7 @@ describe('RuleActionPanel', () => {
ACTION.SKIP_MODEL_UPDATE
],
scope: {
instance: {
airline: {
filter_id: 'eu-airlines',
filter_type: 'exclude'
}
@ -69,51 +91,70 @@ describe('RuleActionPanel', () => {
},
};
const anomaly = {
actual: [50],
typical: [1.23],
detectorIndex: 0,
source: {
function: 'mean',
airline: ['AAL'],
},
};
const setEditRuleIndex = jest.fn(() => {});
const updateRuleAtIndex = jest.fn(() => {});
const deleteRuleAtIndex = jest.fn(() => {});
const addItemToFilterList = jest.fn(() => {});
const requiredProps = {
job,
anomaly,
detectorIndex: 0,
setEditRuleIndex,
updateRuleAtIndex,
deleteRuleAtIndex,
addItemToFilterList,
};
test('renders panel for rule with a condition', () => {
const props = {
...requiredProps,
ruleIndex: 0,
};
const component = shallow(
<RuleActionPanel
job={job}
detectorIndex={0}
ruleIndex={0}
setEditRuleIndex={() => {}}
deleteRuleAtIndex={() => {}}
/>
<RuleActionPanel {...props} />
);
expect(component).toMatchSnapshot();
});
test('renders panel for rule with scope ', () => {
test('renders panel for rule with scope, value in filter list', () => {
const props = {
...requiredProps,
ruleIndex: 1,
};
const component = shallow(
<RuleActionPanel
job={job}
detectorIndex={0}
ruleIndex={1}
setEditRuleIndex={() => {}}
deleteRuleAtIndex={() => {}}
/>
<RuleActionPanel {...props} />
);
expect(component).toMatchSnapshot();
});
test('renders panel for rule with a condition and scope ', () => {
test('renders panel for rule with a condition and scope, value not in filter list', () => {
const props = {
...requiredProps,
ruleIndex: 1,
};
const component = shallow(
<RuleActionPanel
job={job}
detectorIndex={0}
ruleIndex={2}
setEditRuleIndex={() => {}}
deleteRuleAtIndex={() => {}}
/>
const wrapper = shallow(
<RuleActionPanel {...props} />
);
expect(component).toMatchSnapshot();
wrapper.setState({ showAddToFilterListLink: true });
expect(wrapper).toMatchSnapshot();
});

View file

@ -25,10 +25,12 @@ import { RuleActionPanel } from './rule_action_panel';
export function SelectRuleAction({
job,
anomaly,
detectorIndex,
setEditRuleIndex,
deleteRuleAtIndex }) {
updateRuleAtIndex,
deleteRuleAtIndex,
addItemToFilterList }) {
const detectorIndex = anomaly.detectorIndex;
const detector = job.analysis_config.detectors[detectorIndex];
const rules = detector.custom_rules || [];
let ruleActionPanels;
@ -38,11 +40,12 @@ export function SelectRuleAction({
<React.Fragment key={`rule_panel_${index}`}>
<RuleActionPanel
job={job}
detectorIndex={detectorIndex}
ruleIndex={index}
anomaly={anomaly}
setEditRuleIndex={setEditRuleIndex}
updateRuleAtIndex={updateRuleAtIndex}
deleteRuleAtIndex={deleteRuleAtIndex}
addItemToFilterList={addItemToFilterList}
/>
<EuiSpacer size="l"/>
</React.Fragment>
@ -57,6 +60,7 @@ export function SelectRuleAction({
<DetectorDescriptionList
job={job}
detector={detector}
anomaly={anomaly}
/>
<EuiSpacer size="m" />
{ruleActionPanels}
@ -78,7 +82,8 @@ export function SelectRuleAction({
SelectRuleAction.propTypes = {
job: PropTypes.object.isRequired,
anomaly: PropTypes.object.isRequired,
detectorIndex: PropTypes.number.isRequired,
setEditRuleIndex: PropTypes.func.isRequired,
updateRuleAtIndex: PropTypes.func.isRequired,
deleteRuleAtIndex: PropTypes.func.isRequired,
addItemToFilterList: PropTypes.func.isRequired,
};

View file

@ -6,20 +6,29 @@
}
.select-rule-action-panel {
padding-top:10px;
padding:10px 0px;
.euiDescriptionList {
.euiDescriptionList__title {
flex-basis: 15%;
padding: 0px 16px;
}
.euiDescriptionList__description {
flex-basis: 85%;
}
.euiDescriptionList__title:nth-child(1),
.euiDescriptionList__description:nth-child(2) {
color: #1a1a1a;
font-weight: 600;
border-bottom : 1px solid #d9d9d9;
padding-bottom: 12px;
}
.euiDescriptionList__title:nth-child(3),
.euiDescriptionList__description:nth-child(4) {
padding-top: 6px;
}
}
@ -52,6 +61,16 @@
font-size: 12px;
}
.condition-edit-value-field {
width: 170px;
height: 28px;
margin: 0px 2px;
input {
height: 28px;
}
}
.euiExpressionButton.disabled {
pointer-events: none;

View file

@ -12,6 +12,7 @@ import {
} from '../../../common/constants/detector_rule';
import { cloneDeep } from 'lodash';
import { ml } from '../../services/ml_api_service';
import { mlJobService } from '../../services/job_service';
export function getNewConditionDefaults() {
@ -157,6 +158,25 @@ export function updateJobRules(job, detectorIndex, rules) {
});
}
// Updates an ML filter used in the scope part of a rule,
// adding an item to the filter with the specified ID.
export function addItemToFilter(item, filterId) {
return new Promise((resolve, reject) => {
ml.filters.updateFilter(
filterId,
undefined,
[item],
undefined
)
.then((updatedFilter) => {
resolve(updatedFilter);
})
.catch((error) => {
reject(error);
});
});
}
export function buildRuleDescription(rule) {
const { actions, conditions, scope } = rule;
let description = 'skip ';
@ -181,7 +201,7 @@ export function buildRuleDescription(rule) {
description += ' AND ';
}
description += `${condition.applies_to} is ${operatorToText(condition.operator)} ${condition.value}`;
description += `${appliesToText(condition.applies_to)} is ${operatorToText(condition.operator)} ${condition.value}`;
});
}
@ -250,3 +270,35 @@ export function operatorToText(operator) {
return (operator !== undefined) ? operator : '';
}
}
// Returns the value of the selected 'applies_to' field from the
// selected anomaly i.e. the actual, typical or diff from typical.
export function getAppliesToValueFromAnomaly(anomaly, appliesTo) {
let actualValue;
let typicalValue;
const actual = anomaly.actual;
if (actual !== undefined) {
actualValue = Array.isArray(actual) ? actual[0] : actual;
}
const typical = anomaly.typical;
if (typical !== undefined) {
typicalValue = Array.isArray(typical) ? typical[0] : typical;
}
switch (appliesTo) {
case APPLIES_TO.ACTUAL:
return actualValue;
case APPLIES_TO.TYPICAL:
return typicalValue;
case APPLIES_TO.DIFF_FROM_TYPICAL:
if (actual !== undefined && typical !== undefined) {
return Math.abs(actualValue - typicalValue);
}
}
return undefined;
}

View file

@ -922,9 +922,8 @@ module.controller('MlExplorerController', function (
anomaly.source.function_description);
// For detectors with rules, add a property with the rule count.
const customRules = detector.custom_rules;
if (customRules !== undefined) {
anomaly.rulesLength = customRules.length;
if (detector !== undefined && detector.custom_rules !== undefined) {
anomaly.rulesLength = detector.custom_rules.length;
}
// Add properties used for building the links menu.

View file

@ -50,14 +50,21 @@ export const filters = {
addItems,
removeItems
) {
const data = {};
if (description !== undefined) {
data.description = description;
}
if (addItems !== undefined) {
data.addItems = addItems;
}
if (removeItems !== undefined) {
data.removeItems = removeItems;
}
return http({
url: `${basePath}/filters/${filterId}`,
method: 'PUT',
data: {
description,
addItems,
removeItems
}
data
});
},

View file

@ -101,14 +101,21 @@ export class FilterManager {
addItems,
removeItems) {
try {
const body = {};
if (description !== undefined) {
body.description = description;
}
if (addItems !== undefined) {
body.add_items = addItems;
}
if (removeItems !== undefined) {
body.remove_items = removeItems;
}
// Returns the newly updated filter.
return await this.callWithRequest('ml.updateFilter', {
filterId,
body: {
description,
add_items: addItems,
remove_items: removeItems
}
body
});
} catch (error) {
return Boom.badRequest(error);