diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx index 299dab193d2f2..68371599fa78f 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.test.tsx @@ -6,10 +6,10 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { NewChatByTitle } from '.'; +import { BUTTON_ICON_TEST_ID, BUTTON_TEST_ID, BUTTON_TEXT_TEST_ID, NewChatByTitle } from '.'; const testProps = { showAssistantOverlay: jest.fn(), @@ -20,60 +20,28 @@ describe('NewChatByTitle', () => { jest.clearAllMocks(); }); - it('renders the default New Chat button with a discuss icon', () => { - render(); + it('should render icon only by default', () => { + const { getByTestId, queryByTestId } = render(); - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument(); - }); - - it('renders the default "New Chat" text when children are NOT provided', () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.textContent).toContain('Chat'); + expect(getByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(BUTTON_ICON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(BUTTON_TEXT_TEST_ID)).not.toBeInTheDocument(); }); - it('renders custom children', async () => { - render({'🪄✨'}); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.textContent).toContain('🪄✨'); - }); - - it('renders custom icons', async () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); + it('should render the button with icon and text', () => { + const { getByTestId } = render(); - expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument(); - }); - - it('does NOT render an icon when iconType is null', () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument(); - }); - - it('renders button icon when iconOnly is true', async () => { - render(); - - const newChatButton = screen.getByTestId('newChatByTitle'); - - expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument(); - expect(newChatButton.textContent).not.toContain('Chat'); + expect(getByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(BUTTON_ICON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(BUTTON_TEXT_TEST_ID)).toHaveTextContent('Ask AI Assistant'); }); it('calls showAssistantOverlay on click', async () => { - render(); - const newChatButton = screen.getByTestId('newChatByTitle'); + const { getByTestId } = render(); + + const button = getByTestId(BUTTON_TEST_ID); - await userEvent.click(newChatButton); + await userEvent.click(button); expect(testProps.showAssistantOverlay).toHaveBeenCalledWith(true); }); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx index 9f21c5764fa1f..bb22b14a48e28 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/index.tsx @@ -5,76 +5,78 @@ * 2.0. */ -import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; - +import type { EuiButtonColor } from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { AssistantIcon } from '@kbn/ai-assistant-icon'; +import { EuiButtonEmptySizes } from '@elastic/eui/src/components/button/button_empty/button_empty'; import * as i18n from './translations'; -export interface Props { - children?: React.ReactNode; - /** Defaults to `discuss`. If null, the button will not have an icon */ - iconType?: string | null; +export const BUTTON_TEST_ID = 'newChatByTitle'; +export const BUTTON_ICON_TEST_ID = 'newChatByTitleIcon'; +export const BUTTON_TEXT_TEST_ID = 'newChatByTitleText'; + +export interface NewChatByTitleComponentProps { + /** + * Optionally specify color of empty button. + * @default 'primary' + */ + color?: EuiButtonColor; + /** + * Callback to display the assistant overlay + */ showAssistantOverlay: (show: boolean) => void; - /** Defaults to false. If true, shows icon button without text */ - iconOnly?: boolean; + /** + * + */ + size?: EuiButtonEmptySizes; + /** + * Optionally specify the text to display. + */ + text?: string; } -const NewChatByTitleComponent: React.FC = ({ - children = i18n.NEW_CHAT, - iconType, +const NewChatByTitleComponent: React.FC = ({ + color = 'primary', showAssistantOverlay, - iconOnly = false, + size = 'm', + text, }) => { - const showOverlay = useCallback(() => { - showAssistantOverlay(true); - }, [showAssistantOverlay]); - - const icon = useMemo(() => { - if (iconType === null) { - return undefined; - } - - return iconType ?? 'discuss'; - }, [iconType]); + const showOverlay = useCallback(() => showAssistantOverlay(true), [showAssistantOverlay]); - return useMemo( - () => - iconOnly ? ( - - - - ) : ( - - {children} - - ), - [children, icon, showOverlay, iconOnly] + return ( + + + + + + {text && ( + + {text} + + )} + + ); }; NewChatByTitleComponent.displayName = 'NewChatByTitleComponent'; /** - * `NewChatByTitle` displays a _New chat_ icon button by providing only the `promptContextId` + * `NewChatByTitle` displays a button by providing only the `promptContextId` * of a context that was (already) registered by the `useAssistantOverlay` hook. You may - * optionally style the button icon, or override the default _New chat_ text with custom - * content, like {'🪄✨'} + * optionally override the default text. * * USE THIS WHEN: all the data necessary to start a new chat is NOT available - * in the same part of the React tree as the _New chat_ button. When paired - * with the `useAssistantOverlay` hook, this option enables context to be be - * registered where the data is available, and then the _New chat_ button can be displayed + * in the same part of the React tree as the button. When paired + * with the `useAssistantOverlay` hook, this option enables context to be + * registered where the data is available, and then the button can be displayed * in another part of the tree. */ export const NewChatByTitle = React.memo(NewChatByTitleComponent); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts index 57de1f990dc6c..590e0a67e9634 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/new_chat_by_title/translations.ts @@ -7,9 +7,9 @@ import { i18n } from '@kbn/i18n'; -export const NEW_CHAT = i18n.translate( +export const ASK_AI_ASSISTANT = i18n.translate( 'xpack.elasticAssistant.assistant.newChatByTitle.newChatByTitleButton', { - defaultMessage: 'Chat', + defaultMessage: 'Ask AI Assistant', } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.test.tsx index 3dac7381cb395..7b632e973090c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.test.tsx @@ -12,11 +12,13 @@ import { ActionsCell } from './actions_cell'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { MORE_ACTIONS_BUTTON_TEST_ID } from './more_actions_row_control_column'; +import { useAssistant } from '../../../hooks/alert_summary/use_assistant'; import { useAddToCaseActions } from '../../alerts_table/timeline_actions/use_add_to_case_actions'; import { useAlertTagsActions } from '../../alerts_table/timeline_actions/use_alert_tags_actions'; import { ROW_ACTION_FLYOUT_ICON_TEST_ID } from './open_flyout_row_control_column'; jest.mock('@kbn/expandable-flyout'); +jest.mock('../../../hooks/alert_summary/use_assistant'); jest.mock('../../alerts_table/timeline_actions/use_add_to_case_actions'); jest.mock('../../alerts_table/timeline_actions/use_alert_tags_actions'); @@ -25,6 +27,10 @@ describe('ActionsCell', () => { (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openFlyout: jest.fn(), }); + (useAssistant as jest.Mock).mockReturnValue({ + showAssistant: true, + showAssistantOverlay: jest.fn(), + }); (useAddToCaseActions as jest.Mock).mockReturnValue({ addToCaseActionItems: [], }); @@ -45,6 +51,7 @@ describe('ActionsCell', () => { const { getByTestId } = render(); expect(getByTestId(ROW_ACTION_FLYOUT_ICON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId('newChatByTitle')).toBeInTheDocument(); expect(getByTestId(MORE_ACTIONS_BUTTON_TEST_ID)).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.tsx index 0d4612b722a23..9de99b9d88a33 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/actions_cell.tsx @@ -9,8 +9,9 @@ import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import type { Alert } from '@kbn/alerting-types'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; -import { OpenFlyoutRowControlColumn } from './open_flyout_row_control_column'; +import { AssistantRowControlColumn } from './assistant_row_control_column'; import { MoreActionsRowControlColumn } from './more_actions_row_control_column'; +import { OpenFlyoutRowControlColumn } from './open_flyout_row_control_column'; export interface ActionsCellProps { /** @@ -28,14 +29,17 @@ export interface ActionsCellProps { * It is passed to the renderActionsCell property of the EuiDataGrid. * It renders all the icons in the row action icons: * - open flyout - * - assistant (soon) - * - more actions (soon) + * - assistant + * - more actions */ export const ActionsCell = memo(({ alert, ecsAlert }: ActionsCellProps) => ( + + + diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/assistant_row_control_column.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/assistant_row_control_column.test.tsx new file mode 100644 index 0000000000000..a7d3d903d50fc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/assistant_row_control_column.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 { render } from '@testing-library/react'; +import { AssistantRowControlColumn } from './assistant_row_control_column'; +import type { Alert } from '@kbn/alerting-types'; +import { useAssistant } from '../../../hooks/alert_summary/use_assistant'; + +jest.mock('../../../hooks/alert_summary/use_assistant'); + +describe('AssistantRowControlColumn', () => { + it('should render the icon button', () => { + (useAssistant as jest.Mock).mockReturnValue({ + showAssistantOverlay: jest.fn(), + }); + + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + + const { getByTestId } = render(); + + expect(getByTestId('newChatByTitle')).toBeInTheDocument(); + expect(getByTestId('newChatByTitleIcon')).toBeInTheDocument(); + }); + + it('should call the callback when clicked', () => { + const showAssistantOverlay = jest.fn(); + (useAssistant as jest.Mock).mockReturnValue({ + showAssistantOverlay, + }); + + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + + const { getByTestId } = render(); + + const button = getByTestId('newChatByTitle'); + expect(button).toBeInTheDocument(); + + button.click(); + + expect(showAssistantOverlay).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/assistant_row_control_column.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/assistant_row_control_column.tsx new file mode 100644 index 0000000000000..ef3a0d2cce090 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/assistant_row_control_column.tsx @@ -0,0 +1,30 @@ +/* + * 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, { memo } from 'react'; +import type { Alert } from '@kbn/alerting-types'; +import { NewChatByTitle } from '@kbn/elastic-assistant/impl/new_chat_by_title'; +import { useAssistant } from '../../../hooks/alert_summary/use_assistant'; + +export interface AssistantRowControlColumnProps { + /** + * Alert data passed from the renderCellValue callback via the AlertWithLegacyFormats interface + */ + alert: Alert; +} + +/** + * Renders the assistant icon and opens the assistant flyout for the current alert when clicked. + * This is used in the AI for SOC alert summary table. + */ +export const AssistantRowControlColumn = memo(({ alert }: AssistantRowControlColumnProps) => { + const { showAssistantOverlay } = useAssistant({ alert }); + + return ; +}); + +AssistantRowControlColumn.displayName = 'AssistantRowControlColumn'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx index f1764890aca33..87d581dbc52f8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alert_summary/table/table.tsx @@ -73,7 +73,7 @@ const columns: EuiDataGridProps['columns'] = [ }, ]; -const ACTION_COLUMN_WIDTH = 72; // px +const ACTION_COLUMN_WIDTH = 98; // px const ALERT_TABLE_CONSUMERS: AlertsTableProps['consumers'] = [AlertConsumers.SIEM]; const RULE_TYPE_IDS = [ESQL_RULE_TYPE_ID, QUERY_RULE_TYPE_ID]; const ROW_HEIGHTS_OPTIONS = { defaultHeight: 40 }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_assistant.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_assistant.test.tsx new file mode 100644 index 0000000000000..52f638a29cb81 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_assistant.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 type { RenderHookResult } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import type { UseAssistantParams, UseAssistantResult } from './use_assistant'; +import { useAssistant } from './use_assistant'; +import { useAssistantOverlay } from '@kbn/elastic-assistant'; +import type { Alert } from '@kbn/alerting-types'; + +jest.mock('@kbn/elastic-assistant'); + +describe('useAssistant', () => { + let hookResult: RenderHookResult; + + it('should return showAssistant true and a value for promptContextId', () => { + const showAssistantOverlay = jest.fn(); + jest + .mocked(useAssistantOverlay) + .mockReturnValue({ showAssistantOverlay, promptContextId: '123' }); + + const alert: Alert = { + _id: '_id', + _index: '_index', + }; + + hookResult = renderHook((props: UseAssistantParams) => useAssistant(props), { + initialProps: { alert }, + }); + + expect(hookResult.result.current.showAssistantOverlay).toEqual(showAssistantOverlay); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_assistant.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_assistant.ts new file mode 100644 index 0000000000000..65bd594945b2d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_assistant.ts @@ -0,0 +1,86 @@ +/* + * 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 { useAssistantOverlay } from '@kbn/elastic-assistant'; +import { useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { Alert } from '@kbn/alerting-types'; +import { flattenAlertType } from '../../utils/flatten_alert_type'; +import { getAlertFieldValueAsStringOrNull } from '../../utils/get_alert_field_value_as_string_or_null'; +import { + PROMPT_CONTEXT_ALERT_CATEGORY, + PROMPT_CONTEXTS, +} from '../../../assistant/content/prompt_contexts'; +import { + ALERT_SUMMARY_CONTEXT_DESCRIPTION, + ALERT_SUMMARY_CONVERSATION_ID, + ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP, +} from '../../../common/components/event_details/translations'; + +const SUMMARY_VIEW = i18n.translate('xpack.securitySolution.eventDetails.summaryView', { + defaultMessage: 'summary', +}); + +export interface UseAssistantParams { + /** + * An array of field objects with category and value + */ + alert: Alert; +} + +export interface UseAssistantResult { + /** + * Function to show assistant overlay + */ + showAssistantOverlay: (show: boolean) => void; +} + +/** + * Hook to return the assistant button visibility and prompt context id. + * This is meant to be used in the AI for SOC tier, where the assistant is always enabled. + */ +export const useAssistant = ({ alert }: UseAssistantParams): UseAssistantResult => { + const getPromptContext = useCallback(async () => { + const cleanedAlert: Alert = { ...alert }; + + // remove all fields that start with signal. as these are legacy fields + for (const key in cleanedAlert) { + if (key.startsWith('signal.')) { + delete cleanedAlert[key]; + } + } + + // makes sure that we do not have any nested values as the getPromptContext is expecting the data in Record format + return flattenAlertType(cleanedAlert); + }, [alert]); + + const conversationTitle = useMemo(() => { + const ruleName = + getAlertFieldValueAsStringOrNull(alert, 'rule.name') ?? + getAlertFieldValueAsStringOrNull(alert, 'kibana.alert.rule.name') ?? + ALERT_SUMMARY_CONVERSATION_ID; + + const timestamp: string = getAlertFieldValueAsStringOrNull(alert, '@timestamp') ?? ''; + + return `${ruleName} - ${timestamp}`; + }, [alert]); + + const { showAssistantOverlay } = useAssistantOverlay( + 'alert', + conversationTitle, + ALERT_SUMMARY_CONTEXT_DESCRIPTION(SUMMARY_VIEW), + getPromptContext, + null, + PROMPT_CONTEXTS[PROMPT_CONTEXT_ALERT_CATEGORY].suggestedUserPrompt, + ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP, + true + ); + + return { + showAssistantOverlay, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.test.tsx new file mode 100644 index 0000000000000..76eeaae083cb9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 type { Alert } from '@kbn/alerting-types'; +import { flattenAlertType } from './flatten_alert_type'; + +describe('flattenAlertType', () => { + it('should handle basic fields', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: ['value1'], + field2: [1], + }; + + const result = flattenAlertType(alert); + + expect(result).toEqual({ + _id: ['_id'], + _index: ['_index'], + field1: ['value1'], + field2: ['1'], + }); + }); + + it('should handle nested fields', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + 'kibana.alert.rule.parameters': [ + { + field1: 'value1', + field2: 1, + field3: ['value3', 'value3bis', 'value3ter'], + field4: false, + field5: { + field6: 'value6', + }, + }, + ], + }; + + const result = flattenAlertType(alert); + + expect(result).toEqual({ + _id: ['_id'], + _index: ['_index'], + 'kibana.alert.rule.parameters.field1': ['value1'], + 'kibana.alert.rule.parameters.field2': ['1'], + 'kibana.alert.rule.parameters.field3': ['value3', 'value3bis', 'value3ter'], + 'kibana.alert.rule.parameters.field4': ['false'], + 'kibana.alert.rule.parameters.field5.field6': ['value6'], + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.ts new file mode 100644 index 0000000000000..1c995323c5ed0 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/flatten_alert_type.ts @@ -0,0 +1,90 @@ +/* + * 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 { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; +import type { Alert } from '@kbn/alerting-types'; +import type { JsonValue } from '@kbn/utility-types'; +import { toObjectArrayOfStrings } from '@kbn/timelines-plugin/common'; + +const nonFlattenedFormatParamsFields = ['related_integrations', 'threat_mapping']; + +/** + * Returns true if the field is related to kibana.alert.rule.parameters. + * This code is similar to x-pack/platform/plugins/shared/timelines/common/utils/field_formatters.ts and once + * the Security Solution and Timelines plugins are merged we should probably share the code. + */ +const isRuleParametersFieldOrSubfield = ( + /** + * Field to check against + */ + field: string, + /** + * Optional value used if we're processing nested fields + */ + prependField?: string +) => + (prependField?.includes(ALERT_RULE_PARAMETERS) || field === ALERT_RULE_PARAMETERS) && + !nonFlattenedFormatParamsFields.includes(field); + +/** + * Recursive function that processes all the fields from an Alert and returns a flattened object as a Record. + * This is used in the AI for SOC alert summary page, in the getPromptContext when passing data to the assistant. + * The logic is similar to x-pack/platform/plugins/shared/timelines/common/utils/field_formatters.ts but for an Alert type. + */ +export const flattenAlertType = ( + /** + * Object of type Alert that needs nested fields flattened + */ + obj: Alert, + /** + * Parent field (populated when the function is called recursively on the nested fields) + */ + prependField?: string +): Record => { + const resultMap: Record = {}; + const allFields: string[] = Object.keys(obj); + + for (let i = 0; i < allFields.length; i++) { + const field: string = allFields[i]; + const value: string | number | JsonValue[] = obj[field]; + + const dotField: string = prependField ? `${prependField}.${field}` : field; + + const valueIntoObjectArrayOfStrings = toObjectArrayOfStrings(value); + const valueAsStringArray = valueIntoObjectArrayOfStrings.map(({ str }) => str); + const valueIsObjectArray = valueIntoObjectArrayOfStrings.some((o) => o.isObjectArray); + + if (!valueIsObjectArray) { + // Handle simple fields + resultMap[dotField] = valueAsStringArray; + } else { + // Process nested fields + const isRuleParameters = isRuleParametersFieldOrSubfield(field, prependField); + + const subField: string | undefined = isRuleParameters ? dotField : undefined; + const subValue: JsonValue = Array.isArray(value) ? value[0] : value; + + const subValueIntoObjectArrayOfStrings = toObjectArrayOfStrings(subValue); + const subValueAsStringArray = subValueIntoObjectArrayOfStrings.map(({ str }) => str); + const subValueIsObjectArray = subValueIntoObjectArrayOfStrings.some((o) => o.isObjectArray); + + if (!subValueIsObjectArray) { + resultMap[dotField] = subValueAsStringArray; + } else { + const nestedFieldValuePairs = flattenAlertType(subValue as Alert, subField); + const nestedFields = Object.keys(nestedFieldValuePairs); + + for (let j = 0; j < nestedFields.length; j++) { + const nestedField = nestedFields[j]; + resultMap[nestedField] = nestedFieldValuePairs[nestedField]; + } + } + } + } + + return resultMap; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.test.tsx new file mode 100644 index 0000000000000..2aa8b1f5c581d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 type { Alert } from '@kbn/alerting-types'; +import { getAlertFieldValueAsStringOrNull } from './get_alert_field_value_as_string_or_null'; + +describe('getAlertFieldValueAsStringOrNull', () => { + it('should handle missing field', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'columnId'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toBe(null); + }); + + it('should handle string value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 'value1', + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('value1'); + }); + + it('should handle a number value', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: 123, + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('123'); + }); + + it('should handle array of booleans', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [true, false], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('true, false'); + }); + + it('should handle array of numbers', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [1, 2], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('1, 2'); + }); + + it('should handle array of null', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [null, null], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual(', '); + }); + + it('should join array of JsonObjects', () => { + const alert: Alert = { + _id: '_id', + _index: '_index', + field1: [{ subField1: 'value1', subField2: 'value2' }], + }; + const field = 'field1'; + + const result = getAlertFieldValueAsStringOrNull(alert, field); + + expect(result).toEqual('[object Object]'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.ts new file mode 100644 index 0000000000000..09b540d1c3cb1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/utils/get_alert_field_value_as_string_or_null.ts @@ -0,0 +1,41 @@ +/* + * 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 type { JsonValue } from '@kbn/utility-types'; +import type { Alert } from '@kbn/alerting-types'; + +/** + * Takes an Alert object and a field string as input and returns the value for the field as a string. + * If the value is already a string, return it. + * If the value is an array, join the values. + * If null the value is null. + * Return the string of the value otherwise. + */ +export const getAlertFieldValueAsStringOrNull = (alert: Alert, field: string): string | null => { + const cellValues: string | number | JsonValue[] = alert[field]; + + if (typeof cellValues === 'string') { + return cellValues; + } else if (typeof cellValues === 'number') { + return cellValues.toString(); + } else if (Array.isArray(cellValues)) { + if (cellValues.length > 1) { + return cellValues.join(', '); + } else { + const value: JsonValue = cellValues[0]; + if (typeof value === 'string') { + return value; + } else if (value == null) { + return null; + } else { + return value.toString(); + } + } + } else { + return null; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/footer.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/footer.tsx index c117c0b780b83..22a40bb2729f1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/footer.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/ai_for_soc/footer.tsx @@ -7,23 +7,49 @@ import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiFlyoutFooter, EuiPanel } from '@elastic/eui'; +import { NewChatByTitle } from '@kbn/elastic-assistant'; +import { i18n } from '@kbn/i18n'; import { TakeActionButton } from './components/take_action_button'; +import { useAIForSOCDetailsContext } from './context'; +import { useBasicDataFromDetailsData } from '../document_details/shared/hooks/use_basic_data_from_details_data'; +import { useAssistant } from '../document_details/right/hooks/use_assistant'; + +export const ASK_AI_ASSISTANT = i18n.translate( + 'xpack.securitySolution.flyout.right.footer.askAIAssistant', + { + defaultMessage: 'Ask AI Assistant', + } +); export const FLYOUT_FOOTER_TEST_ID = 'ai-for-soc-alert-flyout-footer'; /** * Bottom section of the flyout that contains the take action button */ -export const PanelFooter = memo(() => ( - - - - - - - - - -)); +export const PanelFooter = memo(() => { + const { dataFormattedForFieldBrowser } = useAIForSOCDetailsContext(); + const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const { showAssistant, showAssistantOverlay } = useAssistant({ + dataFormattedForFieldBrowser, + isAlert, + }); + + return ( + + + + {showAssistant && ( + + + + )} + + + + + + + ); +}); PanelFooter.displayName = 'PanelFooter'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx index a0769828051ac..92afb8059cf15 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx @@ -8,16 +8,14 @@ import React from 'react'; import { render } from '@testing-library/react'; import { DocumentDetailsContext } from '../../shared/context'; -import { SHARE_BUTTON_TEST_ID, CHAT_BUTTON_TEST_ID } from './test_ids'; +import { SHARE_BUTTON_TEST_ID } from './test_ids'; import { HeaderActions } from './header_actions'; -import { useAssistant } from '../hooks/use_assistant'; import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { TestProvidersComponent } from '../../../../common/mock'; import { useGetFlyoutLink } from '../hooks/use_get_flyout_link'; jest.mock('../../../../common/lib/kibana'); -jest.mock('../hooks/use_assistant'); jest.mock('../hooks/use_get_flyout_link'); jest.mock('@elastic/eui', () => ({ @@ -52,11 +50,6 @@ describe('', () => { beforeEach(() => { window.location.search = '?'; jest.mocked(useGetFlyoutLink).mockReturnValue(alertUrl); - jest.mocked(useAssistant).mockReturnValue({ - showAssistantOverlay: jest.fn(), - showAssistant: true, - promptContextId: '', - }); }); describe('Share alert url action', () => { @@ -79,23 +72,5 @@ describe('', () => { }); expect(queryByTestId(SHARE_BUTTON_TEST_ID)).not.toBeInTheDocument(); }); - - it('should render chat button in the title', () => { - const { getByTestId } = renderHeaderActions(mockContextValue); - - expect(getByTestId(CHAT_BUTTON_TEST_ID)).toBeInTheDocument(); - }); - - it('should not render chat button in the title if should not be shown', () => { - jest.mocked(useAssistant).mockReturnValue({ - showAssistantOverlay: jest.fn(), - showAssistant: false, - promptContextId: '', - }); - - const { queryByTestId } = renderHeaderActions(mockContextValue); - - expect(queryByTestId(CHAT_BUTTON_TEST_ID)).not.toBeInTheDocument(); - }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx index 322b9cae1865a..84d60dfe76065 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx @@ -9,10 +9,8 @@ import type { VFC } from 'react'; import React, { memo } from 'react'; import { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { NewChatByTitle } from '@kbn/elastic-assistant'; import { useGetFlyoutLink } from '../hooks/use_get_flyout_link'; import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data'; -import { useAssistant } from '../hooks/use_assistant'; import { useDocumentDetailsContext } from '../../shared/context'; import { SHARE_BUTTON_TEST_ID } from './test_ids'; @@ -31,11 +29,6 @@ export const HeaderActions: VFC = memo(() => { const showShareAlertButton = isAlert && alertDetailsLink; - const { showAssistant, showAssistantOverlay } = useAssistant({ - dataFormattedForFieldBrowser, - isAlert, - }); - return ( { gutterSize="none" responsive={false} > - {showAssistant && ( - - - - )} {showShareAlertButton && ( + render( + + + + + + ); describe('PanelFooter', () => { + beforeEach(() => { + jest.mocked(useAssistant).mockReturnValue({ + showAssistantOverlay: jest.fn(), + showAssistant: true, + promptContextId: '', + }); + }); + it('should not render the take action dropdown if preview mode', () => { - const { queryByTestId } = render( - - - - - - ); + const { queryByTestId } = renderPanelFooter(true); expect(queryByTestId(FLYOUT_FOOTER_TEST_ID)).not.toBeInTheDocument(); }); @@ -57,14 +71,27 @@ describe('PanelFooter', () => { }); (useAddToCaseActions as jest.Mock).mockReturnValue({ addToCaseActionItems: [] }); - const wrapper = render( - - - - - - ); - expect(wrapper.getByTestId(FLYOUT_FOOTER_TEST_ID)).toBeInTheDocument(); - expect(wrapper.getByTestId(FLYOUT_FOOTER_DROPDOWN_BUTTON_TEST_ID)).toBeInTheDocument(); + const { getByTestId } = renderPanelFooter(false); + + expect(getByTestId(FLYOUT_FOOTER_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(FLYOUT_FOOTER_DROPDOWN_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should render chat button', () => { + const { getByTestId } = renderPanelFooter(false); + + expect(getByTestId(CHAT_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render chat button', () => { + jest.mocked(useAssistant).mockReturnValue({ + showAssistantOverlay: jest.fn(), + showAssistant: false, + promptContextId: '', + }); + + const { queryByTestId } = renderPanelFooter(true); + + expect(queryByTestId(CHAT_BUTTON_TEST_ID)).not.toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx index ce955a0b87ddc..b2226f5f144bc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/footer.tsx @@ -8,9 +8,21 @@ import type { FC } from 'react'; import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiFlyoutFooter, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NewChatByTitle } from '@kbn/elastic-assistant'; +import { useBasicDataFromDetailsData } from '../shared/hooks/use_basic_data_from_details_data'; +import { useDocumentDetailsContext } from '../shared/context'; +import { useAssistant } from './hooks/use_assistant'; import { FLYOUT_FOOTER_TEST_ID } from './test_ids'; import { TakeActionButton } from '../shared/components/take_action_button'; +export const ASK_AI_ASSISTANT = i18n.translate( + 'xpack.securitySolution.ai4soc.flyout.right.footer.askAIAssistant', + { + defaultMessage: 'Ask AI Assistant', + } +); + interface PanelFooterProps { /** * Boolean that indicates whether flyout is in preview and action should be hidden @@ -22,12 +34,24 @@ interface PanelFooterProps { * Bottom section of the flyout that contains the take action button */ export const PanelFooter: FC = ({ isPreview }) => { + const { dataFormattedForFieldBrowser } = useDocumentDetailsContext(); + const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const { showAssistant, showAssistantOverlay } = useAssistant({ + dataFormattedForFieldBrowser, + isAlert, + }); + if (isPreview) return null; return ( + {showAssistant && ( + + + + )}