[8.x] Kb settings followup (#195733) (#196477)

# Backport

This will backport the following commits from `main` to `8.x`:
- [Kb settings followup
(#195733)](https://github.com/elastic/kibana/pull/195733)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Patryk
Kopyciński","email":"contact@patrykkopycinski.com"},"sourceCommit":{"committedDate":"2024-10-16T03:41:57Z","message":"Kb
settings followup
(#195733)","sha":"983a3e5723f7c2ab6e33663e03355f431723b1b5","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["v9.0.0","release_note:feature","Feature:Security
Assistant","Team:Security Generative
AI","v8.16.0","backport:version"],"title":"Kb settings
followup","number":195733,"url":"https://github.com/elastic/kibana/pull/195733","mergeCommit":{"message":"Kb
settings followup
(#195733)","sha":"983a3e5723f7c2ab6e33663e03355f431723b1b5"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/195733","number":195733,"mergeCommit":{"message":"Kb
settings followup
(#195733)","sha":"983a3e5723f7c2ab6e33663e03355f431723b1b5"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Patryk Kopyciński <contact@patrykkopycinski.com>
This commit is contained in:
Kibana Machine 2024-10-16 16:21:47 +11:00 committed by GitHub
parent 85145569bd
commit cbd40a81e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1449 additions and 785 deletions

View file

@ -17,6 +17,7 @@ describe('IndexPatternsApiClient', () => {
let indexPatternsApiClient: DataViewsApiClient;
beforeEach(() => {
jest.clearAllMocks();
fetchSpy = jest.spyOn(http, 'fetch').mockImplementation(() => Promise.resolve({}));
indexPatternsApiClient = new DataViewsApiClient(http as HttpSetup, () =>
Promise.resolve(undefined)
@ -46,4 +47,15 @@ describe('IndexPatternsApiClient', () => {
version: '1', // version header
});
});
test('Correctly formats fieldTypes argument', async function () {
const fieldTypes = ['text', 'keyword'];
await indexPatternsApiClient.getFieldsForWildcard({
pattern: 'blah',
fieldTypes,
allowHidden: false,
});
expect(fetchSpy.mock.calls[0][1].query.field_types).toEqual(fieldTypes);
});
});

View file

@ -112,7 +112,7 @@ export class DataViewsApiClient implements IDataViewsApiClient {
allow_no_index: allowNoIndex,
include_unmapped: includeUnmapped,
fields,
fieldTypes,
field_types: fieldTypes,
// default to undefined to keep value out of URL params and improve caching
allow_hidden: allowHidden || undefined,
include_empty_fields: includeEmptyFields,

View file

@ -12,11 +12,7 @@ import useEvent from 'react-use/lib/useEvent';
import { css } from '@emotion/react';
// eslint-disable-next-line @kbn/eslint/module_migration
import { createGlobalStyle } from 'styled-components';
import {
ShowAssistantOverlayProps,
useAssistantContext,
UserAvatar,
} from '../../assistant_context';
import { ShowAssistantOverlayProps, useAssistantContext } from '../../assistant_context';
import { Assistant, CONVERSATION_SIDE_PANEL_WIDTH } from '..';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
@ -25,9 +21,6 @@ const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
* Modal container for Elastic AI Assistant conversations, receiving the page contents as context, plus whatever
* component currently has focus and any specific context it may provide through the SAssInterface.
*/
export interface Props {
currentUserAvatar?: UserAvatar;
}
export const UnifiedTimelineGlobalStyles = createGlobalStyle`
body:has(.timeline-portal-overlay-mask) .euiOverlayMask {
@ -35,7 +28,7 @@ export const UnifiedTimelineGlobalStyles = createGlobalStyle`
}
`;
export const AssistantOverlay = React.memo<Props>(({ currentUserAvatar }) => {
export const AssistantOverlay = React.memo(() => {
const [isModalVisible, setIsModalVisible] = useState(false);
// Why is this named Title and not Id?
const [conversationTitle, setConversationTitle] = useState<string | undefined>(undefined);
@ -144,7 +137,6 @@ export const AssistantOverlay = React.memo<Props>(({ currentUserAvatar }) => {
onCloseFlyout={handleCloseModal}
chatHistoryVisible={chatHistoryVisible}
setChatHistoryVisible={toggleChatHistory}
currentUserAvatar={currentUserAvatar}
/>
</EuiFlyoutResizable>
<UnifiedTimelineGlobalStyles />

View file

@ -28,6 +28,7 @@ interface Props {
onSaveCancelled: () => void;
onSaveConfirmed: () => void;
saveButtonDisabled?: boolean;
saveButtonLoading?: boolean;
}
const FlyoutComponent: React.FC<Props> = ({
@ -38,9 +39,11 @@ const FlyoutComponent: React.FC<Props> = ({
onSaveCancelled,
onSaveConfirmed,
saveButtonDisabled = false,
saveButtonLoading = false,
}) => {
return flyoutVisible ? (
<EuiFlyout
data-test-subj={'flyout'}
ownFocus
onClose={onClose}
css={css`
@ -74,6 +77,7 @@ const FlyoutComponent: React.FC<Props> = ({
onClick={onSaveConfirmed}
iconType="check"
disabled={saveButtonDisabled}
isLoading={saveButtonLoading}
fill
>
{i18n.FLYOUT_SAVE_BUTTON_TITLE}

View file

@ -48,6 +48,7 @@ export const useInlineActions = <T extends { isDefault?: boolean | undefined }>(
},
{
name: i18n.DELETE_BUTTON,
'data-test-subj': 'delete-button',
description: i18n.DELETE_BUTTON,
icon: 'trash',
type: 'icon',

View file

@ -23,7 +23,6 @@ import { Conversation } from '../assistant_context/types';
import * as all from './chat_send/use_chat_send';
import { useConversation } from './use_conversation';
import { AIConnector } from '../connectorland/connector_selector';
import { omit } from 'lodash';
jest.mock('../connectorland/use_load_connectors');
jest.mock('../connectorland/connector_setup');
@ -139,111 +138,6 @@ describe('Assistant', () => {
>);
});
describe('persistent storage', () => {
it('should refetchCurrentUserConversations after settings save button click', async () => {
const chatSendSpy = jest.spyOn(all, 'useChatSend');
await renderAssistant();
fireEvent.click(screen.getByTestId('settings'));
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: {
...mockData,
welcome_id: {
...mockData.welcome_id,
apiConfig: { newProp: true },
},
},
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: {
...mockData,
welcome_id: {
...mockData.welcome_id,
apiConfig: { newProp: true },
},
},
}),
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
await act(async () => {
fireEvent.click(screen.getByTestId('save-button'));
});
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: {
apiConfig: { newProp: true },
category: 'assistant',
id: mockData.welcome_id.id,
messages: [],
title: 'Welcome',
replacements: {},
},
})
);
});
it('should refetchCurrentUserConversations after settings save button click, but do not update convos when refetch returns bad results', async () => {
jest.mocked(useFetchCurrentUserConversations).mockReturnValue({
data: mockData,
isLoading: false,
refetch: jest.fn().mockResolvedValue({
isLoading: false,
data: omit(mockData, 'welcome_id'),
}),
isFetched: true,
} as unknown as DefinedUseQueryResult<Record<string, Conversation>, unknown>);
const chatSendSpy = jest.spyOn(all, 'useChatSend');
await renderAssistant();
fireEvent.click(screen.getByTestId('settings'));
await act(async () => {
fireEvent.click(screen.getByTestId('save-button'));
});
expect(chatSendSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
currentConversation: {
apiConfig: { connectorId: '123' },
replacements: {},
category: 'assistant',
id: mockData.welcome_id.id,
messages: [],
title: 'Welcome',
},
})
);
});
it('should delete conversation when delete button is clicked', async () => {
await renderAssistant();
const deleteButton = screen.getAllByTestId('delete-option')[0];
await act(async () => {
fireEvent.click(deleteButton);
});
await act(async () => {
fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));
});
await waitFor(() => {
expect(mockDeleteConvo).toHaveBeenCalledWith(mockData.electric_sheep_id.id);
});
});
it('should refetchCurrentUserConversations after clear chat history button click', async () => {
await renderAssistant();
fireEvent.click(screen.getByTestId('chat-context-menu'));
fireEvent.click(screen.getByTestId('clear-chat'));
fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));
await waitFor(() => {
expect(clearConversation).toHaveBeenCalled();
expect(refetchResults).toHaveBeenCalled();
});
});
});
describe('when selected conversation changes and some connectors are loaded', () => {
it('should persist the conversation id to local storage', async () => {
const getConversation = jest.fn().mockResolvedValue(mockData.electric_sheep_id);

View file

@ -38,7 +38,7 @@ import { ChatSend } from './chat_send';
import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations';
import { getDefaultConnector } from './helpers';
import { useAssistantContext, UserAvatar } from '../assistant_context';
import { useAssistantContext } from '../assistant_context';
import { ContextPills } from './context_pills';
import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context';
import type { PromptContext, SelectedPromptContext } from './prompt_context/types';
@ -61,7 +61,6 @@ const CommentContainer = styled('span')`
export interface Props {
chatHistoryVisible?: boolean;
conversationTitle?: string;
currentUserAvatar?: UserAvatar;
onCloseFlyout?: () => void;
promptContextId?: string;
setChatHistoryVisible?: Dispatch<SetStateAction<boolean>>;
@ -75,7 +74,6 @@ export interface Props {
const AssistantComponent: React.FC<Props> = ({
chatHistoryVisible,
conversationTitle,
currentUserAvatar,
onCloseFlyout,
promptContextId = '',
setChatHistoryVisible,
@ -90,12 +88,10 @@ const AssistantComponent: React.FC<Props> = ({
getLastConversationId,
http,
promptContexts,
setCurrentUserAvatar,
currentUserAvatar,
setLastConversationId,
} = useAssistantContext();
setCurrentUserAvatar(currentUserAvatar);
const [selectedPromptContexts, setSelectedPromptContexts] = useState<
Record<string, SelectedPromptContext>
>({});

View file

@ -31,10 +31,10 @@ describe('AlertsSettings', () => {
);
const rangeSlider = screen.getByTestId('alertsRange');
fireEvent.change(rangeSlider, { target: { value: '10' } });
fireEvent.change(rangeSlider, { target: { value: '90' } });
expect(setUpdatedKnowledgeBaseSettings).toHaveBeenCalledWith({
latestAlerts: 10,
latestAlerts: 90,
});
});
});

View file

@ -0,0 +1,63 @@
/*
* 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, { useCallback } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import { ALERTS_LABEL } from '../../../knowledge_base/translations';
import {
DEFAULT_CONVERSATIONS,
DEFAULT_PROMPTS,
useSettingsUpdater,
} from '../use_settings_updater/use_settings_updater';
import { AlertsSettings } from './alerts_settings';
import { CANCEL, SAVE } from '../translations';
interface AlertSettingsModalProps {
onClose: () => void;
}
export const AlertsSettingsModal = ({ onClose }: AlertSettingsModalProps) => {
const { knowledgeBase, setUpdatedKnowledgeBaseSettings, saveSettings } = useSettingsUpdater(
DEFAULT_CONVERSATIONS, // Alerts settings do not require conversations
DEFAULT_PROMPTS, // Alerts settings do not require prompts
false, // Alerts settings do not require conversations
false // Alerts settings do not require prompts
);
const handleSave = useCallback(() => {
saveSettings();
onClose();
}, [onClose, saveSettings]);
return (
<EuiModal onClose={onClose}>
<EuiModalHeader>
<EuiModalHeaderTitle>{ALERTS_LABEL}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<AlertsSettings
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose}>{CANCEL}</EuiButtonEmpty>
<EuiButton type="submit" onClick={handleSave} fill>
{SAVE}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -64,12 +64,12 @@ jest.mock('../../assistant_context');
jest.mock('.', () => {
return {
AnonymizationSettings: () => <span data-test-subj="ANONYMIZATION_TAB-tab" />,
ConversationSettings: () => <span data-test-subj="CONVERSATIONS_TAB-tab" />,
EvaluationSettings: () => <span data-test-subj="EVALUATION_TAB-tab" />,
KnowledgeBaseSettings: () => <span data-test-subj="KNOWLEDGE_BASE_TAB-tab" />,
QuickPromptSettings: () => <span data-test-subj="QUICK_PROMPTS_TAB-tab" />,
SystemPromptSettings: () => <span data-test-subj="SYSTEM_PROMPTS_TAB-tab" />,
AnonymizationSettings: () => <span data-test-subj="anonymization-tab" />,
ConversationSettings: () => <span data-test-subj="conversations-tab" />,
EvaluationSettings: () => <span data-test-subj="evaluation-tab" />,
KnowledgeBaseSettings: () => <span data-test-subj="knowledge_base-tab" />,
QuickPromptSettings: () => <span data-test-subj="quick_prompts-tab" />,
SystemPromptSettings: () => <span data-test-subj="system_prompts-tab" />,
};
});
@ -136,17 +136,6 @@ describe('AssistantSettings', () => {
QUICK_PROMPTS_TAB,
SYSTEM_PROMPTS_TAB,
])('%s', (tab) => {
it('Opens the tab on button click', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...mockContext,
selectedSettingsTab: tab === CONVERSATIONS_TAB ? ANONYMIZATION_TAB : CONVERSATIONS_TAB,
}));
const { getByTestId } = render(<AssistantSettings {...testProps} />, {
wrapper,
});
fireEvent.click(getByTestId(`${tab}-button`));
expect(setSelectedSettingsTab).toHaveBeenCalledWith(tab);
});
it('renders with the correct tab open', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...mockContext,

View file

@ -9,14 +9,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiIcon,
EuiModal,
EuiModalFooter,
EuiKeyPadMenu,
EuiKeyPadMenuItem,
EuiPage,
EuiPageBody,
EuiPageSidebar,
EuiSplitPanel,
} from '@elastic/eui';
@ -80,13 +76,7 @@ export const AssistantSettings: React.FC<Props> = React.memo(
conversations,
conversationsLoaded,
}) => {
const {
assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled },
http,
toasts,
selectedSettingsTab,
setSelectedSettingsTab,
} = useAssistantContext();
const { http, toasts, selectedSettingsTab, setSelectedSettingsTab } = useAssistantContext();
useEffect(() => {
if (selectedSettingsTab == null) {
@ -211,112 +201,6 @@ export const AssistantSettings: React.FC<Props> = React.memo(
return (
<StyledEuiModal data-test-subj={TEST_IDS.SETTINGS_MODAL} onClose={onClose}>
<EuiPage paddingSize="none">
<EuiPageSidebar
paddingSize="xs"
css={css`
min-inline-size: unset !important;
max-width: 104px;
`}
>
<EuiKeyPadMenu>
<EuiKeyPadMenuItem
id={CONVERSATIONS_TAB}
label={i18n.CONVERSATIONS_MENU_ITEM}
isSelected={!selectedSettingsTab || selectedSettingsTab === CONVERSATIONS_TAB}
onClick={() => setSelectedSettingsTab(CONVERSATIONS_TAB)}
data-test-subj={`${CONVERSATIONS_TAB}-button`}
>
<>
<EuiIcon
type="editorComment"
size="xl"
css={css`
position: relative;
top: -10px;
`}
/>
<EuiIcon
type="editorComment"
size="l"
css={css`
position: relative;
transform: rotateY(180deg);
top: -7px;
`}
/>
</>
</EuiKeyPadMenuItem>
<EuiKeyPadMenuItem
id={QUICK_PROMPTS_TAB}
label={i18n.QUICK_PROMPTS_MENU_ITEM}
isSelected={selectedSettingsTab === QUICK_PROMPTS_TAB}
onClick={() => setSelectedSettingsTab(QUICK_PROMPTS_TAB)}
data-test-subj={`${QUICK_PROMPTS_TAB}-button`}
>
<>
<EuiIcon type="editorComment" size="xxl" />
<EuiIcon
type="bolt"
size="s"
color="warning"
css={css`
position: absolute;
top: 11px;
left: 14px;
`}
/>
</>
</EuiKeyPadMenuItem>
<EuiKeyPadMenuItem
id={SYSTEM_PROMPTS_TAB}
label={i18n.SYSTEM_PROMPTS_MENU_ITEM}
isSelected={selectedSettingsTab === SYSTEM_PROMPTS_TAB}
onClick={() => setSelectedSettingsTab(SYSTEM_PROMPTS_TAB)}
data-test-subj={`${SYSTEM_PROMPTS_TAB}-button`}
>
<EuiIcon type="editorComment" size="xxl" />
<EuiIcon
type="storage"
size="s"
color="success"
css={css`
position: absolute;
top: 11px;
left: 14px;
`}
/>
</EuiKeyPadMenuItem>
<EuiKeyPadMenuItem
id={ANONYMIZATION_TAB}
label={i18n.ANONYMIZATION_MENU_ITEM}
isSelected={selectedSettingsTab === ANONYMIZATION_TAB}
onClick={() => setSelectedSettingsTab(ANONYMIZATION_TAB)}
data-test-subj={`${ANONYMIZATION_TAB}-button`}
>
<EuiIcon type="eyeClosed" size="l" />
</EuiKeyPadMenuItem>
<EuiKeyPadMenuItem
id={KNOWLEDGE_BASE_TAB}
label={i18n.KNOWLEDGE_BASE_MENU_ITEM}
isSelected={selectedSettingsTab === KNOWLEDGE_BASE_TAB}
onClick={() => setSelectedSettingsTab(KNOWLEDGE_BASE_TAB)}
data-test-subj={`${KNOWLEDGE_BASE_TAB}-button`}
>
<EuiIcon type="notebookApp" size="l" />
</EuiKeyPadMenuItem>
{modelEvaluatorEnabled && (
<EuiKeyPadMenuItem
id={EVALUATION_TAB}
label={i18n.EVALUATION_MENU_ITEM}
isSelected={selectedSettingsTab === EVALUATION_TAB}
onClick={() => setSelectedSettingsTab(EVALUATION_TAB)}
data-test-subj={`${EVALUATION_TAB}-button`}
>
<EuiIcon type="crossClusterReplicationApp" size="l" />
</EuiKeyPadMenuItem>
)}
</EuiKeyPadMenu>
</EuiPageSidebar>
<EuiPageBody paddingSize="none" panelled={true}>
<EuiSplitPanel.Outer grow={true}>
<EuiSplitPanel.Inner

View file

@ -11,7 +11,6 @@ import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/c
import { AssistantSettingsButton } from './assistant_settings_button';
import { welcomeConvo } from '../../mock/conversation';
import { CONVERSATIONS_TAB } from './const';
const setIsSettingsModalVisible = jest.fn();
const onConversationSelected = jest.fn();
@ -57,12 +56,6 @@ describe('AssistantSettingsButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Clicking the settings gear opens the conversations tab', () => {
const { getByTestId } = render(<AssistantSettingsButton {...testProps} />);
fireEvent.click(getByTestId('settings'));
expect(setSelectedSettingsTab).toHaveBeenCalledWith(CONVERSATIONS_TAB);
expect(setIsSettingsModalVisible).toHaveBeenCalledWith(true);
});
it('Settings modal is visible and calls correct actions per click', () => {
const { getByTestId } = render(

View file

@ -6,8 +6,6 @@
*/
import React, { useCallback } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { QueryObserverResult, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query';
import { DataStreamApis } from '../use_data_stream_apis';
import { AIConnector } from '../../connectorland/connector_selector';
@ -15,7 +13,6 @@ import { Conversation } from '../../..';
import { AssistantSettings } from './assistant_settings';
import * as i18n from './translations';
import { useAssistantContext } from '../../assistant_context';
import { CONVERSATIONS_TAB } from './const';
interface Props {
defaultConnector?: AIConnector;
@ -48,7 +45,7 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo(
refetchCurrentUserConversations,
refetchPrompts,
}) => {
const { toasts, setSelectedSettingsTab } = useAssistantContext();
const { toasts } = useAssistantContext();
// Modal control functions
const cleanupAndCloseModal = useCallback(() => {
@ -76,37 +73,18 @@ export const AssistantSettingsButton: React.FC<Props> = React.memo(
[cleanupAndCloseModal, refetchCurrentUserConversations, refetchPrompts, toasts]
);
const handleShowConversationSettings = useCallback(() => {
setSelectedSettingsTab(CONVERSATIONS_TAB);
setIsSettingsModalVisible(true);
}, [setIsSettingsModalVisible, setSelectedSettingsTab]);
return (
<>
<EuiToolTip position="right" content={i18n.SETTINGS_TOOLTIP}>
<EuiButtonIcon
aria-label={i18n.SETTINGS}
data-test-subj="settings"
onClick={handleShowConversationSettings}
isDisabled={isDisabled}
iconType="gear"
size="xs"
color="text"
/>
</EuiToolTip>
{isSettingsModalVisible && (
<AssistantSettings
defaultConnector={defaultConnector}
selectedConversationId={selectedConversationId}
onConversationSelected={onConversationSelected}
onClose={handleCloseModal}
onSave={handleSave}
conversations={conversations}
conversationsLoaded={conversationsLoaded}
/>
)}
</>
isSettingsModalVisible && (
<AssistantSettings
defaultConnector={defaultConnector}
selectedConversationId={selectedConversationId}
onConversationSelected={onConversationSelected}
onClose={handleCloseModal}
onSave={handleSave}
conversations={conversations}
conversationsLoaded={conversationsLoaded}
/>
)
);
}
);

View file

@ -16,8 +16,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AssistantSettingsManagement } from './assistant_settings_management';
import {
ANONYMIZATION_TAB,
CONNECTORS_TAB,
ANONYMIZATION_TAB,
CONVERSATIONS_TAB,
EVALUATION_TAB,
KNOWLEDGE_BASE_TAB,
@ -40,15 +40,12 @@ const mockValues = {
quickPromptSettings: [],
};
const setSelectedSettingsTab = jest.fn();
const mockContext = {
basePromptContexts: MOCK_QUICK_PROMPTS,
setSelectedSettingsTab,
http: {
get: jest.fn(),
},
assistantFeatures: { assistantModelEvaluation: true },
selectedSettingsTab: null,
assistantAvailability: {
isAssistantEnabled: true,
},
@ -58,39 +55,42 @@ const mockDataViews = {
getIndices: jest.fn(),
} as unknown as DataViewsContract;
const onTabChange = jest.fn();
const testProps = {
selectedConversation: welcomeConvo,
dataViews: mockDataViews,
onTabChange,
currentTab: CONNECTORS_TAB,
};
jest.mock('../../assistant_context');
jest.mock('../../connectorland/connector_settings_management', () => ({
ConnectorsSettingsManagement: () => <span data-test-subj="CONNECTORS_TAB-tab" />,
ConnectorsSettingsManagement: () => <span data-test-subj="connectors-tab" />,
}));
jest.mock('../conversations/conversation_settings_management', () => ({
ConversationSettingsManagement: () => <span data-test-subj="CONVERSATIONS_TAB-tab" />,
ConversationSettingsManagement: () => <span data-test-subj="conversations-tab" />,
}));
jest.mock('../quick_prompts/quick_prompt_settings_management', () => ({
QuickPromptSettingsManagement: () => <span data-test-subj="QUICK_PROMPTS_TAB-tab" />,
QuickPromptSettingsManagement: () => <span data-test-subj="quick_prompts-tab" />,
}));
jest.mock('../prompt_editor/system_prompt/system_prompt_settings_management', () => ({
SystemPromptSettingsManagement: () => <span data-test-subj="SYSTEM_PROMPTS_TAB-tab" />,
SystemPromptSettingsManagement: () => <span data-test-subj="system_prompts-tab" />,
}));
jest.mock('../../knowledge_base/knowledge_base_settings_management', () => ({
KnowledgeBaseSettingsManagement: () => <span data-test-subj="KNOWLEDGE_BASE_TAB-tab" />,
KnowledgeBaseSettingsManagement: () => <span data-test-subj="knowledge_base-tab" />,
}));
jest.mock('../../data_anonymization/settings/anonymization_settings_management', () => ({
AnonymizationSettingsManagement: () => <span data-test-subj="ANONYMIZATION_TAB-tab" />,
AnonymizationSettingsManagement: () => <span data-test-subj="anonymization-tab" />,
}));
jest.mock('.', () => {
return {
EvaluationSettings: () => <span data-test-subj="EVALUATION_TAB-tab" />,
EvaluationSettings: () => <span data-test-subj="evaluation-tab" />,
};
});
@ -138,25 +138,23 @@ describe('AssistantSettingsManagement', () => {
SYSTEM_PROMPTS_TAB,
])('%s', (tab) => {
it('Opens the tab on button click', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...mockContext,
selectedSettingsTab: tab,
}));
const { getByTestId } = render(<AssistantSettingsManagement {...testProps} />, {
wrapper,
});
const { getByTestId } = render(
<AssistantSettingsManagement {...testProps} currentTab={tab} />,
{
wrapper,
}
);
fireEvent.click(getByTestId(`settingsPageTab-${tab}`));
expect(setSelectedSettingsTab).toHaveBeenCalledWith(tab);
expect(onTabChange).toHaveBeenCalledWith(tab);
});
it('renders with the correct tab open', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...mockContext,
selectedSettingsTab: tab,
}));
const { getByTestId } = render(<AssistantSettingsManagement {...testProps} />, {
wrapper,
});
expect(getByTestId(`${tab}-tab`)).toBeInTheDocument();
const { getByTestId } = render(
<AssistantSettingsManagement {...testProps} currentTab={tab} />,
{
wrapper,
}
);
expect(getByTestId(`tab-${tab}`)).toBeInTheDocument();
});
});
});

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import React, { useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import { EuiAvatar, EuiPageTemplate, EuiTitle, useEuiShadow, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
import { Conversation } from '../../..';
@ -32,10 +31,13 @@ import {
} from './const';
import { KnowledgeBaseSettingsManagement } from '../../knowledge_base/knowledge_base_settings_management';
import { EvaluationSettings } from '.';
import { SettingsTabs } from './types';
interface Props {
dataViews: DataViewsContract;
selectedConversation: Conversation;
onTabChange?: (tabId: string) => void;
currentTab?: SettingsTabs;
}
/**
@ -43,14 +45,16 @@ interface Props {
* anonymization, knowledge base, and evaluation via the `isModelEvaluationEnabled` feature flag.
*/
export const AssistantSettingsManagement: React.FC<Props> = React.memo(
({ dataViews, selectedConversation: defaultSelectedConversation }) => {
({
dataViews,
selectedConversation: defaultSelectedConversation,
onTabChange,
currentTab: selectedSettingsTab,
}) => {
const {
assistantFeatures: { assistantModelEvaluation: modelEvaluatorEnabled },
http,
selectedSettingsTab,
setSelectedSettingsTab,
} = useAssistantContext();
const { data: connectors } = useLoadConnectors({
http,
});
@ -59,12 +63,6 @@ export const AssistantSettingsManagement: React.FC<Props> = React.memo(
const { euiTheme } = useEuiTheme();
const headerIconShadow = useEuiShadow('s');
useEffect(() => {
if (selectedSettingsTab == null) {
setSelectedSettingsTab(CONNECTORS_TAB);
}
}, [selectedSettingsTab, setSelectedSettingsTab]);
const tabsConfig = useMemo(
() => [
{
@ -107,10 +105,12 @@ export const AssistantSettingsManagement: React.FC<Props> = React.memo(
return tabsConfig.map((t) => ({
...t,
'data-test-subj': `settingsPageTab-${t.id}`,
onClick: () => setSelectedSettingsTab(t.id),
onClick: () => {
onTabChange?.(t.id);
},
isSelected: t.id === selectedSettingsTab,
}));
}, [setSelectedSettingsTab, selectedSettingsTab, tabsConfig]);
}, [onTabChange, selectedSettingsTab, tabsConfig]);
return (
<>
@ -143,6 +143,7 @@ export const AssistantSettingsManagement: React.FC<Props> = React.memo(
padding-top: ${euiTheme.base * 0.75}px;
padding-bottom: ${euiTheme.base * 0.75}px;
`}
data-test-subj={`tab-${selectedSettingsTab}`}
>
{selectedSettingsTab === CONNECTORS_TAB && <ConnectorsSettingsManagement />}
{selectedSettingsTab === CONVERSATIONS_TAB && (

View file

@ -4,12 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const CONNECTORS_TAB = 'CONNECTORS_TAB' as const;
export const CONVERSATIONS_TAB = 'CONVERSATIONS_TAB' as const;
export const QUICK_PROMPTS_TAB = 'QUICK_PROMPTS_TAB' as const;
export const SYSTEM_PROMPTS_TAB = 'SYSTEM_PROMPTS_TAB' as const;
export const ANONYMIZATION_TAB = 'ANONYMIZATION_TAB' as const;
export const KNOWLEDGE_BASE_TAB = 'KNOWLEDGE_BASE_TAB' as const;
export const EVALUATION_TAB = 'EVALUATION_TAB' as const;
export const CONNECTORS_TAB = 'connectors' as const;
export const CONVERSATIONS_TAB = 'conversations' as const;
export const QUICK_PROMPTS_TAB = 'quick_prompts' as const;
export const SYSTEM_PROMPTS_TAB = 'system_prompts' as const;
export const ANONYMIZATION_TAB = 'anonymization' as const;
export const KNOWLEDGE_BASE_TAB = 'knowledge_base' as const;
export const EVALUATION_TAB = 'evaluation' as const;
export const DEFAULT_PAGE_SIZE = 25;

View file

@ -18,8 +18,11 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { AnonymizationSettingsManagement } from '../../../data_anonymization/settings/anonymization_settings_management';
import { useAssistantContext } from '../../../..';
import * as i18n from '../../assistant_header/translations';
import { AlertsSettingsModal } from '../alerts_settings/alerts_settings_modal';
import { KNOWLEDGE_BASE_TAB } from '../const';
interface Params {
isDisabled?: boolean;
@ -37,6 +40,15 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
const [isPopoverOpen, setPopover] = useState(false);
const [isResetConversationModalVisible, setIsResetConversationModalVisible] = useState(false);
const [isAlertsSettingsModalVisible, setIsAlertsSettingsModalVisible] = useState(false);
const closeAlertSettingsModal = useCallback(() => setIsAlertsSettingsModalVisible(false), []);
const showAlertSettingsModal = useCallback(() => setIsAlertsSettingsModalVisible(true), []);
const [isAnonymizationModalVisible, setIsAnonymizationModalVisible] = useState(false);
const closeAnonymizationModal = useCallback(() => setIsAnonymizationModalVisible(false), []);
const showAnonymizationModal = useCallback(() => setIsAnonymizationModalVisible(true), []);
const closeDestroyModal = useCallback(() => setIsResetConversationModalVisible(false), []);
const onButtonClick = useCallback(() => {
@ -60,14 +72,24 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
[navigateToApp]
);
const handleNavigateToAnonymization = useCallback(() => {
showAnonymizationModal();
closePopover();
}, [closePopover, showAnonymizationModal]);
const handleNavigateToKnowledgeBase = useCallback(
() =>
navigateToApp('management', {
path: 'kibana/securityAiAssistantManagement',
path: `kibana/securityAiAssistantManagement?tab=${KNOWLEDGE_BASE_TAB}`,
}),
[navigateToApp]
);
const handleShowAlertsModal = useCallback(() => {
showAlertSettingsModal();
closePopover();
}, [closePopover, showAlertSettingsModal]);
// We are migrating away from the settings modal in favor of the new Stack Management UI
// Currently behind `assistantKnowledgeBaseByDefault` FF
const newItems: ReactElement[] = useMemo(
@ -80,14 +102,6 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
>
{i18n.AI_ASSISTANT_SETTINGS}
</EuiContextMenuItem>,
<EuiContextMenuItem
aria-label={'anonymization'}
onClick={handleNavigateToSettings}
icon={'eye'}
data-test-subj={'anonymization'}
>
{i18n.ANONYMIZATION}
</EuiContextMenuItem>,
<EuiContextMenuItem
aria-label={'knowledge-base'}
onClick={handleNavigateToKnowledgeBase}
@ -96,9 +110,17 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
>
{i18n.KNOWLEDGE_BASE}
</EuiContextMenuItem>,
<EuiContextMenuItem
aria-label={'anonymization'}
onClick={handleNavigateToAnonymization}
icon={'eye'}
data-test-subj={'anonymization'}
>
{i18n.ANONYMIZATION}
</EuiContextMenuItem>,
<EuiContextMenuItem
aria-label={'alerts-to-analyze'}
onClick={handleNavigateToSettings}
onClick={handleShowAlertsModal}
icon={'magnifyWithExclamation'}
data-test-subj={'alerts-to-analyze'}
>
@ -112,7 +134,13 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
</EuiFlexGroup>
</EuiContextMenuItem>,
],
[handleNavigateToKnowledgeBase, handleNavigateToSettings, knowledgeBase]
[
handleNavigateToAnonymization,
handleNavigateToKnowledgeBase,
handleNavigateToSettings,
handleShowAlertsModal,
knowledgeBase.latestAlerts,
]
);
const items = useMemo(
@ -164,6 +192,10 @@ export const SettingsContextMenu: React.FC<Params> = React.memo(
`}
/>
</EuiPopover>
{isAlertsSettingsModalVisible && <AlertsSettingsModal onClose={closeAlertSettingsModal} />}
{isAnonymizationModalVisible && (
<AnonymizationSettingsManagement modalMode onClose={closeAnonymizationModal} />
)}
{isResetConversationModalVisible && (
<EuiConfirmModal
title={i18n.RESET_CONVERSATION}

View file

@ -21,7 +21,7 @@ export const SYSTEM_PROMPT_TABLE_SESSION_STORAGE_KEY = 'systemPromptTable';
export const ANONYMIZATION_TABLE_SESSION_STORAGE_KEY = 'anonymizationTable';
/** The default `n` latest alerts, ordered by risk score, sent as context to the assistant */
export const DEFAULT_LATEST_ALERTS = 20;
export const DEFAULT_LATEST_ALERTS = 100;
/** The default maximum number of alerts to be sent as context when generating Attack discoveries */
export const DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS = 200;

View file

@ -13,7 +13,8 @@ import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/publ
import { useLocalStorage, useSessionStorage } from 'react-use';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { AssistantFeatures, defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { NavigateToAppOptions } from '@kbn/core/public';
import { NavigateToAppOptions, UserProfileService } from '@kbn/core/public';
import { useQuery } from '@tanstack/react-query';
import { updatePromptContexts } from './helpers';
import type {
PromptContext,
@ -74,6 +75,7 @@ export interface AssistantProviderProps {
title?: string;
toasts?: IToasts;
currentAppId: string;
userProfileService: UserProfileService;
}
export interface UserAvatar {
@ -107,7 +109,6 @@ export interface UseAssistantContext {
registerPromptContext: RegisterPromptContext;
selectedSettingsTab: SettingsTabs | null;
setAssistantStreamingEnabled: React.Dispatch<React.SetStateAction<boolean | undefined>>;
setCurrentUserAvatar: React.Dispatch<React.SetStateAction<UserAvatar | undefined>>;
setKnowledgeBase: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig | undefined>>;
setLastConversationId: React.Dispatch<React.SetStateAction<string | undefined>>;
setSelectedSettingsTab: React.Dispatch<React.SetStateAction<SettingsTabs | null>>;
@ -125,6 +126,7 @@ export interface UseAssistantContext {
unRegisterPromptContext: UnRegisterPromptContext;
currentAppId: string;
codeBlockRef: React.MutableRefObject<(codeBlock: string) => void>;
userProfileService: UserProfileService;
}
const AssistantContext = React.createContext<UseAssistantContext | undefined>(undefined);
@ -147,6 +149,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
title = DEFAULT_ASSISTANT_TITLE,
toasts,
currentAppId,
userProfileService,
}) => {
/**
* Session storage for traceOptions, including APM URL and LangSmith Project/API Key
@ -223,7 +226,18 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
/**
* Current User Avatar
*/
const [currentUserAvatar, setCurrentUserAvatar] = useState<UserAvatar>();
const { data: currentUserAvatar } = useQuery({
queryKey: ['currentUserAvatar'],
queryFn: async () =>
userProfileService.getCurrent<{ avatar: UserAvatar }>({
dataPath: 'avatar',
}),
select: (data) => {
return data.data.avatar;
},
keepPreviousData: true,
refetchOnWindowFocus: false,
});
/**
* Settings State
@ -274,7 +288,6 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
assistantStreamingEnabled: localStorageStreaming ?? true,
setAssistantStreamingEnabled: setLocalStorageStreaming,
setKnowledgeBase: setLocalStorageKnowledgeBase,
setCurrentUserAvatar,
setSelectedSettingsTab,
setShowAssistantOverlay,
setTraceOptions: setSessionStorageTraceOptions,
@ -288,6 +301,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
baseConversations,
currentAppId,
codeBlockRef,
userProfileService,
}),
[
actionTypeRegistry,
@ -322,6 +336,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
baseConversations,
currentAppId,
codeBlockRef,
userProfileService,
]
);

View file

@ -69,6 +69,8 @@ export interface AssistantAvailability {
hasConnectorsReadPrivilege: boolean;
// When true, user has `Edit` privilege for `AnonymizationFields`
hasUpdateAIAssistantAnonymization: boolean;
// When true, user has `Edit` privilege for `Global Knowledge Base`
hasManageGlobalKnowledgeBase: boolean;
}
export type GetAssistantMessages = (commentArgs: {

View file

@ -20,6 +20,7 @@ describe('connectorMissingCallout', () => {
hasConnectorsAllPrivilege: false,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
};
@ -58,6 +59,7 @@ describe('connectorMissingCallout', () => {
hasConnectorsAllPrivilege: false,
hasConnectorsReadPrivilege: false,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: false,
isAssistantEnabled: true,
};

View file

@ -78,6 +78,7 @@ const mockUseAssistantContext = {
],
assistantAvailability: {
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
},
baseAllow: ['@timestamp', 'event.category', 'user.name'],
baseAllowReplacement: ['user.name', 'host.ip'],

View file

@ -5,7 +5,19 @@
* 2.0.
*/
import { EuiFlexGroup, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
@ -25,13 +37,23 @@ import {
import { useFetchAnonymizationFields } from '../../../assistant/api/anonymization_fields/use_fetch_anonymization_fields';
import { AssistantSettingsBottomBar } from '../../../assistant/settings/assistant_settings_bottom_bar';
import { useAssistantContext } from '../../../assistant_context';
import { SETTINGS_UPDATED_TOAST_TITLE } from '../../../assistant/settings/translations';
import {
CANCEL,
SAVE,
SETTINGS_UPDATED_TOAST_TITLE,
} from '../../../assistant/settings/translations';
export interface Props {
defaultPageSize?: number;
modalMode?: boolean;
onClose?: () => void;
}
const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPageSize = 5 }) => {
const AnonymizationSettingsManagementComponent: React.FC<Props> = ({
defaultPageSize = 5,
modalMode = false,
onClose,
}) => {
const { toasts } = useAssistantContext();
const { data: anonymizationFields } = useFetchAnonymizationFields();
const [hasPendingChanges, setHasPendingChanges] = useState(false);
@ -52,9 +74,10 @@ const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPage
);
const onCancelClick = useCallback(() => {
onClose?.();
resetSettings();
setHasPendingChanges(false);
}, [resetSettings]);
}, [onClose, resetSettings]);
const handleSave = useCallback(
async (param?: { callback?: () => void }) => {
@ -71,7 +94,8 @@ const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPage
const onSaveButtonClicked = useCallback(() => {
handleSave();
}, [handleSave]);
onClose?.();
}, [handleSave, onClose]);
const handleAnonymizationFieldsBulkActions = useCallback<
UseAnonymizationListUpdateProps['setAnonymizationFieldsBulkActions']
@ -99,6 +123,47 @@ const AnonymizationSettingsManagementComponent: React.FC<Props> = ({ defaultPage
setAnonymizationFieldsBulkActions: handleAnonymizationFieldsBulkActions,
setUpdatedAnonymizationData: handleUpdatedAnonymizationData,
});
if (modalMode) {
return (
<EuiModal onClose={onCancelClick}>
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.SETTINGS_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText size="m">{i18n.SETTINGS_DESCRIPTION}</EuiText>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center" data-test-subj="summary" gutterSize="none">
<Stats
isDataAnonymizable={true}
anonymizationFields={updatedAnonymizationData.data}
titleSize="m"
gap={euiThemeVars.euiSizeS}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
<ContextEditor
anonymizationFields={updatedAnonymizationData}
compressed={false}
onListUpdated={onListUpdated}
rawData={null}
pageSize={defaultPageSize}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancelClick}>{CANCEL}</EuiButtonEmpty>
<EuiButton type="submit" onClick={onSaveButtonClicked} fill disabled={!hasPendingChanges}>
{SAVE}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
}
return (
<>
<EuiPanel hasShadow={false} hasBorder paddingSize="l">

View file

@ -66,6 +66,7 @@ export const AlertsRange: React.FC<Props> = React.memo(
return (
<EuiRange
aria-label={ALERTS_RANGE}
fullWidth
compressed={compressed}
css={css`
max-inline-size: ${MAX_ALERTS_RANGE_WIDTH}px;

View file

@ -36,13 +36,14 @@ const KNOWLEDGE_BASE_INDEX_PATTERN = '.kibana-elastic-ai-assistant-knowledge-bas
interface Props {
knowledgeBase: KnowledgeBaseConfig;
setUpdatedKnowledgeBaseSettings: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig>>;
modalMode?: boolean;
}
/**
* Knowledge Base Settings -- set up the Knowledge Base and configure RAG on alerts
*/
export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
({ knowledgeBase, setUpdatedKnowledgeBaseSettings }) => {
({ knowledgeBase, setUpdatedKnowledgeBaseSettings, modalMode = false }) => {
const { http, toasts } = useAssistantContext();
const { data: kbStatus, isLoading, isFetching } = useKnowledgeBaseStatus({ http });
const { mutate: setupKB, isLoading: isSettingUpKB } = useSetupKnowledgeBase({ http, toasts });
@ -113,7 +114,7 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
return (
<>
<EuiTitle size={'s'}>
<EuiTitle size={'s'} data-test-subj="knowledge-base-settings">
<h2>
{i18n.SETTINGS_TITLE}{' '}
<EuiBetaBadge iconType={'beaker'} label={i18n.SETTINGS_BADGE} size="s" color="hollow" />
@ -194,10 +195,12 @@ export const KnowledgeBaseSettings: React.FC<Props> = React.memo(
<EuiSpacer size="s" />
<AlertsSettings
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings}
/>
{!modalMode && (
<AlertsSettings
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings}
/>
)}
</>
);
}

View file

@ -58,6 +58,7 @@ export const AddEntryButton: React.FC<Props> = React.memo(
aria-label={i18n.DOCUMENT}
key={i18n.DOCUMENT}
icon="document"
data-test-subj="addDocument"
onClick={handleDocumentClicked}
disabled={!isDocumentAvailable}
>
@ -67,7 +68,12 @@ export const AddEntryButton: React.FC<Props> = React.memo(
return onIndexClicked || onDocumentClicked ? (
<EuiPopover
button={
<EuiButton iconType="arrowDown" iconSide="right" onClick={onButtonClick}>
<EuiButton
data-test-subj="addEntry"
iconType="arrowDown"
iconSide="right"
onClick={onButtonClick}
>
<EuiIcon type="plusInCircle" />
{i18n.NEW}
</EuiButton>

View file

@ -21,116 +21,124 @@ import * as i18n from './translations';
interface Props {
entry?: DocumentEntry;
setEntry: React.Dispatch<React.SetStateAction<Partial<DocumentEntry>>>;
hasManageGlobalKnowledgeBase: boolean;
}
export const DocumentEntryEditor: React.FC<Props> = React.memo(({ entry, setEntry }) => {
// Name
const setName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })),
[setEntry]
);
export const DocumentEntryEditor: React.FC<Props> = React.memo(
({ entry, setEntry, hasManageGlobalKnowledgeBase }) => {
// Name
const setName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })),
[setEntry]
);
// Sharing
const setSharingOptions = useCallback(
(value: string) =>
setEntry((prevEntry) => ({
...prevEntry,
users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined,
})),
[setEntry]
);
// TODO: KB-RBAC Disable global option if no RBAC
const sharingOptions = [
{
value: i18n.SHARING_PRIVATE_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="lock"
/>
{i18n.SHARING_PRIVATE_OPTION_LABEL}
</EuiText>
),
},
{
value: i18n.SHARING_GLOBAL_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="globe"
/>
{i18n.SHARING_GLOBAL_OPTION_LABEL}
</EuiText>
),
},
];
const selectedSharingOption =
entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value;
// Sharing
const setSharingOptions = useCallback(
(value: string) =>
setEntry((prevEntry) => ({
...prevEntry,
users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined,
})),
[setEntry]
);
const sharingOptions = [
{
value: i18n.SHARING_PRIVATE_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="lock"
/>
{i18n.SHARING_PRIVATE_OPTION_LABEL}
</EuiText>
),
},
{
value: i18n.SHARING_GLOBAL_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="globe"
/>
{i18n.SHARING_GLOBAL_OPTION_LABEL}
</EuiText>
),
disabled: !hasManageGlobalKnowledgeBase,
},
];
const selectedSharingOption =
entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value;
// Text / markdown
const setMarkdownValue = useCallback(
(value: string) => {
setEntry((prevEntry) => ({ ...prevEntry, text: value }));
},
[setEntry]
);
// Text / markdown
const setMarkdownValue = useCallback(
(value: string) => {
setEntry((prevEntry) => ({ ...prevEntry, text: value }));
},
[setEntry]
);
// Required checkbox
const onRequiredKnowledgeChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setEntry((prevEntry) => ({ ...prevEntry, required: e.target.checked }));
},
[setEntry]
);
// Required checkbox
const onRequiredKnowledgeChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setEntry((prevEntry) => ({ ...prevEntry, required: e.target.checked }));
},
[setEntry]
);
return (
<EuiForm>
<EuiFormRow label={i18n.ENTRY_NAME_INPUT_LABEL} fullWidth>
<EuiFieldText
name="name"
placeholder={i18n.ENTRY_NAME_INPUT_PLACEHOLDER}
return (
<EuiForm>
<EuiFormRow
label={i18n.ENTRY_NAME_INPUT_LABEL}
helpText={i18n.ENTRY_NAME_INPUT_PLACEHOLDER}
fullWidth
value={entry?.name}
onChange={setName}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_SHARING_INPUT_LABEL}
helpText={i18n.SHARING_HELP_TEXT}
fullWidth
>
<EuiSuperSelect
options={sharingOptions}
valueOfSelected={selectedSharingOption}
onChange={setSharingOptions}
>
<EuiFieldText
name="name"
data-test-subj="entryNameInput"
fullWidth
value={entry?.name}
onChange={setName}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_SHARING_INPUT_LABEL}
helpText={i18n.SHARING_HELP_TEXT}
fullWidth
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_MARKDOWN_INPUT_TEXT} fullWidth>
<EuiMarkdownEditor
aria-label={i18n.ENTRY_MARKDOWN_INPUT_TEXT}
placeholder="# Title"
value={entry?.text ?? ''}
onChange={setMarkdownValue}
height={400}
initialViewMode={'editing'}
/>
</EuiFormRow>
<EuiFormRow fullWidth helpText={i18n.ENTRY_REQUIRED_KNOWLEDGE_HELP_TEXT}>
<EuiCheckbox
label={i18n.ENTRY_REQUIRED_KNOWLEDGE_CHECKBOX_LABEL}
id="requiredKnowledge"
onChange={onRequiredKnowledgeChanged}
checked={entry?.required ?? false}
/>
</EuiFormRow>
</EuiForm>
);
});
>
<EuiSuperSelect
options={sharingOptions}
valueOfSelected={selectedSharingOption}
onChange={setSharingOptions}
fullWidth
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_MARKDOWN_INPUT_TEXT} fullWidth>
<EuiMarkdownEditor
aria-label={i18n.ENTRY_MARKDOWN_INPUT_TEXT}
data-test-subj="entryMarkdownInput"
placeholder="# Title"
value={entry?.text ?? ''}
onChange={setMarkdownValue}
height={400}
initialViewMode={'editing'}
/>
</EuiFormRow>
<EuiFormRow fullWidth helpText={i18n.ENTRY_REQUIRED_KNOWLEDGE_HELP_TEXT}>
<EuiCheckbox
label={i18n.ENTRY_REQUIRED_KNOWLEDGE_CHECKBOX_LABEL}
id="requiredKnowledge"
onChange={onRequiredKnowledgeChanged}
checked={entry?.required ?? false}
/>
</EuiFormRow>
</EuiForm>
);
}
);
DocumentEntryEditor.displayName = 'DocumentEntryEditor';

View file

@ -23,6 +23,10 @@ export const isSystemEntry = (
);
};
export const isGlobalEntry = (
entry: KnowledgeBaseEntryResponse
): entry is KnowledgeBaseEntryResponse => entry.users != null && !entry.users.length;
export const isKnowledgeBaseEntryCreateProps = (
entry: unknown
): entry is z.infer<typeof KnowledgeBaseEntryCreateProps> => {

View file

@ -0,0 +1,244 @@
/*
* 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 userEvent from '@testing-library/user-event';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
import { KnowledgeBaseSettingsManagement } from '.';
import { useCreateKnowledgeBaseEntry } from '../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry';
import { useDeleteKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries';
import { useFlyoutModalVisibility } from '../../assistant/common/components/assistant_settings_management/flyout/use_flyout_modal_visibility';
import { useKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_knowledge_base_entries';
import {
isKnowledgeBaseSetup,
useKnowledgeBaseStatus,
} from '../../assistant/api/knowledge_base/use_knowledge_base_status';
import { useSettingsUpdater } from '../../assistant/settings/use_settings_updater/use_settings_updater';
import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries';
import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt';
import { useAssistantContext } from '../../..';
import { I18nProvider } from '@kbn/i18n-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const mockContext = {
basePromptContexts: MOCK_QUICK_PROMPTS,
setSelectedSettingsTab: jest.fn(),
http: {
get: jest.fn(),
},
assistantFeatures: { assistantKnowledgeBaseByDefault: true },
selectedSettingsTab: null,
assistantAvailability: {
isAssistantEnabled: true,
},
};
jest.mock('../../assistant_context');
jest.mock('../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry');
jest.mock('../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries');
jest.mock('../../assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries');
jest.mock('../../assistant/settings/use_settings_updater/use_settings_updater');
jest.mock('../../assistant/api/knowledge_base/use_knowledge_base_status');
jest.mock('../../assistant/api/knowledge_base/entries/use_knowledge_base_entries');
jest.mock(
'../../assistant/common/components/assistant_settings_management/flyout/use_flyout_modal_visibility'
);
const mockDataViews = {
getIndices: jest.fn().mockResolvedValue([{ name: 'index-1' }, { name: 'index-2' }]),
getFieldsForWildcard: jest.fn().mockResolvedValue([
{ name: 'field-1', esTypes: ['semantic_text'] },
{ name: 'field-2', esTypes: ['text'] },
{ name: 'field-3', esTypes: ['semantic_text'] },
]),
} as unknown as DataViewsContract;
const queryClient = new QueryClient();
const wrapper = (props: { children: React.ReactNode }) => (
<I18nProvider>
<QueryClientProvider client={queryClient}>{props.children}</QueryClientProvider>
</I18nProvider>
);
describe('KnowledgeBaseSettingsManagement', () => {
const mockData = [
{ id: '1', name: 'Test Entry 1', type: 'document', kbResource: 'user', users: [{ id: 'hi' }] },
{ id: '2', name: 'Test Entry 2', type: 'index', kbResource: 'global', users: [] },
];
beforeEach(() => {
jest.clearAllMocks();
(useAssistantContext as jest.Mock).mockImplementation(() => mockContext);
(useSettingsUpdater as jest.Mock).mockReturnValue({
knowledgeBase: { latestAlerts: 20 },
setUpdatedKnowledgeBaseSettings: jest.fn(),
resetSettings: jest.fn(),
saveSettings: jest.fn(),
});
(isKnowledgeBaseSetup as jest.Mock).mockReturnValue(true);
(useKnowledgeBaseStatus as jest.Mock).mockReturnValue({
data: {
elser_exists: true,
security_labs_exists: true,
index_exists: true,
pipeline_exists: true,
},
isFetched: true,
});
(useKnowledgeBaseEntries as jest.Mock).mockReturnValue({
data: { data: mockData },
isFetching: false,
refetch: jest.fn(),
});
(useFlyoutModalVisibility as jest.Mock).mockReturnValue({
isFlyoutOpen: false,
openFlyout: jest.fn(),
closeFlyout: jest.fn(),
});
(useCreateKnowledgeBaseEntry as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
});
(useUpdateKnowledgeBaseEntries as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
});
(useDeleteKnowledgeBaseEntries as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
});
});
it('renders old kb settings when enableKnowledgeBaseByDefault is not enabled', () => {
(useAssistantContext as jest.Mock).mockImplementation(() => ({
...mockContext,
assistantFeatures: {
assistantKnowledgeBaseByDefault: false,
},
}));
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, { wrapper });
expect(screen.getByTestId('knowledge-base-settings')).toBeInTheDocument();
});
it('renders loading spinner when data is not fetched', () => {
(useKnowledgeBaseStatus as jest.Mock).mockReturnValue({ data: {}, isFetched: false });
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, {
wrapper,
});
expect(screen.getByTestId('spinning')).toBeInTheDocument();
});
it('Prompts user to set up knowledge base when isKbSetup', async () => {
(useKnowledgeBaseStatus as jest.Mock).mockReturnValue({
data: {
elser_exists: false,
security_labs_exists: false,
index_exists: false,
pipeline_exists: false,
},
isFetched: true,
});
(isKnowledgeBaseSetup as jest.Mock).mockReturnValue(false);
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, {
wrapper,
});
expect(screen.getByTestId('setup-knowledge-base-button')).toBeInTheDocument();
});
it('renders knowledge base table with entries', async () => {
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, {
wrapper,
});
waitFor(() => {
expect(screen.getByTestId('knowledge-base-entries-table')).toBeInTheDocument();
expect(screen.getByText('Test Entry 1')).toBeInTheDocument();
expect(screen.getByText('Test Entry 2')).toBeInTheDocument();
});
});
it('opens the flyout when add document button is clicked', async () => {
const openFlyoutMock = jest.fn();
(useFlyoutModalVisibility as jest.Mock).mockReturnValue({
isFlyoutOpen: false,
openFlyout: openFlyoutMock,
closeFlyout: jest.fn(),
});
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, {
wrapper,
});
await waitFor(() => {
fireEvent.click(screen.getByTestId('addEntry'));
});
await waitFor(() => {
fireEvent.click(screen.getByTestId('addDocument'));
});
expect(openFlyoutMock).toHaveBeenCalled();
});
it('refreshes table on refresh button click', async () => {
const refetchMock = jest.fn();
(useKnowledgeBaseEntries as jest.Mock).mockReturnValue({
data: { data: mockData },
isFetching: false,
refetch: refetchMock,
});
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, {
wrapper,
});
await waitFor(() => {
fireEvent.click(screen.getByTestId('refresh-entries'));
});
expect(refetchMock).toHaveBeenCalled();
});
it('handles save and cancel actions for the flyout', async () => {
const closeFlyoutMock = jest.fn();
(useFlyoutModalVisibility as jest.Mock).mockReturnValue({
isFlyoutOpen: true,
openFlyout: jest.fn(),
closeFlyout: closeFlyoutMock,
});
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, {
wrapper,
});
await waitFor(() => {
fireEvent.click(screen.getByTestId('addEntry'));
});
await waitFor(() => {
fireEvent.click(screen.getByTestId('addDocument'));
});
expect(screen.getByTestId('flyout')).toBeVisible();
await userEvent.type(screen.getByTestId('entryNameInput'), 'hi');
await waitFor(() => {
fireEvent.click(screen.getByTestId('cancel-button'));
});
expect(closeFlyoutMock).toHaveBeenCalled();
});
it('handles delete confirmation modal actions', async () => {
render(<KnowledgeBaseSettingsManagement dataViews={mockDataViews} />, {
wrapper,
});
await waitFor(() => {
fireEvent.click(screen.getAllByTestId('delete-button')[0]);
});
expect(screen.getByTestId('delete-entry-confirmation')).toBeInTheDocument();
await waitFor(() => {
fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));
});
expect(screen.queryByTestId('delete-entry-confirmation')).not.toBeInTheDocument();
});
});

View file

@ -7,6 +7,7 @@
import {
EuiButton,
EuiConfirmModal,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
@ -52,15 +53,17 @@ import {
isSystemEntry,
isKnowledgeBaseEntryCreateProps,
isKnowledgeBaseEntryResponse,
isGlobalEntry,
} from './helpers';
import { useCreateKnowledgeBaseEntry } from '../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry';
import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries';
import { SETTINGS_UPDATED_TOAST_TITLE } from '../../assistant/settings/translations';
import { DELETE, SETTINGS_UPDATED_TOAST_TITLE } from '../../assistant/settings/translations';
import { KnowledgeBaseConfig } from '../../assistant/types';
import {
isKnowledgeBaseSetup,
useKnowledgeBaseStatus,
} from '../../assistant/api/knowledge_base/use_knowledge_base_status';
import { CANCEL_BUTTON_TEXT } from '../../assistant/assistant_header/translations';
interface Params {
dataViews: DataViewsContract;
@ -69,6 +72,7 @@ interface Params {
export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ dataViews }) => {
const {
assistantFeatures: { assistantKnowledgeBaseByDefault: enableKnowledgeBaseByDefault },
assistantAvailability: { hasManageGlobalKnowledgeBase },
http,
toasts,
} = useAssistantContext();
@ -76,6 +80,8 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
const { data: kbStatus, isFetched } = useKnowledgeBaseStatus({ http });
const isKbSetup = isKnowledgeBaseSetup(kbStatus);
const [deleteKBItem, setDeleteKBItem] = useState<DocumentEntry | IndexEntry | null>(null);
// Only needed for legacy settings management
const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } =
useSettingsUpdater(
@ -123,24 +129,28 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
useState<Partial<DocumentEntry | IndexEntry | KnowledgeBaseEntryCreateProps>>();
// CRUD API accessors
const { mutate: createEntry, isLoading: isCreatingEntry } = useCreateKnowledgeBaseEntry({
const { mutateAsync: createEntry, isLoading: isCreatingEntry } = useCreateKnowledgeBaseEntry({
http,
toasts,
});
const { mutate: updateEntries, isLoading: isUpdatingEntries } = useUpdateKnowledgeBaseEntries({
http,
toasts,
});
const { mutate: deleteEntry, isLoading: isDeletingEntries } = useDeleteKnowledgeBaseEntries({
const { mutateAsync: updateEntries, isLoading: isUpdatingEntries } =
useUpdateKnowledgeBaseEntries({
http,
toasts,
});
const { mutateAsync: deleteEntry, isLoading: isDeletingEntries } = useDeleteKnowledgeBaseEntries({
http,
toasts,
});
const isModifyingEntry = isCreatingEntry || isUpdatingEntries || isDeletingEntries;
// Flyout Save/Cancel Actions
const onSaveConfirmed = useCallback(() => {
const onSaveConfirmed = useCallback(async () => {
if (isKnowledgeBaseEntryResponse(selectedEntry)) {
updateEntries([selectedEntry]);
await updateEntries([selectedEntry]);
closeFlyout();
} else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) {
await createEntry(selectedEntry);
closeFlyout();
} else if (isKnowledgeBaseEntryCreateProps(selectedEntry)) {
createEntry(selectedEntry);
@ -166,19 +176,19 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
const columns = useMemo(
() =>
getColumns({
onEntryNameClicked: ({ id }: KnowledgeBaseEntryResponse) => {
const entry = entries.data.find((e) => e.id === id);
setSelectedEntry(entry);
openFlyout();
},
isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => {
return !isSystemEntry(entry);
return (
!isSystemEntry(entry) && (isGlobalEntry(entry) ? hasManageGlobalKnowledgeBase : true)
);
},
onDeleteActionClicked: ({ id }: KnowledgeBaseEntryResponse) => {
deleteEntry({ ids: [id] });
// Add delete popover
onDeleteActionClicked: (item: KnowledgeBaseEntryResponse) => {
setDeleteKBItem(item);
},
isEditEnabled: (entry: KnowledgeBaseEntryResponse) => {
return !isSystemEntry(entry);
return (
!isSystemEntry(entry) && (isGlobalEntry(entry) ? hasManageGlobalKnowledgeBase : true)
);
},
onEditActionClicked: ({ id }: KnowledgeBaseEntryResponse) => {
const entry = entries.data.find((e) => e.id === id);
@ -186,7 +196,7 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
openFlyout();
},
}),
[deleteEntry, entries.data, getColumns, openFlyout]
[entries.data, getColumns, hasManageGlobalKnowledgeBase, openFlyout]
);
// Refresh button
@ -214,6 +224,7 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
<EuiFlexItem>
<EuiButton
color={'text'}
data-test-subj={'refresh-entries'}
isDisabled={isFetchingEntries}
onClick={handleRefreshTable}
iconType={'refresh'}
@ -251,6 +262,24 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
: i18n.NEW_INDEX_FLYOUT_TITLE;
}, [selectedEntry]);
const sorting = {
sort: {
field: 'name',
direction: 'desc' as const,
},
};
const handleCancelDeleteEntry = useCallback(() => {
setDeleteKBItem(null);
}, [setDeleteKBItem]);
const handleDeleteEntry = useCallback(async () => {
if (deleteKBItem?.id) {
await deleteEntry({ ids: [deleteKBItem?.id] });
setDeleteKBItem(null);
}
}, [deleteEntry, deleteKBItem, setDeleteKBItem]);
if (!enableKnowledgeBaseByDefault) {
return (
<>
@ -267,13 +296,6 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
);
}
const sorting = {
sort: {
field: 'name',
direction: 'desc' as const,
},
};
return (
<>
<EuiPanel hasShadow={false} hasBorder paddingSize="l">
@ -298,9 +320,10 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
{!isFetched ? (
<EuiLoadingSpinner size="l" />
<EuiLoadingSpinner data-test-subj="spinning" size="l" />
) : isKbSetup ? (
<EuiInMemoryTable
data-test-subj="knowledge-base-entries-table"
columns={columns}
items={entries.data ?? []}
search={search}
@ -344,7 +367,13 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
onClose={onSaveCancelled}
onSaveCancelled={onSaveCancelled}
onSaveConfirmed={onSaveConfirmed}
saveButtonDisabled={!isKnowledgeBaseEntryCreateProps(selectedEntry) || isModifyingEntry} // TODO: KB-RBAC disable for global entries if user doesn't have global RBAC
saveButtonDisabled={
!isKnowledgeBaseEntryCreateProps(selectedEntry) ||
(selectedEntry.users != null &&
!selectedEntry.users.length &&
!hasManageGlobalKnowledgeBase)
}
saveButtonLoading={isModifyingEntry}
>
<>
{selectedEntry?.type === DocumentEntryType.value ? (
@ -353,6 +382,7 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
setEntry={
setSelectedEntry as React.Dispatch<React.SetStateAction<Partial<DocumentEntry>>>
}
hasManageGlobalKnowledgeBase={hasManageGlobalKnowledgeBase}
/>
) : (
<IndexEntryEditor
@ -361,10 +391,27 @@ export const KnowledgeBaseSettingsManagement: React.FC<Params> = React.memo(({ d
setEntry={
setSelectedEntry as React.Dispatch<React.SetStateAction<Partial<IndexEntry>>>
}
hasManageGlobalKnowledgeBase={hasManageGlobalKnowledgeBase}
/>
)}
</>
</Flyout>
{deleteKBItem && (
<EuiConfirmModal
data-test-subj="delete-entry-confirmation"
title={i18n.DELETE_ENTRY_CONFIRMATION_TITLE(deleteKBItem.name)}
onCancel={handleCancelDeleteEntry}
onConfirm={handleDeleteEntry}
cancelButtonText={CANCEL_BUTTON_TEXT}
confirmButtonText={DELETE}
buttonColor="danger"
defaultFocusedButton="cancel"
confirmButtonDisabled={isModifyingEntry}
isLoading={isModifyingEntry}
>
<p>{i18n.DELETE_ENTRY_CONFIRMATION_CONTENT}</p>
</EuiConfirmModal>
)}
</>
);
});

View file

@ -0,0 +1,150 @@
/*
* 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 userEvent from '@testing-library/user-event';
import { render, fireEvent, waitFor, within } from '@testing-library/react';
import { IndexEntryEditor } from './index_entry_editor';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
import { IndexEntry } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
describe('IndexEntryEditor', () => {
const mockSetEntry = jest.fn();
const mockDataViews = {
getIndices: jest.fn().mockResolvedValue([{ name: 'index-1' }, { name: 'index-2' }]),
getFieldsForWildcard: jest.fn().mockResolvedValue([
{ name: 'field-1', esTypes: ['semantic_text'] },
{ name: 'field-2', esTypes: ['text'] },
{ name: 'field-3', esTypes: ['semantic_text'] },
]),
} as unknown as DataViewsContract;
const defaultProps = {
dataViews: mockDataViews,
setEntry: mockSetEntry,
hasManageGlobalKnowledgeBase: true,
entry: {
name: 'Test Entry',
index: 'index-1',
field: 'field-1',
description: 'Test Description',
queryDescription: 'Test Query Description',
users: [],
} as unknown as IndexEntry,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the form fields with initial values', () => {
const { getByDisplayValue } = render(<IndexEntryEditor {...defaultProps} />);
waitFor(() => {
expect(getByDisplayValue('Test Entry')).toBeInTheDocument();
expect(getByDisplayValue('Test Description')).toBeInTheDocument();
expect(getByDisplayValue('Test Query Description')).toBeInTheDocument();
expect(getByDisplayValue('index-1')).toBeInTheDocument();
expect(getByDisplayValue('field-1')).toBeInTheDocument();
});
});
it('updates the name field on change', () => {
const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />);
waitFor(() => {
const nameInput = getByTestId('entry-name');
fireEvent.change(nameInput, { target: { value: 'New Entry Name' } });
});
expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function));
});
it('updates the description field on change', () => {
const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />);
waitFor(() => {
const descriptionInput = getByTestId('entry-description');
fireEvent.change(descriptionInput, { target: { value: 'New Description' } });
});
expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function));
});
it('updates the query description field on change', () => {
const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />);
waitFor(() => {
const queryDescriptionInput = getByTestId('query-description');
fireEvent.change(queryDescriptionInput, { target: { value: 'New Query Description' } });
});
expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function));
});
it('displays sharing options and updates on selection', async () => {
const { getByTestId } = render(<IndexEntryEditor {...defaultProps} />);
await waitFor(() => {
fireEvent.click(getByTestId('sharing-select'));
fireEvent.click(getByTestId('sharing-private-option'));
});
await waitFor(() => {
expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function));
});
});
it('fetches index options and updates on selection', async () => {
const { getAllByTestId, getByTestId } = render(<IndexEntryEditor {...defaultProps} />);
await waitFor(() => expect(mockDataViews.getIndices).toHaveBeenCalled());
await waitFor(() => {
fireEvent.click(getByTestId('index-combobox'));
fireEvent.click(getAllByTestId('comboBoxToggleListButton')[0]);
});
fireEvent.click(getByTestId('index-2'));
expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function));
});
it('fetches field options based on selected index and updates on selection', async () => {
const { getByTestId, getAllByTestId } = render(<IndexEntryEditor {...defaultProps} />);
await waitFor(() =>
expect(mockDataViews.getFieldsForWildcard).toHaveBeenCalledWith({
pattern: 'index-1',
fieldTypes: ['semantic_text'],
})
);
await waitFor(() => {
fireEvent.click(getByTestId('index-combobox'));
fireEvent.click(getAllByTestId('comboBoxToggleListButton')[0]);
});
fireEvent.click(getByTestId('index-2'));
await waitFor(() => {
fireEvent.click(getByTestId('entry-combobox'));
});
await userEvent.type(
within(getByTestId('entry-combobox')).getByTestId('comboBoxSearchInput'),
'field-3'
);
expect(mockSetEntry).toHaveBeenCalledWith(expect.any(Function));
});
it('disables the field combo box if no index is selected', () => {
const { getByRole } = render(
<IndexEntryEditor {...defaultProps} entry={{ ...defaultProps.entry, index: '' }} />
);
waitFor(() => {
expect(getByRole('combobox', { name: i18n.ENTRY_FIELD_PLACEHOLDER })).toBeDisabled();
});
});
});

View file

@ -12,9 +12,11 @@ import {
EuiFormRow,
EuiComboBoxOptionOption,
EuiText,
EuiTextArea,
EuiIcon,
EuiSuperSelect,
} from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import React, { useCallback } from 'react';
import { IndexEntry } from '@kbn/elastic-assistant-common';
import { DataViewsContract } from '@kbn/data-views-plugin/public';
@ -24,200 +26,270 @@ interface Props {
dataViews: DataViewsContract;
entry?: IndexEntry;
setEntry: React.Dispatch<React.SetStateAction<Partial<IndexEntry>>>;
hasManageGlobalKnowledgeBase: boolean;
}
export const IndexEntryEditor: React.FC<Props> = React.memo(({ dataViews, entry, setEntry }) => {
// Name
const setName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })),
[setEntry]
);
export const IndexEntryEditor: React.FC<Props> = React.memo(
({ dataViews, entry, setEntry, hasManageGlobalKnowledgeBase }) => {
// Name
const setName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })),
[setEntry]
);
// Sharing
const setSharingOptions = useCallback(
(value: string) =>
setEntry((prevEntry) => ({
...prevEntry,
users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined,
})),
[setEntry]
);
// TODO: KB-RBAC Disable global option if no RBAC
const sharingOptions = [
{
value: i18n.SHARING_PRIVATE_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="lock"
/>
{i18n.SHARING_PRIVATE_OPTION_LABEL}
</EuiText>
),
},
{
value: i18n.SHARING_GLOBAL_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="globe"
/>
{i18n.SHARING_GLOBAL_OPTION_LABEL}
</EuiText>
),
},
];
const selectedSharingOption =
entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value;
// Sharing
const setSharingOptions = useCallback(
(value: string) =>
setEntry((prevEntry) => ({
...prevEntry,
users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined,
})),
[setEntry]
);
const sharingOptions = [
{
'data-test-subj': 'sharing-private-option',
value: i18n.SHARING_PRIVATE_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="lock"
/>
{i18n.SHARING_PRIVATE_OPTION_LABEL}
</EuiText>
),
},
{
'data-test-subj': 'sharing-global-option',
value: i18n.SHARING_GLOBAL_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="globe"
/>
{i18n.SHARING_GLOBAL_OPTION_LABEL}
</EuiText>
),
disabled: !hasManageGlobalKnowledgeBase,
},
];
// Index
// TODO: For index field autocomplete
// const indexOptions = useMemo(() => {
// const indices = await dataViews.getIndices({
// pattern: e[0]?.value ?? '',
// isRollupIndex: () => false,
// });
// }, [dataViews]);
const setIndex = useCallback(
async (e: Array<EuiComboBoxOptionOption<string>>) => {
setEntry((prevEntry) => ({ ...prevEntry, index: e[0]?.value }));
},
[setEntry]
);
const selectedSharingOption =
entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value;
const onCreateOption = (searchValue: string) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
// Index
const indexOptions = useAsync(async () => {
const indices = await dataViews.getIndices({
pattern: '*',
isRollupIndex: () => false,
});
if (!normalizedSearchValue) {
return;
}
return indices.map((index) => ({
'data-test-subj': index.name,
label: index.name,
value: index.name,
}));
}, [dataViews]);
const newOption: EuiComboBoxOptionOption<string> = {
label: searchValue,
value: searchValue,
const fieldOptions = useAsync(async () => {
const fields = await dataViews.getFieldsForWildcard({
pattern: entry?.index ?? '',
fieldTypes: ['semantic_text'],
});
return fields
.filter((field) => field.esTypes?.includes('semantic_text'))
.map((field) => ({
'data-test-subj': field.name,
label: field.name,
value: field.name,
}));
}, [entry]);
const setIndex = useCallback(
async (e: Array<EuiComboBoxOptionOption<string>>) => {
setEntry((prevEntry) => ({ ...prevEntry, index: e[0]?.value }));
},
[setEntry]
);
const onCreateOption = (searchValue: string) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const newOption: EuiComboBoxOptionOption<string> = {
label: searchValue,
value: searchValue,
};
setIndex([newOption]);
setField([{ label: '', value: '' }]);
};
setIndex([newOption]);
};
const onCreateFieldOption = (searchValue: string) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
// Field
const setField = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, field: e.target.value })),
[setEntry]
);
if (!normalizedSearchValue) {
return;
}
// Description
const setDescription = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, description: e.target.value })),
[setEntry]
);
const newOption: EuiComboBoxOptionOption<string> = {
label: searchValue,
value: searchValue,
};
// Query Description
const setQueryDescription = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, queryDescription: e.target.value })),
[setEntry]
);
setField([newOption]);
};
return (
<EuiForm>
<EuiFormRow label={i18n.ENTRY_NAME_INPUT_LABEL} fullWidth>
<EuiFieldText
name="name"
placeholder={i18n.ENTRY_NAME_INPUT_PLACEHOLDER}
// Field
const setField = useCallback(
async (e: Array<EuiComboBoxOptionOption<string>>) =>
setEntry((prevEntry) => ({ ...prevEntry, field: e[0]?.value })),
[setEntry]
);
// Description
const setDescription = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, description: e.target.value })),
[setEntry]
);
// Query Description
const setQueryDescription = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setEntry((prevEntry) => ({ ...prevEntry, queryDescription: e.target.value })),
[setEntry]
);
return (
<EuiForm>
<EuiFormRow
label={i18n.ENTRY_NAME_INPUT_LABEL}
helpText={i18n.ENTRY_NAME_INPUT_PLACEHOLDER}
fullWidth
value={entry?.name}
onChange={setName}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_SHARING_INPUT_LABEL}
helpText={i18n.SHARING_HELP_TEXT}
fullWidth
>
<EuiSuperSelect
options={sharingOptions}
valueOfSelected={selectedSharingOption}
onChange={setSharingOptions}
>
<EuiFieldText
data-test-subj="entry-name"
name="name"
fullWidth
value={entry?.name}
onChange={setName}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_SHARING_INPUT_LABEL}
helpText={i18n.SHARING_HELP_TEXT}
fullWidth
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} fullWidth>
<EuiComboBox
aria-label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL}
isClearable={true}
singleSelection={{ asPlainText: true }}
onCreateOption={onCreateOption}
>
<EuiSuperSelect
data-test-subj="sharing-select"
options={sharingOptions}
valueOfSelected={selectedSharingOption}
onChange={setSharingOptions}
fullWidth
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} fullWidth>
<EuiComboBox
data-test-subj="index-combobox"
aria-label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL}
isClearable={true}
singleSelection={{ asPlainText: true }}
onCreateOption={onCreateOption}
fullWidth
options={indexOptions.value ?? []}
selectedOptions={
entry?.index
? [
{
label: entry?.index,
value: entry?.index,
},
]
: []
}
onChange={setIndex}
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_FIELD_INPUT_LABEL} fullWidth>
<EuiComboBox
aria-label={i18n.ENTRY_FIELD_PLACEHOLDER}
data-test-subj="entry-combobox"
isClearable={true}
singleSelection={{ asPlainText: true }}
onCreateOption={onCreateFieldOption}
fullWidth
options={fieldOptions.value ?? []}
selectedOptions={
entry?.field
? [
{
label: entry?.field,
value: entry?.field,
},
]
: []
}
onChange={setField}
isDisabled={!entry?.index}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_DESCRIPTION_INPUT_LABEL}
helpText={i18n.ENTRY_DESCRIPTION_HELP_LABEL}
fullWidth
selectedOptions={
entry?.index
? [
{
label: entry?.index,
value: entry?.index,
},
]
: []
}
onChange={setIndex}
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_FIELD_INPUT_LABEL} fullWidth>
<EuiFieldText
name="field"
placeholder={i18n.ENTRY_FIELD_PLACEHOLDER}
>
<EuiTextArea
name="description"
fullWidth
placeholder={i18n.ENTRY_DESCRIPTION_PLACEHOLDER}
data-test-subj="entry-description"
value={entry?.description}
onChange={setDescription}
rows={2}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_QUERY_DESCRIPTION_INPUT_LABEL}
helpText={i18n.ENTRY_QUERY_DESCRIPTION_HELP_LABEL}
fullWidth
value={entry?.field}
onChange={setField}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_DESCRIPTION_INPUT_LABEL}
helpText={i18n.ENTRY_DESCRIPTION_HELP_LABEL}
fullWidth
>
<EuiFieldText
name="description"
>
<EuiTextArea
name="query_description"
placeholder={i18n.ENTRY_QUERY_DESCRIPTION_PLACEHOLDER}
data-test-subj="query-description"
value={entry?.queryDescription}
onChange={setQueryDescription}
fullWidth
rows={3}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL}
helpText={i18n.ENTRY_OUTPUT_FIELDS_HELP_LABEL}
fullWidth
value={entry?.description}
onChange={setDescription}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_QUERY_DESCRIPTION_INPUT_LABEL}
helpText={i18n.ENTRY_QUERY_DESCRIPTION_HELP_LABEL}
fullWidth
>
<EuiFieldText
name="description"
fullWidth
value={entry?.queryDescription}
onChange={setQueryDescription}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL}
helpText={i18n.ENTRY_OUTPUT_FIELDS_HELP_LABEL}
fullWidth
>
<EuiComboBox
aria-label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL}
isClearable={true}
singleSelection={{ asPlainText: true }}
onCreateOption={onCreateOption}
fullWidth
selectedOptions={[]}
onChange={setIndex}
/>
</EuiFormRow>
</EuiForm>
);
});
>
<EuiComboBox
aria-label={i18n.ENTRY_OUTPUT_FIELDS_INPUT_LABEL}
isClearable={true}
singleSelection={{ asPlainText: true }}
onCreateOption={onCreateOption}
fullWidth
selectedOptions={[]}
onChange={setIndex}
/>
</EuiFormRow>
</EuiForm>
);
}
);
IndexEntryEditor.displayName = 'IndexEntryEditor';

View file

@ -212,6 +212,13 @@ export const DELETE_ENTRY_CONFIRMATION_TITLE = (title: string) =>
}
);
export const DELETE_ENTRY_CONFIRMATION_CONTENT = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.deleteEntryContent',
{
defaultMessage: "You will not be able to recover this knowledge base entry once it's deleted.",
}
);
export const ENTRY_MARKDOWN_INPUT_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryMarkdownInputText',
{
@ -258,8 +265,14 @@ export const ENTRY_DESCRIPTION_INPUT_LABEL = i18n.translate(
export const ENTRY_DESCRIPTION_HELP_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionHelpLabel',
{
defaultMessage:
'A description of the type of data in this index and/or when the assistant should look for data here.',
defaultMessage: 'Describe when this custom knowledge should be used during a conversation.',
}
);
export const ENTRY_DESCRIPTION_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionPlaceholder',
{
defaultMessage: 'Use this index to answer any question related to asset information.',
}
);
@ -273,7 +286,16 @@ export const ENTRY_QUERY_DESCRIPTION_INPUT_LABEL = i18n.translate(
export const ENTRY_QUERY_DESCRIPTION_HELP_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionHelpLabel',
{
defaultMessage: 'Any instructions for extracting the search query from the user request.',
defaultMessage:
'Describe what query should be constructed by the model to retrieve this custom knowledge.',
}
);
export const ENTRY_QUERY_DESCRIPTION_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionPlaceholder',
{
defaultMessage:
'Key terms to retrieve asset related information, like host names, IP Addresses or cloud objects.',
}
);

View file

@ -7,21 +7,69 @@
import { EuiAvatar, EuiBadge, EuiBasicTableColumn, EuiIcon, EuiText } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { FormattedDate } from '@kbn/i18n-react';
import {
DocumentEntryType,
IndexEntryType,
KnowledgeBaseEntryResponse,
} from '@kbn/elastic-assistant-common';
import useAsync from 'react-use/lib/useAsync';
import { useAssistantContext } from '../../..';
import * as i18n from './translations';
import { BadgesColumn } from '../../assistant/common/components/assistant_settings_management/badges';
import { useInlineActions } from '../../assistant/common/components/assistant_settings_management/inline_actions';
import { isSystemEntry } from './helpers';
const AuthorColumn = ({ entry }: { entry: KnowledgeBaseEntryResponse }) => {
const { currentUserAvatar, userProfileService } = useAssistantContext();
const userProfile = useAsync(async () => {
const profile = await userProfileService?.bulkGet({ uids: new Set([entry.createdBy]) });
return profile?.[0].user.username;
}, []);
const userName = useMemo(() => userProfile?.value ?? 'Unknown', [userProfile?.value]);
const badgeItem = isSystemEntry(entry) ? 'Elastic' : userName;
const userImage = isSystemEntry(entry) ? (
<EuiIcon
type={'logoElastic'}
css={css`
margin-left: 4px;
margin-right: 14px;
`}
/>
) : currentUserAvatar?.imageUrl != null ? (
<EuiAvatar
name={userName}
imageUrl={currentUserAvatar.imageUrl}
size={'s'}
color={currentUserAvatar?.color ?? 'subdued'}
css={css`
margin-right: 10px;
`}
/>
) : (
<EuiAvatar
name={userName}
initials={currentUserAvatar?.initials}
size={'s'}
color={currentUserAvatar?.color ?? 'subdued'}
css={css`
margin-right: 10px;
`}
/>
);
return (
<>
{userImage}
<EuiText size={'s'}>{badgeItem}</EuiText>
</>
);
};
export const useKnowledgeBaseTable = () => {
const { currentUserAvatar } = useAssistantContext();
const getActions = useInlineActions<KnowledgeBaseEntryResponse & { isDefault?: undefined }>();
const getIconForEntry = (entry: KnowledgeBaseEntryResponse): string => {
@ -43,13 +91,11 @@ export const useKnowledgeBaseTable = () => {
({
isDeleteEnabled,
isEditEnabled,
onEntryNameClicked,
onDeleteActionClicked,
onEditActionClicked,
}: {
isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => boolean;
isEditEnabled: (entry: KnowledgeBaseEntryResponse) => boolean;
onEntryNameClicked: (entry: KnowledgeBaseEntryResponse) => void;
onDeleteActionClicked: (entry: KnowledgeBaseEntryResponse) => void;
onEditActionClicked: (entry: KnowledgeBaseEntryResponse) => void;
}): Array<EuiBasicTableColumn<KnowledgeBaseEntryResponse>> => {
@ -78,46 +124,7 @@ export const useKnowledgeBaseTable = () => {
{
name: i18n.COLUMN_AUTHOR,
sortable: ({ users }: KnowledgeBaseEntryResponse) => users[0]?.name,
render: (entry: KnowledgeBaseEntryResponse) => {
// TODO: Look up user from `createdBy` id if privileges allow
const userName = entry.users?.[0]?.name ?? 'Unknown';
const badgeItem = isSystemEntry(entry) ? 'Elastic' : userName;
const userImage = isSystemEntry(entry) ? (
<EuiIcon
type={'logoElastic'}
css={css`
margin-left: 4px;
margin-right: 14px;
`}
/>
) : currentUserAvatar?.imageUrl != null ? (
<EuiAvatar
name={userName}
imageUrl={currentUserAvatar.imageUrl}
size={'s'}
color={currentUserAvatar?.color ?? 'subdued'}
css={css`
margin-right: 10px;
`}
/>
) : (
<EuiAvatar
name={userName}
initials={currentUserAvatar?.initials}
size={'s'}
color={currentUserAvatar?.color ?? 'subdued'}
css={css`
margin-right: 10px;
`}
/>
);
return (
<>
{userImage}
<EuiText size={'s'}>{badgeItem}</EuiText>
</>
);
},
render: (entry: KnowledgeBaseEntryResponse) => <AuthorColumn entry={entry} />,
},
{
name: i18n.COLUMN_ENTRIES,
@ -157,7 +164,7 @@ export const useKnowledgeBaseTable = () => {
},
];
},
[currentUserAvatar, getActions]
[getActions]
);
return { getColumns };
};

View file

@ -14,6 +14,7 @@ import React from 'react';
import { ThemeProvider } from 'styled-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserProfileService } from '@kbn/core/public';
import { AssistantProvider, AssistantProviderProps } from '../../assistant_context';
import { AssistantAvailability } from '../../assistant_context/types';
@ -31,6 +32,7 @@ export const mockAssistantAvailability: AssistantAvailability = {
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
};
@ -82,6 +84,7 @@ export const TestProvidersComponent: React.FC<Props> = ({
navigateToApp={mockNavigateToApp}
{...providerContext}
currentAppId={'test'}
userProfileService={jest.fn() as unknown as UserProfileService}
>
{children}
</AssistantProvider>

View file

@ -16,6 +16,7 @@ import { ThemeProvider } from 'styled-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Theme } from '@elastic/charts';
import { UserProfileService } from '@kbn/core/public';
import { DataQualityProvider, DataQualityProviderProps } from '../../data_quality_context';
import { ResultsRollupContext } from '../../contexts/results_rollup_context';
import { IndicesCheckContext } from '../../contexts/indices_check_context';
@ -48,6 +49,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
};
const queryClient = new QueryClient({
@ -81,6 +83,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
baseConversations={{}}
navigateToApp={mockNavigateToApp}
currentAppId={'securitySolutionUI'}
userProfileService={jest.fn() as unknown as UserProfileService}
>
{children}
</AssistantProvider>

View file

@ -48,8 +48,48 @@ const updateAnonymizationSubFeature: SubFeatureConfig = {
],
};
const manageGlobalKnowledgeBaseSubFeature: SubFeatureConfig = {
name: i18n.translate(
'securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureName',
{
defaultMessage: 'Knowledge Base',
}
),
description: i18n.translate(
'securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureDescription',
{
defaultMessage:
'Make changes to any space level (global) custom knowledge base entries. This will also allow users to modify global entries created by other users.',
}
),
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
api: [`${APP_ID}-manageGlobalKnowledgeBaseAIAssistant`],
id: 'manage_global_knowledge_base',
name: i18n.translate(
'securitySolutionPackages.features.featureRegistry.assistant.manageGlobalKnowledgeBaseSubFeatureDetails',
{
defaultMessage: 'Allow Changes to Global Entries',
}
),
includeIn: 'all',
savedObject: {
all: [],
read: [],
},
ui: ['manageGlobalKnowledgeBaseAIAssistant'],
},
],
},
],
};
export enum AssistantSubFeatureId {
updateAnonymization = 'updateAnonymizationSubFeature',
manageGlobalKnowledgeBase = 'manageGlobalKnowledgeBaseSubFeature',
}
/**
@ -65,5 +105,6 @@ export const getAssistantBaseKibanaSubFeatureIds = (): AssistantSubFeatureId[] =
export const assistantSubFeaturesMap = Object.freeze(
new Map<AssistantSubFeatureId, SubFeatureConfig>([
[AssistantSubFeatureId.updateAnonymization, updateAnonymizationSubFeature],
[AssistantSubFeatureId.manageGlobalKnowledgeBase, manageGlobalKnowledgeBaseSubFeature],
])
);

View file

@ -28,6 +28,9 @@ export const assistantDefaultProductFeaturesConfig: Record<
ui: ['ai-assistant'],
},
},
subFeatureIds: [AssistantSubFeatureId.updateAnonymization],
subFeatureIds: [
AssistantSubFeatureId.updateAnonymization,
AssistantSubFeatureId.manageGlobalKnowledgeBase,
],
},
};

View file

@ -153,4 +153,5 @@ export enum CasesSubFeatureId {
/** Sub-features IDs for Security Assistant */
export enum AssistantSubFeatureId {
updateAnonymization = 'updateAnonymizationSubFeature',
manageGlobalKnowledgeBase = 'manageGlobalKnowledgeBaseSubFeature',
}

View file

@ -171,6 +171,15 @@ export const getUpdateScript = ({
if (params.assignEmpty == true || params.containsKey('text')) {
ctx._source.text = params.text;
}
if (params.assignEmpty == true || params.containsKey('description')) {
ctx._source.description = params.description;
}
if (params.assignEmpty == true || params.containsKey('field')) {
ctx._source.field = params.field;
}
if (params.assignEmpty == true || params.containsKey('index')) {
ctx._source.index = params.index;
}
ctx._source.updated_at = params.updated_at;
ctx._source.updated_by = params.updated_by;
`,

View file

@ -54,6 +54,7 @@ import { loadSecurityLabs } from '../../lib/langchain/content_loaders/security_l
export interface GetAIAssistantKnowledgeBaseDataClientParams {
modelIdOverride?: string;
v2KnowledgeBaseEnabled?: boolean;
manageGlobalKnowledgeBaseAIAssistant?: boolean;
}
interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams {
@ -63,6 +64,7 @@ interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams {
ingestPipelineResourceName: string;
setIsKBSetupInProgress: (isInProgress: boolean) => void;
v2KnowledgeBaseEnabled: boolean;
manageGlobalKnowledgeBaseAIAssistant: boolean;
}
export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
constructor(public readonly options: KnowledgeBaseDataClientParams) {
@ -307,12 +309,16 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
const writer = await this.getWriter();
const changedAt = new Date().toISOString();
const authenticatedUser = this.options.currentUser;
// TODO: KB-RBAC check for when `global:true`
if (authenticatedUser == null) {
throw new Error(
'Authenticated user not found! Ensure kbDataClient was initialized from a request.'
);
}
if (global && !this.options.manageGlobalKnowledgeBaseAIAssistant) {
throw new Error('User lacks privileges to create global knowledge base entries');
}
const { errors, docs_created: docsCreated } = await writer.bulk({
documentsToCreate: documents.map((doc) => {
// v1 schema has metadata nested in a `metadata` object
@ -521,12 +527,17 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
global?: boolean;
}): Promise<KnowledgeBaseEntryResponse | null> => {
const authenticatedUser = this.options.currentUser;
// TODO: KB-RBAC check for when `global:true`
if (authenticatedUser == null) {
throw new Error(
'Authenticated user not found! Ensure kbDataClient was initialized from a request.'
);
}
if (global && !this.options.manageGlobalKnowledgeBaseAIAssistant) {
throw new Error('User lacks privileges to create global knowledge base entries');
}
this.options.logger.debug(
() => `Creating Knowledge Base Entry:\n ${JSON.stringify(knowledgeBaseEntry, null, 2)}`
);

View file

@ -392,6 +392,7 @@ export class AIAssistantService {
setIsKBSetupInProgress: this.setIsKBSetupInProgress.bind(this),
spaceId: opts.spaceId,
v2KnowledgeBaseEnabled: opts.v2KnowledgeBaseEnabled ?? false,
manageGlobalKnowledgeBaseAIAssistant: opts.manageGlobalKnowledgeBaseAIAssistant ?? false,
});
}

View file

@ -66,7 +66,6 @@ export const createKnowledgeBaseEntryRoute = (router: ElasticAssistantPluginRout
logger.debug(() => `Creating KB Entry:\n${JSON.stringify(request.body)}`);
const createResponse = await kbDataClient?.createKnowledgeBaseEntry({
knowledgeBaseEntry: request.body,
// TODO: KB-RBAC check, required when users != null as entry will either be created globally if empty
global: request.body.users != null && request.body.users.length === 0,
});

View file

@ -50,7 +50,7 @@ export class RequestContextFactory implements IRequestContextFactory {
const { options } = this;
const { core } = options;
const [, startPlugins] = await core.getStartServices();
const [coreStart, startPlugins] = await core.getStartServices();
const coreContext = await context.core;
const getSpaceId = (): string =>
@ -88,14 +88,24 @@ export class RequestContextFactory implements IRequestContextFactory {
// Additionally, modelIdOverride is used here to enable setting up the KB using a different ELSER model, which
// is necessary for testing purposes (`pt_tiny_elser`).
getAIAssistantKnowledgeBaseDataClient: memoize(
({ modelIdOverride, v2KnowledgeBaseEnabled = false }) => {
async ({ modelIdOverride, v2KnowledgeBaseEnabled = false }) => {
const currentUser = getCurrentUser();
const { securitySolutionAssistant } = await coreStart.capabilities.resolveCapabilities(
request,
{
capabilityPath: 'securitySolutionAssistant.*',
}
);
return this.assistantService.createAIAssistantKnowledgeBaseDataClient({
spaceId: getSpaceId(),
logger: this.logger,
currentUser,
modelIdOverride,
v2KnowledgeBaseEnabled,
manageGlobalKnowledgeBaseAIAssistant:
securitySolutionAssistant.manageGlobalKnowledgeBaseAIAssistant as boolean,
});
}
),

View file

@ -71,7 +71,8 @@
"osquery",
"savedObjectsTaggingOss",
"guidedOnboarding",
"integrationAssistant"
"integrationAssistant",
"serverless"
],
"requiredBundles": [
"esUiShared",
@ -87,4 +88,4 @@
"common"
]
}
}
}

View file

@ -9,31 +9,13 @@ import {
AssistantOverlay as ElasticAssistantOverlay,
useAssistantContext,
} from '@kbn/elastic-assistant';
import { useQuery } from '@tanstack/react-query';
import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context';
import { useKibana } from '../common/lib/kibana';
export const AssistantOverlay: React.FC = () => {
const { services } = useKibana();
const { data: currentUserAvatar } = useQuery({
queryKey: ['currentUserAvatar'],
queryFn: () =>
services.security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({
dataPath: 'avatar',
}),
select: (data) => {
return data.data.avatar;
},
keepPreviousData: true,
refetchOnWindowFocus: false,
});
const { assistantAvailability } = useAssistantContext();
if (!assistantAvailability.hasAssistantPrivilege) {
return null;
}
return <ElasticAssistantOverlay currentUserAvatar={currentUserAvatar} />;
return <ElasticAssistantOverlay />;
};

View file

@ -142,6 +142,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
storage,
triggersActionsUi: { actionTypeRegistry },
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
userProfile,
} = useKibana().services;
const basePath = useBasePath();
@ -225,6 +226,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
title={ASSISTANT_TITLE}
toasts={toasts}
currentAppId={currentAppId ?? 'securitySolutionUI'}
userProfileService={userProfile}
>
{children}
</ElasticAssistantProvider>

View file

@ -7,6 +7,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from '@kbn/shared-ux-router';
import { ManagementSettings } from './management_settings';
import type { Conversation } from '@kbn/elastic-assistant';
import {
@ -77,6 +78,12 @@ describe('ManagementSettings', () => {
securitySolutionAssistant: { 'ai-assistant': false },
},
},
chrome: {
docTitle: {
change: jest.fn(),
},
setBreadcrumbs: jest.fn(),
},
data: {
dataViews: {
getIndices: jest.fn(),
@ -95,9 +102,11 @@ describe('ManagementSettings', () => {
});
return render(
<QueryClientProvider client={queryClient}>
<ManagementSettings />
</QueryClientProvider>
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<ManagementSettings />
</QueryClientProvider>
</MemoryRouter>
);
};

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { AssistantSettingsManagement } from '@kbn/elastic-assistant/impl/assistant/settings/assistant_settings_management';
import type { Conversation } from '@kbn/elastic-assistant';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { i18n } from '@kbn/i18n';
import {
mergeBaseWithPersistedConversations,
useAssistantContext,
@ -16,8 +18,9 @@ import {
} from '@kbn/elastic-assistant';
import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation';
import type { FetchConversationsResponse } from '@kbn/elastic-assistant/impl/assistant/api';
import { useQuery } from '@tanstack/react-query';
import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context';
import { SECURITY_AI_SETTINGS } from '@kbn/elastic-assistant/impl/assistant/settings/translations';
import { CONNECTORS_TAB } from '@kbn/elastic-assistant/impl/assistant/settings/const';
import type { SettingsTabs } from '@kbn/elastic-assistant/impl/assistant/settings/types';
import { useKibana } from '../../common/lib/kibana';
const defaultSelectedConversationId = WELCOME_CONVERSATION_TITLE;
@ -27,7 +30,6 @@ export const ManagementSettings = React.memo(() => {
baseConversations,
http,
assistantAvailability: { isAssistantEnabled },
setCurrentUserAvatar,
} = useAssistantContext();
const {
@ -38,23 +40,10 @@ export const ManagementSettings = React.memo(() => {
},
},
data: { dataViews },
security,
chrome: { docTitle, setBreadcrumbs },
serverless,
} = useKibana().services;
const { data: currentUserAvatar } = useQuery({
queryKey: ['currentUserAvatar'],
queryFn: () =>
security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({
dataPath: 'avatar',
}),
select: (d) => {
return d.data.avatar;
},
keepPreviousData: true,
refetchOnWindowFocus: false,
});
setCurrentUserAvatar(currentUserAvatar);
const onFetchedConversations = useCallback(
(conversationsData: FetchConversationsResponse): Record<string, Conversation> =>
mergeBaseWithPersistedConversations(baseConversations, conversationsData),
@ -75,6 +64,67 @@ export const ManagementSettings = React.memo(() => {
[conversations, getDefaultConversation]
);
docTitle.change(SECURITY_AI_SETTINGS);
const [searchParams] = useSearchParams();
const currentTab = useMemo(
() => (searchParams.get('tab') as SettingsTabs) ?? CONNECTORS_TAB,
[searchParams]
);
const handleTabChange = useCallback(
(tab: string) => {
navigateToApp('management', {
path: `kibana/securityAiAssistantManagement?tab=${tab}`,
});
},
[navigateToApp]
);
useEffect(() => {
if (serverless) {
serverless.setBreadcrumbs([
{
text: i18n.translate(
'xpack.securitySolution.assistant.settings.breadcrumb.serverless.security',
{
defaultMessage: 'AI Assistant for Security Settings',
}
),
},
]);
} else {
setBreadcrumbs([
{
text: i18n.translate(
'xpack.securitySolution.assistant.settings.breadcrumb.stackManagement',
{
defaultMessage: 'Stack Management',
}
),
onClick: (e) => {
e.preventDefault();
navigateToApp('management');
},
},
{
text: i18n.translate('xpack.securitySolution.assistant.settings.breadcrumb.index', {
defaultMessage: 'AI Assistants',
}),
onClick: (e) => {
e.preventDefault();
navigateToApp('management', { path: '/kibana/aiAssistantManagementSelection' });
},
},
{
text: i18n.translate('xpack.securitySolution.assistant.settings.breadcrumb.security', {
defaultMessage: 'Security',
}),
},
]);
}
}, [navigateToApp, serverless, setBreadcrumbs]);
if (!securityAIAssistantEnabled) {
navigateToApp('home');
}
@ -84,6 +134,8 @@ export const ManagementSettings = React.memo(() => {
<AssistantSettingsManagement
selectedConversation={currentConversation}
dataViews={dataViews}
onTabChange={handleTabChange}
currentTab={currentTab}
/>
);
}

View file

@ -20,6 +20,8 @@ export interface UseAssistantAvailability {
hasConnectorsReadPrivilege: boolean;
// When true, user has `Edit` privilege for `AnonymizationFields`
hasUpdateAIAssistantAnonymization: boolean;
// When true, user has `Edit` privilege for `Global Knowledge Base`
hasManageGlobalKnowledgeBase: boolean;
}
export const useAssistantAvailability = (): UseAssistantAvailability => {
@ -28,6 +30,8 @@ export const useAssistantAvailability = (): UseAssistantAvailability => {
const hasAssistantPrivilege = capabilities[ASSISTANT_FEATURE_ID]?.['ai-assistant'] === true;
const hasUpdateAIAssistantAnonymization =
capabilities[ASSISTANT_FEATURE_ID]?.updateAIAssistantAnonymization === true;
const hasManageGlobalKnowledgeBase =
capabilities[ASSISTANT_FEATURE_ID]?.manageGlobalKnowledgeBaseAIAssistant === true;
// Connectors & Actions capabilities as defined in x-pack/plugins/actions/server/feature.ts
// `READ` ui capabilities defined as: { ui: ['show', 'execute'] }
@ -45,5 +49,6 @@ export const useAssistantAvailability = (): UseAssistantAvailability => {
hasConnectorsReadPrivilege,
isAssistantEnabled: isEnterprise,
hasUpdateAIAssistantAnonymization,
hasManageGlobalKnowledgeBase,
};
};

View file

@ -10,6 +10,7 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/a
import React from 'react';
import type { AssistantAvailability } from '@kbn/elastic-assistant';
import { AssistantProvider } from '@kbn/elastic-assistant';
import type { UserProfileService } from '@kbn/core/public';
import { BASE_SECURITY_CONVERSATIONS } from '../../assistant/content/conversations';
interface Props {
@ -33,6 +34,7 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
};
@ -51,6 +53,7 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({
navigateToApp={mockNavigateToApp}
baseConversations={BASE_SECURITY_CONVERSATIONS}
currentAppId={'test'}
userProfileService={jest.fn() as unknown as UserProfileService}
>
{children}
</AssistantProvider>

View file

@ -18,6 +18,7 @@ import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BASE_SECURITY_CONVERSATIONS } from '../../../../assistant/content/conversations';
import type { UserProfileService } from '@kbn/core-user-profile-browser';
jest.mock('../../../../common/lib/kibana');
@ -34,6 +35,7 @@ const mockAssistantAvailability: AssistantAvailability = {
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
};
const queryClient = new QueryClient({
@ -65,6 +67,7 @@ const ContextWrapper: FC<PropsWithChildren<unknown>> = ({ children }) => (
navigateToApp={mockNavigationToApp}
baseConversations={BASE_SECURITY_CONVERSATIONS}
currentAppId={'security'}
userProfileService={jest.fn() as unknown as UserProfileService}
>
{children}
</AssistantProvider>

View file

@ -33,6 +33,7 @@ describe('useAssistant', () => {
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
});
jest
@ -51,6 +52,7 @@ describe('useAssistant', () => {
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
});
jest
@ -69,6 +71,7 @@ describe('useAssistant', () => {
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
});
jest

View file

@ -60,6 +60,7 @@ import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/publ
import type { PluginStartContract } from '@kbn/alerting-plugin/public/plugin';
import type { MapsStartApi } from '@kbn/maps-plugin/public';
import type { IntegrationAssistantPluginStart } from '@kbn/integration-assistant-plugin/public';
import type { ServerlessPluginStart } from '@kbn/serverless/public';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { Detections } from './detections';
@ -154,6 +155,7 @@ export interface StartPlugins {
alerting: PluginStartContract;
core: CoreStart;
integrationAssistant?: IntegrationAssistantPluginStart;
serverless?: ServerlessPluginStart;
}
export interface StartPluginsDependencies extends StartPlugins {

View file

@ -229,5 +229,7 @@
"@kbn/core-saved-objects-server-mocks",
"@kbn/core-http-router-server-internal",
"@kbn/core-security-server-mocks",
"@kbn/serverless",
"@kbn/core-user-profile-browser",
]
}

View file

@ -4,12 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CardNavExtensionDefinition } from '@kbn/management-cards-navigation';
import { appCategories, type CardNavExtensionDefinition } from '@kbn/management-cards-navigation';
import {
getNavigationPropsFromId,
SecurityPageName,
ExternalPageName,
} from '@kbn/security-solution-navigation';
import { i18n } from '@kbn/i18n';
import type { Services } from '../common/services';
const SecurityManagementCards = new Map<string, CardNavExtensionDefinition['category']>([
@ -42,6 +43,12 @@ export const enableManagementCardsLanding = (services: Services) => {
{}
);
const securityAiAssistantManagement = getSecurityAiAssistantManagementDefinition(services);
if (securityAiAssistantManagement) {
cardNavDefinitions.securityAiAssistantManagement = securityAiAssistantManagement;
}
management.setupCardsNavigation({
enabled: true,
extendCardNavDefinitions: services.serverless.getNavigationCards(
@ -51,3 +58,29 @@ export const enableManagementCardsLanding = (services: Services) => {
});
});
};
const getSecurityAiAssistantManagementDefinition = (services: Services) => {
const { application } = services;
const aiAssistantIsEnabled = application.capabilities.securitySolutionAssistant?.['ai-assistant'];
if (aiAssistantIsEnabled) {
return {
category: appCategories.OTHER,
title: i18n.translate(
'xpack.securitySolutionServerless.securityAiAssistantManagement.app.title',
{
defaultMessage: 'AI assistant for Security settings',
}
),
description: i18n.translate(
'xpack.securitySolutionServerless.securityAiAssistantManagement.app.description',
{
defaultMessage: 'Manage your AI assistant for Security settings.',
}
),
icon: 'sparkles',
};
}
return null;
};

View file

@ -78,6 +78,7 @@ export default function ({ getService }: FtrProviderContext) {
'minimal_all',
'minimal_read',
'update_anonymization',
'manage_global_knowledge_base',
],
securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'],
securitySolutionCases: [

View file

@ -166,6 +166,7 @@ export default function ({ getService }: FtrProviderContext) {
'minimal_all',
'minimal_read',
'update_anonymization',
'manage_global_knowledge_base',
],
securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'],
securitySolutionCases: [

View file

@ -42,7 +42,6 @@ import {
QUICK_PROMPT_BADGE,
ADD_NEW_CONNECTOR,
SHOW_ANONYMIZED_BUTTON,
ASSISTANT_SETTINGS_BUTTON,
SEND_TO_TIMELINE_BUTTON,
} from '../screens/ai_assistant';
import { TOASTER } from '../screens/alerts_detection_rules';
@ -224,5 +223,4 @@ export const assertConversationReadOnly = () => {
cy.get(CHAT_CONTEXT_MENU).should('be.disabled');
cy.get(FLYOUT_NAV_TOGGLE).should('be.disabled');
cy.get(NEW_CHAT).should('be.disabled');
cy.get(ASSISTANT_SETTINGS_BUTTON).should('be.disabled');
};