Skip to content

Commit 56b45b2

Browse files
opensearch-trigger-bot[bot]kaituo
andauthoredSep 3, 2024
Add Suppression Anomaly Rules in Advanced Settings (opensearch-project#859) (opensearch-project#860)
This PR introduces suppression anomaly rules under the Advanced Settings section, enabling users to suppress anomalies based on the difference between expected and actual values, either as an absolute value or a relative percentage. Testing: * Added unit tests to verify the suppression rules functionality. * Conducted manual end-to-end (e2e) tests to validate the implementation. Signed-off-by: Kaituo Li <kaituo@amazon.com> (cherry picked from commit 9874c48) Co-authored-by: Kaituo Li <kaituo@amazon.com>

File tree

25 files changed

+3901
-920
lines changed

25 files changed

+3901
-920
lines changed
 

‎.github/workflows/remote-integ-tests-workflow.yml

+1-4
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,7 @@ jobs:
149149

150150
- name: Run spec files from output
151151
run: |
152-
for i in $FILELIST; do
153-
yarn cypress:run-without-security --browser electron --spec "${i}"
154-
sleep 60
155-
done
152+
env CYPRESS_NO_COMMAND_LOG=1 yarn cypress:run-without-security --browser chromium --spec 'cypress/integration/plugins/anomaly-detection-dashboards-plugin/*'
156153
working-directory: opensearch-dashboards-functional-test
157154

158155
- name: Capture failure screenshots

‎public/models/interfaces.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { DETECTOR_STATE } from '../../server/utils/constants';
1515
import { Duration } from 'moment';
1616
import moment from 'moment';
1717
import { MDSQueryParams } from '../../server/models/types';
18-
import { ImputationOption } from './types';
18+
import {
19+
ImputationOption,
20+
Rule
21+
} from './types';
1922

2023
export type FieldInfo = {
2124
label: string;
@@ -212,6 +215,7 @@ export type Detector = {
212215
taskProgress?: number;
213216
taskError?: string;
214217
imputationOption?: ImputationOption;
218+
rules?: Rule[];
215219
};
216220

217221
export type DetectorListItem = {

‎public/models/types.ts

+84
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,87 @@ export enum ImputationMethod {
3333
PREVIOUS = 'PREVIOUS',
3434
}
3535

36+
// Constants for field names
37+
export const RULES_FIELD = "rules";
38+
export const ACTION_FIELD = "action";
39+
export const CONDITIONS_FIELD = "conditions";
40+
export const FEATURE_NAME_FIELD = "feature_name";
41+
export const THRESHOLD_TYPE_FIELD = "threshold_type";
42+
export const OPERATOR_FIELD = "operator";
43+
export const VALUE_FIELD = "value";
44+
45+
// Enums
46+
export enum Action {
47+
IGNORE_ANOMALY = "IGNORE_ANOMALY", // ignore anomaly if found
48+
}
49+
50+
export enum ThresholdType {
51+
/**
52+
* Specifies a threshold for ignoring anomalies where the actual value
53+
* exceeds the expected value by a certain margin.
54+
*
55+
* Assume a represents the actual value and b signifies the expected value.
56+
* IGNORE_SIMILAR_FROM_ABOVE implies the anomaly should be disregarded if a-b
57+
* is less than or equal to ignoreSimilarFromAbove.
58+
*/
59+
ACTUAL_OVER_EXPECTED_MARGIN = "ACTUAL_OVER_EXPECTED_MARGIN",
60+
61+
/**
62+
* Specifies a threshold for ignoring anomalies where the actual value
63+
* is below the expected value by a certain margin.
64+
*
65+
* Assume a represents the actual value and b signifies the expected value.
66+
* Likewise, IGNORE_SIMILAR_FROM_BELOW
67+
* implies the anomaly should be disregarded if b-a is less than or equal to
68+
* ignoreSimilarFromBelow.
69+
*/
70+
EXPECTED_OVER_ACTUAL_MARGIN = "EXPECTED_OVER_ACTUAL_MARGIN",
71+
72+
/**
73+
* Specifies a threshold for ignoring anomalies based on the ratio of
74+
* the difference to the actual value when the actual value exceeds
75+
* the expected value.
76+
*
77+
* Assume a represents the actual value and b signifies the expected value.
78+
* The variable IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO presumably implies the
79+
* anomaly should be disregarded if the ratio of the deviation from the actual
80+
* to the expected (a-b)/|a| is less than or equal to IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO.
81+
*/
82+
ACTUAL_OVER_EXPECTED_RATIO = "ACTUAL_OVER_EXPECTED_RATIO",
83+
84+
/**
85+
* Specifies a threshold for ignoring anomalies based on the ratio of
86+
* the difference to the actual value when the actual value is below
87+
* the expected value.
88+
*
89+
* Assume a represents the actual value and b signifies the expected value.
90+
* Likewise, IGNORE_NEAR_EXPECTED_FROM_BELOW_BY_RATIO appears to indicate that the anomaly
91+
* should be ignored if the ratio of the deviation from the expected to the actual
92+
* (b-a)/|a| is less than or equal to ignoreNearExpectedFromBelowByRatio.
93+
*/
94+
EXPECTED_OVER_ACTUAL_RATIO = "EXPECTED_OVER_ACTUAL_RATIO",
95+
}
96+
97+
// Method to get the description of ThresholdType
98+
export function getThresholdTypeDescription(thresholdType: ThresholdType): string {
99+
return thresholdType; // In TypeScript, the enum itself holds the description.
100+
}
101+
102+
// Enums for Operators
103+
export enum Operator {
104+
LTE = "LTE",
105+
}
106+
107+
// Interfaces for Rule and Condition
108+
export interface Rule {
109+
action: Action;
110+
conditions: Condition[];
111+
}
112+
113+
export interface Condition {
114+
featureName: string;
115+
thresholdType: ThresholdType;
116+
operator: Operator;
117+
value: number;
118+
}
119+

‎public/pages/ConfigureModel/components/AdvancedSettings/AdvancedSettings.tsx

+277-77
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { Formik } from 'formik';
5+
import { AdvancedSettings } from '../AdvancedSettings'; // Adjust the path as necessary
6+
7+
describe('AdvancedSettings Component', () => {
8+
test('displays error when -1 is entered in suppression rules absolute threshold', async () => {
9+
render(
10+
<Formik
11+
initialValues={{
12+
suppressionRules: [{ featureName: '', absoluteThreshold: '', relativeThreshold: '', aboveBelow: 'above' }],
13+
}}
14+
onSubmit={jest.fn()}
15+
>
16+
{() => <AdvancedSettings />}
17+
</Formik>
18+
);
19+
20+
// Open the advanced settings
21+
userEvent.click(screen.getByText('Show'));
22+
23+
screen.logTestingPlaygroundURL();
24+
25+
// Click to add a new suppression rule
26+
const addButton = screen.getByRole('button', { name: /add rule/i });
27+
fireEvent.click(addButton);
28+
29+
// Find the absolute threshold input and type -1
30+
const absoluteThresholdInput = screen.getAllByPlaceholderText('Absolute')[0]; // Select the first absolute threshold input
31+
userEvent.type(absoluteThresholdInput, '-1');
32+
33+
// Trigger validation
34+
fireEvent.blur(absoluteThresholdInput);
35+
36+
// Wait for the error message to appear
37+
await waitFor(() => {
38+
expect(screen.getByText('absolute threshold must be a positive number greater than zero')).toBeInTheDocument();
39+
});
40+
});
41+
test('displays error when -1 is entered in suppression rules relative threshold', async () => {
42+
render(
43+
<Formik
44+
initialValues={{
45+
suppressionRules: [{ featureName: '', absoluteThreshold: '', relativeThreshold: '', aboveBelow: 'above' }],
46+
}}
47+
onSubmit={jest.fn()}
48+
>
49+
{() => <AdvancedSettings />}
50+
</Formik>
51+
);
52+
53+
// Open the advanced settings
54+
userEvent.click(screen.getByText('Show'));
55+
56+
screen.logTestingPlaygroundURL();
57+
58+
// Click to add a new suppression rule
59+
const addButton = screen.getByRole('button', { name: /add rule/i });
60+
fireEvent.click(addButton);
61+
62+
// Find the relative threshold input and type -1
63+
const relativeThresholdInput = screen.getAllByPlaceholderText('Relative')[0]; // Select the first absolute threshold input
64+
userEvent.type(relativeThresholdInput, '-1');
65+
66+
// Trigger validation
67+
fireEvent.blur(relativeThresholdInput);
68+
69+
// Wait for the error message to appear
70+
await waitFor(() => {
71+
expect(screen.getByText('relative threshold must be a positive number greater than zero')).toBeInTheDocument();
72+
});
73+
});
74+
});

‎public/pages/ConfigureModel/containers/ConfigureModel.tsx

+46-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
EuiSpacer,
2222
EuiText,
2323
EuiLink,
24-
EuiIcon,
2524
} from '@elastic/eui';
2625
import { FormikProps, Formik } from 'formik';
2726
import { get, isEmpty } from 'lodash';
@@ -41,6 +40,7 @@ import {
4140
focusOnCategoryField,
4241
modelConfigurationToFormik,
4342
focusOnImputationOption,
43+
focusOnSuppressionRules,
4444
} from '../utils/helpers';
4545
import { formikToDetector } from '../../ReviewAndCreate/utils/helpers';
4646
import { formikToModelConfiguration } from '../utils/helpers';
@@ -53,7 +53,11 @@ import { CoreServicesContext } from '../../../components/CoreServices/CoreServic
5353
import { Detector } from '../../../models/interfaces';
5454
import { prettifyErrorMessage } from '../../../../server/utils/helpers';
5555
import { DetectorDefinitionFormikValues } from '../../DefineDetector/models/interfaces';
56-
import { ModelConfigurationFormikValues, FeaturesFormikValues } from '../models/interfaces';
56+
import {
57+
ModelConfigurationFormikValues,
58+
FeaturesFormikValues,
59+
RuleFormikValues
60+
} from '../models/interfaces';
5761
import { CreateDetectorFormikValues } from '../../CreateDetectorSteps/models/interfaces';
5862
import { DETECTOR_STATE } from '../../../../server/utils/constants';
5963
import { getErrorMessage } from '../../../utils/utils';
@@ -217,6 +221,35 @@ export function ConfigureModel(props: ConfigureModelProps) {
217221
}
218222
};
219223

224+
const validateRules = (
225+
formikValues: ModelConfigurationFormikValues,
226+
errors: any
227+
) => {
228+
const rules = formikValues.suppressionRules || [];
229+
230+
// Initialize an array to hold individual error messages
231+
const featureNameErrors: string[] = [];
232+
233+
// List of enabled features
234+
const enabledFeatures = formikValues.featureList
235+
.filter((feature: FeaturesFormikValues) => feature.featureEnabled)
236+
.map((feature: FeaturesFormikValues) => feature.featureName);
237+
238+
// Validate that each featureName in suppressionRules exists in enabledFeatures
239+
rules.forEach((rule: RuleFormikValues) => {
240+
if (!enabledFeatures.includes(rule.featureName)) {
241+
featureNameErrors.push(
242+
`Feature "${rule.featureName}" in suppression rules does not exist or is not enabled in the feature list.`
243+
);
244+
}
245+
});
246+
247+
// If there are any custom value errors, join them into a single string with proper formatting
248+
if (featureNameErrors.length > 0) {
249+
errors.suppressionRules = featureNameErrors.join(' ');
250+
}
251+
};
252+
220253
const handleFormValidation = async (
221254
formikProps: FormikProps<ModelConfigurationFormikValues>
222255
) => {
@@ -230,10 +263,12 @@ export function ConfigureModel(props: ConfigureModelProps) {
230263
formikProps.setFieldTouched('categoryField', isHCDetector);
231264
formikProps.setFieldTouched('shingleSize');
232265
formikProps.setFieldTouched('imputationOption');
266+
formikProps.setFieldTouched('suppressionRules');
233267

234268
formikProps.validateForm().then((errors) => {
235269
// Call the extracted validation method
236270
validateImputationOption(formikProps.values, errors);
271+
validateRules(formikProps.values, errors);
237272

238273
if (isEmpty(errors)) {
239274
if (props.isEdit) {
@@ -262,6 +297,15 @@ export function ConfigureModel(props: ConfigureModelProps) {
262297
return;
263298
}
264299

300+
const ruleValueError = get(errors, 'suppressionRules')
301+
if (ruleValueError) {
302+
core.notifications.toasts.addDanger(
303+
ruleValueError
304+
);
305+
focusOnSuppressionRules();
306+
return;
307+
}
308+
265309
// TODO: can add focus to all components or possibly customize error message too
266310
if (get(errors, 'featureList')) {
267311
focusOnFirstWrongFeature(errors, formikProps.setFieldTouched);

‎public/pages/ConfigureModel/models/interfaces.ts

+8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ModelConfigurationFormikValues {
1919
categoryField: string[];
2020
shingleSize: number;
2121
imputationOption?: ImputationFormikValues;
22+
suppressionRules?: RuleFormikValues[];
2223
}
2324

2425
export interface FeaturesFormikValues {
@@ -41,3 +42,10 @@ export interface CustomValueFormikValues {
4142
featureName: string;
4243
data: number;
4344
}
45+
46+
export interface RuleFormikValues {
47+
featureName: string;
48+
absoluteThreshold?: number;
49+
relativeThreshold?: number;
50+
aboveBelow: string;
51+
}

‎public/pages/ConfigureModel/utils/__tests__/helpers.test.tsx

+18-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { getRandomDetector } from '../../../../redux/reducers/__tests__/utils';
1313
import { prepareDetector } from '../../utils/helpers';
1414
import { FEATURE_TYPE } from '../../../../models/interfaces';
1515
import { FeaturesFormikValues } from '../../models/interfaces';
16-
import { modelConfigurationToFormik } from '../helpers';
16+
import { modelConfigurationToFormik, rulesToFormik } from '../helpers';
1717
import { SparseDataOptionValue } from '../constants';
1818
import { ImputationMethod } from '../../../../models/types';
1919

@@ -127,4 +127,21 @@ describe('featuresToFormik', () => {
127127
);
128128
}
129129
});
130+
test('should return correct rules', () => {
131+
const randomDetector = getRandomDetector(); // Generate a random detector object for testing
132+
const adFormikValues = modelConfigurationToFormik(randomDetector); // Convert detector to Formik values
133+
134+
const rules = randomDetector.rules; // Get the rules from the detector
135+
136+
if (rules) {
137+
// If rules exist, convert them to formik format using rulesToFormik
138+
const expectedFormikRules = rulesToFormik(rules); // Convert rules to Formik-compatible format
139+
140+
// Compare the converted rules with the suppressionRules in Formik values
141+
expect(adFormikValues.suppressionRules).toEqual(expectedFormikRules);
142+
} else {
143+
// If no rules exist, suppressionRules should be undefined
144+
expect(adFormikValues.suppressionRules).toEqual([]);
145+
}
146+
});
130147
});

‎public/pages/ConfigureModel/utils/helpers.ts

+148
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
FeaturesFormikValues,
2424
CustomValueFormikValues,
2525
ImputationFormikValues,
26+
RuleFormikValues,
2627
} from '../../ConfigureModel/models/interfaces';
2728
import { INITIAL_MODEL_CONFIGURATION_VALUES } from '../../ConfigureModel/utils/constants';
2829
import {
@@ -32,6 +33,11 @@ import {
3233
import {
3334
ImputationMethod,
3435
ImputationOption,
36+
Condition,
37+
Rule,
38+
ThresholdType,
39+
Operator,
40+
Action,
3541
} from '../../../models/types';
3642
import {
3743
SparseDataOptionValue
@@ -218,6 +224,11 @@ export const focusOnImputationOption = () => {
218224
component?.focus();
219225
};
220226

227+
export const focusOnSuppressionRules = () => {
228+
const component = document.getElementById('suppressionRules');
229+
component?.focus();
230+
};
231+
221232
export const getShingleSizeFromObject = (obj: object) => {
222233
return get(obj, 'shingleSize', DEFAULT_SHINGLE_SIZE);
223234
};
@@ -269,6 +280,7 @@ export function modelConfigurationToFormik(
269280
categoryField: get(detector, 'categoryField', []),
270281
shingleSize: get(detector, 'shingleSize', DEFAULT_SHINGLE_SIZE),
271282
imputationOption: imputationFormikValues,
283+
suppressionRules: rulesToFormik(detector.rules),
272284
};
273285
}
274286

@@ -317,6 +329,7 @@ export function formikToModelConfiguration(
317329
? values.categoryField
318330
: undefined,
319331
imputationOption: formikToImputationOption(values.imputationOption),
332+
rules: formikToRules(values.suppressionRules),
320333
} as Detector;
321334

322335
return detectorBody;
@@ -425,3 +438,138 @@ export const getCustomValueStrArray = (imputationMethodStr : string, detector: D
425438
}
426439
return []
427440
}
441+
442+
export const getSuppressionRulesArray = (detector: Detector): string[] => {
443+
if (!detector.rules || detector.rules.length === 0) {
444+
return []; // Return an empty array if there are no rules
445+
}
446+
447+
return detector.rules.flatMap((rule) => {
448+
// Convert each condition to a readable string
449+
return rule.conditions.map((condition) => {
450+
const featureName = condition.featureName;
451+
const thresholdType = condition.thresholdType;
452+
let value = condition.value;
453+
const isPercentage = thresholdType === ThresholdType.ACTUAL_OVER_EXPECTED_RATIO || thresholdType === ThresholdType.EXPECTED_OVER_ACTUAL_RATIO;
454+
455+
// If it is a percentage, multiply by 100
456+
if (isPercentage) {
457+
value *= 100;
458+
}
459+
460+
// Determine whether it is "above" or "below" based on ThresholdType
461+
const aboveOrBelow = thresholdType === ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN || thresholdType === ThresholdType.ACTUAL_OVER_EXPECTED_RATIO ? 'above' : 'below';
462+
463+
// Construct the formatted string
464+
return `Ignore anomalies for feature "${featureName}" with no more than ${value}${isPercentage ? '%' : ''} ${aboveOrBelow} expected value.`;
465+
});
466+
});
467+
};
468+
469+
470+
// Convert RuleFormikValues[] to Rule[]
471+
export const formikToRules = (formikValues?: RuleFormikValues[]): Rule[] | undefined => {
472+
if (!formikValues || formikValues.length === 0) {
473+
return undefined; // Return undefined for undefined or empty input
474+
}
475+
476+
return formikValues.map((formikValue) => {
477+
const conditions: Condition[] = [];
478+
479+
// Determine the threshold type based on aboveBelow and the threshold type (absolute or relative)
480+
const getThresholdType = (aboveBelow: string, isAbsolute: boolean): ThresholdType => {
481+
if (isAbsolute) {
482+
return aboveBelow === 'above'
483+
? ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN
484+
: ThresholdType.EXPECTED_OVER_ACTUAL_MARGIN;
485+
} else {
486+
return aboveBelow === 'above'
487+
? ThresholdType.ACTUAL_OVER_EXPECTED_RATIO
488+
: ThresholdType.EXPECTED_OVER_ACTUAL_RATIO;
489+
}
490+
};
491+
492+
// Check if absoluteThreshold is provided, create a condition
493+
if (formikValue.absoluteThreshold !== undefined && formikValue.absoluteThreshold !== 0 && formikValue.absoluteThreshold !== null
494+
&& typeof formikValue.absoluteThreshold === 'number' && // Check if it's a number
495+
!isNaN(formikValue.absoluteThreshold) && // Ensure it's not NaN
496+
formikValue.absoluteThreshold > 0 // Check if it's positive
497+
) {
498+
conditions.push({
499+
featureName: formikValue.featureName,
500+
thresholdType: getThresholdType(formikValue.aboveBelow, true),
501+
operator: Operator.LTE,
502+
value: formikValue.absoluteThreshold,
503+
});
504+
}
505+
506+
// Check if relativeThreshold is provided, create a condition
507+
if (formikValue.relativeThreshold !== undefined && formikValue.relativeThreshold !== 0 && formikValue.relativeThreshold !== null
508+
&& typeof formikValue.relativeThreshold === 'number' && // Check if it's a number
509+
!isNaN(formikValue.relativeThreshold) && // Ensure it's not NaN
510+
formikValue.relativeThreshold > 0 // Check if it's positive
511+
) {
512+
conditions.push({
513+
featureName: formikValue.featureName,
514+
thresholdType: getThresholdType(formikValue.aboveBelow, false),
515+
operator: Operator.LTE,
516+
value: formikValue.relativeThreshold / 100, // Convert percentage to decimal,
517+
});
518+
}
519+
520+
return {
521+
action: Action.IGNORE_ANOMALY,
522+
conditions,
523+
};
524+
});
525+
};
526+
527+
// Convert Rule[] to RuleFormikValues[]
528+
export const rulesToFormik = (rules?: Rule[]): RuleFormikValues[] => {
529+
if (!rules || rules.length === 0) {
530+
return []; // Return empty array for undefined or empty input
531+
}
532+
533+
return rules.map((rule) => {
534+
// Start with default values
535+
const formikValue: RuleFormikValues = {
536+
featureName: '',
537+
absoluteThreshold: undefined,
538+
relativeThreshold: undefined,
539+
aboveBelow: 'above', // Default to 'above', adjust as needed
540+
};
541+
542+
// Loop through conditions to populate formikValue
543+
rule.conditions.forEach((condition) => {
544+
formikValue.featureName = condition.featureName;
545+
546+
// Determine the value and type of threshold
547+
switch (condition.thresholdType) {
548+
case ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN:
549+
formikValue.absoluteThreshold = condition.value;
550+
formikValue.aboveBelow = 'above';
551+
break;
552+
case ThresholdType.EXPECTED_OVER_ACTUAL_MARGIN:
553+
formikValue.absoluteThreshold = condition.value;
554+
formikValue.aboveBelow = 'below';
555+
break;
556+
case ThresholdType.ACTUAL_OVER_EXPECTED_RATIO:
557+
// *100 to convert to percentage
558+
formikValue.relativeThreshold = condition.value * 100;
559+
formikValue.aboveBelow = 'above';
560+
break;
561+
case ThresholdType.EXPECTED_OVER_ACTUAL_RATIO:
562+
// *100 to convert to percentage
563+
formikValue.relativeThreshold = condition.value * 100;
564+
formikValue.aboveBelow = 'below';
565+
break;
566+
default:
567+
break;
568+
}
569+
});
570+
571+
return formikValue;
572+
});
573+
};
574+
575+

‎public/pages/DefineDetector/utils/constants.tsx

-2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,4 @@ export const INITIAL_DETECTOR_DEFINITION_VALUES: DetectorDefinitionFormikValues
4848
resultIndexMinSize: 51200,
4949
resultIndexTtl: 60,
5050
flattenCustomResultIndex: false,
51-
imputationMethod: undefined,
52-
customImputationValue: undefined
5351
};

‎public/pages/DetectorConfig/components/AdditionalSettings/AdditionalSettings.tsx

+46-5
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,58 @@
99
* GitHub history for details.
1010
*/
1111

12-
import React from 'react';
12+
import React, { useState } from 'react';
1313
import { get, isEmpty } from 'lodash';
14-
import { EuiBasicTable } from '@elastic/eui';
14+
import {
15+
EuiBasicTable,
16+
EuiButtonEmpty,
17+
EuiOverlayMask
18+
} from '@elastic/eui';
1519
import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
1620
import { convertToCategoryFieldString } from '../../../utils/anomalyResultUtils';
21+
import { SuppressionRulesModal } from '../../../ReviewAndCreate/components/AdditionalSettings/SuppressionRulesModal';
1722

1823
interface AdditionalSettingsProps {
1924
shingleSize: number;
2025
categoryField: string[];
2126
imputationMethod: string;
2227
customValues: string[];
28+
suppressionRules: string[];
2329
}
2430

2531
export function AdditionalSettings(props: AdditionalSettingsProps) {
2632
const renderCustomValues = (customValues: string[]) => (
2733
<div>
28-
{customValues.map((value, index) => (
29-
<p key={index}>{value}</p>
30-
))}
34+
{customValues.length > 0 ? (
35+
customValues.map((value, index) => <p key={index}>{value}</p>)
36+
) : (
37+
<p>-</p>
38+
)}
3139
</div>
3240
);
41+
42+
const [isModalVisible, setIsModalVisible] = useState(false);
43+
const [modalContent, setModalContent] = useState<string[]>([]);
44+
45+
const closeModal = () => setIsModalVisible(false);
46+
47+
const showRulesInModal = (rules: string[]) => {
48+
setModalContent(rules);
49+
setIsModalVisible(true);
50+
};
51+
52+
const renderSuppressionRules = (suppressionRules: string[]) => (
53+
<div>
54+
{suppressionRules.length > 0 ? (
55+
<EuiButtonEmpty size="s" onClick={() => showRulesInModal(suppressionRules)}>
56+
{suppressionRules.length} rules
57+
</EuiButtonEmpty>
58+
) : (
59+
<p>-</p>
60+
)}
61+
</div>
62+
);
63+
3364
const tableItems = [
3465
{
3566
categoryField: isEmpty(get(props, 'categoryField', []))
@@ -38,6 +69,7 @@ export function AdditionalSettings(props: AdditionalSettingsProps) {
3869
shingleSize: props.shingleSize,
3970
imputationMethod: props.imputationMethod,
4071
customValues: props.customValues,
72+
suppresionRules: props.suppressionRules,
4173
},
4274
];
4375
const tableColumns = [
@@ -48,6 +80,10 @@ export function AdditionalSettings(props: AdditionalSettingsProps) {
4880
field: 'customValues',
4981
render: (customValues: string[]) => renderCustomValues(customValues), // Use a custom render function
5082
},
83+
{ name: 'Suppression rules',
84+
field: 'suppresionRules',
85+
render: (suppresionRules: string[]) => renderSuppressionRules(suppresionRules), // Use a custom render function
86+
},
5187
];
5288
return (
5389
<ContentPanel title="Additional settings" titleSize="s">
@@ -56,6 +92,11 @@ export function AdditionalSettings(props: AdditionalSettingsProps) {
5692
items={tableItems}
5793
columns={tableColumns}
5894
/>
95+
{isModalVisible && (
96+
<EuiOverlayMask>
97+
<SuppressionRulesModal onClose={closeModal} rules={modalContent} />
98+
</EuiOverlayMask>
99+
)}
59100
</ContentPanel>
60101
);
61102
}

‎public/pages/DetectorConfig/containers/Features.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
EuiBasicTable,
1515
EuiText,
1616
EuiLink,
17-
EuiIcon,
1817
EuiSmallButton,
1918
EuiEmptyPrompt,
2019
EuiSpacer,
@@ -34,6 +33,7 @@ import {
3433
getShingleSizeFromObject,
3534
imputationMethodToFormik,
3635
getCustomValueStrArray,
36+
getSuppressionRulesArray,
3737
} from '../../ConfigureModel/utils/helpers';
3838

3939
interface FeaturesProps {
@@ -256,6 +256,7 @@ export const Features = (props: FeaturesProps) => {
256256
imputationMethodStr,
257257
props.detector
258258
)}
259+
suppressionRules={getSuppressionRulesArray(props.detector)}
259260
/>
260261
</div>
261262
)}

‎public/pages/DetectorConfig/containers/__tests__/DetectorConfig.test.tsx

+108-2
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,24 @@ import {
2828
UiFeature,
2929
FeatureAttributes,
3030
OPERATORS_MAP,
31+
UNITS,
3132
} from '../../../../models/interfaces';
3233
import {
3334
getRandomDetector,
34-
randomFixedValue,
35+
getUIMetadata,
3536
} from '../../../../redux/reducers/__tests__/utils';
3637
import { coreServicesMock } from '../../../../../test/mocks';
3738
import { toStringConfigCell } from '../../../ReviewAndCreate/utils/helpers';
3839
import { DATA_TYPES } from '../../../../utils/constants';
3940
import { mockedStore, initialState } from '../../../../redux/utils/testUtils';
4041
import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices';
41-
import { ImputationMethod } from '../../../../models/types';
42+
import {
43+
ImputationMethod,
44+
Action,
45+
ThresholdType,
46+
Operator,
47+
} from '../../../../models/types';
48+
import { DETECTOR_STATE } from '../../../../../server/utils/constants';
4249

4350
const renderWithRouter = (detector: Detector) => ({
4451
...render(
@@ -143,6 +150,25 @@ describe('<DetectorConfig /> spec', () => {
143150
const randomDetector = {
144151
...getRandomDetector(false),
145152
imputationOption: { method: ImputationMethod.PREVIOUS },
153+
rules: [
154+
{
155+
action: Action.IGNORE_ANOMALY,
156+
conditions: [
157+
{
158+
featureName: 'value', // Matches a feature in featureAttributes
159+
thresholdType: ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN,
160+
operator: Operator.LTE,
161+
value: 5,
162+
},
163+
{
164+
featureName: 'value2', // Matches another feature in featureAttributes
165+
thresholdType: ThresholdType.EXPECTED_OVER_ACTUAL_RATIO,
166+
operator: Operator.LTE,
167+
value: 10,
168+
},
169+
],
170+
},
171+
],
146172
};
147173
const { container } = renderWithRouter(randomDetector);
148174
expect(container.firstChild).toMatchSnapshot();
@@ -362,8 +388,88 @@ describe('<DetectorConfig /> spec', () => {
362388
},
363389
} as UiMetaData,
364390
imputationOption: imputationOption,
391+
rules: [
392+
{
393+
action: Action.IGNORE_ANOMALY,
394+
conditions: [
395+
{
396+
featureName: 'value', // Matches a feature in featureAttributes
397+
thresholdType: ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN,
398+
operator: Operator.LTE,
399+
value: 5,
400+
},
401+
{
402+
featureName: 'value2', // Matches another feature in featureAttributes
403+
thresholdType: ThresholdType.EXPECTED_OVER_ACTUAL_RATIO,
404+
operator: Operator.LTE,
405+
value: 10,
406+
},
407+
],
408+
},
409+
],
365410
};
366411
const { container } = renderWithRouter(randomDetector);
367412
expect(container.firstChild).toMatchSnapshot();
368413
});
414+
test('renders rules', () => {
415+
// Define example features
416+
const features = [
417+
{
418+
featureName: 'value',
419+
featureEnabled: true,
420+
aggregationQuery: featureQuery1,
421+
},
422+
{
423+
featureName: 'value2',
424+
featureEnabled: true,
425+
aggregationQuery: featureQuery2,
426+
},
427+
{
428+
featureName: 'value',
429+
featureEnabled: false,
430+
},
431+
] as FeatureAttributes[];
432+
433+
// Updated example detector
434+
const testDetector: Detector = {
435+
primaryTerm: 1,
436+
seqNo: 1,
437+
id: 'detector-1',
438+
name: 'Sample Detector',
439+
description: 'A sample detector for testing',
440+
timeField: 'timestamp',
441+
indices: ['index1'],
442+
filterQuery: {},
443+
featureAttributes: features, // Using the provided features
444+
windowDelay: { period: { interval: 1, unit: UNITS.MINUTES } },
445+
detectionInterval: { period: { interval: 1, unit: UNITS.MINUTES } },
446+
shingleSize: 8,
447+
lastUpdateTime: 1586823218000,
448+
curState: DETECTOR_STATE.RUNNING,
449+
stateError: '',
450+
uiMetadata: getUIMetadata(features),
451+
rules: [
452+
{
453+
action: Action.IGNORE_ANOMALY,
454+
conditions: [
455+
{
456+
featureName: 'value', // Matches a feature in featureAttributes
457+
thresholdType: ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN,
458+
operator: Operator.LTE,
459+
value: 5,
460+
},
461+
{
462+
featureName: 'value2', // Matches another feature in featureAttributes
463+
thresholdType: ThresholdType.EXPECTED_OVER_ACTUAL_RATIO,
464+
operator: Operator.LTE,
465+
value: 10,
466+
},
467+
],
468+
},
469+
],
470+
};
471+
472+
const { container } = renderWithRouter(testDetector);
473+
expect(container.firstChild).toMatchSnapshot();
474+
});
369475
});

‎public/pages/DetectorConfig/containers/__tests__/__snapshots__/DetectorConfig.test.tsx.snap

+1,562-1
Large diffs are not rendered by default.

‎public/pages/ReviewAndCreate/components/AdditionalSettings/AdditionalSettings.tsx

+50-6
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,64 @@
99
* GitHub history for details.
1010
*/
1111

12-
import React from 'react';
12+
import React, { useState } from 'react';
1313
import { get } from 'lodash';
14-
import { EuiBasicTable } from '@elastic/eui';
14+
import {
15+
EuiBasicTable,
16+
EuiButtonEmpty,
17+
EuiOverlayMask
18+
} from '@elastic/eui';
19+
import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
20+
import { SuppressionRulesModal } from './SuppressionRulesModal';
1521

1622
interface AdditionalSettingsProps {
1723
shingleSize: number;
1824
categoryField: string[];
1925
imputationMethod: string;
2026
customValues: string[];
27+
suppressionRules: string[];
2128
}
2229

2330
export function AdditionalSettings(props: AdditionalSettingsProps) {
2431
const renderCustomValues = (customValues: string[]) => (
2532
<div>
26-
{customValues.map((value, index) => (
27-
<p key={index}>{value}</p>
28-
))}
33+
{customValues.length > 0 ? (
34+
customValues.map((value, index) => <p key={index}>{value}</p>)
35+
) : (
36+
<p>-</p>
37+
)}
2938
</div>
3039
);
40+
41+
const [isModalVisible, setIsModalVisible] = useState(false);
42+
const [modalContent, setModalContent] = useState<string[]>([]);
43+
44+
const closeModal = () => setIsModalVisible(false);
45+
46+
const showRulesInModal = (rules: string[]) => {
47+
setModalContent(rules);
48+
setIsModalVisible(true);
49+
};
50+
51+
const renderSuppressionRules = (suppressionRules: string[]) => (
52+
<div>
53+
{suppressionRules.length > 0 ? (
54+
<EuiButtonEmpty size="s" onClick={() => showRulesInModal(suppressionRules)}>
55+
{suppressionRules.length} rules
56+
</EuiButtonEmpty>
57+
) : (
58+
<p>-</p>
59+
)}
60+
</div>
61+
);
62+
3163
const tableItems = [
3264
{
3365
categoryField: get(props, 'categoryField.0', '-'),
3466
shingleSize: props.shingleSize,
3567
imputationMethod: props.imputationMethod,
3668
customValues: props.customValues,
69+
suppresionRules: props.suppressionRules,
3770
},
3871
];
3972
const tableColumns = [
@@ -43,13 +76,24 @@ export function AdditionalSettings(props: AdditionalSettingsProps) {
4376
{ name: 'Custom values',
4477
field: 'customValues',
4578
render: (customValues: string[]) => renderCustomValues(customValues), // Use a custom render function
46-
},
79+
},
80+
{ name: 'Suppression rules',
81+
field: 'suppresionRules',
82+
render: (suppressionRules: string[]) => renderSuppressionRules(suppressionRules), // Use a custom render function
83+
},
4784
];
4885
return (
86+
<ContentPanel title="Additional settings" titleSize="s">
4987
<EuiBasicTable
5088
className="header-single-value-euiBasicTable"
5189
items={tableItems}
5290
columns={tableColumns}
5391
/>
92+
{isModalVisible && (
93+
<EuiOverlayMask>
94+
<SuppressionRulesModal onClose={closeModal} rules={modalContent} />
95+
</EuiOverlayMask>
96+
)}
97+
</ContentPanel>
5498
);
5599
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle, EuiText, EuiSpacer } from '@elastic/eui';
3+
4+
interface SuppressionRulesModalProps {
5+
rules: string[];
6+
onClose: () => void;
7+
}
8+
9+
export const SuppressionRulesModal: React.FC<SuppressionRulesModalProps> = ({ rules, onClose }) => {
10+
return (
11+
<EuiModal onClose={onClose} style={{ maxWidth: 600 }} role="dialog">
12+
<EuiModalHeader>
13+
<EuiModalHeaderTitle>
14+
<h2>Suppression Rules</h2>
15+
</EuiModalHeaderTitle>
16+
</EuiModalHeader>
17+
18+
<EuiModalBody>
19+
<EuiText size="s">
20+
{rules.map((rule, index) => (
21+
<EuiText key={index} size="s">
22+
{rule}
23+
</EuiText>
24+
))}
25+
</EuiText>
26+
<EuiSpacer size="m" />
27+
</EuiModalBody>
28+
</EuiModal>
29+
);
30+
};

‎public/pages/ReviewAndCreate/components/ModelConfigurationFields/ModelConfigurationFields.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
getShingleSizeFromObject,
3636
imputationMethodToFormik,
3737
getCustomValueStrArray,
38+
getSuppressionRulesArray,
3839
} from '../../../ConfigureModel/utils/helpers';
3940
import { SORT_DIRECTION } from '../../../../../server/utils/constants';
4041

@@ -324,6 +325,7 @@ export const ModelConfigurationFields = (
324325
categoryField={get(props, 'detector.categoryField', [])}
325326
imputationMethod={imputationMethodStr}
326327
customValues={getCustomValueStrArray(imputationMethodStr, props.detector)}
328+
suppressionRules={getSuppressionRulesArray(props.detector)}
327329
/>
328330
<EuiSpacer />
329331
<ContentPanel

‎public/pages/ReviewAndCreate/components/__tests__/AdditionalSettings.test.tsx

+76-21
Original file line numberDiff line numberDiff line change
@@ -10,52 +10,107 @@
1010
*/
1111

1212
import React from 'react';
13-
import { render } from '@testing-library/react';
13+
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
1414
import { AdditionalSettings } from '../AdditionalSettings/AdditionalSettings';
1515

1616
import { Formik } from 'formik';
1717

1818
describe('<AdditionalSettings /> spec', () => {
1919
test('renders the component with high cardinality disabled', () => {
20-
const { container, getByText, getAllByText } = render(
20+
const { container, getByText, getAllByText, queryByRole } = render(
2121
<Formik initialValues={{ detectorName: '' }} onSubmit={jest.fn()}>
2222
{() => (
2323
<div>
24-
<AdditionalSettings categoryField={[]} shingleSize={8} imputationMethod="Ignore" customValues={[]}/>
24+
<AdditionalSettings
25+
categoryField={[]}
26+
shingleSize={8}
27+
imputationMethod="Ignore"
28+
customValues={[]}
29+
suppressionRules={[]}
30+
/>
2531
</div>
2632
)}
2733
</Formik>
2834
);
2935
expect(container.firstChild).toMatchSnapshot();
3036
getAllByText('Category field');
3137
getAllByText('Shingle size');
32-
getByText('-');
3338
getByText('8');
34-
getByText("Ignore");
39+
getByText('Ignore');
40+
41+
// Assert that multiple elements with the text '-' are present
42+
const dashElements = getAllByText('-');
43+
expect(dashElements.length).toBeGreaterThan(1); // Checks that more than one '-' is found
44+
45+
// Check that the 'Suppression rules' title is present
46+
// Assert that multiple elements with the text '-' are present
47+
const ruleElements = getAllByText('Suppression rules');
48+
expect(ruleElements.length).toBeGreaterThan(1); // one is table cell title, another is the button
49+
50+
// Use queryByRole to check that the button link is not present
51+
const button = screen.queryByRole('button', { name: '0 rules' });
52+
expect(button).toBeNull();
3553
});
36-
test('renders the component with high cardinality enabled', () => {
37-
const { container, getByText, getAllByText } = render(
38-
<Formik initialValues={{ detectorName: '' }} onSubmit={jest.fn()}>
39-
{() => (
40-
<div>
41-
<AdditionalSettings
42-
categoryField={['test_field']}
43-
shingleSize={8}
44-
imputationMethod="Custom"
45-
customValues={["denyMax:5", "denySum:10"]}
46-
/>
47-
</div>
48-
)}
49-
</Formik>
50-
);
54+
test('renders the component with high cardinality enabled', async () => {
55+
const { container, getByText, getAllByText, getByRole, queryByRole } =
56+
render(
57+
<Formik initialValues={{ detectorName: '' }} onSubmit={jest.fn()}>
58+
{() => (
59+
<div>
60+
<AdditionalSettings
61+
categoryField={['test_field']}
62+
shingleSize={8}
63+
imputationMethod="Custom"
64+
customValues={['denyMax:5', 'denySum:10']}
65+
suppressionRules={[
66+
"Ignore anomalies for feature 'CPU Usage' with no more than 5 above expected value.",
67+
"Ignore anomalies for feature 'Memory Usage' with no more than 10% below expected value.",
68+
]}
69+
/>
70+
</div>
71+
)}
72+
</Formik>
73+
);
5174
expect(container.firstChild).toMatchSnapshot();
5275
getAllByText('Category field');
5376
getAllByText('Shingle size');
5477
getByText('test_field');
5578
getByText('8');
56-
getByText("Custom");
79+
getByText('Custom');
5780
// Check for the custom values
5881
getByText('denyMax:5');
5982
getByText('denySum:10');
83+
84+
// Check for the suppression rules button link
85+
const button = getByRole('button', { name: '2 rules' });
86+
expect(button).toBeInTheDocument();
87+
88+
// Click the button to open the modal
89+
fireEvent.click(button);
90+
91+
// Wait for the modal to appear and check for its content
92+
await waitFor(() => {
93+
expect(screen.getByRole('dialog')).toBeInTheDocument(); // Ensure modal is opened
94+
});
95+
96+
getByText('Suppression Rules'); // Modal header
97+
getByText(
98+
"Ignore anomalies for feature 'CPU Usage' with no more than 5 above expected value."
99+
);
100+
getByText(
101+
"Ignore anomalies for feature 'Memory Usage' with no more than 10% below expected value."
102+
);
103+
104+
// Close the modal by clicking the close button (X)
105+
// Close the modal by clicking the close button (X)
106+
const closeButton = getByRole('button', {
107+
name: 'Closes this modal window',
108+
});
109+
fireEvent.click(closeButton);
110+
111+
// Ensure the modal is closed
112+
await waitFor(() => {
113+
expect(queryByRole('dialog')).toBeNull();
114+
});
60115
});
61116
});

‎public/pages/ReviewAndCreate/components/__tests__/ModelConfigurationFields.test.tsx

+30-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import React from 'react';
77
import chance from 'chance';
88
import userEvent from '@testing-library/user-event';
9-
import { render, waitFor } from '@testing-library/react';
9+
import { render, waitFor, fireEvent, screen, within } from '@testing-library/react';
1010
import { ModelConfigurationFields } from '../ModelConfigurationFields/ModelConfigurationFields';
1111
import {
1212
Detector,
@@ -19,13 +19,32 @@ import { DATA_TYPES } from '../../../../utils/constants';
1919
import { getRandomFeature } from '../../../../redux/reducers/__tests__/utils';
2020
import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices';
2121
import { coreServicesMock } from '../../../../../test/mocks';
22-
import { ImputationMethod } from '../../../../models/types';
22+
import {
23+
ImputationMethod,
24+
ThresholdType,
25+
Action,
26+
Operator,
27+
Rule
28+
} from '../../../../models/types';
2329

2430
const detectorFaker = new chance('seed');
2531
const features = new Array(detectorFaker.natural({ min: 1, max: 5 }))
2632
.fill(null)
2733
.map(() => getRandomFeature(false));
2834

35+
// Generate rules based on the existing features
36+
const rules = features.map((feature, index) => ({
37+
action: Action.IGNORE_ANOMALY,
38+
conditions: [
39+
{
40+
featureName: feature.featureName,
41+
thresholdType: index % 2 === 0 ? ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN : ThresholdType.EXPECTED_OVER_ACTUAL_RATIO, // Alternate threshold types for variety
42+
operator: Operator.LTE,
43+
value: index % 2 === 0 ? 5 : 0.1, // Use different values for variety
44+
},
45+
],
46+
})) as Rule[];
47+
2948
const testDetector = {
3049
id: 'test-id',
3150
name: 'test-detector',
@@ -60,13 +79,14 @@ const testDetector = {
6079
],
6180
},
6281
featureAttributes: features,
63-
imputationOption: { method: ImputationMethod.ZERO}
82+
imputationOption: { method: ImputationMethod.ZERO},
83+
rules: rules
6484
} as Detector;
6585

6686
describe('ModelConfigurationFields', () => {
6787
test('renders the component in create mode (no ID)', async () => {
6888
const onEditModelConfiguration = jest.fn();
69-
const { container, getByText, getByTestId, queryByText } = render(
89+
const { container, getByText, getByTestId, queryByText, getByRole, queryByRole } = render(
7090
<CoreServicesContext.Provider value={coreServicesMock}>
7191
<ModelConfigurationFields
7292
detector={testDetector}
@@ -80,10 +100,15 @@ describe('ModelConfigurationFields', () => {
80100
</CoreServicesContext.Provider>
81101
);
82102
expect(container.firstChild).toMatchSnapshot();
103+
getByText('set_to_zero');
104+
105+
// Check for the suppression rules button link
106+
const button = getByRole('button', { name: '2 rules' });
107+
expect(button).toBeInTheDocument();
108+
83109
userEvent.click(getByTestId('viewFeature-0'));
84110
await waitFor(() => {
85111
queryByText('max');
86-
queryByText('Zero');
87112
});
88113
});
89114
});

‎public/pages/ReviewAndCreate/components/__tests__/__snapshots__/AdditionalSettings.test.tsx.snap

+505-319
Large diffs are not rendered by default.

‎public/pages/ReviewAndCreate/components/__tests__/__snapshots__/ModelConfigurationFields.test.tsx.snap

+257-156
Large diffs are not rendered by default.

‎public/pages/ReviewAndCreate/containers/__tests__/__snapshots__/ReviewAndCreate.test.tsx.snap

+490-312
Large diffs are not rendered by default.

‎public/pages/ReviewAndCreate/utils/helpers.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { CreateDetectorFormikValues } from '../../CreateDetectorSteps/models/int
2525
import { OPERATORS_QUERY_MAP } from '../../DefineDetector/utils/whereFilters';
2626
import { convertTimestampToNumber } from '../../../utils/utils';
2727
import { CUSTOM_AD_RESULT_INDEX_PREFIX } from '../../../../server/utils/constants';
28-
import { formikToImputationOption } from '../../ConfigureModel/utils/helpers';
28+
import { formikToImputationOption, formikToRules } from '../../ConfigureModel/utils/helpers';
2929

3030
export function formikToDetector(values: CreateDetectorFormikValues): Detector {
3131
const detectionDateRange = values.historical
@@ -77,6 +77,7 @@ export function formikToDetector(values: CreateDetectorFormikValues): Detector {
7777
? values.flattenCustomResultIndex
7878
: undefined,
7979
imputationOption: formikToImputationOption(values.imputationOption),
80+
rules: formikToRules(values.suppressionRules),
8081
} as Detector;
8182

8283
// Optionally add detection date range

‎public/redux/reducers/__tests__/utils.ts

+65-4
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
Detector,
1616
FeatureAttributes,
1717
FEATURE_TYPE,
18-
FILTER_TYPES,
1918
UiMetaData,
2019
UNITS,
2120
Monitor,
@@ -24,7 +23,13 @@ import moment from 'moment';
2423
import { DETECTOR_STATE } from '../../../../server/utils/constants';
2524
import { DEFAULT_SHINGLE_SIZE } from '../../../utils/constants';
2625
import {
27-
ImputationMethod, ImputationOption,
26+
ImputationMethod,
27+
ImputationOption,
28+
Rule,
29+
Action,
30+
Condition,
31+
ThresholdType,
32+
Operator,
2833
} from '../../../models/types';
2934

3035
const detectorFaker = new chance('seed');
@@ -67,7 +72,7 @@ const randomQuery = () => {
6772
};
6873
};
6974

70-
const getUIMetadata = (features: FeatureAttributes[]) => {
75+
export const getUIMetadata = (features: FeatureAttributes[]) => {
7176
const metaFeatures = features.reduce(
7277
(acc, feature) => ({
7378
...acc,
@@ -127,7 +132,8 @@ export const getRandomDetector = (
127132
resultIndexMinSize: 51200,
128133
resultIndexTtl: 60,
129134
flattenCustomResultIndex: true,
130-
imputationOption: randomImputationOption(features)
135+
imputationOption: randomImputationOption(features),
136+
rules: randomRules(features)
131137
};
132138
};
133139

@@ -247,3 +253,58 @@ export const randomImputationOption = (features: FeatureAttributes[]): Imputatio
247253
}
248254
return options[randomIndex];
249255
};
256+
257+
// Helper function to get a random item from an array
258+
function getRandomItem<T>(items: T[]): T {
259+
return items[random(0, items.length - 1)];
260+
}
261+
262+
// Helper function to generate a random value (for simplicity, let's use a range of 0 to 100)
263+
function getRandomValue(): number {
264+
return random(0, 100, true); // Generates a random float between 0 and 100
265+
}
266+
267+
export const randomRules = (features: FeatureAttributes[]): Rule[] | undefined => {
268+
// If there are no features, return undefined
269+
if (features.length === 0) {
270+
return undefined;
271+
}
272+
273+
const rules: Rule[] = [];
274+
275+
// Generate a random number of rules (between 1 and 3 for testing)
276+
const numberOfRules = random(1, 3);
277+
278+
for (let i = 0; i < numberOfRules; i++) {
279+
// Random action
280+
const action = Action.IGNORE_ANOMALY;
281+
282+
// Generate a random number of conditions (between 1 and 2 for testing)
283+
const numberOfConditions = random(1, 2);
284+
const conditions: Condition[] = [];
285+
286+
for (let j = 0; j < numberOfConditions; j++) {
287+
const featureName = getRandomItem(features.map((f) => f.featureName));
288+
const thresholdType = getRandomItem(Object.values(ThresholdType));
289+
const operator = getRandomItem(Object.values(Operator));
290+
const value = getRandomValue();
291+
292+
conditions.push({
293+
featureName,
294+
thresholdType,
295+
operator,
296+
value,
297+
});
298+
}
299+
300+
// Create the rule with the generated action and conditions
301+
rules.push({
302+
action,
303+
conditions,
304+
});
305+
}
306+
307+
// Randomly decide whether to return undefined or the generated rules
308+
const shouldReturnUndefined = random(0, 1) === 0;
309+
return shouldReturnUndefined ? undefined : rules;
310+
};

‎public/utils/utils.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ export const validatePositiveInteger = (value: any) => {
110110
return 'Must be a positive integer';
111111
};
112112

113+
// Validation function for positive decimal numbers
114+
export function validatePositiveDecimal(value: any) {
115+
// Allow empty, NaN, or non-number values without showing an error
116+
if (value === '' || value === null || isNaN(value) || typeof value !== 'number') {
117+
return undefined; // No error for empty, NaN, or non-number values
118+
}
119+
120+
// Validate that the value is a positive number greater than zero
121+
if (value <= 0) {
122+
return 'Must be a positive number greater than zero';
123+
}
124+
125+
return undefined; // No error if the value is valid
126+
}
127+
113128
export const validateEmptyOrPositiveInteger = (value: any) => {
114129
if (Number.isInteger(value) && value < 1)
115130
return 'Must be a positive integer';

0 commit comments

Comments
 (0)
Please sign in to comment.