diff --git a/public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx b/public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx index ac4479a5..d93ce71e 100644 --- a/public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx +++ b/public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx @@ -20,6 +20,7 @@ import SuggestAnomalyDetector from './SuggestAnomalyDetector'; import userEvent from '@testing-library/user-event'; import { HttpFetchOptionsWithPath } from '../../../../../src/core/public'; import { getAssistantClient, getQueryService, getUsageCollection } from '../../services'; +import { getMappings } from '../../redux/reducers/opensearch'; const notifications = { toasts: { @@ -131,6 +132,23 @@ describe('GenerateAnomalyDetector spec', () => { (getAssistantClient().agentConfigExists as jest.Mock).mockResolvedValueOnce({ exists: true }); + + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: true, + response: { + mappings: { + test: { + mappings: { + properties: { + field: { + type: 'date', + } + } + } + } + } + }, + }); }); it('renders with empty generated parameters', async () => { @@ -176,7 +194,7 @@ describe('GenerateAnomalyDetector spec', () => { 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!' + 'Generate parameters for creating anomaly detector failed, reason: Error: Cannot find aggregation field, aggregation method or date fields!' ); }); }); @@ -255,41 +273,11 @@ describe('GenerateAnomalyDetector spec', () => { }); }); - describe('Test agent not configured', () => { - 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().agentConfigExists as jest.Mock).mockResolvedValueOnce({ - exists: false - }); - - 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: Agent for suggest anomaly detector not found, please configure an agent firstly!' - ); - }); - }); - }); - describe('Test feedback', () => { let reportUiStatsMock: any; beforeEach(() => { + jest.clearAllMocks(); const queryService = getQueryService(); queryService.queryString.getQuery.mockReturnValue({ dataset: { @@ -404,6 +392,40 @@ describe('GenerateAnomalyDetector spec', () => { (getAssistantClient().agentConfigExists as jest.Mock).mockResolvedValueOnce({ exists: true }); + + httpClientMock.get = jest.fn((pathOrOptions: string | HttpFetchOptionsWithPath) => { + const url = typeof pathOrOptions === 'string' ? pathOrOptions : pathOrOptions.path; + switch (url) { + case '/api/anomaly_detectors/_mappings': + return Promise.resolve({ + ok: true, + response: { + mappings: { + test: { + mappings: { + properties: { + field: { + type: 'date', + } + } + } + } + } + }, + }); + case '/api/anomaly_detectors/detectors/_count': + return Promise.resolve({ + ok: true, + response: { + count: 0 + }, + }); + default: + return Promise.resolve({ + ok: true + }); + } + }); }); it('All API calls execute successfully', async () => { @@ -494,13 +516,6 @@ describe('GenerateAnomalyDetector spec', () => { } }); - httpClientMock.get = jest.fn().mockResolvedValue({ - ok: true, - response: { - count: 0 - }, - }); - const { queryByText, getByTestId } = renderWithRouter(); expect(queryByText('Suggested anomaly detector')).not.toBeNull(); @@ -546,13 +561,6 @@ describe('GenerateAnomalyDetector spec', () => { } }); - httpClientMock.get = jest.fn().mockResolvedValue({ - ok: true, - response: { - count: 0 - }, - }); - (getAssistantClient().executeAgentByConfigName as jest.Mock).mockResolvedValueOnce({ body: { inference_results: [ @@ -587,4 +595,40 @@ describe('GenerateAnomalyDetector spec', () => { }); }); }); + + describe('Test getting index mapping failed', () => { + beforeEach(() => { + jest.clearAllMocks(); + const queryService = getQueryService(); + queryService.queryString.getQuery.mockReturnValue({ + dataset: { + id: 'test-pattern', + title: 'test-pattern', + type: 'INDEX_PATTERN', + timeFieldName: '@timestamp', + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders with getting index mapping failed', async () => { + httpClientMock.get = jest.fn().mockResolvedValue({ + ok: false, + error: 'failed to get index mapping' + }); + + const { queryByText } = renderWithRouter(); + expect(queryByText('Suggested anomaly detector')).not.toBeNull(); + + await waitFor(() => { + expect(getNotifications().toasts.addDanger).toHaveBeenCalledTimes(1); + expect(getNotifications().toasts.addDanger).toHaveBeenCalledWith( + 'failed to get index mapping' + ); + }); + }); + }); }); diff --git a/public/components/DiscoverAction/SuggestAnomalyDetector.tsx b/public/components/DiscoverAction/SuggestAnomalyDetector.tsx index 1a3e202e..e6923022 100644 --- a/public/components/DiscoverAction/SuggestAnomalyDetector.tsx +++ b/public/components/DiscoverAction/SuggestAnomalyDetector.tsx @@ -77,6 +77,7 @@ import { formikToDetectorName } from '../FeatureAnywhereContextMenu/CreateAnomal import { DEFAULT_DATA } from '../../../../../src/plugins/data/common'; import { AppState } from '../../redux/reducers'; import { v4 as uuidv4 } from 'uuid'; +import { getPathsPerDataType } from '../../redux/reducers/mapper'; export interface GeneratedParameters { categoryField: string; @@ -107,8 +108,7 @@ function SuggestAnomalyDetector({ 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) { + if (!indexPatternId || !indexName) { notifications.toasts.addDanger( 'Cannot extract complete index info from the context' ); @@ -135,11 +135,12 @@ function SuggestAnomalyDetector({ ); const categoricalFields = getCategoryFields(indexDataTypes); - const dateFields = get(indexDataTypes, 'date', []) as string[]; - const dateNanoFields = get(indexDataTypes, 'date_nanos', []) as string[]; - const allDateFields = dateFields.concat(dateNanoFields); - + // const dateFields = get(indexDataTypes, 'date', []) as string[]; + // const dateNanoFields = get(indexDataTypes, 'date_nanos', []) as string[]; + // const allDateFields = dateFields.concat(dateNanoFields); + const [allDateFields, setAllDateFields] = useState([]); const [feedbackResult, setFeedbackResult] = useState(undefined); + const [timeFieldName, setTimeFieldName] = useState(dataset.timeFieldName || ''); // let LLM to generate parameters for creating anomaly detector async function getParameters() { @@ -166,6 +167,16 @@ function SuggestAnomalyDetector({ initialDetectorValue.categoryFieldEnabled = !!generatedParameters.categoryField; initialDetectorValue.categoryField = initialDetectorValue.categoryFieldEnabled ? [generatedParameters.categoryField] : []; + // if the dataset has no time field, then we find a root level field from the mapping, or we use the first one as the default time field + if (!timeFieldName) { + if (generatedParameters.dateFields.length == 0) { + throw new Error('Cannot find any date type fields!'); + } + const defaultTimeField = generatedParameters.dateFields.find(dateField => !dateField.includes('.')) || generatedParameters.dateFields[0]; + setTimeFieldName(defaultTimeField); + initialDetectorValue.timeField = defaultTimeField; + } + setIsLoading(false); setButtonName('Create detector'); setCategoryFieldEnabled(!!generatedParameters.categoryField); @@ -183,7 +194,7 @@ function SuggestAnomalyDetector({ const rawAggregationMethods = rawGeneratedParameters['aggregationMethod']; const rawDataFields = rawGeneratedParameters['dateFields']; if (!rawAggregationFields || !rawAggregationMethods || !rawDataFields) { - throw new Error('Cannot find aggregation field, aggregation method or data fields!'); + throw new Error('Cannot find aggregation field, aggregation method or date fields!'); } const aggregationFields = rawAggregationFields.split(','); @@ -196,19 +207,21 @@ function SuggestAnomalyDetector({ } const featureList = aggregationFields.map((field: string, index: number) => { - const method = aggregationMethods[index]; + let method = aggregationMethods[index]; if (!field || !method) { throw new Error('The generated aggregation field or aggregation method is empty!'); } + // for the count aggregation method, display name and actual name are different, need to convert the display name to actual name + method = method.replace('count', 'value_count'); const aggregationOption = { label: field, }; const feature: FeaturesFormikValues = { - featureName: `feature_${field}`, + featureName: `feature_${field}`.substring(0, 64), featureType: FEATURE_TYPE.SIMPLE, featureEnabled: true, aggregationQuery: '', - aggregationBy: aggregationMethods[index], + aggregationBy: method, aggregationOf: [aggregationOption], }; return feature; @@ -223,8 +236,31 @@ function SuggestAnomalyDetector({ useEffect(() => { async function fetchData() { - await dispatch(getMappings(indexName, dataSourceId)); - await getParameters(); + await dispatch(getMappings(indexName, dataSourceId)) + .then(async (result: any) => { + const indexDataTypes = getPathsPerDataType(result.response.mappings); + const dateFields = get(indexDataTypes, 'date', []) as string[]; + const dateNanoFields = get(indexDataTypes, 'date_nanos', []) as string[]; + const allDateFields = dateFields.concat(dateNanoFields); + setAllDateFields(allDateFields); + if (allDateFields.length == 0) { + notifications.toasts.addDanger( + 'Cannot find any date type fields!' + ); + } else { + await getParameters(); + } + }) + .catch((err: any) => { + notifications.toasts.addDanger( + prettifyErrorMessage( + getErrorMessage( + err, + 'There was a problem getting the index mapping' + ) + ) + ); + }); } fetchData(); }, []); diff --git a/public/utils/contextMenu/getActions.tsx b/public/utils/contextMenu/getActions.tsx index 6bb6bf3e..e0745a17 100644 --- a/public/utils/contextMenu/getActions.tsx +++ b/public/utils/contextMenu/getActions.tsx @@ -7,16 +7,24 @@ 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, createAction } 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'; import configureStore from '../../redux/configureStore'; import DocumentationTitle from '../../components/FeatureAnywhereContextMenu/DocumentationTitle/containers/DocumentationTitle'; import { AD_FEATURE_ANYWHERE_LINK, ANOMALY_DETECTION_ICON } from '../constants'; -import { getClient, getOverlays } from '../../../public/services'; +import { + getAssistantClient, + getClient, + getOverlays, +} from '../../../public/services'; import { FLYOUT_MODES } from '../../../public/components/FeatureAnywhereContextMenu/AnywhereParentFlyout/constants'; import SuggestAnomalyDetector from '../../../public/components/DiscoverAction/SuggestAnomalyDetector'; +import { SUGGEST_ANOMALY_DETECTOR_CONFIG_ID } from '../../../server/utils/constants'; export const ACTION_SUGGEST_AD = 'suggestAnomalyDetector'; @@ -99,13 +107,11 @@ export const getSuggestAnomalyDetectorAction = () => { const overlay = openFlyout( toMountPoint( - overlay.close()} - /> + overlay.close()} /> ) ); - } + }; return createAction({ id: 'suggestAnomalyDetector', @@ -113,8 +119,20 @@ export const getSuggestAnomalyDetectorAction = () => { type: ACTION_SUGGEST_AD, getDisplayName: () => 'Suggest anomaly detector', getIconType: () => ANOMALY_DETECTION_ICON, + // suggestAD is only compatible with data sources that have certain agents configured + isCompatible: async (context) => { + if (context.datasetId) { + const assistantClient = getAssistantClient(); + const res = await assistantClient.agentConfigExists( + SUGGEST_ANOMALY_DETECTOR_CONFIG_ID, + { dataSourceId: context.dataSourceId } + ); + return res.exists; + } + return false; + }, execute: async () => { onClick(); }, }); -} +}; \ No newline at end of file