mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution] add chat assistant button to expandable flyout (#159633)
This commit is contained in:
parent
7e067ec478
commit
f1db0cd851
7 changed files with 252 additions and 30 deletions
|
@ -38,6 +38,7 @@ import {
|
|||
DOCUMENT_DETAILS_FLYOUT_FOOTER_MARK_AS_CLOSED,
|
||||
DOCUMENT_DETAILS_FLYOUT_FOOTER_RESPOND,
|
||||
DOCUMENT_DETAILS_FLYOUT_FOOTER_TAKE_ACTION_BUTTON,
|
||||
DOCUMENT_DETAILS_FLYOUT_HEADER_CHAT_BUTTON,
|
||||
DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE,
|
||||
DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE_VALUE,
|
||||
DOCUMENT_DETAILS_FLYOUT_HEADER_SEVERITY,
|
||||
|
@ -65,7 +66,7 @@ import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
|
|||
|
||||
describe(
|
||||
'Alert details expandable flyout right panel',
|
||||
{ env: { ftrConfig: { enableExperimental: ['securityFlyoutEnabled'] } } },
|
||||
{ env: { ftrConfig: { enableExperimental: ['securityFlyoutEnabled', 'assistantEnabled'] } } },
|
||||
() => {
|
||||
const rule = getNewRule();
|
||||
|
||||
|
@ -82,7 +83,7 @@ describe(
|
|||
|
||||
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('be.visible').and('have.text', rule.name);
|
||||
|
||||
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('be.visible').and('have.text', rule.name);
|
||||
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_CHAT_BUTTON).should('be.visible');
|
||||
|
||||
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE).should('be.visible');
|
||||
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE_VALUE)
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
import {
|
||||
COLLAPSE_DETAILS_BUTTON_TEST_ID,
|
||||
EXPAND_DETAILS_BUTTON_TEST_ID,
|
||||
FLYOUT_HEADER_CHAT_BUTTON_TEST_ID,
|
||||
FLYOUT_HEADER_RISK_SCORE_TITLE_TEST_ID,
|
||||
FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID,
|
||||
FLYOUT_HEADER_SEVERITY_TITLE_TEST_ID,
|
||||
|
@ -53,6 +54,9 @@ export const DOCUMENT_DETAILS_FLYOUT_HEADER_SEVERITY = getDataTestSubjectSelecto
|
|||
export const DOCUMENT_DETAILS_FLYOUT_HEADER_SEVERITY_VALUE = getDataTestSubjectSelector(
|
||||
FLYOUT_HEADER_SEVERITY_VALUE_TEST_ID
|
||||
);
|
||||
export const DOCUMENT_DETAILS_FLYOUT_HEADER_CHAT_BUTTON = getDataTestSubjectSelector(
|
||||
FLYOUT_HEADER_CHAT_BUTTON_TEST_ID
|
||||
);
|
||||
|
||||
/* Footer */
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import React from 'react';
|
|||
import { render } from '@testing-library/react';
|
||||
import { RightPanelContext } from '../context';
|
||||
import {
|
||||
FLYOUT_HEADER_CHAT_BUTTON_TEST_ID,
|
||||
FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID,
|
||||
FLYOUT_HEADER_SEVERITY_TITLE_TEST_ID,
|
||||
FLYOUT_HEADER_SHARE_BUTTON_TEST_ID,
|
||||
|
@ -19,19 +20,44 @@ import { DOCUMENT_DETAILS } from './translations';
|
|||
import moment from 'moment-timezone';
|
||||
import { useDateFormat, useTimeZone } from '../../../common/lib/kibana';
|
||||
import { mockDataFormattedForFieldBrowser, mockGetFieldsData } from '../mocks/mock_context';
|
||||
import { AssistantProvider } from '@kbn/elastic-assistant';
|
||||
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import { useAssistant } from '../hooks/use_assistant';
|
||||
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
jest.mock('../hooks/use_assistant');
|
||||
|
||||
moment.suppressDeprecationWarnings = true;
|
||||
moment.tz.setDefault('UTC');
|
||||
|
||||
const mockUseDateFormat = useDateFormat as jest.Mock;
|
||||
const mockUseTimeZone = useTimeZone as jest.Mock;
|
||||
const dateFormat = 'MMM D, YYYY @ HH:mm:ss.SSS';
|
||||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
const mockGetInitialConversations = jest.fn(() => ({}));
|
||||
const mockGetComments = jest.fn(() => []);
|
||||
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
|
||||
|
||||
const renderHeader = (contextValue: RightPanelContext) =>
|
||||
render(
|
||||
<AssistantProvider
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
augmentMessageCodeBlocks={jest.fn()}
|
||||
getComments={mockGetComments}
|
||||
getInitialConversations={mockGetInitialConversations}
|
||||
setConversations={jest.fn()}
|
||||
http={mockHttp}
|
||||
>
|
||||
<RightPanelContext.Provider value={contextValue}>
|
||||
<HeaderTitle />
|
||||
</RightPanelContext.Provider>
|
||||
</AssistantProvider>
|
||||
);
|
||||
|
||||
describe('<HeaderTitle />', () => {
|
||||
beforeEach(() => {
|
||||
mockUseDateFormat.mockImplementation(() => dateFormat);
|
||||
mockUseTimeZone.mockImplementation(() => 'UTC');
|
||||
jest.mocked(useDateFormat).mockImplementation(() => dateFormat);
|
||||
jest.mocked(useTimeZone).mockImplementation(() => 'UTC');
|
||||
jest.mocked(useAssistant).mockReturnValue({ showAssistant: true, promptContextId: '' });
|
||||
});
|
||||
|
||||
it('should render mitre attack information', () => {
|
||||
|
@ -40,11 +66,7 @@ describe('<HeaderTitle />', () => {
|
|||
getFieldsData: jest.fn().mockImplementation(mockGetFieldsData),
|
||||
} as unknown as RightPanelContext;
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RightPanelContext.Provider value={contextValue}>
|
||||
<HeaderTitle />
|
||||
</RightPanelContext.Provider>
|
||||
);
|
||||
const { getByTestId } = renderHeader(contextValue);
|
||||
|
||||
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID)).toBeInTheDocument();
|
||||
|
@ -72,16 +94,12 @@ describe('<HeaderTitle />', () => {
|
|||
getFieldsData: () => [],
|
||||
} as unknown as RightPanelContext;
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RightPanelContext.Provider value={contextValue}>
|
||||
<HeaderTitle />
|
||||
</RightPanelContext.Provider>
|
||||
);
|
||||
const { getByTestId } = renderHeader(contextValue);
|
||||
|
||||
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent('test');
|
||||
});
|
||||
|
||||
it('should render share button in the title if document is an alert', () => {
|
||||
it('should render share button in the title if document is an alert with url info', () => {
|
||||
const contextValue = {
|
||||
dataFormattedForFieldBrowser: [
|
||||
{
|
||||
|
@ -102,15 +120,53 @@ describe('<HeaderTitle />', () => {
|
|||
getFieldsData: () => [],
|
||||
} as unknown as RightPanelContext;
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RightPanelContext.Provider value={contextValue}>
|
||||
<HeaderTitle />
|
||||
</RightPanelContext.Provider>
|
||||
);
|
||||
const { getByTestId } = renderHeader(contextValue);
|
||||
|
||||
expect(getByTestId(FLYOUT_HEADER_SHARE_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render share button in the title if alert is missing url info', () => {
|
||||
const contextValue = {
|
||||
dataFormattedForFieldBrowser: [
|
||||
{
|
||||
category: 'kibana',
|
||||
field: 'kibana.alert.rule.uuid',
|
||||
values: ['123'],
|
||||
originalValue: ['123'],
|
||||
isObjectArray: false,
|
||||
},
|
||||
],
|
||||
getFieldsData: () => [],
|
||||
} as unknown as RightPanelContext;
|
||||
|
||||
const { queryByTestId } = renderHeader(contextValue);
|
||||
|
||||
expect(queryByTestId(FLYOUT_HEADER_SHARE_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render chat button in the title', () => {
|
||||
const contextValue = {
|
||||
dataFormattedForFieldBrowser: [],
|
||||
getFieldsData: () => [],
|
||||
} as unknown as RightPanelContext;
|
||||
|
||||
const { getByTestId } = renderHeader(contextValue);
|
||||
|
||||
expect(getByTestId(FLYOUT_HEADER_CHAT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render chat button in the title if should not be shown', () => {
|
||||
jest.mocked(useAssistant).mockReturnValue({ showAssistant: false, promptContextId: '' });
|
||||
const contextValue = {
|
||||
dataFormattedForFieldBrowser: [],
|
||||
getFieldsData: () => [],
|
||||
} as unknown as RightPanelContext;
|
||||
|
||||
const { queryByTestId } = renderHeader(contextValue);
|
||||
|
||||
expect(queryByTestId(FLYOUT_HEADER_CHAT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render default document detail title if document is not an alert', () => {
|
||||
const contextValue = {
|
||||
dataFormattedForFieldBrowser: [
|
||||
|
@ -125,11 +181,7 @@ describe('<HeaderTitle />', () => {
|
|||
getFieldsData: () => [],
|
||||
} as unknown as RightPanelContext;
|
||||
|
||||
const { getByTestId } = render(
|
||||
<RightPanelContext.Provider value={contextValue}>
|
||||
<HeaderTitle />
|
||||
</RightPanelContext.Provider>
|
||||
);
|
||||
const { getByTestId } = renderHeader(contextValue);
|
||||
|
||||
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent(DOCUMENT_DETAILS);
|
||||
});
|
||||
|
|
|
@ -7,8 +7,14 @@
|
|||
|
||||
import type { FC } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { NewChatById } from '@kbn/elastic-assistant';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useAssistant } from '../hooks/use_assistant';
|
||||
import {
|
||||
ALERT_SUMMARY_CONVERSATION_ID,
|
||||
EVENT_SUMMARY_CONVERSATION_ID,
|
||||
} from '../../../common/components/event_details/translations';
|
||||
import { DocumentSeverity } from './severity';
|
||||
import { RiskScore } from './risk_score';
|
||||
import { DOCUMENT_DETAILS } from './translations';
|
||||
|
@ -26,16 +32,38 @@ export const HeaderTitle: FC = memo(() => {
|
|||
const { isAlert, ruleName, timestamp, alertUrl } = useBasicDataFromDetailsData(
|
||||
dataFormattedForFieldBrowser
|
||||
);
|
||||
const { showAssistant, promptContextId } = useAssistant({
|
||||
dataFormattedForFieldBrowser,
|
||||
isAlert,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiTitle size="s" data-test-subj={FLYOUT_HEADER_TITLE_TEST_ID}>
|
||||
<EuiTitle size="s">
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<h4>{isAlert && !isEmpty(ruleName) ? ruleName : DOCUMENT_DETAILS}</h4>
|
||||
<h4 data-test-subj={FLYOUT_HEADER_TITLE_TEST_ID}>
|
||||
{isAlert && !isEmpty(ruleName) ? ruleName : DOCUMENT_DETAILS}
|
||||
</h4>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{isAlert && alertUrl && <ShareButton alertUrl={alertUrl} />}
|
||||
<EuiFlexGroup alignItems="center">
|
||||
{isAlert && alertUrl && (
|
||||
<EuiFlexItem>
|
||||
<ShareButton alertUrl={alertUrl} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{showAssistant && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<NewChatById
|
||||
conversationId={
|
||||
isAlert ? ALERT_SUMMARY_CONVERSATION_ID : EVENT_SUMMARY_CONVERSATION_ID
|
||||
}
|
||||
promptContextId={promptContextId}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiTitle>
|
||||
|
|
|
@ -23,6 +23,7 @@ export const FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID =
|
|||
'securitySolutionAlertDetailsFlyoutHeaderRiskScoreValue';
|
||||
export const FLYOUT_HEADER_SHARE_BUTTON_TEST_ID =
|
||||
'securitySolutionAlertDetailsFlyoutHeaderShareButton';
|
||||
export const FLYOUT_HEADER_CHAT_BUTTON_TEST_ID = 'newChatById';
|
||||
|
||||
/* Description section */
|
||||
|
||||
|
|
|
@ -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 type { RenderHookResult } from '@testing-library/react-hooks';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type { UseAssistantParams, UseAssistantResult } from './use_assistant';
|
||||
import { useAssistant } from './use_assistant';
|
||||
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
import { useAssistantOverlay } from '@kbn/elastic-assistant';
|
||||
|
||||
jest.mock('../../../common/hooks/use_experimental_features');
|
||||
jest.mock('@kbn/elastic-assistant');
|
||||
|
||||
const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
|
||||
const isAlert = true;
|
||||
|
||||
const renderUseAssistant = () =>
|
||||
renderHook((props: UseAssistantParams) => useAssistant(props), {
|
||||
initialProps: { dataFormattedForFieldBrowser, isAlert },
|
||||
});
|
||||
|
||||
describe('useAssistant', () => {
|
||||
let hookResult: RenderHookResult<UseAssistantParams, UseAssistantResult>;
|
||||
|
||||
it(`should return showAssistant true and a value for promptContextId`, () => {
|
||||
jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(true);
|
||||
jest
|
||||
.mocked(useAssistantOverlay)
|
||||
.mockReturnValue({ showAssistantOverlay: jest.fn, promptContextId: '123' });
|
||||
|
||||
hookResult = renderUseAssistant();
|
||||
|
||||
expect(hookResult.result.current.showAssistant).toEqual(true);
|
||||
expect(hookResult.result.current.promptContextId).toEqual('123');
|
||||
});
|
||||
|
||||
it(`should return showAssistant false if feature flag is off`, () => {
|
||||
jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(false);
|
||||
jest
|
||||
.mocked(useAssistantOverlay)
|
||||
.mockReturnValue({ showAssistantOverlay: jest.fn, promptContextId: '123' });
|
||||
|
||||
hookResult = renderUseAssistant();
|
||||
|
||||
expect(hookResult.result.current.showAssistant).toEqual(false);
|
||||
expect(hookResult.result.current.promptContextId).toEqual('');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import { useAssistantOverlay } from '@kbn/elastic-assistant';
|
||||
import { useCallback } from 'react';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
import { getPromptContextFromEventDetailsItem } from '../../../assistant/helpers';
|
||||
import {
|
||||
ALERT_SUMMARY_CONTEXT_DESCRIPTION,
|
||||
ALERT_SUMMARY_CONVERSATION_ID,
|
||||
ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP,
|
||||
EVENT_SUMMARY_CONTEXT_DESCRIPTION,
|
||||
EVENT_SUMMARY_CONVERSATION_ID,
|
||||
EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP,
|
||||
SUMMARY_VIEW,
|
||||
} from '../../../common/components/event_details/translations';
|
||||
import {
|
||||
PROMPT_CONTEXT_ALERT_CATEGORY,
|
||||
PROMPT_CONTEXT_EVENT_CATEGORY,
|
||||
PROMPT_CONTEXTS,
|
||||
} from '../../../assistant/content/prompt_contexts';
|
||||
|
||||
const useAssistantNoop = () => ({ promptContextId: undefined });
|
||||
|
||||
export interface UseAssistantParams {
|
||||
/**
|
||||
* An array of field objects with category and value
|
||||
*/
|
||||
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
|
||||
/**
|
||||
* Is true if the document is an alert
|
||||
*/
|
||||
isAlert: boolean;
|
||||
}
|
||||
|
||||
export interface UseAssistantResult {
|
||||
/**
|
||||
* Returns true if the assistant button is visible
|
||||
*/
|
||||
showAssistant: boolean;
|
||||
/**
|
||||
* Unique identifier for prompt context
|
||||
*/
|
||||
promptContextId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to return the assistant button visibility and prompt context id
|
||||
*/
|
||||
export const useAssistant = ({
|
||||
dataFormattedForFieldBrowser,
|
||||
isAlert,
|
||||
}: UseAssistantParams): UseAssistantResult => {
|
||||
const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled');
|
||||
const useAssistantHook = isAssistantEnabled ? useAssistantOverlay : useAssistantNoop;
|
||||
const getPromptContext = useCallback(
|
||||
async () => getPromptContextFromEventDetailsItem(dataFormattedForFieldBrowser ?? []),
|
||||
[dataFormattedForFieldBrowser]
|
||||
);
|
||||
const { promptContextId } = useAssistantHook(
|
||||
isAlert ? 'alert' : 'event',
|
||||
isAlert ? ALERT_SUMMARY_CONVERSATION_ID : EVENT_SUMMARY_CONVERSATION_ID,
|
||||
isAlert
|
||||
? ALERT_SUMMARY_CONTEXT_DESCRIPTION(SUMMARY_VIEW)
|
||||
: EVENT_SUMMARY_CONTEXT_DESCRIPTION(SUMMARY_VIEW),
|
||||
getPromptContext,
|
||||
null,
|
||||
isAlert
|
||||
? PROMPT_CONTEXTS[PROMPT_CONTEXT_ALERT_CATEGORY].suggestedUserPrompt
|
||||
: PROMPT_CONTEXTS[PROMPT_CONTEXT_EVENT_CATEGORY].suggestedUserPrompt,
|
||||
isAlert ? ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP : EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP
|
||||
);
|
||||
|
||||
return {
|
||||
showAssistant: isAssistantEnabled && promptContextId !== null,
|
||||
promptContextId: promptContextId || '',
|
||||
};
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue