From 9874c48773ffcc1a8e76ccd4a0680ecd6a2f82f6 Mon Sep 17 00:00:00 2001 From: Kaituo Li Date: Mon, 2 Sep 2024 23:38:50 -0700 Subject: [PATCH] Add Suppression Anomaly Rules in Advanced Settings (#859) 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 --- .../workflows/remote-integ-tests-workflow.yml | 5 +- public/models/interfaces.ts | 6 +- public/models/types.ts | 84 + .../AdvancedSettings/AdvancedSettings.tsx | 354 +++- .../__tests__/AdvancedSettings.test.tsx | 74 + .../containers/ConfigureModel.tsx | 48 +- .../pages/ConfigureModel/models/interfaces.ts | 8 + .../utils/__tests__/helpers.test.tsx | 19 +- public/pages/ConfigureModel/utils/helpers.ts | 148 ++ .../pages/DefineDetector/utils/constants.tsx | 2 - .../AdditionalSettings/AdditionalSettings.tsx | 51 +- .../DetectorConfig/containers/Features.tsx | 3 +- .../__tests__/DetectorConfig.test.tsx | 110 +- .../DetectorConfig.test.tsx.snap | 1563 ++++++++++++++++- .../AdditionalSettings/AdditionalSettings.tsx | 56 +- .../SuppressionRulesModal.tsx | 30 + .../ModelConfigurationFields.tsx | 2 + .../__tests__/AdditionalSettings.test.tsx | 97 +- .../ModelConfigurationFields.test.tsx | 35 +- .../AdditionalSettings.test.tsx.snap | 824 +++++---- .../ModelConfigurationFields.test.tsx.snap | 413 +++-- .../ReviewAndCreate.test.tsx.snap | 802 +++++---- public/pages/ReviewAndCreate/utils/helpers.ts | 3 +- public/redux/reducers/__tests__/utils.ts | 69 +- public/utils/utils.tsx | 15 + 25 files changed, 3901 insertions(+), 920 deletions(-) create mode 100644 public/pages/ConfigureModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx create mode 100644 public/pages/ReviewAndCreate/components/AdditionalSettings/SuppressionRulesModal.tsx diff --git a/.github/workflows/remote-integ-tests-workflow.yml b/.github/workflows/remote-integ-tests-workflow.yml index 05b96d1f..31f56a6c 100644 --- a/.github/workflows/remote-integ-tests-workflow.yml +++ b/.github/workflows/remote-integ-tests-workflow.yml @@ -151,10 +151,7 @@ jobs: - name: Run spec files from output run: | - for i in $FILELIST; do - yarn cypress:run-without-security --browser electron --spec "${i}" - sleep 60 - done + env CYPRESS_NO_COMMAND_LOG=1 yarn cypress:run-without-security --browser chromium --spec 'cypress/integration/plugins/anomaly-detection-dashboards-plugin/*' working-directory: opensearch-dashboards-functional-test - name: Capture failure screenshots diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts index f6dfc651..74a0a1e6 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -15,7 +15,10 @@ import { DETECTOR_STATE } from '../../server/utils/constants'; import { Duration } from 'moment'; import moment from 'moment'; import { MDSQueryParams } from '../../server/models/types'; -import { ImputationOption } from './types'; +import { + ImputationOption, + Rule +} from './types'; export type FieldInfo = { label: string; @@ -212,6 +215,7 @@ export type Detector = { taskProgress?: number; taskError?: string; imputationOption?: ImputationOption; + rules?: Rule[]; }; export type DetectorListItem = { diff --git a/public/models/types.ts b/public/models/types.ts index 6d559276..866396cf 100644 --- a/public/models/types.ts +++ b/public/models/types.ts @@ -33,3 +33,87 @@ export enum ImputationMethod { PREVIOUS = 'PREVIOUS', } +// Constants for field names +export const RULES_FIELD = "rules"; +export const ACTION_FIELD = "action"; +export const CONDITIONS_FIELD = "conditions"; +export const FEATURE_NAME_FIELD = "feature_name"; +export const THRESHOLD_TYPE_FIELD = "threshold_type"; +export const OPERATOR_FIELD = "operator"; +export const VALUE_FIELD = "value"; + +// Enums +export enum Action { + IGNORE_ANOMALY = "IGNORE_ANOMALY", // ignore anomaly if found +} + +export enum ThresholdType { + /** + * Specifies a threshold for ignoring anomalies where the actual value + * exceeds the expected value by a certain margin. + * + * Assume a represents the actual value and b signifies the expected value. + * IGNORE_SIMILAR_FROM_ABOVE implies the anomaly should be disregarded if a-b + * is less than or equal to ignoreSimilarFromAbove. + */ + ACTUAL_OVER_EXPECTED_MARGIN = "ACTUAL_OVER_EXPECTED_MARGIN", + + /** + * Specifies a threshold for ignoring anomalies where the actual value + * is below the expected value by a certain margin. + * + * Assume a represents the actual value and b signifies the expected value. + * Likewise, IGNORE_SIMILAR_FROM_BELOW + * implies the anomaly should be disregarded if b-a is less than or equal to + * ignoreSimilarFromBelow. + */ + EXPECTED_OVER_ACTUAL_MARGIN = "EXPECTED_OVER_ACTUAL_MARGIN", + + /** + * Specifies a threshold for ignoring anomalies based on the ratio of + * the difference to the actual value when the actual value exceeds + * the expected value. + * + * Assume a represents the actual value and b signifies the expected value. + * The variable IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO presumably implies the + * anomaly should be disregarded if the ratio of the deviation from the actual + * to the expected (a-b)/|a| is less than or equal to IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO. + */ + ACTUAL_OVER_EXPECTED_RATIO = "ACTUAL_OVER_EXPECTED_RATIO", + + /** + * Specifies a threshold for ignoring anomalies based on the ratio of + * the difference to the actual value when the actual value is below + * the expected value. + * + * Assume a represents the actual value and b signifies the expected value. + * Likewise, IGNORE_NEAR_EXPECTED_FROM_BELOW_BY_RATIO appears to indicate that the anomaly + * should be ignored if the ratio of the deviation from the expected to the actual + * (b-a)/|a| is less than or equal to ignoreNearExpectedFromBelowByRatio. + */ + EXPECTED_OVER_ACTUAL_RATIO = "EXPECTED_OVER_ACTUAL_RATIO", +} + +// Method to get the description of ThresholdType +export function getThresholdTypeDescription(thresholdType: ThresholdType): string { + return thresholdType; // In TypeScript, the enum itself holds the description. +} + +// Enums for Operators +export enum Operator { + LTE = "LTE", +} + +// Interfaces for Rule and Condition +export interface Rule { + action: Action; + conditions: Condition[]; +} + +export interface Condition { + featureName: string; + thresholdType: ThresholdType; + operator: Operator; + value: number; +} + diff --git a/public/pages/ConfigureModel/components/AdvancedSettings/AdvancedSettings.tsx b/public/pages/ConfigureModel/components/AdvancedSettings/AdvancedSettings.tsx index 0ccac512..3cae536e 100644 --- a/public/pages/ConfigureModel/components/AdvancedSettings/AdvancedSettings.tsx +++ b/public/pages/ConfigureModel/components/AdvancedSettings/AdvancedSettings.tsx @@ -20,8 +20,9 @@ import { EuiCompressedSelect, EuiButtonIcon, EuiCompressedFieldText, + EuiToolTip, } from '@elastic/eui'; -import { Field, FieldProps, FieldArray, } from 'formik'; +import { Field, FieldProps, FieldArray } from 'formik'; import React, { useEffect, useState } from 'react'; import ContentPanel from '../../../../components/ContentPanel/ContentPanel'; import { BASE_DOCS_LINK } from '../../../../utils/constants'; @@ -29,6 +30,7 @@ import { isInvalid, getError, validatePositiveInteger, + validatePositiveDecimal, } from '../../../../utils/utils'; import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow'; import { SparseDataOptionValue } from '../../utils/constants'; @@ -47,6 +49,46 @@ export function AdvancedSettings(props: AdvancedSettingsProps) { { value: SparseDataOptionValue.CUSTOM_VALUE, text: 'Custom value' }, ]; + const aboveBelowOptions = [ + { value: 'above', text: 'above' }, + { value: 'below', text: 'below' }, + ]; + + function extractArrayError(fieldName: string, form: any): string { + const error = form.errors[fieldName]; + console.log('Error for field:', fieldName, error); // Log the error for debugging + + // Check if the error is an array with objects inside + if (Array.isArray(error) && error.length > 0) { + // Iterate through the array to find the first non-empty error message + for (const err of error) { + if (typeof err === 'object' && err !== null) { + const entry = Object.entries(err).find( + ([_, fieldError]) => fieldError + ); // Find the first entry with a non-empty error message + if (entry) { + const [fieldKey, fieldError] = entry; + + // Replace fieldKey with a more user-friendly name if it matches specific fields + const friendlyFieldName = + fieldKey === 'absoluteThreshold' + ? 'absolute threshold' + : fieldKey === 'relativeThreshold' + ? 'relative threshold' + : fieldKey; // Use the original fieldKey if no match + + return typeof fieldError === 'string' + ? `${friendlyFieldName} ${fieldError.toLowerCase()}` // Format the error message with the friendly field name + : String(fieldError || ''); + } + } + } + } + + // Default case to handle other types of errors + return typeof error === 'string' ? error : String(error || ''); + } + return ( - - - + <> + + + - {/* Conditionally render the "Custom value" title and the input fields when 'Custom value' is selected */} - {field.value === SparseDataOptionValue.CUSTOM_VALUE && ( - <> - - -
Custom value
-
- - - {(arrayHelpers) => ( - <> - {form.values.imputationOption.custom_value?.map((_, index) => ( - - - + + +
Custom value
+
+ + + {(arrayHelpers) => ( + <> + {form.values.imputationOption.custom_value?.map( + (_, index) => ( + - {({ field }: FieldProps) => ( - + + {({ field }: FieldProps) => ( + + )} + +
+ + + {/* the value is set to field.value || '' to avoid displaying 0 as a default value. */} + {({ field, form }: FieldProps) => ( + + )} + + + + arrayHelpers.remove(index)} /> - )} - - - - - {/* the value is set to field.value || '' to avoid displaying 0 as a default value. */ } - {({ field, form }: FieldProps) => ( - - )} - - - - arrayHelpers.remove(index)} - /> - -
- ))} - - { /* add new rows with empty values when the add button is clicked. */} - - arrayHelpers.push({ featureName: '', value: 0 }) - } - aria-label="Add row" - /> - - )} -
+ + + ) + )} + + {/* add new rows with empty values when the add button is clicked. */} + + arrayHelpers.push({ featureName: '', value: 0 }) + } + aria-label="Add row" + /> + + )} + )} ); }} + + + + {(arrayHelpers) => ( + <> + + {({ field, form }: FieldProps) => ( + <> + + {/* Controls the width of the whole row as FormattedFormRow does not allow that. Otherwise, our row is too packed. */} + + + <> + {form.values.suppressionRules?.map( + (rule, index) => ( + + + + Ignore anomalies for the feature + + + + + {({ field }: FieldProps) => ( + + )} + + + + + when the actual value is no more than + + + + + + {({ field }: FieldProps) => ( + + )} + + + + + or + + + + + {({ field }: FieldProps) => ( +
+ + % +
+ )} +
+
+
+ + + + {({ field }: FieldProps) => ( + + )} + + + + + + the expected value. + + + + + arrayHelpers.remove(index) + } + /> + +
+ ) + )} + +
+
+
+ + )} +
+ + + arrayHelpers.push({ + fieldName: '', + absoluteThreshold: null, // Set to null to allow empty inputs + relativeThreshold: null, // Set to null to allow empty inputs + aboveBelow: 'above', + }) + } + aria-label="Add rule" + /> + + )} +
) : null}
diff --git a/public/pages/ConfigureModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx b/public/pages/ConfigureModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx new file mode 100644 index 00000000..0d4853ff --- /dev/null +++ b/public/pages/ConfigureModel/components/AdvancedSettings/__tests__/AdvancedSettings.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Formik } from 'formik'; +import { AdvancedSettings } from '../AdvancedSettings'; // Adjust the path as necessary + +describe('AdvancedSettings Component', () => { + test('displays error when -1 is entered in suppression rules absolute threshold', async () => { + render( + + {() => } + + ); + + // Open the advanced settings + userEvent.click(screen.getByText('Show')); + + screen.logTestingPlaygroundURL(); + + // Click to add a new suppression rule + const addButton = screen.getByRole('button', { name: /add rule/i }); + fireEvent.click(addButton); + + // Find the absolute threshold input and type -1 + const absoluteThresholdInput = screen.getAllByPlaceholderText('Absolute')[0]; // Select the first absolute threshold input + userEvent.type(absoluteThresholdInput, '-1'); + + // Trigger validation + fireEvent.blur(absoluteThresholdInput); + + // Wait for the error message to appear + await waitFor(() => { + expect(screen.getByText('absolute threshold must be a positive number greater than zero')).toBeInTheDocument(); + }); + }); + test('displays error when -1 is entered in suppression rules relative threshold', async () => { + render( + + {() => } + + ); + + // Open the advanced settings + userEvent.click(screen.getByText('Show')); + + screen.logTestingPlaygroundURL(); + + // Click to add a new suppression rule + const addButton = screen.getByRole('button', { name: /add rule/i }); + fireEvent.click(addButton); + + // Find the relative threshold input and type -1 + const relativeThresholdInput = screen.getAllByPlaceholderText('Relative')[0]; // Select the first absolute threshold input + userEvent.type(relativeThresholdInput, '-1'); + + // Trigger validation + fireEvent.blur(relativeThresholdInput); + + // Wait for the error message to appear + await waitFor(() => { + expect(screen.getByText('relative threshold must be a positive number greater than zero')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/pages/ConfigureModel/containers/ConfigureModel.tsx b/public/pages/ConfigureModel/containers/ConfigureModel.tsx index 8b31ae40..cfd18338 100644 --- a/public/pages/ConfigureModel/containers/ConfigureModel.tsx +++ b/public/pages/ConfigureModel/containers/ConfigureModel.tsx @@ -21,7 +21,6 @@ import { EuiSpacer, EuiText, EuiLink, - EuiIcon, } from '@elastic/eui'; import { FormikProps, Formik } from 'formik'; import { get, isEmpty } from 'lodash'; @@ -41,6 +40,7 @@ import { focusOnCategoryField, modelConfigurationToFormik, focusOnImputationOption, + focusOnSuppressionRules, } from '../utils/helpers'; import { formikToDetector } from '../../ReviewAndCreate/utils/helpers'; import { formikToModelConfiguration } from '../utils/helpers'; @@ -53,7 +53,11 @@ import { CoreServicesContext } from '../../../components/CoreServices/CoreServic import { Detector } from '../../../models/interfaces'; import { prettifyErrorMessage } from '../../../../server/utils/helpers'; import { DetectorDefinitionFormikValues } from '../../DefineDetector/models/interfaces'; -import { ModelConfigurationFormikValues, FeaturesFormikValues } from '../models/interfaces'; +import { + ModelConfigurationFormikValues, + FeaturesFormikValues, + RuleFormikValues +} from '../models/interfaces'; import { CreateDetectorFormikValues } from '../../CreateDetectorSteps/models/interfaces'; import { DETECTOR_STATE } from '../../../../server/utils/constants'; import { getErrorMessage } from '../../../utils/utils'; @@ -217,6 +221,35 @@ export function ConfigureModel(props: ConfigureModelProps) { } }; + const validateRules = ( + formikValues: ModelConfigurationFormikValues, + errors: any + ) => { + const rules = formikValues.suppressionRules || []; + + // Initialize an array to hold individual error messages + const featureNameErrors: string[] = []; + + // List of enabled features + const enabledFeatures = formikValues.featureList + .filter((feature: FeaturesFormikValues) => feature.featureEnabled) + .map((feature: FeaturesFormikValues) => feature.featureName); + + // Validate that each featureName in suppressionRules exists in enabledFeatures + rules.forEach((rule: RuleFormikValues) => { + if (!enabledFeatures.includes(rule.featureName)) { + featureNameErrors.push( + `Feature "${rule.featureName}" in suppression rules does not exist or is not enabled in the feature list.` + ); + } + }); + + // If there are any custom value errors, join them into a single string with proper formatting + if (featureNameErrors.length > 0) { + errors.suppressionRules = featureNameErrors.join(' '); + } + }; + const handleFormValidation = async ( formikProps: FormikProps ) => { @@ -230,10 +263,12 @@ export function ConfigureModel(props: ConfigureModelProps) { formikProps.setFieldTouched('categoryField', isHCDetector); formikProps.setFieldTouched('shingleSize'); formikProps.setFieldTouched('imputationOption'); + formikProps.setFieldTouched('suppressionRules'); formikProps.validateForm().then((errors) => { // Call the extracted validation method validateImputationOption(formikProps.values, errors); + validateRules(formikProps.values, errors); if (isEmpty(errors)) { if (props.isEdit) { @@ -262,6 +297,15 @@ export function ConfigureModel(props: ConfigureModelProps) { return; } + const ruleValueError = get(errors, 'suppressionRules') + if (ruleValueError) { + core.notifications.toasts.addDanger( + ruleValueError + ); + focusOnSuppressionRules(); + return; + } + // TODO: can add focus to all components or possibly customize error message too if (get(errors, 'featureList')) { focusOnFirstWrongFeature(errors, formikProps.setFieldTouched); diff --git a/public/pages/ConfigureModel/models/interfaces.ts b/public/pages/ConfigureModel/models/interfaces.ts index f7575bc4..9bf2a496 100644 --- a/public/pages/ConfigureModel/models/interfaces.ts +++ b/public/pages/ConfigureModel/models/interfaces.ts @@ -19,6 +19,7 @@ export interface ModelConfigurationFormikValues { categoryField: string[]; shingleSize: number; imputationOption?: ImputationFormikValues; + suppressionRules?: RuleFormikValues[]; } export interface FeaturesFormikValues { @@ -41,3 +42,10 @@ export interface CustomValueFormikValues { featureName: string; data: number; } + +export interface RuleFormikValues { + featureName: string; + absoluteThreshold?: number; + relativeThreshold?: number; + aboveBelow: string; +} diff --git a/public/pages/ConfigureModel/utils/__tests__/helpers.test.tsx b/public/pages/ConfigureModel/utils/__tests__/helpers.test.tsx index 49b19750..0f8798c0 100644 --- a/public/pages/ConfigureModel/utils/__tests__/helpers.test.tsx +++ b/public/pages/ConfigureModel/utils/__tests__/helpers.test.tsx @@ -13,7 +13,7 @@ import { getRandomDetector } from '../../../../redux/reducers/__tests__/utils'; import { prepareDetector } from '../../utils/helpers'; import { FEATURE_TYPE } from '../../../../models/interfaces'; import { FeaturesFormikValues } from '../../models/interfaces'; -import { modelConfigurationToFormik } from '../helpers'; +import { modelConfigurationToFormik, rulesToFormik } from '../helpers'; import { SparseDataOptionValue } from '../constants'; import { ImputationMethod } from '../../../../models/types'; @@ -127,4 +127,21 @@ describe('featuresToFormik', () => { ); } }); + test('should return correct rules', () => { + const randomDetector = getRandomDetector(); // Generate a random detector object for testing + const adFormikValues = modelConfigurationToFormik(randomDetector); // Convert detector to Formik values + + const rules = randomDetector.rules; // Get the rules from the detector + + if (rules) { + // If rules exist, convert them to formik format using rulesToFormik + const expectedFormikRules = rulesToFormik(rules); // Convert rules to Formik-compatible format + + // Compare the converted rules with the suppressionRules in Formik values + expect(adFormikValues.suppressionRules).toEqual(expectedFormikRules); + } else { + // If no rules exist, suppressionRules should be undefined + expect(adFormikValues.suppressionRules).toEqual([]); + } + }); }); diff --git a/public/pages/ConfigureModel/utils/helpers.ts b/public/pages/ConfigureModel/utils/helpers.ts index 73e4ce78..c88c135e 100644 --- a/public/pages/ConfigureModel/utils/helpers.ts +++ b/public/pages/ConfigureModel/utils/helpers.ts @@ -23,6 +23,7 @@ import { FeaturesFormikValues, CustomValueFormikValues, ImputationFormikValues, + RuleFormikValues, } from '../../ConfigureModel/models/interfaces'; import { INITIAL_MODEL_CONFIGURATION_VALUES } from '../../ConfigureModel/utils/constants'; import { @@ -32,6 +33,11 @@ import { import { ImputationMethod, ImputationOption, + Condition, + Rule, + ThresholdType, + Operator, + Action, } from '../../../models/types'; import { SparseDataOptionValue @@ -218,6 +224,11 @@ export const focusOnImputationOption = () => { component?.focus(); }; +export const focusOnSuppressionRules = () => { + const component = document.getElementById('suppressionRules'); + component?.focus(); +}; + export const getShingleSizeFromObject = (obj: object) => { return get(obj, 'shingleSize', DEFAULT_SHINGLE_SIZE); }; @@ -269,6 +280,7 @@ export function modelConfigurationToFormik( categoryField: get(detector, 'categoryField', []), shingleSize: get(detector, 'shingleSize', DEFAULT_SHINGLE_SIZE), imputationOption: imputationFormikValues, + suppressionRules: rulesToFormik(detector.rules), }; } @@ -317,6 +329,7 @@ export function formikToModelConfiguration( ? values.categoryField : undefined, imputationOption: formikToImputationOption(values.imputationOption), + rules: formikToRules(values.suppressionRules), } as Detector; return detectorBody; @@ -425,3 +438,138 @@ export const getCustomValueStrArray = (imputationMethodStr : string, detector: D } return [] } + +export const getSuppressionRulesArray = (detector: Detector): string[] => { + if (!detector.rules || detector.rules.length === 0) { + return []; // Return an empty array if there are no rules + } + + return detector.rules.flatMap((rule) => { + // Convert each condition to a readable string + return rule.conditions.map((condition) => { + const featureName = condition.featureName; + const thresholdType = condition.thresholdType; + let value = condition.value; + const isPercentage = thresholdType === ThresholdType.ACTUAL_OVER_EXPECTED_RATIO || thresholdType === ThresholdType.EXPECTED_OVER_ACTUAL_RATIO; + + // If it is a percentage, multiply by 100 + if (isPercentage) { + value *= 100; + } + + // Determine whether it is "above" or "below" based on ThresholdType + const aboveOrBelow = thresholdType === ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN || thresholdType === ThresholdType.ACTUAL_OVER_EXPECTED_RATIO ? 'above' : 'below'; + + // Construct the formatted string + return `Ignore anomalies for feature "${featureName}" with no more than ${value}${isPercentage ? '%' : ''} ${aboveOrBelow} expected value.`; + }); + }); +}; + + +// Convert RuleFormikValues[] to Rule[] +export const formikToRules = (formikValues?: RuleFormikValues[]): Rule[] | undefined => { + if (!formikValues || formikValues.length === 0) { + return undefined; // Return undefined for undefined or empty input + } + + return formikValues.map((formikValue) => { + const conditions: Condition[] = []; + + // Determine the threshold type based on aboveBelow and the threshold type (absolute or relative) + const getThresholdType = (aboveBelow: string, isAbsolute: boolean): ThresholdType => { + if (isAbsolute) { + return aboveBelow === 'above' + ? ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN + : ThresholdType.EXPECTED_OVER_ACTUAL_MARGIN; + } else { + return aboveBelow === 'above' + ? ThresholdType.ACTUAL_OVER_EXPECTED_RATIO + : ThresholdType.EXPECTED_OVER_ACTUAL_RATIO; + } + }; + + // Check if absoluteThreshold is provided, create a condition + if (formikValue.absoluteThreshold !== undefined && formikValue.absoluteThreshold !== 0 && formikValue.absoluteThreshold !== null + && typeof formikValue.absoluteThreshold === 'number' && // Check if it's a number + !isNaN(formikValue.absoluteThreshold) && // Ensure it's not NaN + formikValue.absoluteThreshold > 0 // Check if it's positive + ) { + conditions.push({ + featureName: formikValue.featureName, + thresholdType: getThresholdType(formikValue.aboveBelow, true), + operator: Operator.LTE, + value: formikValue.absoluteThreshold, + }); + } + + // Check if relativeThreshold is provided, create a condition + if (formikValue.relativeThreshold !== undefined && formikValue.relativeThreshold !== 0 && formikValue.relativeThreshold !== null + && typeof formikValue.relativeThreshold === 'number' && // Check if it's a number + !isNaN(formikValue.relativeThreshold) && // Ensure it's not NaN + formikValue.relativeThreshold > 0 // Check if it's positive + ) { + conditions.push({ + featureName: formikValue.featureName, + thresholdType: getThresholdType(formikValue.aboveBelow, false), + operator: Operator.LTE, + value: formikValue.relativeThreshold / 100, // Convert percentage to decimal, + }); + } + + return { + action: Action.IGNORE_ANOMALY, + conditions, + }; + }); +}; + +// Convert Rule[] to RuleFormikValues[] +export const rulesToFormik = (rules?: Rule[]): RuleFormikValues[] => { + if (!rules || rules.length === 0) { + return []; // Return empty array for undefined or empty input + } + + return rules.map((rule) => { + // Start with default values + const formikValue: RuleFormikValues = { + featureName: '', + absoluteThreshold: undefined, + relativeThreshold: undefined, + aboveBelow: 'above', // Default to 'above', adjust as needed + }; + + // Loop through conditions to populate formikValue + rule.conditions.forEach((condition) => { + formikValue.featureName = condition.featureName; + + // Determine the value and type of threshold + switch (condition.thresholdType) { + case ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN: + formikValue.absoluteThreshold = condition.value; + formikValue.aboveBelow = 'above'; + break; + case ThresholdType.EXPECTED_OVER_ACTUAL_MARGIN: + formikValue.absoluteThreshold = condition.value; + formikValue.aboveBelow = 'below'; + break; + case ThresholdType.ACTUAL_OVER_EXPECTED_RATIO: + // *100 to convert to percentage + formikValue.relativeThreshold = condition.value * 100; + formikValue.aboveBelow = 'above'; + break; + case ThresholdType.EXPECTED_OVER_ACTUAL_RATIO: + // *100 to convert to percentage + formikValue.relativeThreshold = condition.value * 100; + formikValue.aboveBelow = 'below'; + break; + default: + break; + } + }); + + return formikValue; + }); +}; + + diff --git a/public/pages/DefineDetector/utils/constants.tsx b/public/pages/DefineDetector/utils/constants.tsx index cb64ce1d..d6ef4dff 100644 --- a/public/pages/DefineDetector/utils/constants.tsx +++ b/public/pages/DefineDetector/utils/constants.tsx @@ -48,6 +48,4 @@ export const INITIAL_DETECTOR_DEFINITION_VALUES: DetectorDefinitionFormikValues resultIndexMinSize: 51200, resultIndexTtl: 60, flattenCustomResultIndex: false, - imputationMethod: undefined, - customImputationValue: undefined }; diff --git a/public/pages/DetectorConfig/components/AdditionalSettings/AdditionalSettings.tsx b/public/pages/DetectorConfig/components/AdditionalSettings/AdditionalSettings.tsx index 18a322d5..ece8466c 100644 --- a/public/pages/DetectorConfig/components/AdditionalSettings/AdditionalSettings.tsx +++ b/public/pages/DetectorConfig/components/AdditionalSettings/AdditionalSettings.tsx @@ -9,27 +9,58 @@ * GitHub history for details. */ -import React from 'react'; +import React, { useState } from 'react'; import { get, isEmpty } from 'lodash'; -import { EuiBasicTable } from '@elastic/eui'; +import { + EuiBasicTable, + EuiButtonEmpty, + EuiOverlayMask +} from '@elastic/eui'; import ContentPanel from '../../../../components/ContentPanel/ContentPanel'; import { convertToCategoryFieldString } from '../../../utils/anomalyResultUtils'; +import { SuppressionRulesModal } from '../../../ReviewAndCreate/components/AdditionalSettings/SuppressionRulesModal'; interface AdditionalSettingsProps { shingleSize: number; categoryField: string[]; imputationMethod: string; customValues: string[]; + suppressionRules: string[]; } export function AdditionalSettings(props: AdditionalSettingsProps) { const renderCustomValues = (customValues: string[]) => (
- {customValues.map((value, index) => ( -

{value}

- ))} + {customValues.length > 0 ? ( + customValues.map((value, index) =>

{value}

) + ) : ( +

-

+ )}
); + + const [isModalVisible, setIsModalVisible] = useState(false); + const [modalContent, setModalContent] = useState([]); + + const closeModal = () => setIsModalVisible(false); + + const showRulesInModal = (rules: string[]) => { + setModalContent(rules); + setIsModalVisible(true); + }; + + const renderSuppressionRules = (suppressionRules: string[]) => ( +
+ {suppressionRules.length > 0 ? ( + showRulesInModal(suppressionRules)}> + {suppressionRules.length} rules + + ) : ( +

-

+ )} +
+ ); + const tableItems = [ { categoryField: isEmpty(get(props, 'categoryField', [])) @@ -38,6 +69,7 @@ export function AdditionalSettings(props: AdditionalSettingsProps) { shingleSize: props.shingleSize, imputationMethod: props.imputationMethod, customValues: props.customValues, + suppresionRules: props.suppressionRules, }, ]; const tableColumns = [ @@ -48,6 +80,10 @@ export function AdditionalSettings(props: AdditionalSettingsProps) { field: 'customValues', render: (customValues: string[]) => renderCustomValues(customValues), // Use a custom render function }, + { name: 'Suppression rules', + field: 'suppresionRules', + render: (suppresionRules: string[]) => renderSuppressionRules(suppresionRules), // Use a custom render function + }, ]; return ( @@ -56,6 +92,11 @@ export function AdditionalSettings(props: AdditionalSettingsProps) { items={tableItems} columns={tableColumns} /> + {isModalVisible && ( + + + + )} ); } diff --git a/public/pages/DetectorConfig/containers/Features.tsx b/public/pages/DetectorConfig/containers/Features.tsx index 2c5c6f8b..10897431 100644 --- a/public/pages/DetectorConfig/containers/Features.tsx +++ b/public/pages/DetectorConfig/containers/Features.tsx @@ -14,7 +14,6 @@ import { EuiBasicTable, EuiText, EuiLink, - EuiIcon, EuiSmallButton, EuiEmptyPrompt, EuiSpacer, @@ -34,6 +33,7 @@ import { getShingleSizeFromObject, imputationMethodToFormik, getCustomValueStrArray, + getSuppressionRulesArray, } from '../../ConfigureModel/utils/helpers'; interface FeaturesProps { @@ -256,6 +256,7 @@ export const Features = (props: FeaturesProps) => { imputationMethodStr, props.detector )} + suppressionRules={getSuppressionRulesArray(props.detector)} /> )} diff --git a/public/pages/DetectorConfig/containers/__tests__/DetectorConfig.test.tsx b/public/pages/DetectorConfig/containers/__tests__/DetectorConfig.test.tsx index b57721ff..e1a2cada 100644 --- a/public/pages/DetectorConfig/containers/__tests__/DetectorConfig.test.tsx +++ b/public/pages/DetectorConfig/containers/__tests__/DetectorConfig.test.tsx @@ -28,17 +28,24 @@ import { UiFeature, FeatureAttributes, OPERATORS_MAP, + UNITS, } from '../../../../models/interfaces'; import { getRandomDetector, - randomFixedValue, + getUIMetadata, } from '../../../../redux/reducers/__tests__/utils'; import { coreServicesMock } from '../../../../../test/mocks'; import { toStringConfigCell } from '../../../ReviewAndCreate/utils/helpers'; import { DATA_TYPES } from '../../../../utils/constants'; import { mockedStore, initialState } from '../../../../redux/utils/testUtils'; import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices'; -import { ImputationMethod } from '../../../../models/types'; +import { + ImputationMethod, + Action, + ThresholdType, + Operator, +} from '../../../../models/types'; +import { DETECTOR_STATE } from '../../../../../server/utils/constants'; const renderWithRouter = (detector: Detector) => ({ ...render( @@ -143,6 +150,25 @@ describe(' spec', () => { const randomDetector = { ...getRandomDetector(false), imputationOption: { method: ImputationMethod.PREVIOUS }, + rules: [ + { + action: Action.IGNORE_ANOMALY, + conditions: [ + { + featureName: 'value', // Matches a feature in featureAttributes + thresholdType: ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN, + operator: Operator.LTE, + value: 5, + }, + { + featureName: 'value2', // Matches another feature in featureAttributes + thresholdType: ThresholdType.EXPECTED_OVER_ACTUAL_RATIO, + operator: Operator.LTE, + value: 10, + }, + ], + }, + ], }; const { container } = renderWithRouter(randomDetector); expect(container.firstChild).toMatchSnapshot(); @@ -362,8 +388,88 @@ describe(' spec', () => { }, } as UiMetaData, imputationOption: imputationOption, + rules: [ + { + action: Action.IGNORE_ANOMALY, + conditions: [ + { + featureName: 'value', // Matches a feature in featureAttributes + thresholdType: ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN, + operator: Operator.LTE, + value: 5, + }, + { + featureName: 'value2', // Matches another feature in featureAttributes + thresholdType: ThresholdType.EXPECTED_OVER_ACTUAL_RATIO, + operator: Operator.LTE, + value: 10, + }, + ], + }, + ], }; const { container } = renderWithRouter(randomDetector); expect(container.firstChild).toMatchSnapshot(); }); + test('renders rules', () => { + // Define example features + const features = [ + { + featureName: 'value', + featureEnabled: true, + aggregationQuery: featureQuery1, + }, + { + featureName: 'value2', + featureEnabled: true, + aggregationQuery: featureQuery2, + }, + { + featureName: 'value', + featureEnabled: false, + }, + ] as FeatureAttributes[]; + + // Updated example detector + const testDetector: Detector = { + primaryTerm: 1, + seqNo: 1, + id: 'detector-1', + name: 'Sample Detector', + description: 'A sample detector for testing', + timeField: 'timestamp', + indices: ['index1'], + filterQuery: {}, + featureAttributes: features, // Using the provided features + windowDelay: { period: { interval: 1, unit: UNITS.MINUTES } }, + detectionInterval: { period: { interval: 1, unit: UNITS.MINUTES } }, + shingleSize: 8, + lastUpdateTime: 1586823218000, + curState: DETECTOR_STATE.RUNNING, + stateError: '', + uiMetadata: getUIMetadata(features), + rules: [ + { + action: Action.IGNORE_ANOMALY, + conditions: [ + { + featureName: 'value', // Matches a feature in featureAttributes + thresholdType: ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN, + operator: Operator.LTE, + value: 5, + }, + { + featureName: 'value2', // Matches another feature in featureAttributes + thresholdType: ThresholdType.EXPECTED_OVER_ACTUAL_RATIO, + operator: Operator.LTE, + value: 10, + }, + ], + }, + ], + }; + + const { container } = renderWithRouter(testDetector); + expect(container.firstChild).toMatchSnapshot(); + }); }); diff --git a/public/pages/DetectorConfig/containers/__tests__/__snapshots__/DetectorConfig.test.tsx.snap b/public/pages/DetectorConfig/containers/__tests__/__snapshots__/DetectorConfig.test.tsx.snap index 46363942..23c72f8d 100644 --- a/public/pages/DetectorConfig/containers/__tests__/__snapshots__/DetectorConfig.test.tsx.snap +++ b/public/pages/DetectorConfig/containers/__tests__/__snapshots__/DetectorConfig.test.tsx.snap @@ -1,5 +1,1465 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` spec renders rules 1`] = ` +
+
+
+
+
+
+

+ Detector settings +

+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

+ Sample Detector +

+
+
+
+
+
+
+
+ +
+
+
+

+ index1 +

+
+
+
+
+
+
+
+ +
+
+
+
+

+ Custom expression: + + +

+
+
+
+
+
+
+
+
+ +
+
+
+

+ 1 Minutes +

+
+
+
+
+
+
+
+ +
+
+
+

+ detector-1 +

+
+
+
+
+
+
+
+ +
+
+
+

+ A sample detector for testing +

+
+
+
+
+
+
+
+ +
+
+
+

+ timestamp +

+
+
+
+
+
+
+
+ +
+
+
+

+ 04/14/20 12:13 AM +

+
+
+
+
+
+
+
+ +
+
+
+

+ 1 Minutes +

+
+
+
+
+
+
+
+ +
+
+
+

+ - +

+
+
+
+
+
+
+
+ +
+
+
+

+ - +

+
+
+
+
+
+
+
+ +
+
+
+

+ - +

+
+
+
+
+
+
+
+ +
+
+
+

+ - +

+
+
+
+
+
+
+
+ +
+
+
+

+ - +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Model configuration +

+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+

+ Features +   +

+

+ (3) +

+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + Feature definition + + + + + + Feature state + + +
+
+ Feature name +
+
+ + value + +
+
+
+ Feature definition +
+
+ +
+

+ Field: + value +

+

+ Aggregation method: + min +

+
+
+
+
+
+ Feature state +
+
+ + Enabled + +
+
+
+ Feature name +
+
+ + value + +
+
+
+ Feature definition +
+
+ +
+

+ Field: + value +

+

+ Aggregation method: + min +

+
+
+
+
+
+ Feature state +
+
+ + Disabled + +
+
+
+ Feature name +
+
+ + value2 + +
+
+
+ Feature definition +
+
+ +
+

+ Field: + value2 +

+

+ Aggregation method: + avg +

+
+
+
+
+
+ Feature state +
+
+ + Enabled + +
+
+
+
+
+
+
+
+
+
+
+

+ Additional settings +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + Categorical fields + + + + + + Shingle size + + + + + + Imputation method + + + + + + Custom values + + + + + + Suppression rules + + +
+
+ Categorical fields +
+
+ + - + +
+
+
+ Shingle size +
+
+ + 8 + +
+
+
+ Imputation method +
+
+ + ignore + +
+
+
+ Custom values +
+
+
+

+ - +

+
+
+
+
+ Suppression rules +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Detector jobs +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

+

+ +

+

+
+
+
+
+
+
+
+ +
+
+
+

+ Disabled +

+
+
+
+
+
+
+
+
+
+
+`; + exports[` spec renders the component 1`] = `
spec renders the component 1`] = ` + + + + Suppression rules + + + @@ -1204,7 +2681,43 @@ exports[` spec renders the component 1`] = ` >
+ > +

+ - +

+
+
+ + +
+ Suppression rules +
+
+
+ +
@@ -2579,6 +4092,22 @@ exports[` spec renders the component with 2 custom and 1 simpl + + + + Suppression rules + + + @@ -2662,6 +4191,38 @@ exports[` spec renders the component with 2 custom and 1 simpl
+ +
+ Suppression rules +
+
+
+ +
+
+ diff --git a/public/pages/ReviewAndCreate/components/AdditionalSettings/AdditionalSettings.tsx b/public/pages/ReviewAndCreate/components/AdditionalSettings/AdditionalSettings.tsx index eb7dcc5a..ac0c1905 100644 --- a/public/pages/ReviewAndCreate/components/AdditionalSettings/AdditionalSettings.tsx +++ b/public/pages/ReviewAndCreate/components/AdditionalSettings/AdditionalSettings.tsx @@ -9,31 +9,64 @@ * GitHub history for details. */ -import React from 'react'; +import React, { useState } from 'react'; import { get } from 'lodash'; -import { EuiBasicTable } from '@elastic/eui'; +import { + EuiBasicTable, + EuiButtonEmpty, + EuiOverlayMask +} from '@elastic/eui'; +import ContentPanel from '../../../../components/ContentPanel/ContentPanel'; +import { SuppressionRulesModal } from './SuppressionRulesModal'; interface AdditionalSettingsProps { shingleSize: number; categoryField: string[]; imputationMethod: string; customValues: string[]; + suppressionRules: string[]; } export function AdditionalSettings(props: AdditionalSettingsProps) { const renderCustomValues = (customValues: string[]) => (
- {customValues.map((value, index) => ( -

{value}

- ))} + {customValues.length > 0 ? ( + customValues.map((value, index) =>

{value}

) + ) : ( +

-

+ )}
); + + const [isModalVisible, setIsModalVisible] = useState(false); + const [modalContent, setModalContent] = useState([]); + + const closeModal = () => setIsModalVisible(false); + + const showRulesInModal = (rules: string[]) => { + setModalContent(rules); + setIsModalVisible(true); + }; + + const renderSuppressionRules = (suppressionRules: string[]) => ( +
+ {suppressionRules.length > 0 ? ( + showRulesInModal(suppressionRules)}> + {suppressionRules.length} rules + + ) : ( +

-

+ )} +
+ ); + const tableItems = [ { categoryField: get(props, 'categoryField.0', '-'), shingleSize: props.shingleSize, imputationMethod: props.imputationMethod, customValues: props.customValues, + suppresionRules: props.suppressionRules, }, ]; const tableColumns = [ @@ -43,13 +76,24 @@ export function AdditionalSettings(props: AdditionalSettingsProps) { { name: 'Custom values', field: 'customValues', render: (customValues: string[]) => renderCustomValues(customValues), // Use a custom render function - }, + }, + { name: 'Suppression rules', + field: 'suppresionRules', + render: (suppressionRules: string[]) => renderSuppressionRules(suppressionRules), // Use a custom render function + }, ]; return ( + + {isModalVisible && ( + + + + )} + ); } diff --git a/public/pages/ReviewAndCreate/components/AdditionalSettings/SuppressionRulesModal.tsx b/public/pages/ReviewAndCreate/components/AdditionalSettings/SuppressionRulesModal.tsx new file mode 100644 index 00000000..22e34407 --- /dev/null +++ b/public/pages/ReviewAndCreate/components/AdditionalSettings/SuppressionRulesModal.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle, EuiText, EuiSpacer } from '@elastic/eui'; + +interface SuppressionRulesModalProps { + rules: string[]; + onClose: () => void; +} + +export const SuppressionRulesModal: React.FC = ({ rules, onClose }) => { + return ( + + + +

Suppression Rules

+
+
+ + + + {rules.map((rule, index) => ( + + {rule} + + ))} + + + +
+ ); +}; diff --git a/public/pages/ReviewAndCreate/components/ModelConfigurationFields/ModelConfigurationFields.tsx b/public/pages/ReviewAndCreate/components/ModelConfigurationFields/ModelConfigurationFields.tsx index 37c14555..fc92a6ce 100644 --- a/public/pages/ReviewAndCreate/components/ModelConfigurationFields/ModelConfigurationFields.tsx +++ b/public/pages/ReviewAndCreate/components/ModelConfigurationFields/ModelConfigurationFields.tsx @@ -35,6 +35,7 @@ import { getShingleSizeFromObject, imputationMethodToFormik, getCustomValueStrArray, + getSuppressionRulesArray, } from '../../../ConfigureModel/utils/helpers'; import { SORT_DIRECTION } from '../../../../../server/utils/constants'; @@ -324,6 +325,7 @@ export const ModelConfigurationFields = ( categoryField={get(props, 'detector.categoryField', [])} imputationMethod={imputationMethodStr} customValues={getCustomValueStrArray(imputationMethodStr, props.detector)} + suppressionRules={getSuppressionRulesArray(props.detector)} /> spec', () => { test('renders the component with high cardinality disabled', () => { - const { container, getByText, getAllByText } = render( + const { container, getByText, getAllByText, queryByRole } = render( {() => (
- +
)}
@@ -29,33 +35,82 @@ describe(' spec', () => { expect(container.firstChild).toMatchSnapshot(); getAllByText('Category field'); getAllByText('Shingle size'); - getByText('-'); getByText('8'); - getByText("Ignore"); + getByText('Ignore'); + + // Assert that multiple elements with the text '-' are present + const dashElements = getAllByText('-'); + expect(dashElements.length).toBeGreaterThan(1); // Checks that more than one '-' is found + + // Check that the 'Suppression rules' title is present + // Assert that multiple elements with the text '-' are present + const ruleElements = getAllByText('Suppression rules'); + expect(ruleElements.length).toBeGreaterThan(1); // one is table cell title, another is the button + + // Use queryByRole to check that the button link is not present + const button = screen.queryByRole('button', { name: '0 rules' }); + expect(button).toBeNull(); }); - test('renders the component with high cardinality enabled', () => { - const { container, getByText, getAllByText } = render( - - {() => ( -
- -
- )} -
- ); + test('renders the component with high cardinality enabled', async () => { + const { container, getByText, getAllByText, getByRole, queryByRole } = + render( + + {() => ( +
+ +
+ )} +
+ ); expect(container.firstChild).toMatchSnapshot(); getAllByText('Category field'); getAllByText('Shingle size'); getByText('test_field'); getByText('8'); - getByText("Custom"); + getByText('Custom'); // Check for the custom values getByText('denyMax:5'); getByText('denySum:10'); + + // Check for the suppression rules button link + const button = getByRole('button', { name: '2 rules' }); + expect(button).toBeInTheDocument(); + + // Click the button to open the modal + fireEvent.click(button); + + // Wait for the modal to appear and check for its content + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); // Ensure modal is opened + }); + + getByText('Suppression Rules'); // Modal header + getByText( + "Ignore anomalies for feature 'CPU Usage' with no more than 5 above expected value." + ); + getByText( + "Ignore anomalies for feature 'Memory Usage' with no more than 10% below expected value." + ); + + // Close the modal by clicking the close button (X) + // Close the modal by clicking the close button (X) + const closeButton = getByRole('button', { + name: 'Closes this modal window', + }); + fireEvent.click(closeButton); + + // Ensure the modal is closed + await waitFor(() => { + expect(queryByRole('dialog')).toBeNull(); + }); }); }); diff --git a/public/pages/ReviewAndCreate/components/__tests__/ModelConfigurationFields.test.tsx b/public/pages/ReviewAndCreate/components/__tests__/ModelConfigurationFields.test.tsx index c0f59a4d..98c7ea09 100644 --- a/public/pages/ReviewAndCreate/components/__tests__/ModelConfigurationFields.test.tsx +++ b/public/pages/ReviewAndCreate/components/__tests__/ModelConfigurationFields.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import chance from 'chance'; import userEvent from '@testing-library/user-event'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, fireEvent, screen, within } from '@testing-library/react'; import { ModelConfigurationFields } from '../ModelConfigurationFields/ModelConfigurationFields'; import { Detector, @@ -19,13 +19,32 @@ import { DATA_TYPES } from '../../../../utils/constants'; import { getRandomFeature } from '../../../../redux/reducers/__tests__/utils'; import { CoreServicesContext } from '../../../../components/CoreServices/CoreServices'; import { coreServicesMock } from '../../../../../test/mocks'; -import { ImputationMethod } from '../../../../models/types'; +import { + ImputationMethod, + ThresholdType, + Action, + Operator, + Rule +} from '../../../../models/types'; const detectorFaker = new chance('seed'); const features = new Array(detectorFaker.natural({ min: 1, max: 5 })) .fill(null) .map(() => getRandomFeature(false)); +// Generate rules based on the existing features +const rules = features.map((feature, index) => ({ + action: Action.IGNORE_ANOMALY, + conditions: [ + { + featureName: feature.featureName, + thresholdType: index % 2 === 0 ? ThresholdType.ACTUAL_OVER_EXPECTED_MARGIN : ThresholdType.EXPECTED_OVER_ACTUAL_RATIO, // Alternate threshold types for variety + operator: Operator.LTE, + value: index % 2 === 0 ? 5 : 0.1, // Use different values for variety + }, + ], +})) as Rule[]; + const testDetector = { id: 'test-id', name: 'test-detector', @@ -60,13 +79,14 @@ const testDetector = { ], }, featureAttributes: features, - imputationOption: { method: ImputationMethod.ZERO} + imputationOption: { method: ImputationMethod.ZERO}, + rules: rules } as Detector; describe('ModelConfigurationFields', () => { test('renders the component in create mode (no ID)', async () => { const onEditModelConfiguration = jest.fn(); - const { container, getByText, getByTestId, queryByText } = render( + const { container, getByText, getByTestId, queryByText, getByRole, queryByRole } = render( { ); expect(container.firstChild).toMatchSnapshot(); + getByText('set_to_zero'); + + // Check for the suppression rules button link + const button = getByRole('button', { name: '2 rules' }); + expect(button).toBeInTheDocument(); + userEvent.click(getByTestId('viewFeature-0')); await waitFor(() => { queryByText('max'); - queryByText('Zero'); }); }); }); diff --git a/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/AdditionalSettings.test.tsx.snap b/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/AdditionalSettings.test.tsx.snap index 022afa0e..4a9e79af 100644 --- a/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/AdditionalSettings.test.tsx.snap +++ b/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/AdditionalSettings.test.tsx.snap @@ -3,180 +3,269 @@ exports[` spec renders the component with high cardinality disabled 1`] = `
-
+
+

+ Additional settings +

+
+
+
+
- +
+
+
-
- - - - - - - - - - - - - - - -
-
- - - Category field - - - - - - Shingle size - - - - - - Imputation method - - - - - - Custom values - - -
-
- Category field -
-
- - - - -
-
-
- Shingle size -
-
- - 8 - -
-
-
- Imputation method -
-
- - Ignore - -
-
+
+
- Custom values -
-
+
-
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + Category field + + + + + + Shingle size + + + + + + Imputation method + + + + + + Custom values + + + + + + Suppression rules + + +
+
+ Category field +
+
+ + - + +
+
+
+ Shingle size +
+
+ + 8 + +
+
+
+ Imputation method +
+
+ + Ignore + +
+
+
+ Custom values +
+
+
+

+ - +

+
+
+
+
+ Suppression rules +
+
+
+

+ - +

+
+
+
+
+
+
@@ -185,187 +274,284 @@ exports[` spec renders the component with high cardinality exports[` spec renders the component with high cardinality enabled 1`] = `
-
+
+

+ Additional settings +

+
+
+
+
- +
+
+
-
- - - - - - - - - - - - - + + + + + + +
-
- - - Category field - - - - - - Shingle size - - - - - - Imputation method - - - - - - Custom values - - -
-
- Category field -
-
- - test_field - -
-
-
- Shingle size -
-
- - 8 - -
-
+
+
- Imputation method -
-
- - Custom - +
+
-
+ -
- Custom values -
-
-
+
+ + + + + + + + + + -

- denyMax:5 -

-

- denySum:10 -

- - - - - -
+ + + Category field + + + + + + Shingle size + + + + + + Imputation method + + + + + + Custom values + + + + + + Suppression rules + + +
+
+
+ Category field +
+
+ + test_field + +
+
+
+ Shingle size +
+
+ + 8 + +
+
+
+ Imputation method +
+
+ + Custom + +
+
+
+ Custom values +
+
+
+

+ denyMax:5 +

+

+ denySum:10 +

+
+
+
+
+ Suppression rules +
+
+
+ +
+
+
+
+
+
diff --git a/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/ModelConfigurationFields.test.tsx.snap b/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/ModelConfigurationFields.test.tsx.snap index a0cb87f0..d1098678 100644 --- a/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/ModelConfigurationFields.test.tsx.snap +++ b/public/pages/ReviewAndCreate/components/__tests__/__snapshots__/ModelConfigurationFields.test.tsx.snap @@ -94,180 +94,281 @@ exports[`ModelConfigurationFields renders the component in create mode (no ID) 1
-
+
+

+ Additional settings +

+
+
+
+
- +
+
+
-
- - - - - - - - - - - - - - - -
-
- - - Category field - - - - - - Shingle size - - - - - - Imputation method - - - - - - Custom values - - -
-
- Category field -
-
- - - - -
-
-
- Shingle size -
-
- - 8 - -
-
-
- Imputation method -
-
- - set_to_zero - -
-
+
+
- Custom values -
-
+
-
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + Category field + + + + + + Shingle size + + + + + + Imputation method + + + + + + Custom values + + + + + + Suppression rules + + +
+
+ Category field +
+
+ + - + +
+
+
+ Shingle size +
+
+ + 8 + +
+
+
+ Imputation method +
+
+ + set_to_zero + +
+
+
+ Custom values +
+
+
+

+ - +

+
+
+
+
+ Suppression rules +
+
+
+ +
+
+
+
+
+
spec renders the component, validation loading 1`]
-
+
+

+ Additional settings +

+
+
+
+
- +
+
+
-
- - - - - - - - - - - - - - - -
-
- - - Category field - - - - - - Shingle size - - - - - - Imputation method - - - - - - Custom values - - -
-
- Category field -
-
- - - - -
-
-
- Shingle size -
-
- - 8 - -
-
-
- Imputation method -
-
- - ignore - -
-
+
+
- Custom values -
-
+
-
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + Category field + + + + + + Shingle size + + + + + + Imputation method + + + + + + Custom values + + + + + + Suppression rules + + +
+
+ Category field +
+
+ + - + +
+
+
+ Shingle size +
+
+ + 8 + +
+
+
+ Imputation method +
+
+ + ignore + +
+
+
+ Custom values +
+
+
+

+ - +

+
+
+
+
+ Suppression rules +
+
+
+

+ - +

+
+
+
+
+
+
-
+
+

+ Additional settings +

+
+
+
+
- +
+
+
-
- - - - - - - - - - - - - - - -
-
- - - Category field - - - - - - Shingle size - - - - - - Imputation method - - - - - - Custom values - - -
-
- Category field -
-
- - - - -
-
-
- Shingle size -
-
- - 8 - -
-
-
- Imputation method -
-
- - ignore - -
-
+
+
- Custom values -
-
+
-
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + Category field + + + + + + Shingle size + + + + + + Imputation method + + + + + + Custom values + + + + + + Suppression rules + + +
+
+ Category field +
+
+ + - + +
+
+
+ Shingle size +
+
+ + 8 + +
+
+
+ Imputation method +
+
+ + ignore + +
+
+
+ Custom values +
+
+
+

+ - +

+
+
+
+
+ Suppression rules +
+
+
+

+ - +

+
+
+
+
+
+
{ }; }; -const getUIMetadata = (features: FeatureAttributes[]) => { +export const getUIMetadata = (features: FeatureAttributes[]) => { const metaFeatures = features.reduce( (acc, feature) => ({ ...acc, @@ -127,7 +132,8 @@ export const getRandomDetector = ( resultIndexMinSize: 51200, resultIndexTtl: 60, flattenCustomResultIndex: true, - imputationOption: randomImputationOption(features) + imputationOption: randomImputationOption(features), + rules: randomRules(features) }; }; @@ -247,3 +253,58 @@ export const randomImputationOption = (features: FeatureAttributes[]): Imputatio } return options[randomIndex]; }; + +// Helper function to get a random item from an array +function getRandomItem(items: T[]): T { + return items[random(0, items.length - 1)]; +} + +// Helper function to generate a random value (for simplicity, let's use a range of 0 to 100) +function getRandomValue(): number { + return random(0, 100, true); // Generates a random float between 0 and 100 +} + +export const randomRules = (features: FeatureAttributes[]): Rule[] | undefined => { + // If there are no features, return undefined + if (features.length === 0) { + return undefined; + } + + const rules: Rule[] = []; + + // Generate a random number of rules (between 1 and 3 for testing) + const numberOfRules = random(1, 3); + + for (let i = 0; i < numberOfRules; i++) { + // Random action + const action = Action.IGNORE_ANOMALY; + + // Generate a random number of conditions (between 1 and 2 for testing) + const numberOfConditions = random(1, 2); + const conditions: Condition[] = []; + + for (let j = 0; j < numberOfConditions; j++) { + const featureName = getRandomItem(features.map((f) => f.featureName)); + const thresholdType = getRandomItem(Object.values(ThresholdType)); + const operator = getRandomItem(Object.values(Operator)); + const value = getRandomValue(); + + conditions.push({ + featureName, + thresholdType, + operator, + value, + }); + } + + // Create the rule with the generated action and conditions + rules.push({ + action, + conditions, + }); + } + + // Randomly decide whether to return undefined or the generated rules + const shouldReturnUndefined = random(0, 1) === 0; + return shouldReturnUndefined ? undefined : rules; +}; diff --git a/public/utils/utils.tsx b/public/utils/utils.tsx index a2af39ff..ea979cb3 100644 --- a/public/utils/utils.tsx +++ b/public/utils/utils.tsx @@ -110,6 +110,21 @@ export const validatePositiveInteger = (value: any) => { return 'Must be a positive integer'; }; +// Validation function for positive decimal numbers +export function validatePositiveDecimal(value: any) { + // Allow empty, NaN, or non-number values without showing an error + if (value === '' || value === null || isNaN(value) || typeof value !== 'number') { + return undefined; // No error for empty, NaN, or non-number values + } + + // Validate that the value is a positive number greater than zero + if (value <= 0) { + return 'Must be a positive number greater than zero'; + } + + return undefined; // No error if the value is valid +} + export const validateEmptyOrPositiveInteger = (value: any) => { if (Number.isInteger(value) && value < 1) return 'Must be a positive integer';