mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
* [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:
parent
3983f30d38
commit
067e162aa8
21 changed files with 1159 additions and 162 deletions
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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": "",
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue