Skip to content

Commit

Permalink
Do not show suggestAD action if the data source has no AI agent (#901) (
Browse files Browse the repository at this point in the history
#905)

Signed-off-by: gaobinlong <[email protected]>
(cherry picked from commit 7b60fed)

Co-authored-by: gaobinlong <[email protected]>
  • Loading branch information
opensearch-trigger-bot[bot] and gaobinlong authored Oct 23, 2024
1 parent 03cc295 commit 1e31334
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 65 deletions.
136 changes: 90 additions & 46 deletions public/components/DiscoverAction/SuggestAnomalyDetector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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!'
);
});
});
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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'
);
});
});
});
});
60 changes: 48 additions & 12 deletions public/components/DiscoverAction/SuggestAnomalyDetector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'
);
Expand All @@ -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<string[]>([]);
const [feedbackResult, setFeedbackResult] = useState<boolean | undefined>(undefined);
const [timeFieldName, setTimeFieldName] = useState(dataset.timeFieldName || '');

// let LLM to generate parameters for creating anomaly detector
async function getParameters() {
Expand All @@ -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);
Expand All @@ -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(',');
Expand All @@ -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;
Expand All @@ -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();
}, []);
Expand Down
32 changes: 25 additions & 7 deletions public/utils/contextMenu/getActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -99,22 +107,32 @@ export const getSuggestAnomalyDetectorAction = () => {
const overlay = openFlyout(
toMountPoint(
<Provider store={store}>
<SuggestAnomalyDetector
closeFlyout={() => overlay.close()}
/>
<SuggestAnomalyDetector closeFlyout={() => overlay.close()} />
</Provider>
)
);
}
};

return createAction({
id: 'suggestAnomalyDetector',
order: 100,
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();
},
});
}
};

0 comments on commit 1e31334

Please sign in to comment.