From 67f7e441958a2e5269809d09b95a527f4f7186a8 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Tue, 10 Sep 2024 00:54:03 +0800 Subject: [PATCH] Add suggest anomaly detector action to discover page (#849) * Add generate anomaly detector action to discover page Signed-off-by: gaobinlong * Add more test code and rename the file Signed-off-by: gaobinlong * Modify flyout header Signed-off-by: gaobinlong * Make the detectorName follow the convention Signed-off-by: gaobinlong * Truncate the index pattern name if it's too long Signed-off-by: gaobinlong * Move entry point to query editor Signed-off-by: gaobinlong * Call the node API in dashboard-assistant plugin to generate parameters Refactor unit test code Signed-off-by: gaobinlong * Fix test failure Signed-off-by: gaobinlong * Revert the code format Signed-off-by: gaobinlong * Remove some empty lines Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong (cherry picked from commit ec02b63e6edc698f48cc09b2f45b354858c6a91a) --- opensearch_dashboards.json | 3 +- .../SuggestAnomalyDetector.test.tsx | 449 +++++++++ .../DiscoverAction/SuggestAnomalyDetector.tsx | 863 ++++++++++++++++++ .../FeatureAccordion/FeatureAccordion.tsx | 1 + public/plugin.ts | 33 +- public/redux/reducers/__tests__/ad.test.ts | 20 +- .../reducers/__tests__/opensearch.test.ts | 4 +- public/redux/reducers/ad.ts | 39 +- public/redux/reducers/opensearch.ts | 2 +- public/services.ts | 11 +- public/utils/contextMenu/getActions.tsx | 33 +- server/utils/constants.ts | 2 + 12 files changed, 1413 insertions(+), 47 deletions(-) create mode 100644 public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx create mode 100644 public/components/DiscoverAction/SuggestAnomalyDetector.tsx diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 2c2df2ba..9a717b83 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -7,7 +7,8 @@ ], "optionalPlugins": [ "dataSource", - "dataSourceManagement" + "dataSourceManagement", + "assistantDashboards" ], "requiredPlugins": [ "opensearchDashboardsUtils", diff --git a/public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx b/public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx new file mode 100644 index 00000000..4e0de621 --- /dev/null +++ b/public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx @@ -0,0 +1,449 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; + +import { CoreServicesContext } from '../CoreServices/CoreServices'; +import { coreServicesMock, httpClientMock } from '../../../test/mocks'; +import { + HashRouter as Router, + RouteComponentProps, + Route, + Switch, +} from 'react-router-dom'; +import { Provider } from 'react-redux'; + +import configureStore from '../../redux/configureStore'; +import SuggestAnomalyDetector from './SuggestAnomalyDetector'; +import userEvent from '@testing-library/user-event'; +import { HttpFetchOptionsWithPath } from '../../../../../src/core/public'; +import { getAssistantClient, getQueryService } from '../../services'; + +const notifications = { + toasts: { + addDanger: jest.fn().mockName('addDanger'), + addSuccess: jest.fn().mockName('addSuccess'), + } +}; + +const getNotifications = () => { + return notifications; +} + +jest.mock('../../services', () => ({ + ...jest.requireActual('../../services'), + getNotifications: getNotifications, + getQueryService: jest.fn().mockReturnValue({ + queryString: { + getQuery: jest.fn(), + }, + }), + getAssistantClient: jest.fn().mockReturnValue({ + executeAgentByName: jest.fn(), + }) +})); + +const renderWithRouter = () => ({ + ...render( + + + + ( + + + + )} + /> + + + + ), +}); + +describe('GenerateAnomalyDetector spec', () => { + describe('Renders failed', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with invalid dataset type', async () => { + const queryService = getQueryService(); + queryService.queryString.getQuery.mockReturnValue({ + dataset: { + id: undefined, + title: undefined, + type: 'INDEX' + }, + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).toBeNull(); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Unsupported dataset type' + ); + }); + }); + + it('renders empty component', async () => { + const queryService = getQueryService(); + queryService.queryString.getQuery.mockReturnValue({ + dataset: { + id: undefined, + title: undefined, + type: 'INDEX_PATTERN' + }, + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).toBeNull(); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Cannot extract complete index info from the context' + ); + }); + }); + }); + + describe('Renders loading component', () => { + beforeEach(() => { + jest.clearAllMocks(); + const queryService = getQueryService(); + queryService.queryString.getQuery.mockReturnValue({ + dataset: { + id: 'test-pattern', + title: 'test-pattern', + type: 'INDEX_PATTERN', + timeFieldName: '@timestamp', + }, + }); + + }); + + it('renders with empty generated parameters', async () => { + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: '' } + ] + } + ] + } + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Generate parameters for creating anomaly detector failed, reason: Error: Cannot get generated parameters!' + ); + }); + }); + + it('renders with empty parameter', async () => { + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: "{\"index\":\"opensearch_dashboards_sample_data_logs\",\"categoryField\":\"ip\",\"aggregationField\":\"\",\"aggregationMethod\":\"\",\"dateFields\":\"utc_time,timestamp\"}" } + ] + } + ] + } + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Generate parameters for creating anomaly detector failed, reason: Error: Cannot find aggregation field, aggregation method or data fields!' + ); + }); + }); + + it('renders with empty aggregation field or empty aggregation method', async () => { + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: "{\"index\":\"opensearch_dashboards_sample_data_logs\",\"categoryField\":\"ip\",\"aggregationField\":\",\",\"aggregationMethod\":\",\",\"dateFields\":\"utc_time,timestamp\"}" } + ] + } + ] + } + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Generate parameters for creating anomaly detector failed, reason: Error: The generated aggregation field or aggregation method is empty!' + ); + }); + }); + + it('renders with different number of aggregation methods and fields', async () => { + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: "{\"index\":\"opensearch_dashboards_sample_data_logs\",\"categoryField\":\"ip\",\"aggregationField\":\"a,b\",\"aggregationMethod\":\"avg\",\"dateFields\":\"utc_time,timestamp\"}" } + ] + } + ] + } + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Generate parameters for creating anomaly detector failed, reason: Error: The number of aggregation fields and the number of aggregation methods are different!' + ); + }); + }); + + it('renders component completely', async () => { + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: "{\"index\":\"opensearch_dashboards_sample_data_logs\",\"categoryField\":\"ip\",\"aggregationField\":\"responseLatency,response\",\"aggregationMethod\":\"avg,sum\",\"dateFields\":\"utc_time,timestamp\"}" } + ] + } + ] + } + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(queryByText('Create detector')).not.toBeNull(); + expect(queryByText('Detector details')).not.toBeNull(); + expect(queryByText('Advanced configuration')).not.toBeNull(); + expect(queryByText('Model Features')).not.toBeNull(); + }); + }); + + }); + + describe('Test API calls', () => { + beforeEach(() => { + jest.clearAllMocks(); + const queryService = getQueryService(); + queryService.queryString.getQuery.mockReturnValue({ + dataset: { + id: 'test-pattern', + title: 'test-pattern', + type: 'INDEX_PATTERN', + timeFieldName: '@timestamp', + }, + }); + }); + + it('All API calls execute successfully', async () => { + httpClientMock.post = jest.fn((pathOrOptions: string | HttpFetchOptionsWithPath) => { + const url = typeof pathOrOptions === 'string' ? pathOrOptions : pathOrOptions.path; + switch (url) { + case '/api/anomaly_detectors/detectors': + return Promise.resolve({ + ok: true, + response: { + id: 'test' + } + }); + default: + return Promise.resolve({ + ok: true + }); + } + }); + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: "{\"index\":\"test-pattern\",\"categoryField\":\"ip\",\"aggregationField\":\"responseLatency,response\",\"aggregationMethod\":\"avg,sum\",\"dateFields\":\"utc_time,timestamp\"}" } + ] + } + ] + } + }); + + + const { queryByText, getByTestId } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + await waitFor(() => { + expect(queryByText('Generating parameters...')).toBeNull(); + expect(queryByText('Create detector')).not.toBeNull(); + expect(queryByText('Detector details')).not.toBeNull(); + expect(queryByText('Advanced configuration')).not.toBeNull(); + expect(queryByText('Model Features')).not.toBeNull(); + }); + + userEvent.click(getByTestId("SuggestAnomalyDetectorCreateButton")); + + await waitFor(() => { + expect(httpClientMock.post).toHaveBeenCalledTimes(2); + expect(getNotifications().toasts.addSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('Generate parameters failed', async () => { + (getAssistantClient().executeAgentByName as jest.Mock).mockRejectedValueOnce('Generate parameters failed'); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Generate parameters for creating anomaly detector failed, reason: Generate parameters failed' + ); + }); + }); + + it('Create anomaly detector failed', async () => { + httpClientMock.post = jest.fn((pathOrOptions: string | HttpFetchOptionsWithPath) => { + const url = typeof pathOrOptions === 'string' ? pathOrOptions : pathOrOptions.path; + switch (url) { + case '/api/anomaly_detectors/detectors': + return Promise.resolve({ + ok: false, + error: 'Create anomaly detector failed' + }); + default: + return Promise.resolve({ + ok: true + }); + } + }); + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: "{\"index\":\"test-pattern\",\"categoryField\":\"ip\",\"aggregationField\":\"responseLatency,response\",\"aggregationMethod\":\"avg,sum\",\"dateFields\":\"utc_time,timestamp\"}" } + ] + } + ] + } + }); + + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + count: 0 + }, + }); + + const { queryByText, getByTestId } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(queryByText('Generating parameters...')).toBeNull(); + expect(queryByText('Create detector')).not.toBeNull(); + expect(queryByText('Detector details')).not.toBeNull(); + expect(queryByText('Advanced configuration')).not.toBeNull(); + expect(queryByText('Model Features')).not.toBeNull(); + }); + + userEvent.click(getByTestId("SuggestAnomalyDetectorCreateButton")); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Create anomaly detector failed' + ); + }); + }); + + + it('Start anomaly detector failed', async () => { + httpClientMock.post = jest.fn((pathOrOptions: string | HttpFetchOptionsWithPath) => { + const url = typeof pathOrOptions === 'string' ? pathOrOptions : pathOrOptions.path; + switch (url) { + case '/api/anomaly_detectors/detectors': + return Promise.resolve({ + ok: true, + response: { + id: 'test' + } + }); + case '/api/anomaly_detectors/detectors/test/start': + return Promise.resolve({ + ok: false, + error: 'Start anomaly detector failed' + }); + default: + return Promise.resolve({ + ok: true + }); + } + }); + + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + count: 0 + }, + }); + + (getAssistantClient().executeAgentByName as jest.Mock).mockResolvedValueOnce({ + body: { + inference_results: [ + { + output: [ + { result: "{\"index\":\"test-pattern\",\"categoryField\":\"ip\",\"aggregationField\":\"responseLatency,response\",\"aggregationMethod\":\"avg,sum\",\"dateFields\":\"utc_time,timestamp\"}" } + ] + } + ] + } + }); + + + const { queryByText, getByTestId } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(queryByText('Generating parameters...')).toBeNull(); + expect(queryByText('Create detector')).not.toBeNull(); + expect(queryByText('Detector details')).not.toBeNull(); + expect(queryByText('Advanced configuration')).not.toBeNull(); + expect(queryByText('Model Features')).not.toBeNull(); + }); + + userEvent.click(getByTestId("SuggestAnomalyDetectorCreateButton")); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'Start anomaly detector failed' + ); + }); + }); + }); +}); diff --git a/public/components/DiscoverAction/SuggestAnomalyDetector.tsx b/public/components/DiscoverAction/SuggestAnomalyDetector.tsx new file mode 100644 index 00000000..96e60234 --- /dev/null +++ b/public/components/DiscoverAction/SuggestAnomalyDetector.tsx @@ -0,0 +1,863 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, Fragment } from 'react'; +import { + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiTitle, + EuiButton, + EuiSpacer, + EuiText, + EuiFormRow, + EuiFieldText, + EuiCheckbox, + EuiFlexItem, + EuiFlexGroup, + EuiFieldNumber, + EuiCallOut, + EuiButtonEmpty, + EuiPanel, + EuiComboBox, +} from '@elastic/eui'; +import '../FeatureAnywhereContextMenu/CreateAnomalyDetector/styles.scss'; +import { useDispatch, useSelector } from 'react-redux'; +import { isEmpty, get } from 'lodash'; +import { + Field, + FieldArray, + FieldArrayRenderProps, + FieldProps, + Formik, +} from 'formik'; +import { + createDetector, + getDetectorCount, + matchDetector, + startDetector, +} from '../../redux/reducers/ad'; +import { + getError, + getErrorMessage, + isInvalid, + validateCategoryField, + validateDetectorName, + validateNonNegativeInteger, + validatePositiveInteger, +} from '../../utils/utils'; +import { + CUSTOM_AD_RESULT_INDEX_PREFIX, + MAX_DETECTORS, + SUGGEST_ANOMALY_DETECTOR_CONFIG_ID, +} from '../../../server/utils/constants'; +import { + focusOnFirstWrongFeature, + getCategoryFields, + initialFeatureValue, + validateFeatures, +} from '../../pages/ConfigureModel/utils/helpers'; +import { formikToDetector } from '../../pages/ReviewAndCreate/utils/helpers'; +import { FormattedFormRow } from '../FormattedFormRow/FormattedFormRow'; +import { FeatureAccordion } from '../../pages/ConfigureModel/components/FeatureAccordion'; +import { AD_DOCS_LINK, DEFAULT_SHINGLE_SIZE, MAX_FEATURE_NUM, PLUGIN_NAME } from '../../utils/constants'; +import { getAssistantClient, getNotifications, getQueryService } from '../../services'; +import { prettifyErrorMessage } from '../../../server/utils/helpers'; +import EnhancedAccordion from '../FeatureAnywhereContextMenu/EnhancedAccordion'; +import MinimalAccordion from '../FeatureAnywhereContextMenu/MinimalAccordion'; +import { DataFilterList } from '../../pages/DefineDetector/components/DataFilterList/DataFilterList'; +import { FEATURE_TYPE } from '../../models/interfaces'; +import { FeaturesFormikValues } from '../../pages/ConfigureModel/models/interfaces'; +import { getMappings } from '../../redux/reducers/opensearch'; +import { mountReactNode } from '../../../../../src/core/public/utils'; +import { formikToDetectorName } from '../FeatureAnywhereContextMenu/CreateAnomalyDetector/helpers'; +import { DEFAULT_DATA } from '../../../../../src/plugins/data/common'; +import { AppState } from '../../redux/reducers'; + +export interface GeneratedParameters { + categoryField: string; + features: FeaturesFormikValues[]; + dateFields: string[]; +} + +function SuggestAnomalyDetector({ + closeFlyout, +}: { + closeFlyout: any; +}) { + const dispatch = useDispatch(); + const notifications = getNotifications(); + const assistantClient = getAssistantClient(); + + const queryString = getQueryService().queryString; + const dataset = queryString.getQuery().dataset || queryString.getDefaultQuery().dataset; + const datasetType = dataset.type; + if (datasetType != DEFAULT_DATA.SET_TYPES.INDEX_PATTERN && datasetType != DEFAULT_DATA.SET_TYPES.INDEX) { + notifications.toasts.addDanger( + 'Unsupported dataset type' + ); + return <>; + } + + const indexPatternId = dataset.id; + // indexName could be a index pattern or a concrete index + const indexName = dataset.title; + const timeFieldName = dataset.timeFieldName; + if (!indexPatternId || !indexName || !timeFieldName) { + notifications.toasts.addDanger( + 'Cannot extract complete index info from the context' + ); + return <>; + } + + const dataSourceId = dataset.dataSource?.id; + const [isLoading, setIsLoading] = useState(true); + const [buttonName, setButtonName] = useState( + 'Generating parameters...' + ); + const [categoryFieldEnabled, setCategoryFieldEnabled] = + useState(false); + + const [accordionsOpen, setAccordionsOpen] = useState>({ modelFeatures: true }); + const [intervalValue, setIntervalalue] = useState(10); + const [delayValue, setDelayValue] = useState(1); + const [enabled, setEnabled] = useState(false); + const [detectorName, setDetectorName] = useState( + formikToDetectorName(indexName.substring(0, 40)) + ); + const indexDataTypes = useSelector( + (state: AppState) => state.opensearch.dataTypes + ); + const categoricalFields = getCategoryFields(indexDataTypes); + + const dateFields = get(indexDataTypes, 'date', []) as string[]; + const dateNanoFields = get(indexDataTypes, 'date_nanos', []) as string[]; + const allDateFields = dateFields.concat(dateNanoFields); + + // let LLM to generate parameters for creating anomaly detector + async function getParameters() { + try { + const executeAgentResponse = await + assistantClient.executeAgentByName(SUGGEST_ANOMALY_DETECTOR_CONFIG_ID, { index: indexName }, { dataSourceId } + ); + const rawGeneratedParameters = executeAgentResponse?.body?.inference_results?.[0]?.output?.[0]?.result; + if (!rawGeneratedParameters) { + throw new Error('Cannot get generated parameters!'); + } + + const generatedParameters = formatGeneratedParameters(JSON.parse(rawGeneratedParameters)); + if (generatedParameters.features.length == 0) { + throw new Error('Generated parameters have empty model features!'); + } + + initialDetectorValue.featureList = generatedParameters.features; + initialDetectorValue.categoryFieldEnabled = !!generatedParameters.categoryField; + initialDetectorValue.categoryField = initialDetectorValue.categoryFieldEnabled ? [generatedParameters.categoryField] : []; + + setIsLoading(false); + setButtonName('Create detector'); + setCategoryFieldEnabled(!!generatedParameters.categoryField); + } catch (error) { + notifications.toasts.addDanger( + 'Generate parameters for creating anomaly detector failed, reason: ' + error + ); + } + } + + const formatGeneratedParameters = function (rawGeneratedParameters: any): GeneratedParameters { + const categoryField = rawGeneratedParameters['categoryField']; + + const rawAggregationFields = rawGeneratedParameters['aggregationField']; + const rawAggregationMethods = rawGeneratedParameters['aggregationMethod']; + const rawDataFields = rawGeneratedParameters['dateFields']; + if (!rawAggregationFields || !rawAggregationMethods || !rawDataFields) { + throw new Error('Cannot find aggregation field, aggregation method or data fields!'); + } + const aggregationFields = + rawAggregationFields.split(','); + const aggregationMethods = + rawAggregationMethods.split(','); + const dateFields = rawDataFields.split(','); + + if (aggregationFields.length != aggregationMethods.length) { + throw new Error('The number of aggregation fields and the number of aggregation methods are different!'); + } + + const featureList = aggregationFields.map((field: string, index: number) => { + const method = aggregationMethods[index]; + if (!field || !method) { + throw new Error('The generated aggregation field or aggregation method is empty!'); + } + const aggregationOption = { + label: field, + }; + const feature: FeaturesFormikValues = { + featureName: `feature_${field}`, + featureType: FEATURE_TYPE.SIMPLE, + featureEnabled: true, + aggregationQuery: '', + aggregationBy: aggregationMethods[index], + aggregationOf: [aggregationOption], + }; + return feature; + }); + + return { + categoryField: categoryField, + features: featureList, + dateFields: dateFields, + }; + }; + + useEffect(() => { + async function fetchData() { + await dispatch(getMappings(indexName, dataSourceId)); + await getParameters(); + } + fetchData(); + }, []); + + const onDetectorNameChange = (e: any, field: any) => { + field.onChange(e); + setDetectorName(e.target.value); + }; + + const onAccordionToggle = (key: string) => { + const newAccordionsOpen = { ...accordionsOpen }; + newAccordionsOpen[key] = !accordionsOpen[key]; + setAccordionsOpen(newAccordionsOpen); + }; + + const onIntervalChange = (e: any, field: any) => { + field.onChange(e); + setIntervalalue(e.target.value); + }; + + const onDelayChange = (e: any, field: any) => { + field.onChange(e); + setDelayValue(e.target.value); + }; + + const handleValidationAndSubmit = (formikProps: any) => { + if (formikProps.values.featureList.length !== 0) { + formikProps.setFieldTouched('featureList', true); + formikProps.validateForm().then(async (errors: any) => { + if (!isEmpty(errors)) { + focusOnFirstWrongFeature(errors, formikProps.setFieldTouched); + notifications.toasts.addDanger( + 'One or more input fields is invalid.' + ); + } else { + handleSubmit(formikProps); + } + }); + } else { + notifications.toasts.addDanger('One or more features are required.'); + } + }; + + const handleSubmit = async (formikProps: any) => { + formikProps.setSubmitting(true); + try { + const detectorToCreate = formikToDetector(formikProps.values); + await dispatch(createDetector(detectorToCreate, dataSourceId)) + .then(async (response: any) => { + const detectorId = response.response.id; + dispatch(startDetector(detectorId, dataSourceId)) + .then(() => { }) + .catch((err: any) => { + notifications.toasts.addDanger( + prettifyErrorMessage( + getErrorMessage( + err, + 'There was a problem starting the real-time detector' + ) + ) + ); + }); + + const shingleSize = get( + formikProps.values, + 'shingleSize', + DEFAULT_SHINGLE_SIZE + ); + notifications.toasts.addSuccess({ + title: mountReactNode( + + Detector created: { + e.preventDefault(); + const url = `../${PLUGIN_NAME}#/detectors/${detectorId}`; + window.open(url, '_blank'); + }} style={{ textDecoration: 'underline' }}>{formikProps.values.name} + + ), + text: mountReactNode( + +

+ Attempting to initialize the detector with historical data. This + initializing process takes approximately 1 minute if you have data in + each of the last {32 + shingleSize} consecutive intervals. +

+
+ ), + className: 'createdAndAssociatedSuccessToast', + }); + + }) + .catch((err: any) => { + dispatch(getDetectorCount(dataSourceId)).then((response: any) => { + const totalDetectors = get(response, 'response.count', 0); + if (totalDetectors === MAX_DETECTORS) { + notifications.toasts.addDanger( + 'Cannot create detector - limit of ' + + MAX_DETECTORS + + ' detectors reached' + ); + } else { + notifications.toasts.addDanger( + prettifyErrorMessage( + getErrorMessage( + err, + 'There was a problem creating the detector' + ) + ) + ); + } + }); + }); + closeFlyout(); + } catch (e) { + } finally { + formikProps.setSubmitting(false); + } + }; + + const validateAnomalyDetectorName = async (detectorName: string) => { + if (isEmpty(detectorName)) { + return 'Detector name cannot be empty'; + } else { + const error = validateDetectorName(detectorName); + if (error) { + return error; + } + const resp = await dispatch(matchDetector(detectorName, dataSourceId)); + const match = get(resp, 'response.match', false); + if (!match) { + return undefined; + } + //If more than one detectors found, duplicate exists. + if (match) { + return 'Duplicate detector name'; + } + } + }; + + let initialDetectorValue = { + name: detectorName, + index: [{ label: indexName }], + timeField: timeFieldName, + interval: intervalValue, + windowDelay: delayValue, + shingleSize: DEFAULT_SHINGLE_SIZE, + filterQuery: { match_all: {} }, + description: 'Created based on the OpenSearch Assistant', + resultIndex: undefined, + filters: [], + featureList: [] as FeaturesFormikValues[], + categoryFieldEnabled: false, + categoryField: [] as string[], + realTime: true, + historical: false, + }; + + return ( +
+ + {(formikProps) => ( + <> + + +

+ Suggested anomaly detector +

+
+ + + Create an anomaly detector based on the parameters(model features and categorical field) suggested by OpenSearch Assistant. + +
+ +
+ +

Detector details

+
+ + + onAccordionToggle('detectorDetails')} + subTitle={ + +

+ Detector interval: {intervalValue} minute(s); Window + delay: {delayValue} minute(s) +

+
+ } + > + + {({ field, form }: FieldProps) => ( + + onDetectorNameChange(e, field)} + /> + + )} + + + + + {({ field, form }: FieldProps) => ( + + + + + + onIntervalChange(e, field)} + /> + + + +

minute(s)

+
+
+
+
+
+
+ )} +
+ + + + {({ field, form }: FieldProps) => ( + + + + onDelayChange(e, field)} + /> + + + +

minute(s)

+
+
+
+
+ )} +
+
+ + + + onAccordionToggle('advancedConfiguration')} + initialIsOpen={false} + > + + + + +

Source: {'test'}

+
+ + +
+ + + + + {({ field, form }: FieldProps) => ( + + + + + + + +

intervals

+
+
+
+
+ )} +
+
+ + + + {({ field, form }: FieldProps) => ( + + + { + if (enabled) { + form.setFieldValue('resultIndex', ''); + } + setEnabled(!enabled); + }} + /> + + + {enabled ? ( + + + + ) : null} + + {enabled ? ( + + + + + + ) : null} + + )} + + + + + + {({ field, form }: FieldProps) => ( + + + { + if (categoryFieldEnabled) { + form.setFieldValue('categoryField', []); + } + setCategoryFieldEnabled(!categoryFieldEnabled); + }} + /> + + {categoryFieldEnabled ? ( + + + + ) : null} + {categoryFieldEnabled ? ( + + + { + return { + label: value, + }; + }) + } + options={categoricalFields?.map((field) => { + return { + label: field, + }; + })} + onBlur={() => { + form.setFieldTouched('categoryField', true); + }} + onChange={(options) => { + const selection = options.map( + (option) => option.label + ); + if (!isEmpty(selection)) { + if (selection.length <= 2) { + form.setFieldValue( + 'categoryField', + selection + ); + } + } else { + form.setFieldValue('categoryField', []); + } + }} + singleSelection={false} + isClearable={true} + /> + + + ) : null} + + )} + + + + + + {({ field, form }: FieldProps) => ( + + { + return { + label: field, + }; + })} + onBlur={() => { + form.setFieldTouched('timeField', true); + }} + onChange={(options) => { + form.setFieldValue( + 'timeField', + get(options, '0.label') + ); + }} + selectedOptions={ + field.value + ? [ + { + label: field.value, + }, + ] + : [{ label: timeFieldName }] + } + singleSelection={{ asPlainText: true }} + isClearable={false} + /> + + )} + + +
+ + + +

Model Features

+
+ + + onAccordionToggle('modelFeatures')} + > + + {({ + push, + remove, + form: { values }, + }: FieldArrayRenderProps) => { + return ( + + {values.featureList.map( + (feature: any, index: number) => ( + { + remove(index); + }} + index={index} + feature={feature} + handleChange={formikProps.handleChange} + displayMode="flyout" + key={index} + /> + ) + )} + + + + = MAX_FEATURE_NUM + } + onClick={() => { + push(initialFeatureValue()); + }} + disabled={isLoading} + > + Add another feature + + + + +

+ You can add up to{' '} + {Math.max( + MAX_FEATURE_NUM - values.featureList.length, + 0 + )}{' '} + more features. +

+
+
+ ); + }} +
+
+ +
+
+ + + + Cancel + + + { + handleValidationAndSubmit(formikProps); + }} + > + {buttonName} + + + + + + )} +
+
+ ); +} + +export default SuggestAnomalyDetector; diff --git a/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx b/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx index 545dda63..53fd616c 100644 --- a/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx +++ b/public/pages/ConfigureModel/components/FeatureAccordion/FeatureAccordion.tsx @@ -113,6 +113,7 @@ export const FeatureAccordion = (props: FeatureAccordionProps) => { if (props.displayMode === 'flyout') { return ( { }, }); expect(httpMockedClient.get).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors/${detectorId}` + `${BASE_NODE_API_PATH}/detectors/${detectorId}` ); }); test('should invoke [REQUEST, FAILURE]', async () => { @@ -76,7 +76,7 @@ describe('detector reducer actions', () => { errorMessage: 'Not found', }); expect(httpMockedClient.get).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors/${detectorId}` + `${BASE_NODE_API_PATH}/detectors/${detectorId}` ); } }); @@ -104,7 +104,7 @@ describe('detector reducer actions', () => { }, }); expect(httpMockedClient.delete).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors/${expectedDetector.id}` + `${BASE_NODE_API_PATH}/detectors/${expectedDetector.id}` ); }); test('should invoke [REQUEST, FAILURE]', async () => { @@ -129,7 +129,7 @@ describe('detector reducer actions', () => { errorMessage: 'Detector is consumed by Monitor', }); expect(httpMockedClient.delete).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors/${expectedDetector.id}` + `${BASE_NODE_API_PATH}/detectors/${expectedDetector.id}` ); } }); @@ -162,7 +162,7 @@ describe('detector reducer actions', () => { }, }); expect(httpMockedClient.post).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors`, + `${BASE_NODE_API_PATH}/detectors`, { body: JSON.stringify(expectedDetector), } @@ -190,7 +190,7 @@ describe('detector reducer actions', () => { errorMessage: 'Internal server error', }); expect(httpMockedClient.post).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors`, + `${BASE_NODE_API_PATH}/detectors`, { body: JSON.stringify(expectedDetector), } @@ -230,7 +230,7 @@ describe('detector reducer actions', () => { }, }); expect(httpMockedClient.put).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors/${detectorId}`, + `${BASE_NODE_API_PATH}/detectors/${detectorId}`, { body: JSON.stringify(randomDetector) } ); }); @@ -258,7 +258,7 @@ describe('detector reducer actions', () => { errorMessage: 'Internal server error', }); expect(httpMockedClient.post).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors`, + `${BASE_NODE_API_PATH}/detectors`, randomDetector, { params: { @@ -298,7 +298,7 @@ describe('detector reducer actions', () => { ), }); expect(httpMockedClient.post).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors/_search`, + `${BASE_NODE_API_PATH}/detectors/_search`, { body: JSON.stringify(query), } @@ -328,7 +328,7 @@ describe('detector reducer actions', () => { errorMessage: 'Internal server error', }); expect(httpMockedClient.post).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/detectors`, + `${BASE_NODE_API_PATH}/detectors`, randomDetector ); } diff --git a/public/redux/reducers/__tests__/opensearch.test.ts b/public/redux/reducers/__tests__/opensearch.test.ts index 18d75ae2..ee3e5fc0 100644 --- a/public/redux/reducers/__tests__/opensearch.test.ts +++ b/public/redux/reducers/__tests__/opensearch.test.ts @@ -175,7 +175,7 @@ describe('opensearch reducer actions', () => { }, }); expect(httpMockedClient.get).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/_mappings`, + `${BASE_NODE_API_PATH}/_mappings`, { query: { indices: [] }, } @@ -202,7 +202,7 @@ describe('opensearch reducer actions', () => { errorMessage: 'Something went wrong', }); expect(httpMockedClient.get).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/_mappings`, + `${BASE_NODE_API_PATH}/_mappings`, { query: { indices: [] }, } diff --git a/public/redux/reducers/ad.ts b/public/redux/reducers/ad.ts index 3fa06ad3..a1a689d1 100644 --- a/public/redux/reducers/ad.ts +++ b/public/redux/reducers/ad.ts @@ -374,9 +374,8 @@ export const createDetector = ( dataSourceId: string = '' ): APIAction => { const url = dataSourceId - ? `..${AD_NODE_API.DETECTOR}/${dataSourceId}` - : `..${AD_NODE_API.DETECTOR}`; - + ? `${AD_NODE_API.DETECTOR}/${dataSourceId}` + : `${AD_NODE_API.DETECTOR}`; return { type: CREATE_DETECTOR, request: (client: HttpSetup) => @@ -391,7 +390,7 @@ export const validateDetector = ( validationType: string, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/_validate/${validationType}`; + const baseUrl = `${AD_NODE_API.DETECTOR}/_validate/${validationType}`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { @@ -407,7 +406,7 @@ export const getDetector = ( detectorId: string, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorId}`; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorId}`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { @@ -422,7 +421,7 @@ export const getDetectorList = ( ): APIAction => { const dataSourceId = queryParams.dataSourceId || ''; - const baseUrl = `..${AD_NODE_API.DETECTOR}/_list`; + const baseUrl = `${AD_NODE_API.DETECTOR}/_list`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; @@ -436,7 +435,7 @@ export const getDetectorList = ( export const searchDetector = (requestBody: any): APIAction => ({ type: SEARCH_DETECTOR, request: (client: HttpSetup) => - client.post(`..${AD_NODE_API.DETECTOR}/_search`, { + client.post(`${AD_NODE_API.DETECTOR}/_search`, { body: JSON.stringify(requestBody), }), }); @@ -446,7 +445,7 @@ export const updateDetector = ( requestBody: Detector, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorId}`; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorId}`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { @@ -463,7 +462,7 @@ export const deleteDetector = ( detectorId: string, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorId}`; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorId}`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { @@ -477,7 +476,7 @@ export const startDetector = ( detectorId: string, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorId}/start`; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorId}/start`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { @@ -493,7 +492,7 @@ export const startHistoricalDetector = ( startTime: number, endTime: number ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorId}`; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorId}`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}/start` : `${baseUrl}/start`; @@ -517,7 +516,7 @@ export const stopDetector = ( detectorId: string, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorId}/stop/${false}`; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorId}/stop/${false}`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { @@ -531,7 +530,7 @@ export const stopHistoricalDetector = ( detectorId: string, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorId}/stop/${true}`; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorId}/stop/${true}`; const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { @@ -544,16 +543,16 @@ export const stopHistoricalDetector = ( export const getDetectorProfile = (detectorId: string): APIAction => ({ type: GET_DETECTOR_PROFILE, request: (client: HttpSetup) => - client.get(`..${AD_NODE_API.DETECTOR}/${detectorId}/_profile`), + client.get(`${AD_NODE_API.DETECTOR}/${detectorId}/_profile`), detectorId, }); export const matchDetector = ( - detectorName: string, + detectorName: string, dataSourceId: string = '' ): APIAction => { - const baseUrl = `..${AD_NODE_API.DETECTOR}/${detectorName}/_match`; - const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; + const baseUrl = `${AD_NODE_API.DETECTOR}/${detectorName}/_match`; + const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; return { type: MATCH_DETECTOR, @@ -562,9 +561,9 @@ export const matchDetector = ( }; export const getDetectorCount = (dataSourceId: string = ''): APIAction => { - const url = dataSourceId ? - `..${AD_NODE_API.DETECTOR}/_count/${dataSourceId}` : - `..${AD_NODE_API.DETECTOR}/_count`; + const url = dataSourceId ? + `${AD_NODE_API.DETECTOR}/_count/${dataSourceId}` : + `${AD_NODE_API.DETECTOR}/_count`; return { type: GET_DETECTOR_COUNT, diff --git a/public/redux/reducers/opensearch.ts b/public/redux/reducers/opensearch.ts index 77667b74..046b2c19 100644 --- a/public/redux/reducers/opensearch.ts +++ b/public/redux/reducers/opensearch.ts @@ -371,7 +371,7 @@ export const getMappings = ( return { type: GET_MAPPINGS, request: (client: HttpSetup) => - client.get(`..${url}`, { + client.get(`${url}`, { query: { indices: searchKey }, }), }; diff --git a/public/services.ts b/public/services.ts index 0c3d45dd..d582ac59 100644 --- a/public/services.ts +++ b/public/services.ts @@ -16,6 +16,7 @@ import { createGetterSetter } from '../../../src/plugins/opensearch_dashboards_u import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; import { SavedAugmentVisLoader } from '../../../src/plugins/vis_augmenter/public'; import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; +import { AssistantPublicPluginStart } from '../../../plugins/dashboards-assistant/public/'; export interface DataSourceEnabled { enabled: boolean; @@ -45,6 +46,12 @@ export const [getUISettings, setUISettings] = export const [getQueryService, setQueryService] = createGetterSetter('Query'); +export const [getAssistantEnabled, setAssistantEnabled] = + createGetterSetter('AssistantClient'); + +export const [getAssistantClient, setAssistantClient] = + createGetterSetter('AssistantClient'); + export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter('SavedObjectsClient'); @@ -54,10 +61,10 @@ export const [getDataSourceManagementPlugin, setDataSourceManagementPlugin] = export const [getDataSourceEnabled, setDataSourceEnabled] = createGetterSetter('DataSourceEnabled'); -export const [getNavigationUI, setNavigationUI] = +export const [getNavigationUI, setNavigationUI] = createGetterSetter('navigation'); -export const [getApplication, setApplication] = +export const [getApplication, setApplication] = createGetterSetter('application'); // This is primarily used for mocking this module and each of its fns in tests. diff --git a/public/utils/contextMenu/getActions.tsx b/public/utils/contextMenu/getActions.tsx index f58a7a9e..6bb6bf3e 100644 --- a/public/utils/contextMenu/getActions.tsx +++ b/public/utils/contextMenu/getActions.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@osd/i18n'; import { EuiIconType } from '@elastic/eui'; import { toMountPoint } from '../../../../../src/plugins/opensearch_dashboards_react/public'; -import { Action } from '../../../../../src/plugins/ui_actions/public'; +import { Action, createAction } from '../../../../../src/plugins/ui_actions/public'; import { createADAction } from '../../action/ad_dashboard_action'; import AnywhereParentFlyout from '../../components/FeatureAnywhereContextMenu/AnywhereParentFlyout'; import { Provider } from 'react-redux'; @@ -16,6 +16,9 @@ import DocumentationTitle from '../../components/FeatureAnywhereContextMenu/Docu import { AD_FEATURE_ANYWHERE_LINK, ANOMALY_DETECTION_ICON } from '../constants'; import { getClient, getOverlays } from '../../../public/services'; import { FLYOUT_MODES } from '../../../public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants'; +import SuggestAnomalyDetector from '../../../public/components/DiscoverAction/SuggestAnomalyDetector'; + +export const ACTION_SUGGEST_AD = 'suggestAnomalyDetector'; // This is used to create all actions in the same context menu const grouping: Action['grouping'] = [ @@ -87,3 +90,31 @@ export const getActions = () => { }, ].map((options) => createADAction({ ...options, grouping })); }; + +export const getSuggestAnomalyDetectorAction = () => { + const onClick = async function () { + const overlayService = getOverlays(); + const openFlyout = overlayService.openFlyout; + const store = configureStore(getClient()); + const overlay = openFlyout( + toMountPoint( + + overlay.close()} + /> + + ) + ); + } + + return createAction({ + id: 'suggestAnomalyDetector', + order: 100, + type: ACTION_SUGGEST_AD, + getDisplayName: () => 'Suggest anomaly detector', + getIconType: () => ANOMALY_DETECTION_ICON, + execute: async () => { + onClick(); + }, + }); +} diff --git a/server/utils/constants.ts b/server/utils/constants.ts index ac3c887a..1a756187 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -132,3 +132,5 @@ export const HISTORICAL_TASK_TYPES = [ ]; export const CUSTOM_AD_RESULT_INDEX_PREFIX = 'opensearch-ad-plugin-result-'; + +export const SUGGEST_ANOMALY_DETECTOR_CONFIG_ID = 'os_suggest_ad';