[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 |
| ------------- | ------------- |
| ![Screenshot 2025-04-15 at 11 54
36 PM](https://github.com/user-attachments/assets/b6039081-d5b8-4bf7-ada1-af3844e17bad)
| ![Screenshot 2025-04-15 at 11 54
09 PM](https://github.com/user-attachments/assets/6833a89c-931e-4eb3-be93-4fc1e2ed96e2)
|

## 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:
Philippe Oberti 2025-04-18 02:37:06 +02:00 committed by GitHub
parent da5e8cc6e9
commit add6e303d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 691 additions and 172 deletions

View file

@ -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);
});

View file

@ -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);

View file

@ -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',
}
);

View file

@ -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();
});
});

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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';

View file

@ -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 };

View file

@ -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);
});
});

View file

@ -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,
};
};

View file

@ -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'],
});
});
});

View file

@ -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;
};

View file

@ -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]');
});
});

View file

@ -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;
}
};

View file

@ -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';

View file

@ -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();
});
});
});

View file

@ -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

View file

@ -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();
});
});

View file

@ -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>