diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx index 730c88413833d..09b186903c0bd 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.test.tsx @@ -88,4 +88,34 @@ describe('NewChat', () => { expect(mockUseAssistantOverlay.showAssistantOverlay).toHaveBeenCalledWith(true); }); + + it('renders new chat as link', () => { + render(); + + const newChatLink = screen.getByTestId('newChatLink'); + + expect(newChatLink).toBeInTheDocument(); + }); + + it('calls onShowOverlay callback on click', () => { + const onShowOverlaySpy = jest.fn(); + render(); + + const newChatButton = screen.getByTestId('newChat'); + + userEvent.click(newChatButton); + + expect(onShowOverlaySpy).toHaveBeenCalled(); + }); + + it('calls onShowOverlay callback on click for link', () => { + const onShowOverlaySpy = jest.fn(); + render(); + + const newChatLink = screen.getByTestId('newChatLink'); + + userEvent.click(newChatLink); + + expect(onShowOverlaySpy).toHaveBeenCalled(); + }); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx index a4793dfd25a9d..d45f94b7d0b49 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/new_chat/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiLink } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { PromptContext } from '../assistant/prompt_context/types'; @@ -17,7 +17,7 @@ export type Props = Omit & { children?: React.ReactNode; /** Optionally automatically add this context to a conversation when the assistant is shown */ conversationId?: string; - /** Defaults to `discuss`. If null, the button will not have an icon */ + /** Defaults to `discuss`. If null, the button will not have an icon. Not available for link */ iconType?: string | null; /** Optionally specify a well known ID, or default to a UUID */ promptContextId?: string; @@ -25,6 +25,10 @@ export type Props = Omit & { color?: 'text' | 'accent' | 'primary' | 'success' | 'warning' | 'danger'; /** Required to identify the availability of the Assistant for the current license level */ isAssistantEnabled: boolean; + /** Optionally render new chat as a link */ + asLink?: boolean; + /** Optional callback when overlay shows */ + onShowOverlay?: () => void; }; const NewChatComponent: React.FC = ({ @@ -39,6 +43,8 @@ const NewChatComponent: React.FC = ({ suggestedUserPrompt, tooltip, isAssistantEnabled, + asLink = false, + onShowOverlay, }) => { const { showAssistantOverlay } = useAssistantOverlay( category, @@ -53,7 +59,8 @@ const NewChatComponent: React.FC = ({ const showOverlay = useCallback(() => { showAssistantOverlay(true); - }, [showAssistantOverlay]); + onShowOverlay?.(); + }, [showAssistantOverlay, onShowOverlay]); const icon = useMemo(() => { if (iconType === null) { @@ -64,12 +71,22 @@ const NewChatComponent: React.FC = ({ }, [iconType]); return useMemo( - () => ( - - {children} - - ), - [children, icon, showOverlay, color] + () => + asLink ? ( + + {children} + + ) : ( + + {children} + + ), + [children, icon, showOverlay, color, asLink] ); }; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 53c5bdd8a657e..526e9943ce703 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -153,6 +153,11 @@ export const allowedExperimentalValues = Object.freeze({ */ protectionUpdatesEnabled: true, + /** + * Enables AI assistant on rule creation form when query has error + */ + AIAssistantOnRuleCreationFormEnabled: false, + /** * Disables the timeline save tour. * This flag is used to disable the tour in cypress tests. diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts index d16fd182928de..4d7c8f180d0ec 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts @@ -46,6 +46,9 @@ export enum TELEMETRY_EVENT { ADD_INVESTIGATION_FIELDS = 'add_investigation_fields', SET_INVESTIGATION_FIELDS = 'set_investigation_fields', DELETE_INVESTIGATION_FIELDS = 'delete_investigation_fields', + + // AI assistant on rule creation form + OPEN_ASSISTANT_ON_RULE_QUERY_ERROR = 'open_assistant_on_rule_query_error', } export enum TelemetryEventTypes { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts index 1f0bcb6596b40..484f78c53f0e0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/esql_validator.ts @@ -66,7 +66,7 @@ export const esqlValidator = async ( if (isMissingMetadataOperator) { return { code: ERROR_CODES.ERR_MISSING_ID_FIELD_FROM_RESULT, - message: i18n.ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR, + message: i18n.ESQL_VALIDATION_MISSING_METADATA_OPERATOR_IN_QUERY_ERROR, }; } @@ -84,7 +84,7 @@ export const esqlValidator = async ( if (!isEsqlQueryAggregating && !isIdFieldPresent) { return { code: ERROR_CODES.ERR_MISSING_ID_FIELD_FROM_RESULT, - message: i18n.ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR, + message: i18n.ESQL_VALIDATION_MISSING_ID_FIELD_IN_QUERY_ERROR, }; } } catch (error) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts index 21dbb474b1235..245924b3d7e0a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/logic/translations.ts @@ -20,8 +20,15 @@ export const esqlValidationErrorMessage = (message: string) => defaultMessage: 'Error validating ES|QL: "{message}"', }); -export const ESQL_VALIDATION_MISSING_ID_IN_QUERY_ERROR = i18n.translate( - 'xpack.securitySolution.detectionEngine.esqlValidation.missingIdInQueryError', +export const ESQL_VALIDATION_MISSING_METADATA_OPERATOR_IN_QUERY_ERROR = i18n.translate( + 'xpack.securitySolution.detectionEngine.esqlValidation.missingMetadataOperatorInQueryError', + { + defaultMessage: `Queries that don’t use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* [metadata _id, _version, _index].`, + } +); + +export const ESQL_VALIDATION_MISSING_ID_FIELD_IN_QUERY_ERROR = i18n.translate( + 'xpack.securitySolution.detectionEngine.esqlValidation.missingIdFieldInQueryError', { defaultMessage: `Queries that don’t use the STATS...BY function (non-aggregating queries) must include the "metadata _id, _version, _index" operator after the source command. For example: FROM logs* metadata _id, _version, _index. In addition, the metadata properties (_id, _version, and _index) must be returned in the query response.`, } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx new file mode 100644 index 0000000000000..9ec6cba770c71 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render } from '@testing-library/react'; + +import { TestProviders } from '../../../../common/mock'; +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; + +import { AiAssistant } from '.'; + +jest.mock('../../../../assistant/use_assistant_availability', () => ({ + useAssistantAvailability: jest.fn(), +})); + +const useAssistantAvailabilityMock = useAssistantAvailability as jest.Mock; + +describe('AiAssistant', () => { + beforeEach(() => { + useAssistantAvailabilityMock.mockReturnValue({ hasAssistantPrivilege: true }); + }); + it('does not render chat component when does not have hasAssistantPrivilege', () => { + useAssistantAvailabilityMock.mockReturnValue({ hasAssistantPrivilege: false }); + + const { container } = render(, { + wrapper: TestProviders, + }); + + expect(container).toBeEmptyDOMElement(); + }); + it('renders chat component when has hasAssistantPrivilege', () => { + render(, { + wrapper: TestProviders, + }); + + expect(screen.getByTestId('newChatLink')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx new file mode 100644 index 0000000000000..83ce75d53f79f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { NewChat, AssistantAvatar } from '@kbn/elastic-assistant'; + +import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../common/lib/telemetry'; +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; +import * as i18nAssistant from '../../../../detections/pages/detection_engine/rules/translations'; +import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types'; +import type { FormHook, ValidationError } from '../../../../shared_imports'; + +import * as i18n from './translations'; + +const getLanguageName = (language: string | undefined) => { + let modifiedLanguage = language; + if (language === 'eql') { + modifiedLanguage = 'EQL(Event Query Language)'; + } + if (language === 'esql') { + modifiedLanguage = 'ES|QL(The Elasticsearch Query Language)'; + } + + return modifiedLanguage; +}; + +const retrieveErrorMessages = (errors: ValidationError[]): string => + errors + .flatMap(({ message, messages }) => [message, ...(Array.isArray(messages) ? messages : [])]) + .join(', '); + +interface AiAssistantProps { + getFields: FormHook['getFields']; + language?: string | undefined; +} + +const AiAssistantComponent: React.FC = ({ getFields, language }) => { + const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability(); + + const languageName = getLanguageName(language); + + const getPromptContext = useCallback(async () => { + const queryField = getFields().queryBar; + const { query } = (queryField.value as DefineStepRule['queryBar']).query; + + if (!query) { + return ''; + } + + if (queryField.errors.length === 0) { + return `No errors in ${languageName} language query detected. Current query: ${query.trim()}`; + } + + return `${languageName} language query written for Elastic Security Detection rules: \"${query.trim()}\" +returns validation error on form: \"${retrieveErrorMessages(queryField.errors)}\" +Fix ${languageName} language query and give an example of it in markdown format that can be copied. +Proposed solution should be valid and must not contain new line symbols (\\n)`; + }, [getFields, languageName]); + + const onShowOverlay = useCallback(() => { + track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.OPEN_ASSISTANT_ON_RULE_QUERY_ERROR); + }, []); + + if (!hasAssistantPrivilege) { + return null; + } + + return ( + <> + + + + {i18n.ASK_ASSISTANT_ERROR_BUTTON} + + ), + }} + /> + + ); +}; + +export const AiAssistant = React.memo(AiAssistantComponent); +AiAssistant.displayName = 'AiAssistant'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts new file mode 100644 index 0000000000000..f80f108aefdab --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/ai_assistant/translations.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ASK_ASSISTANT_ERROR_BUTTON = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistant', + { + defaultMessage: 'Ask Assistant', + } +); + +export const ASK_ASSISTANT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantDesc', + { + defaultMessage: 'Rule query error', + } +); + +export const ASK_ASSISTANT_USER_PROMPT = (language: string | undefined) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantUserPrompt', + { + defaultMessage: + 'Explain all the errors present in the {language} query above. Generate a new working query, making sure all the errors are resolved in your response.', + values: { language }, + } + ); + +export const ASK_ASSISTANT_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.askAssistantToolTip', + { + defaultMessage: 'Fix query or generate new one', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx index f9ef3a1cf4643..3a0573f3f9b75 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_define_rule/index.test.tsx @@ -10,6 +10,7 @@ import { screen, fireEvent, render, within, act, waitFor } from '@testing-librar import type { Type as RuleType } from '@kbn/securitysolution-io-ts-alerting-types'; import type { DataViewBase } from '@kbn/es-query'; import { StepDefineRule, aggregatableFields } from '.'; +import type { StepDefineRuleProps } from '.'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { useRuleFromTimeline } from '../../../../detections/containers/detection_engine/rules/use_rule_from_timeline'; import { TestProviders } from '../../../../common/mock'; @@ -24,6 +25,7 @@ import { createIndexPatternField, getSelectToggleButtonForName, } from '../../../rule_creation/components/required_fields/required_fields.test'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; // Mocks integrations jest.mock('../../../fleet_integrations/api'); @@ -35,6 +37,23 @@ jest.mock('../../../../common/components/query_bar', () => { }; }); +jest.mock('../../../rule_creation/components/pick_timeline', () => ({ + PickTimeline: 'pick-timeline', +})); + +jest.mock('../ai_assistant', () => { + return { + AiAssistant: jest.fn(() => { + return
; + }), + }; +}); + +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn(), +})); + +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; const mockRedirectLegacyUrl = jest.fn(); const mockGetLegacyUrlConflict = jest.fn(); jest.mock('../../../../common/lib/kibana', () => { @@ -610,6 +629,45 @@ describe('StepDefineRule', () => { ); }); }); + + describe('AI assistant', () => { + beforeEach(() => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + }); + it('renders assistant when query is not valid', () => { + render(, { + wrapper: TestProviders, + }); + + expect(screen.getByTestId('ai-assistant')).toBeInTheDocument(); + }); + + it('does not render assistant when feature flag is disabled', () => { + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + + render(, { + wrapper: TestProviders, + }); + + expect(screen.queryByTestId('ai-assistant')).toBe(null); + }); + + it('does not render assistant when query is valid', () => { + render(, { + wrapper: TestProviders, + }); + + expect(screen.queryByTestId('ai-assistant')).toBe(null); + }); + + it('does not render assistant for ML rule type', () => { + render(, { + wrapper: TestProviders, + }); + + expect(screen.queryByTestId('ai-assistant')).toBe(null); + }); + }); }); interface TestFormProps { @@ -617,6 +675,7 @@ interface TestFormProps { ruleType?: RuleType; indexPattern?: DataViewBase; onSubmit?: FormSubmitHandler; + formProps?: Partial; } function TestForm({ @@ -624,6 +683,7 @@ function TestForm({ ruleType = stepDefineDefaultValue.ruleType, indexPattern = { fields: [], title: '' }, onSubmit, + formProps, }: TestFormProps): JSX.Element { const [selectedEqlOptions, setSelectedEqlOptions] = useState(stepDefineDefaultValue.eqlOptions); const { form } = useForm({ @@ -658,6 +718,7 @@ function TestForm({ queryBarSavedId="" thresholdFields={[]} enableThresholdSuppression={false} + {...formProps} />