mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[AI4DSOC] Alert summary table and flyout ai assistant (#217744)
## Summary This PR builds up on the previous https://github.com/elastic/kibana/pull/216744 and the AI assistant in 2 places in the AI for SOC alert summary page: - in each row of the alert table as a row action - in the footer of the alert details flyout https://github.com/user-attachments/assets/65fb10f1-c22b-4796-9109-3b7dbdba6313 To keep consistency between the alert summary and the alerts page, this PR also removes the Chat icon button in the header of the alert details flyout and adds a `Ask AI Assistant` button in the footer. | Before | After | | ------------- | ------------- | |  |  | ## How to test This needs to be ran in Serverless: - `yarn es serverless --projectType security` - `yarn serverless-security --no-base-path` You also need to enable the AI for SOC tier, by adding the following to your `serverless.security.dev.yaml` file: ``` xpack.securitySolutionServerless.productTypes: [ { product_line: 'ai_soc', product_tier: 'search_ai_lake' }, ] ``` Use one of these Serverless users: - `platform_engineer` - `endpoint_operations_analyst` - `endpoint_policy_manager` - `admin` - `system_indices_superuser` Then: - generate data: `yarn test:generate:serverless-dev` - create 4 catch all rules, each with a name of a AI for SOC integration (`google_secops`, `microsoft_sentinel`,, `sentinel_one` and `crowdstrike`) - change [this line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.ts#L73) to `installedPackages: availablePackages` to force having some packages installed - change [this line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.ts#L63) to `r.name === p.name` to make sure there will be matches between integrations and rules ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios https://github.com/elastic/security-team/issues/11973
This commit is contained in:
parent
da5e8cc6e9
commit
add6e303d2
19 changed files with 691 additions and 172 deletions
|
@ -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(<NewChatByTitle {...testProps} />);
|
||||
it('should render icon only by default', () => {
|
||||
const { getByTestId, queryByTestId } = render(<NewChatByTitle {...testProps} />);
|
||||
|
||||
const newChatButton = screen.getByTestId('newChatByTitle');
|
||||
|
||||
expect(newChatButton.querySelector('[data-euiicon-type="discuss"]')).toBeInTheDocument();
|
||||
expect(getByTestId(BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(BUTTON_ICON_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId(BUTTON_TEXT_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the default "New Chat" text when children are NOT provided', () => {
|
||||
render(<NewChatByTitle {...testProps} />);
|
||||
it('should render the button with icon and text', () => {
|
||||
const { getByTestId } = render(<NewChatByTitle {...testProps} text={'Ask AI Assistant'} />);
|
||||
|
||||
const newChatButton = screen.getByTestId('newChatByTitle');
|
||||
|
||||
expect(newChatButton.textContent).toContain('Chat');
|
||||
});
|
||||
|
||||
it('renders custom children', async () => {
|
||||
render(<NewChatByTitle {...testProps}>{'🪄✨'}</NewChatByTitle>);
|
||||
|
||||
const newChatButton = screen.getByTestId('newChatByTitle');
|
||||
|
||||
expect(newChatButton.textContent).toContain('🪄✨');
|
||||
});
|
||||
|
||||
it('renders custom icons', async () => {
|
||||
render(<NewChatByTitle {...testProps} iconType="help" />);
|
||||
|
||||
const newChatButton = screen.getByTestId('newChatByTitle');
|
||||
|
||||
expect(newChatButton.querySelector('[data-euiicon-type="help"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT render an icon when iconType is null', () => {
|
||||
render(<NewChatByTitle {...testProps} iconType={null} />);
|
||||
|
||||
const newChatButton = screen.getByTestId('newChatByTitle');
|
||||
|
||||
expect(newChatButton.querySelector('.euiButtonContent__icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders button icon when iconOnly is true', async () => {
|
||||
render(<NewChatByTitle {...testProps} iconOnly />);
|
||||
|
||||
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(<NewChatByTitle {...testProps} />);
|
||||
const newChatButton = screen.getByTestId('newChatByTitle');
|
||||
const { getByTestId } = render(<NewChatByTitle {...testProps} />);
|
||||
|
||||
await userEvent.click(newChatButton);
|
||||
const button = getByTestId(BUTTON_TEST_ID);
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(testProps.showAssistantOverlay).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
|
|
@ -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<Props> = ({
|
||||
children = i18n.NEW_CHAT,
|
||||
iconType,
|
||||
const NewChatByTitleComponent: React.FC<NewChatByTitleComponentProps> = ({
|
||||
color = 'primary',
|
||||
showAssistantOverlay,
|
||||
iconOnly = false,
|
||||
size = 'm',
|
||||
text,
|
||||
}) => {
|
||||
const showOverlay = useCallback(() => {
|
||||
showAssistantOverlay(true);
|
||||
}, [showAssistantOverlay]);
|
||||
const showOverlay = useCallback(() => showAssistantOverlay(true), [showAssistantOverlay]);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
if (iconType === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return iconType ?? 'discuss';
|
||||
}, [iconType]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
iconOnly ? (
|
||||
<EuiToolTip content={i18n.NEW_CHAT}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="newChatByTitle"
|
||||
iconType={icon ?? 'discuss'}
|
||||
onClick={showOverlay}
|
||||
color={'text'}
|
||||
aria-label={i18n.NEW_CHAT}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
) : (
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="newChatByTitle"
|
||||
iconType={icon}
|
||||
onClick={showOverlay}
|
||||
aria-label={i18n.NEW_CHAT}
|
||||
>
|
||||
{children}
|
||||
</EuiButtonEmpty>
|
||||
),
|
||||
[children, icon, showOverlay, iconOnly]
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
aria-label={i18n.ASK_AI_ASSISTANT}
|
||||
color={color}
|
||||
data-test-subj={BUTTON_TEST_ID}
|
||||
onClick={showOverlay}
|
||||
size={size}
|
||||
>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<AssistantIcon data-test-subj={BUTTON_ICON_TEST_ID} size="m" />
|
||||
</EuiFlexItem>
|
||||
{text && (
|
||||
<EuiFlexItem data-test-subj={BUTTON_TEXT_TEST_ID} grow={false}>
|
||||
{text}
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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(<ActionsCell alert={alert} ecsAlert={ecsAlert} />);
|
||||
|
||||
expect(getByTestId(ROW_ACTION_FLYOUT_ICON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId('newChatByTitle')).toBeInTheDocument();
|
||||
expect(getByTestId(MORE_ACTIONS_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) => (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs">
|
||||
<EuiFlexItem>
|
||||
<OpenFlyoutRowControlColumn alert={alert} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<AssistantRowControlColumn alert={alert} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<MoreActionsRowControlColumn ecsAlert={ecsAlert} />
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -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(<AssistantRowControlColumn alert={alert} />);
|
||||
|
||||
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(<AssistantRowControlColumn alert={alert} />);
|
||||
|
||||
const button = getByTestId('newChatByTitle');
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
button.click();
|
||||
|
||||
expect(showAssistantOverlay).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -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 <NewChatByTitle showAssistantOverlay={showAssistantOverlay} size="xs" />;
|
||||
});
|
||||
|
||||
AssistantRowControlColumn.displayName = 'AssistantRowControlColumn';
|
|
@ -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 };
|
||||
|
|
|
@ -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<UseAssistantResult, UseAssistantParams>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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<string, string[]> 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,
|
||||
};
|
||||
};
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<string, string[]>.
|
||||
* 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<string, string[]> => {
|
||||
const resultMap: Record<string, string[]> = {};
|
||||
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;
|
||||
};
|
|
@ -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]');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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(() => (
|
||||
<EuiFlyoutFooter data-test-subj={FLYOUT_FOOTER_TEST_ID}>
|
||||
<EuiPanel color="transparent">
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<TakeActionButton />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlyoutFooter>
|
||||
));
|
||||
export const PanelFooter = memo(() => {
|
||||
const { dataFormattedForFieldBrowser } = useAIForSOCDetailsContext();
|
||||
const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
|
||||
const { showAssistant, showAssistantOverlay } = useAssistant({
|
||||
dataFormattedForFieldBrowser,
|
||||
isAlert,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlyoutFooter data-test-subj={FLYOUT_FOOTER_TEST_ID}>
|
||||
<EuiPanel color="transparent">
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
|
||||
{showAssistant && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<NewChatByTitle showAssistantOverlay={showAssistantOverlay} text={ASK_AI_ASSISTANT} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<TakeActionButton />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</EuiFlyoutFooter>
|
||||
);
|
||||
});
|
||||
|
||||
PanelFooter.displayName = 'PanelFooter';
|
||||
|
|
|
@ -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('<HeaderAction />', () => {
|
|||
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('<HeaderAction />', () => {
|
|||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
|
@ -44,11 +37,6 @@ export const HeaderActions: VFC = memo(() => {
|
|||
gutterSize="none"
|
||||
responsive={false}
|
||||
>
|
||||
{showAssistant && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<NewChatByTitle showAssistantOverlay={showAssistantOverlay} iconOnly />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{showShareAlertButton && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
|
|
|
@ -11,8 +11,10 @@ import { TestProviders } from '../../../common/mock';
|
|||
import { mockContextValue } from '../shared/mocks/mock_context';
|
||||
import { DocumentDetailsContext } from '../shared/context';
|
||||
import { FLYOUT_FOOTER_TEST_ID } from './test_ids';
|
||||
import { CHAT_BUTTON_TEST_ID } from './components/test_ids';
|
||||
import { FLYOUT_FOOTER_DROPDOWN_BUTTON_TEST_ID } from '../shared/components/test_ids';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { useAssistant } from './hooks/use_assistant';
|
||||
import { useAlertExceptionActions } from '../../../detections/components/alerts_table/timeline_actions/use_add_exception_actions';
|
||||
import { useInvestigateInTimeline } from '../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline';
|
||||
import { useAddToCaseActions } from '../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions';
|
||||
|
@ -30,16 +32,28 @@ jest.mock(
|
|||
'../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'
|
||||
);
|
||||
jest.mock('../../../detections/components/alerts_table/timeline_actions/use_add_to_case_actions');
|
||||
jest.mock('./hooks/use_assistant');
|
||||
|
||||
const renderPanelFooter = (isPreview: boolean) =>
|
||||
render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={mockContextValue}>
|
||||
<PanelFooter isPreview={isPreview} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
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(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={mockContextValue}>
|
||||
<PanelFooter isPreview={true} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
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(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={mockContextValue}>
|
||||
<PanelFooter isPreview={false} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<PanelFooterProps> = ({ isPreview }) => {
|
||||
const { dataFormattedForFieldBrowser } = useDocumentDetailsContext();
|
||||
const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
|
||||
const { showAssistant, showAssistantOverlay } = useAssistant({
|
||||
dataFormattedForFieldBrowser,
|
||||
isAlert,
|
||||
});
|
||||
|
||||
if (isPreview) return null;
|
||||
|
||||
return (
|
||||
<EuiFlyoutFooter data-test-subj={FLYOUT_FOOTER_TEST_ID}>
|
||||
<EuiPanel color="transparent">
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
|
||||
{showAssistant && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<NewChatByTitle showAssistantOverlay={showAssistantOverlay} text={ASK_AI_ASSISTANT} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<TakeActionButton />
|
||||
</EuiFlexItem>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue