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..906b9959 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,130 @@ 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) { + 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) { + 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..2d3e4fcb 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(); @@ -366,4 +392,65 @@ describe(' spec', () => { 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..57f2351f 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,26 @@ 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';