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 && (
+
+
+
+ )}