Skip to content

Commit

Permalink
[Security Solution] Adds new Elastic AI Assistant logo and global hea…
Browse files Browse the repository at this point in the history
…der menu item (elastic#164763)

## Summary

Adds new Elastic AI Assistant logo and global header menu item to all
Security Solution pages.

Resolves elastic/security-team#7407

New logo within the assistant itself (header and assistant avatar):

<p align="center">
<img width="500"
src="https://github.com/elastic/kibana/assets/2946766/2a94c2ca-37d6-49f0-af59-2b15fd37d81e"
/>
</p> 

New global header menu for both on-prem and serverless security
`complete` deployments:

<p align="center">
<img width="500"
src="https://github.com/elastic/kibana/assets/2946766/67b030fe-fb36-4a68-9331-d636e15a68f4"
/>
</p> 

<p align="center">
<img width="500"
src="https://github.com/elastic/kibana/assets/2946766/74751e3a-a88a-4b39-bec0-73497dcd98b1"
/>
</p> 


Note: If Security Assistant RBAC privileges are `NONE` (which includes
serverless deployments that are NOT security `complete`), the global
header button will be hidden. We can revisit the upsell messaging
opportunity here for serverless deployments.



### Checklist

Delete any items that are not applicable to this PR.

- [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/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
* @elastic/security-docs, will need to update images and make note of
new global header item, will create issue...
elastic/security-docs#3804
- [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
  • Loading branch information
spong authored Aug 26, 2023
1 parent d8d355d commit 5cac49a
Show file tree
Hide file tree
Showing 12 changed files with 182 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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, { ReactNode } from 'react';

export interface AssistantAvatarProps {
size?: keyof typeof sizeMap;
// Required for EuiAvatar `iconType` prop
// eslint-disable-next-line react/no-unused-prop-types
children?: ReactNode;
}

export const sizeMap = {
xl: 64,
l: 48,
m: 32,
s: 24,
xs: 16,
};

/**
* Default Elastic AI Assistant logo
*
* TODO: Can be removed once added to EUI
*/
export const AssistantAvatar = ({ size = 's' }: AssistantAvatarProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={sizeMap[size]}
height={sizeMap[size]}
viewBox="0 0 64 64"
fill="none"
>
<path fill="#F04E98" d="M36 28h24v36H36V28Z" />
<path fill="#00BFB3" d="M4 46c0-9.941 8.059-18 18-18h6v36h-6c-9.941 0-18-8.059-18-18Z" />
<path
fill="#343741"
d="M60 12c0 6.627-5.373 12-12 12s-12-5.373-12-12S41.373 0 48 0s12 5.373 12 12Z"
/>
<path fill="#FA744E" d="M6 23C6 10.85 15.85 1 28 1v22H6Z" />
</svg>
);
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ import { alertConvo, emptyWelcomeConvo } from '../../mock/conversation';

const testProps = {
currentConversation: emptyWelcomeConvo,
currentTitle: {
title: 'Test Title',
titleIcon: 'logoSecurity',
},
title: 'Test Title',
docLinks: {
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'master',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import * as i18n from '../translations';

interface OwnProps {
currentConversation: Conversation;
currentTitle: { title: string | JSX.Element; titleIcon: string };
defaultConnectorId?: string;
defaultProvider?: OpenAiProviderType;
docLinks: Omit<DocLinksStart, 'links'>;
Expand All @@ -39,6 +38,7 @@ interface OwnProps {
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
shouldDisableKeyboardShortcut?: () => boolean;
showAnonymizedValues: boolean;
title: string | JSX.Element;
}

type Props = OwnProps;
Expand All @@ -49,7 +49,6 @@ type Props = OwnProps;
*/
export const AssistantHeader: React.FC<Props> = ({
currentConversation,
currentTitle,
defaultConnectorId,
defaultProvider,
docLinks,
Expand All @@ -62,6 +61,7 @@ export const AssistantHeader: React.FC<Props> = ({
setSelectedConversationId,
shouldDisableKeyboardShortcut,
showAnonymizedValues,
title,
}) => {
const showAnonymizedValuesChecked = useMemo(
() =>
Expand All @@ -81,10 +81,10 @@ export const AssistantHeader: React.FC<Props> = ({
>
<EuiFlexItem grow={false}>
<AssistantTitle
{...currentTitle}
isDisabled={isDisabled}
docLinks={docLinks}
selectedConversation={currentConversation}
title={title}
/>
</EuiFlexItem>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,18 @@ import { TestProviders } from '../../mock/test_providers/test_providers';

const testProps = {
title: 'Test Title',
titleIcon: 'globe',
docLinks: { ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', DOC_LINK_VERSION: '7.15' },
selectedConversation: undefined,
};

describe('AssistantTitle', () => {
it('the component renders correctly with valid props', () => {
const { getByText, container } = render(
const { getByText } = render(
<TestProviders>
<AssistantTitle {...testProps} />
</TestProviders>
);
expect(getByText('Test Title')).toBeInTheDocument();
expect(container.querySelector('[data-euiicon-type="globe"]')).not.toBeNull();
});

it('clicking on the popover button opens the popover with the correct link', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiModalHeaderTitle,
EuiPopover,
Expand All @@ -19,22 +18,21 @@ import {
} from '@elastic/eui';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import * as i18n from '../translations';
import type { Conversation } from '../../..';
import { ConnectorSelectorInline } from '../../connectorland/connector_selector_inline/connector_selector_inline';
import { AssistantAvatar } from '../assistant_avatar/assistant_avatar';

/**
* Renders a header title with an icon, a tooltip button, and a popover with
* Renders a header title, a tooltip button, and a popover with
* information about the assistant feature and access to documentation.
*/
export const AssistantTitle: React.FC<{
isDisabled?: boolean;
title: string | JSX.Element;
titleIcon: string;
docLinks: Omit<DocLinksStart, 'links'>;
selectedConversation: Conversation | undefined;
}> = ({ isDisabled = false, title, titleIcon, docLinks, selectedConversation }) => {
}> = ({ isDisabled = false, title, docLinks, selectedConversation }) => {
const selectedConnectorId = selectedConversation?.apiConfig?.connectorId;

const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
Expand Down Expand Up @@ -75,13 +73,8 @@ export const AssistantTitle: React.FC<{
return (
<EuiModalHeaderTitle>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem
grow={false}
css={css`
margin-top: 3px;
`}
>
<EuiIcon data-test-subj="titleIcon" type={titleIcon} size="xl" />
<EuiFlexItem grow={false}>
<AssistantAvatar data-test-subj="titleIcon" size={'m'} />
</EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
Expand Down
11 changes: 3 additions & 8 deletions x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,8 @@ const AssistantComponent: React.FC<Props> = ({
},
});

const currentTitle: { title: string | JSX.Element; titleIcon: string } =
isWelcomeSetup && blockBotConversation.theme?.title && blockBotConversation.theme?.titleIcon
? {
title: blockBotConversation.theme?.title,
titleIcon: blockBotConversation.theme?.titleIcon,
}
: { title, titleIcon: 'logoSecurity' };
const currentTitle: string | JSX.Element =
isWelcomeSetup && blockBotConversation.theme?.title ? blockBotConversation.theme?.title : title;

const bottomRef = useRef<HTMLDivElement | null>(null);
const lastCommentRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -426,7 +421,6 @@ const AssistantComponent: React.FC<Props> = ({
{showTitle && (
<AssistantHeader
currentConversation={currentConversation}
currentTitle={currentTitle}
defaultConnectorId={defaultConnectorId}
defaultProvider={defaultProvider}
docLinks={docLinks}
Expand All @@ -438,6 +432,7 @@ const AssistantComponent: React.FC<Props> = ({
setIsSettingsModalVisible={setIsSettingsModalVisible}
setSelectedConversationId={setSelectedConversationId}
showAnonymizedValues={showAnonymizedValues}
title={currentTitle}
/>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { clearPresentationData, conversationHasNoPresentationData } from './help
import * as i18n from '../translations';
import { useAssistantContext } from '../../assistant_context';
import { useLoadConnectors } from '../use_load_connectors';
import { AssistantAvatar } from '../../assistant/assistant_avatar/assistant_avatar';

const ConnectorButtonWrapper = styled.div`
margin-bottom: 10px;
Expand Down Expand Up @@ -186,21 +187,14 @@ export const useConnectorSetup = ({
name={i18n.CONNECTOR_SETUP_USER_ASSISTANT}
size="l"
color="subdued"
iconType={conversation?.theme?.assistant?.icon ?? 'logoElastic'}
iconType={AssistantAvatar}
/>
),
timestamp: `${i18n.CONNECTOR_SETUP_TIMESTAMP_AT}: ${message.timestamp}`,
};
return commentProps;
}),
[
assistantName,
commentBody,
conversation.messages,
conversation?.theme?.assistant?.icon,
currentMessageIndex,
userName,
]
[assistantName, commentBody, conversation.messages, currentMessageIndex, userName]
);

return {
Expand Down
3 changes: 3 additions & 0 deletions x-pack/packages/kbn-elastic-assistant/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export { useAssistantOverlay } from './impl/assistant/use_assistant_overlay';
/** a helper that enriches content returned from a query with action buttons */
export { analyzeMarkdown } from './impl/assistant/use_conversation/helpers';

/** Default Elastic AI Assistant logo, can be removed once included in EUI **/
export { AssistantAvatar } from './impl/assistant/assistant_avatar/assistant_avatar';

export {
ELASTIC_AI_ASSISTANT_TITLE,
WELCOME_CONVERSATION_TITLE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiHeaderLink, EuiToolTip } from '@elastic/eui';
import React, { useCallback } from 'react';

import { i18n } from '@kbn/i18n';
import { useAssistantContext } from '@kbn/elastic-assistant/impl/assistant_context';
import { AssistantAvatar } from '@kbn/elastic-assistant';

const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;

/**
* Elastic AI Assistant header link
*/
export const AssistantHeaderLink = React.memo(() => {
const { showAssistantOverlay } = useAssistantContext();

const keyboardShortcut = isMac ? '⌘ ;' : 'Ctrl ;';

const tooltipContent = i18n.translate(
'xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip',
{
values: { keyboardShortcut },
defaultMessage: 'Keyboard shortcut {keyboardShortcut}',
}
);

const showOverlay = useCallback(
() => showAssistantOverlay({ showOverlay: true }),
[showAssistantOverlay]
);

return (
<EuiToolTip content={tooltipContent}>
<EuiHeaderLink data-test-subj="assistantHeaderLink" color="primary" onClick={showOverlay}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantAvatar size="xs" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{i18n.translate('xpack.securitySolution.globalHeader.assistantHeaderLink', {
defaultMessage: 'AI Assistant',
})}
</EuiFlexItem>
</EuiFlexGroup>
</EuiHeaderLink>
</EuiToolTip>
);
});

AssistantHeaderLink.displayName = 'AssistantHeaderLink';
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import { useVariationMock } from '../../../common/components/utils.mocks';
import { GlobalHeader } from '.';
Expand All @@ -26,6 +26,7 @@ import { TimelineId } from '../../../../common/types/timeline';
import { createStore } from '../../../common/store';
import { kibanaObservable } from '@kbn/timelines-plugin/public/mock';
import { sourcererPaths } from '../../../common/containers/sourcerer';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';

jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
Expand All @@ -48,6 +49,15 @@ jest.mock('react-reverse-portal', () => ({
createHtmlPortalNode: () => ({ unmount: jest.fn() }),
}));

jest.mock('../../../assistant/use_assistant_availability');

jest.mocked(useAssistantAvailability).mockReturnValue({
hasAssistantPrivilege: false,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
isAssistantEnabled: true,
});

describe('global header', () => {
const mockSetHeaderActionMenu = jest.fn();
const state = {
Expand Down Expand Up @@ -172,4 +182,46 @@ describe('global header', () => {

expect(queryByTestId('sourcerer-trigger')).not.toBeInTheDocument();
});

it('shows AI Assistant header link if user has necessary privileges', () => {
(useLocation as jest.Mock).mockReturnValue([
{ pageName: SecurityPageName.overview, detailName: undefined },
]);

jest.mocked(useAssistantAvailability).mockReturnValue({
hasAssistantPrivilege: true,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
isAssistantEnabled: true,
});

const { findByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);

waitFor(() => expect(findByTestId('assistantHeaderLink')).toBeInTheDocument());
});

it('does not show AI Assistant header link if user does not have necessary privileges', () => {
(useLocation as jest.Mock).mockReturnValue([
{ pageName: SecurityPageName.overview, detailName: undefined },
]);

jest.mocked(useAssistantAvailability).mockReturnValue({
hasAssistantPrivilege: false,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
isAssistantEnabled: true,
});

const { findByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);

waitFor(() => expect(findByTestId('assistantHeaderLink')).not.toBeInTheDocument());
});
});
Loading

0 comments on commit 5cac49a

Please sign in to comment.