mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# 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:
parent
85145569bd
commit
cbd40a81e4
59 changed files with 1449 additions and 785 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
>({});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -78,6 +78,7 @@ const mockUseAssistantContext = {
|
|||
],
|
||||
assistantAvailability: {
|
||||
hasUpdateAIAssistantAnonymization: true,
|
||||
hasManageGlobalKnowledgeBase: true,
|
||||
},
|
||||
baseAllow: ['@timestamp', 'event.category', 'user.name'],
|
||||
baseAllowReplacement: ['user.name', 'host.ip'],
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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.',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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],
|
||||
])
|
||||
);
|
||||
|
|
|
@ -28,6 +28,9 @@ export const assistantDefaultProductFeaturesConfig: Record<
|
|||
ui: ['ai-assistant'],
|
||||
},
|
||||
},
|
||||
subFeatureIds: [AssistantSubFeatureId.updateAnonymization],
|
||||
subFeatureIds: [
|
||||
AssistantSubFeatureId.updateAnonymization,
|
||||
AssistantSubFeatureId.manageGlobalKnowledgeBase,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -153,4 +153,5 @@ export enum CasesSubFeatureId {
|
|||
/** Sub-features IDs for Security Assistant */
|
||||
export enum AssistantSubFeatureId {
|
||||
updateAnonymization = 'updateAnonymizationSubFeature',
|
||||
manageGlobalKnowledgeBase = 'manageGlobalKnowledgeBaseSubFeature',
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
`,
|
||||
|
|
|
@ -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)}`
|
||||
);
|
||||
|
|
|
@ -392,6 +392,7 @@ export class AIAssistantService {
|
|||
setIsKBSetupInProgress: this.setIsKBSetupInProgress.bind(this),
|
||||
spaceId: opts.spaceId,
|
||||
v2KnowledgeBaseEnabled: opts.v2KnowledgeBaseEnabled ?? false,
|
||||
manageGlobalKnowledgeBaseAIAssistant: opts.manageGlobalKnowledgeBaseAIAssistant ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
),
|
||||
|
|
|
@ -71,7 +71,8 @@
|
|||
"osquery",
|
||||
"savedObjectsTaggingOss",
|
||||
"guidedOnboarding",
|
||||
"integrationAssistant"
|
||||
"integrationAssistant",
|
||||
"serverless"
|
||||
],
|
||||
"requiredBundles": [
|
||||
"esUiShared",
|
||||
|
@ -87,4 +88,4 @@
|
|||
"common"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue