Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 2.18] Do not show suggestAD action if the data source has no AI agent #906

Merged
merged 1 commit into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
},
});
}
};
Loading