diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index c5d55e96..551bd678 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -7,7 +7,8 @@ ], "optionalPlugins": [ "dataSource", - "dataSourceManagement" + "dataSourceManagement", + "dataExplorer" ], "requiredPlugins": [ "opensearchDashboardsUtils", diff --git a/public/components/DiscoverAction/GenerateAnomalyDetector.tsx b/public/components/DiscoverAction/GenerateAnomalyDetector.tsx new file mode 100644 index 00000000..357a0de6 --- /dev/null +++ b/public/components/DiscoverAction/GenerateAnomalyDetector.tsx @@ -0,0 +1,853 @@ +/* + * 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 } from 'react-redux'; +import { isEmpty, get } from 'lodash'; +import { + Field, + FieldArray, + FieldArrayRenderProps, + FieldProps, + Formik, +} from 'formik'; +import { + createDetector, + getDetectorCount, + matchDetector, + startDetector, +} from '../../../public/redux/reducers/ad'; +import { + getError, + getErrorMessage, + isInvalid, + validateCategoryField, + validateDetectorName, + validateNonNegativeInteger, + validatePositiveInteger, +} from '../../../public/utils/utils'; +import { + CUSTOM_AD_RESULT_INDEX_PREFIX, + MAX_DETECTORS, +} from '../../../server/utils/constants'; +import { + focusOnFirstWrongFeature, + initialFeatureValue, + validateFeatures, +} from '../../../public/pages/ConfigureModel/utils/helpers'; +import { formikToDetector } from '../../../public/pages/ReviewAndCreate/utils/helpers'; +import { FormattedFormRow } from '../../../public/components/FormattedFormRow/FormattedFormRow'; +import { FeatureAccordion } from '../../../public/pages/ConfigureModel/components/FeatureAccordion'; +import { AD_DOCS_LINK, DEFAULT_SHINGLE_SIZE, MAX_FEATURE_NUM, PLUGIN_NAME } from '../../../public/utils/constants'; +import { getNotifications } from '../../services'; +import { prettifyErrorMessage } from '../../../server/utils/helpers'; +import EnhancedAccordion from '../FeatureAnywhereContextMenu/EnhancedAccordion'; +import MinimalAccordion from '../FeatureAnywhereContextMenu/MinimalAccordion'; +import { DataFilterList } from '../../../public/pages/DefineDetector/components/DataFilterList/DataFilterList'; +import { generateParameters } from '../../../public/redux/reducers/assistant'; +import { FEATURE_TYPE } from '../../../public/models/interfaces'; +import { FeaturesFormikValues } from '../../../public/pages/ConfigureModel/models/interfaces'; +import { DiscoverActionContext } from '../../../../../src/plugins/data_explorer/public/types'; +import { getMappings } from '../../../public/redux/reducers/opensearch'; +import { mountReactNode } from '../../../../../src/core/public/utils'; + +export interface GeneratedParameters { + categoryField: string; + features: FeaturesFormikValues[]; + dateFields: string[]; +} + +function GenerateAnomalyDetector({ + closeFlyout, + context, +}: { + closeFlyout: any; + context: DiscoverActionContext; +}) { + const dispatch = useDispatch(); + const notifications = getNotifications(); + const indexPatternId = context.indexPattern?.id; + const indexPatternName = context.indexPattern?.title; + if (!indexPatternId || !indexPatternName) { + notifications.toasts.addDanger( + 'Cannot extract index pattern from the context' + ); + return <>; + } + + const dataSourceId = context.indexPattern?.dataSourceRef?.id; + const timeFieldFromIndexPattern = context.indexPattern?.timeFieldName; + const fieldsFromContext = context.indexPattern?.fields || []; + const [categoricalFields, dateFields] = fieldsFromContext.reduce( + ([cFields, dFields], indexPatternField) => { + const esType = indexPatternField.spec.esTypes?.[0]; + const name = indexPatternField.spec.name; + if (esType === 'keyword' || esType === 'ip') { + cFields.push(name); + } else if (esType === 'date') { + dFields.push(name); + } + return [cFields, dFields]; + }, + [[], []] as [string[], string[]] + ) || [[], []]; + + 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( + indexPatternName.replace('*', '-') + '_anomaly_detector' + ); + + // let LLM to generate parameters for creating anomaly detector + async function getParameters() { + try { + const result = await dispatch( + generateParameters(indexPatternName!, dataSourceId) + ); + const rawGeneratedParameters = get(result, 'generatedParameters'); + if (!rawGeneratedParameters) { + throw new Error('Cannot get generated parameters'); + } + + const generatedParameters = formatGeneratedParameters(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 getParameters(); + const getMappingDispatchCall = dispatch( + getMappings(indexPatternName, dataSourceId) + ); + await Promise.all([getMappingDispatchCall]); + } + 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: indexPatternName }], + timeField: timeFieldFromIndexPattern, + 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) => ( + <> + + +

+ Generate anomaly detector +

+
+
+ +
+ +

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: timeFieldFromIndexPattern }] + } + 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" + /> + ) + )} + + + + = MAX_FEATURE_NUM + } + onClick={() => { + push(initialFeatureValue()); + }} + > + 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 GenerateAnomalyDetector; diff --git a/public/plugin.ts b/public/plugin.ts index 95274204..68eedfcf 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -54,6 +54,8 @@ import { DataPublicPluginStart } from '../../../src/plugins/data/public'; import { DataSourceManagementPluginSetup } from '../../../src/plugins/data_source_management/public'; import { DataSourcePluginSetup } from '../../../src/plugins/data_source/public'; import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; +import { getDiscoverAction } from './utils/discoverAction'; +import { DataExplorerPluginSetup } from '../../../src/plugins/data_explorer/public'; declare module '../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { @@ -68,6 +70,7 @@ export interface AnomalyDetectionSetupDeps { visAugmenter: VisAugmenterSetup; dataSourceManagement: DataSourceManagementPluginSetup; dataSource: DataSourcePluginSetup; + dataExplorer: DataExplorerPluginSetup; } export interface AnomalyDetectionStartDeps { @@ -102,7 +105,7 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin }); // register applications with category and use case information - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability,[ + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ { id: PLUGIN_NAME, category: DEFAULT_APP_CATEGORIES.detect, @@ -121,7 +124,7 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin const [coreStart] = await core.getStartServices(); return renderApp(coreStart, params, APP_PATH.OVERVIEW, hideInAppSideNavBar); }, - }); + }); } if (core.chrome.navGroup.getNavGroupEnabled()) { @@ -135,7 +138,7 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin const [coreStart] = await core.getStartServices(); return renderApp(coreStart, params, APP_PATH.DASHBOARD, hideInAppSideNavBar); }, - }); + }); } if (core.chrome.navGroup.getNavGroupEnabled()) { @@ -149,15 +152,15 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin const [coreStart] = await core.getStartServices(); return renderApp(coreStart, params, APP_PATH.LIST_DETECTORS, hideInAppSideNavBar); }, - }); + }); } // link the sub applications to the parent application core.chrome.navGroup.addNavLinksToGroup( DEFAULT_NAV_GROUPS.observability, [{ - id: OVERVIEW_PAGE_NAV_ID, - parentNavLinkId: PLUGIN_NAME + id: OVERVIEW_PAGE_NAV_ID, + parentNavLinkId: PLUGIN_NAME }, { id: DASHBOARD_PAGE_NAV_ID, @@ -189,6 +192,10 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); }); + // Add action to Discover + const discoverAction = getDiscoverAction(); + plugins.dataExplorer.registerDiscoverAction(discoverAction); + // registers the expression function used to render anomalies on an Augmented Visualization plugins.expressions.registerFunction(overlayAnomaliesFunction); return {}; diff --git a/public/redux/reducers/__tests__/ad.test.ts b/public/redux/reducers/__tests__/ad.test.ts index 79bd8f48..b99b82ba 100644 --- a/public/redux/reducers/__tests__/ad.test.ts +++ b/public/redux/reducers/__tests__/ad.test.ts @@ -54,7 +54,7 @@ describe('detector reducer actions', () => { }, }); 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 cb434c0c..bfd3c99c 100644 --- a/public/redux/reducers/__tests__/opensearch.test.ts +++ b/public/redux/reducers/__tests__/opensearch.test.ts @@ -173,7 +173,7 @@ describe('opensearch reducer actions', () => { }, }); expect(httpMockedClient.get).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/_mappings`, + `${BASE_NODE_API_PATH}/_mappings`, { query: { index: '' }, } @@ -200,7 +200,7 @@ describe('opensearch reducer actions', () => { errorMessage: 'Something went wrong', }); expect(httpMockedClient.get).toHaveBeenCalledWith( - `..${BASE_NODE_API_PATH}/_mappings`, + `${BASE_NODE_API_PATH}/_mappings`, { query: { index: '' }, } @@ -278,5 +278,5 @@ describe('opensearch reducer actions', () => { } }); }); - describe('getPrioritizedIndices', () => {}); + describe('getPrioritizedIndices', () => { }); }); 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/assistant.ts b/public/redux/reducers/assistant.ts new file mode 100644 index 00000000..7a359e18 --- /dev/null +++ b/public/redux/reducers/assistant.ts @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { APIAction, APIResponseAction, HttpSetup } from '../middleware/types'; +import handleActions from '../utils/handleActions'; +import { ASSISTANT_NODE_API } from '../../../utils/constants'; + +const GENERATE_PARAMETERS = 'assistant/GENERATE_PARAMETERS'; + +export interface GeneratedParametersState { + requesting: boolean; + errorMessage: string; +} + +export const initialState: GeneratedParametersState = { + requesting: false, + errorMessage: '', +}; + +const reducer = handleActions( + { + [GENERATE_PARAMETERS]: { + REQUEST: (state: GeneratedParametersState): GeneratedParametersState => ({ + ...state, + requesting: true, + errorMessage: '', + }), + SUCCESS: ( + state: GeneratedParametersState, + action: APIResponseAction + ): GeneratedParametersState => ({ + ...state, + requesting: false, + }), + FAILURE: ( + state: GeneratedParametersState, + action: APIResponseAction + ): GeneratedParametersState => ({ + ...state, + requesting: false, + errorMessage: action.error, + }), + }, + }, + initialState +); + +export const generateParameters = ( + index: string, + dataSourceId: string = '' +): APIAction => { + const baseUrl = `${ASSISTANT_NODE_API.GENERATE_PARAMETERS}`; + const url = dataSourceId ? `${baseUrl}/${dataSourceId}` : baseUrl; + return { + type: GENERATE_PARAMETERS, + request: (client: HttpSetup) => + client.post(url, { + body: JSON.stringify({ index: index }), + }), + }; +}; + +export default reducer; diff --git a/public/redux/reducers/opensearch.ts b/public/redux/reducers/opensearch.ts index 4a9a3d32..9ef6354e 100644 --- a/public/redux/reducers/opensearch.ts +++ b/public/redux/reducers/opensearch.ts @@ -279,7 +279,7 @@ export const getMappings = (searchKey: string = '', dataSourceId: string = ''): return { type: GET_MAPPINGS, request: (client: HttpSetup) => - client.get(`..${url}`, { + client.get(`${url}`, { query: { index: searchKey }, }), }; diff --git a/public/utils/discoverAction.tsx b/public/utils/discoverAction.tsx new file mode 100644 index 00000000..6ed2c85a --- /dev/null +++ b/public/utils/discoverAction.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' +import { ANOMALY_DETECTION_ICON } from "./constants"; +import GenerateAnomalyDetector from "../components/DiscoverAction/GenerateAnomalyDetector"; +import { getClient, getOverlays } from '../../public/services'; +import { toMountPoint } from "../../../../src/plugins/opensearch_dashboards_react/public"; +import { Provider } from "react-redux"; +import configureStore from '../redux/configureStore'; +import { DiscoverAction, DiscoverActionContext } from "../../../../src/plugins/data_explorer/public/types"; + +export const getDiscoverAction = (): DiscoverAction => { + const onClick = function (context: DiscoverActionContext) { + const overlayService = getOverlays(); + const openFlyout = overlayService.openFlyout; + const store = configureStore(getClient()); + const overlay = openFlyout( + toMountPoint( + + overlay.close()} + context={context} + /> + + ) + ); + } + + return { + order: 0, + name: 'Generate anomaly detector', + iconType: ANOMALY_DETECTION_ICON, + onClick: onClick, + } +}; diff --git a/server/cluster/ad/mlPlugin.ts b/server/cluster/ad/mlPlugin.ts new file mode 100644 index 00000000..ad39f085 --- /dev/null +++ b/server/cluster/ad/mlPlugin.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export default function mlPlugin( + Client: any, + config: any, + components: any +) { + const ca = components.clientAction.factory; + + Client.prototype.ml = components.clientAction.namespaceFactory(); + const ml = Client.prototype.ml.prototype; + + ml.getAgent = ca({ + url: { + fmt: `/_plugins/_ml/config/<%=id%>`, + req: { + id: { + type: 'string', + required: true, + }, + }, + }, + method: 'GET', + }); + + ml.executeAgent = ca({ + url: { + fmt: `/_plugins/_ml/agents/<%=agentId%>/_execute`, + req: { + agentId: { + type: 'string', + required: true, + }, + }, + }, + needBody: true, + method: 'POST', + }); +} diff --git a/server/plugin.ts b/server/plugin.ts index a6dfc3b0..8a40c9ec 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -37,6 +37,8 @@ import SampleDataService, { import { DEFAULT_HEADERS } from './utils/constants'; import { DataSourcePluginSetup } from '../../../src/plugins/data_source/server/types'; import { DataSourceManagementPlugin } from '../../../src/plugins/data_source_management/public'; +import AssistantService, { registerAssistantRoutes } from './routes/assistant'; +import mlPlugin from './cluster/ad/mlPlugin'; export interface ADPluginSetupDependencies { dataSourceManagement?: ReturnType; @@ -45,10 +47,10 @@ export interface ADPluginSetupDependencies { export class AnomalyDetectionOpenSearchDashboardsPlugin implements - Plugin< - AnomalyDetectionOpenSearchDashboardsPluginSetup, - AnomalyDetectionOpenSearchDashboardsPluginStart - > + Plugin< + AnomalyDetectionOpenSearchDashboardsPluginSetup, + AnomalyDetectionOpenSearchDashboardsPluginStart + > { private readonly logger: Logger; private readonly globalConfig$: any; @@ -69,7 +71,7 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin const client: ILegacyClusterClient = core.opensearch.legacy.createClient( 'anomaly_detection', { - plugins: [adPlugin, alertingPlugin], + plugins: [adPlugin, alertingPlugin, mlPlugin], customHeaders: { ...customHeaders, ...DEFAULT_HEADERS }, ...rest, } @@ -80,6 +82,7 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin if (dataSourceEnabled) { dataSource.registerCustomApiSchema(adPlugin); dataSource.registerCustomApiSchema(alertingPlugin); + dataSource.registerCustomApiSchema(mlPlugin); } // Create router @@ -93,12 +96,14 @@ export class AnomalyDetectionOpenSearchDashboardsPlugin const alertingService = new AlertingService(client, dataSourceEnabled); const opensearchService = new OpenSearchService(client, dataSourceEnabled); const sampleDataService = new SampleDataService(client, dataSourceEnabled); + const assistantService = new AssistantService(client, dataSourceEnabled); // Register server routes with the service registerADRoutes(apiRouter, adService); registerAlertingRoutes(apiRouter, alertingService); registerOpenSearchRoutes(apiRouter, opensearchService); registerSampleDataRoutes(apiRouter, sampleDataService); + registerAssistantRoutes(apiRouter, assistantService); return {}; } diff --git a/server/routes/assistant.ts b/server/routes/assistant.ts new file mode 100644 index 00000000..4a620e59 --- /dev/null +++ b/server/routes/assistant.ts @@ -0,0 +1,104 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +//@ts-ignore +import { get, set } from 'lodash'; +import { Router } from '../router'; +import { getErrorMessage } from './utils/adHelpers'; +import { + RequestHandlerContext, + OpenSearchDashboardsRequest, + OpenSearchDashboardsResponseFactory, + IOpenSearchDashboardsResponse, +} from '../../../../src/core/server'; +import { getClientBasedOnDataSource } from '../utils/helpers'; +import { GENERATE_ANOMALY_DETECTOR_CONFIG_ID } from '../utils/constants'; + +export function registerAssistantRoutes( + apiRouter: Router, + assistantService: AssistantService +) { + apiRouter.post('/_generate_parameters', assistantService.generateParameters); +} + +export default class AssistantService { + private client: any; + dataSourceEnabled: boolean; + + constructor(client: any, dataSourceEnabled: boolean) { + this.client = client; + this.dataSourceEnabled = dataSourceEnabled; + } + + generateParameters = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + opensearchDashboardsResponse: OpenSearchDashboardsResponseFactory + ): Promise> => { + try { + const { dataSourceId = '' } = request.params as { dataSourceId?: string }; + const { index } = request.body as { index: string }; + if (!index) { + throw new Error('index cannot be empty'); + } + const callWithRequest = getClientBasedOnDataSource( + context, + this.dataSourceEnabled, + request, + dataSourceId, + this.client + ); + + const getAgentResponse = await callWithRequest('ml.getAgent', { + id: GENERATE_ANOMALY_DETECTOR_CONFIG_ID, + }); + + if ( + !getAgentResponse || + !getAgentResponse['configuration'] || + !getAgentResponse['configuration']['agent_id'] + ) { + throw new Error( + 'Cannot get flow agent id for generating anomaly detector' + ); + } + + const agentId = getAgentResponse['configuration']['agent_id']; + + const executeAgentResponse = await callWithRequest('ml.executeAgent', { + agentId: agentId, + body: { + parameters: { + index: index, + }, + }, + }); + if ( + !executeAgentResponse || + !executeAgentResponse['inference_results'] || + !executeAgentResponse['inference_results'][0].output[0] || + !executeAgentResponse['inference_results'][0].output[0].result + ) { + throw new Error('Execute agent for generating anomaly detector failed'); + } + + return opensearchDashboardsResponse.ok({ + body: { + ok: true, + generatedParameters: JSON.parse( + executeAgentResponse['inference_results'][0].output[0].result + ), + }, + }); + } catch (err) { + return opensearchDashboardsResponse.ok({ + body: { + ok: false, + error: getErrorMessage(err), + }, + }); + } + }; +} diff --git a/server/utils/constants.ts b/server/utils/constants.ts index ac3c887a..19902ebb 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 GENERATE_ANOMALY_DETECTOR_CONFIG_ID = 'generate_anomaly_detector'; diff --git a/utils/constants.ts b/utils/constants.ts index 231bd91b..6546e87a 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -27,3 +27,6 @@ export const ALERTING_NODE_API = Object.freeze({ ALERTS: `${BASE_NODE_API_PATH}/monitors/alerts`, MONITORS: `${BASE_NODE_API_PATH}/monitors`, }); +export const ASSISTANT_NODE_API = Object.freeze({ + GENERATE_PARAMETERS: `${BASE_NODE_API_PATH}/_generate_parameters`, +});