mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Add Security AI assistant settings to the Stack management (#176656)
## Summary <img width="3005" alt="Zrzut ekranu 2024-04-2 o 22 58 37" src="f7814891
-d018-45e6-96a2-3da3321d56fd"> <img width="3006" alt="Zrzut ekranu 2024-04-2 o 22 58 45" src="a1ec8d96
-b48e-4f57-9a6c-3f1823d164f1"> <img width="3007" alt="Zrzut ekranu 2024-04-2 o 22 58 54" src="f67fc0f0
-b28c-40c8-8b25-5a180c115610"> <img width="3005" alt="Zrzut ekranu 2024-04-2 o 23 38 32" src="e79631ea
-c87c-4dd1-8fe6-c5d257cf2fe7"> --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Garrett Spong <spong@users.noreply.github.com> Co-authored-by: Garrett Spong <garrett.spong@elastic.co> Co-authored-by: Tomasz Ciecierski <tomasz.ciecierski@elastic.co>
This commit is contained in:
parent
4546d274e8
commit
b53624d472
23 changed files with 612 additions and 84 deletions
|
@ -28,6 +28,7 @@ export type IntegrationsDeepLinkId = IntegrationsAppId | FleetAppId | OsQueryApp
|
|||
export type ManagementAppId = typeof MANAGEMENT_APP_ID;
|
||||
export type ManagementId =
|
||||
| 'aiAssistantManagementSelection'
|
||||
| 'securityAiAssistantManagement'
|
||||
| 'observabilityAiAssistantManagement'
|
||||
| 'api_keys'
|
||||
| 'cases'
|
||||
|
|
|
@ -131,7 +131,7 @@ pageLoadAssetSize:
|
|||
searchPlayground: 19325
|
||||
searchprofiler: 67080
|
||||
security: 81771
|
||||
securitySolution: 82780
|
||||
securitySolution: 98429
|
||||
securitySolutionEss: 16573
|
||||
securitySolutionServerless: 62488
|
||||
serverless: 16573
|
||||
|
|
|
@ -25,6 +25,7 @@ export function AiAssistantSelectionPage() {
|
|||
const { capabilities, setBreadcrumbs, navigateToApp } = useAppContext();
|
||||
|
||||
const observabilityAIAssistantEnabled = capabilities.observabilityAIAssistant.show;
|
||||
const securityAIAssistantEnabled = capabilities.securitySolutionAssistant?.['ai-assistant'];
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
|
@ -113,6 +114,55 @@ export function AiAssistantSelectionPage() {
|
|||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow>
|
||||
<EuiCard
|
||||
description={
|
||||
<div>
|
||||
{!securityAIAssistantEnabled ? (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
iconType="warning"
|
||||
title={i18n.translate(
|
||||
'aiAssistantManagementSelection.aiAssistantSelectionPage.thisFeatureIsDisabledCallOutLabel',
|
||||
{
|
||||
defaultMessage:
|
||||
'This feature is disabled. It can be enabled from Spaces > Features.',
|
||||
}
|
||||
)}
|
||||
size="s"
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
) : null}
|
||||
<EuiLink
|
||||
data-test-subj="securityAiAssistantSelectionPageDocumentationLink"
|
||||
external
|
||||
target="_blank"
|
||||
href="https://www.elastic.co/guide/en/security/current/security-assistant.html"
|
||||
>
|
||||
{i18n.translate(
|
||||
'aiAssistantManagementSelection.aiAssistantSettingsPage.securityAssistant.documentationLinkLabel',
|
||||
{ defaultMessage: 'Documentation' }
|
||||
)}
|
||||
</EuiLink>
|
||||
</div>
|
||||
}
|
||||
display="plain"
|
||||
hasBorder
|
||||
icon={<EuiIcon size="l" type="logoSecurity" />}
|
||||
isDisabled={!securityAIAssistantEnabled}
|
||||
layout="horizontal"
|
||||
title={i18n.translate(
|
||||
'aiAssistantManagementSelection.aiAssistantSelectionPage.securityLabel',
|
||||
{ defaultMessage: 'Elastic AI Assistant for Security' }
|
||||
)}
|
||||
titleSize="xs"
|
||||
onClick={() =>
|
||||
navigateToApp('management', { path: 'kibana/securityAiAssistantManagement' })
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGrid>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -17,6 +17,7 @@ export const capabilitiesProvider = () => ({
|
|||
indexPatterns: true,
|
||||
objects: true,
|
||||
aiAssistantManagementSelection: true,
|
||||
securityAiAssistantManagement: true,
|
||||
observabilityAiAssistantManagement: true,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,339 @@
|
|||
/*
|
||||
* 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, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiIcon,
|
||||
EuiFlexItem,
|
||||
EuiPageTemplate,
|
||||
EuiFlexGroup,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import { Conversation, Prompt, QuickPrompt } from '../../..';
|
||||
import * as i18n from './translations';
|
||||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { useSettingsUpdater } from './use_settings_updater/use_settings_updater';
|
||||
import {
|
||||
AnonymizationSettings,
|
||||
ConversationSettings,
|
||||
EvaluationSettings,
|
||||
KnowledgeBaseSettings,
|
||||
QuickPromptSettings,
|
||||
SystemPromptSettings,
|
||||
} from '.';
|
||||
import { useLoadConnectors } from '../../connectorland/use_load_connectors';
|
||||
import { getDefaultConnector } from '../helpers';
|
||||
import { useFetchAnonymizationFields } from '../api/anonymization_fields/use_fetch_anonymization_fields';
|
||||
|
||||
export const CONVERSATIONS_TAB = 'CONVERSATION_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 type SettingsTabs =
|
||||
| typeof CONVERSATIONS_TAB
|
||||
| typeof QUICK_PROMPTS_TAB
|
||||
| typeof SYSTEM_PROMPTS_TAB
|
||||
| typeof ANONYMIZATION_TAB
|
||||
| typeof KNOWLEDGE_BASE_TAB
|
||||
| typeof EVALUATION_TAB;
|
||||
interface Props {
|
||||
conversations: Record<string, Conversation>;
|
||||
selectedConversation: Conversation;
|
||||
setSelectedConversationId: React.Dispatch<React.SetStateAction<string>>;
|
||||
isFlyoutMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for overall Assistant Settings, including conversation settings, quick prompts, system prompts,
|
||||
* anonymization, knowledge base, and evaluation via the `isModelEvaluationEnabled` feature flag.
|
||||
*/
|
||||
export const AssistantSettingsManagement: React.FC<Props> = React.memo(
|
||||
({
|
||||
selectedConversation: defaultSelectedConversation,
|
||||
setSelectedConversationId,
|
||||
conversations,
|
||||
isFlyoutMode,
|
||||
}) => {
|
||||
const {
|
||||
actionTypeRegistry,
|
||||
modelEvaluatorEnabled,
|
||||
http,
|
||||
selectedSettingsTab,
|
||||
setSelectedSettingsTab,
|
||||
toasts,
|
||||
} = useAssistantContext();
|
||||
|
||||
const { data: anonymizationFields } = useFetchAnonymizationFields();
|
||||
|
||||
// Connector details
|
||||
const { data: connectors } = useLoadConnectors({
|
||||
http,
|
||||
});
|
||||
const defaultConnector = useMemo(() => getDefaultConnector(connectors), [connectors]);
|
||||
|
||||
const [hasPendingChanges, setHasPendingChanges] = useState(false);
|
||||
|
||||
const {
|
||||
conversationSettings,
|
||||
setConversationSettings,
|
||||
knowledgeBase,
|
||||
quickPromptSettings,
|
||||
systemPromptSettings,
|
||||
assistantStreamingEnabled,
|
||||
setUpdatedAssistantStreamingEnabled,
|
||||
setUpdatedKnowledgeBaseSettings,
|
||||
setUpdatedQuickPromptSettings,
|
||||
setUpdatedSystemPromptSettings,
|
||||
saveSettings,
|
||||
conversationsSettingsBulkActions,
|
||||
updatedAnonymizationData,
|
||||
setConversationsSettingsBulkActions,
|
||||
anonymizationFieldsBulkActions,
|
||||
setAnonymizationFieldsBulkActions,
|
||||
setUpdatedAnonymizationData,
|
||||
resetSettings,
|
||||
} = useSettingsUpdater(
|
||||
conversations,
|
||||
anonymizationFields ?? { page: 0, perPage: 0, total: 0, data: [] }
|
||||
);
|
||||
|
||||
// Local state for saving previously selected items so tab switching is friendlier
|
||||
// Conversation Selection State
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | undefined>(
|
||||
() => {
|
||||
return conversationSettings[defaultSelectedConversation.title];
|
||||
}
|
||||
);
|
||||
|
||||
const onHandleSelectedConversationChange = useCallback((conversation?: Conversation) => {
|
||||
setSelectedConversation(conversation);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConversation != null) {
|
||||
setSelectedConversation(conversationSettings[selectedConversation.title]);
|
||||
}
|
||||
}, [conversationSettings, selectedConversation]);
|
||||
|
||||
// Quick Prompt Selection State
|
||||
const [selectedQuickPrompt, setSelectedQuickPrompt] = useState<QuickPrompt | undefined>();
|
||||
const onHandleSelectedQuickPromptChange = useCallback((quickPrompt?: QuickPrompt) => {
|
||||
setSelectedQuickPrompt(quickPrompt);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (selectedQuickPrompt != null) {
|
||||
setSelectedQuickPrompt(
|
||||
quickPromptSettings.find((q) => q.title === selectedQuickPrompt.title)
|
||||
);
|
||||
}
|
||||
}, [quickPromptSettings, selectedQuickPrompt]);
|
||||
|
||||
// System Prompt Selection State
|
||||
const [selectedSystemPrompt, setSelectedSystemPrompt] = useState<Prompt | undefined>();
|
||||
const onHandleSelectedSystemPromptChange = useCallback((systemPrompt?: Prompt) => {
|
||||
setSelectedSystemPrompt(systemPrompt);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (selectedSystemPrompt != null) {
|
||||
setSelectedSystemPrompt(systemPromptSettings.find((p) => p.id === selectedSystemPrompt.id));
|
||||
}
|
||||
}, [selectedSystemPrompt, systemPromptSettings]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
// If the selected conversation is deleted, we need to select a new conversation to prevent a crash creating a conversation that already exists
|
||||
const isSelectedConversationDeleted =
|
||||
conversationSettings[defaultSelectedConversation.title] == null;
|
||||
const newSelectedConversationId: string | undefined = Object.keys(conversationSettings)[0];
|
||||
if (isSelectedConversationDeleted && newSelectedConversationId != null) {
|
||||
setSelectedConversationId(conversationSettings[newSelectedConversationId].title);
|
||||
}
|
||||
saveSettings();
|
||||
toasts?.addSuccess({
|
||||
iconType: 'check',
|
||||
title: i18n.SETTINGS_UPDATED_TOAST_TITLE,
|
||||
});
|
||||
}, [
|
||||
conversationSettings,
|
||||
defaultSelectedConversation.title,
|
||||
saveSettings,
|
||||
setSelectedConversationId,
|
||||
toasts,
|
||||
]);
|
||||
|
||||
const tabsConfig = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: CONVERSATIONS_TAB,
|
||||
label: i18n.CONVERSATIONS_MENU_ITEM,
|
||||
prepend: <EuiIcon type="discuss" />,
|
||||
},
|
||||
{
|
||||
id: QUICK_PROMPTS_TAB,
|
||||
label: i18n.QUICK_PROMPTS_MENU_ITEM,
|
||||
prepend: <EuiIcon type="editorComment" />,
|
||||
},
|
||||
{
|
||||
id: SYSTEM_PROMPTS_TAB,
|
||||
label: i18n.SYSTEM_PROMPTS_MENU_ITEM,
|
||||
prepend: <EuiIcon type="editorComment" />,
|
||||
},
|
||||
{
|
||||
id: ANONYMIZATION_TAB,
|
||||
label: i18n.ANONYMIZATION_MENU_ITEM,
|
||||
prepend: <EuiIcon type="eyeClosed" />,
|
||||
},
|
||||
{
|
||||
id: KNOWLEDGE_BASE_TAB,
|
||||
label: i18n.KNOWLEDGE_BASE_MENU_ITEM,
|
||||
prepend: <EuiIcon type="notebookApp" />,
|
||||
},
|
||||
...(modelEvaluatorEnabled
|
||||
? [
|
||||
{
|
||||
id: EVALUATION_TAB,
|
||||
label: i18n.EVALUATION_MENU_ITEM,
|
||||
prepend: <EuiIcon type="crossClusterReplicationApp" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[modelEvaluatorEnabled]
|
||||
);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
return tabsConfig.map((t) => ({
|
||||
...t,
|
||||
'data-test-subj': `settingsPageTab-${t.id}`,
|
||||
onClick: () => setSelectedSettingsTab(t.id),
|
||||
isSelected: t.id === selectedSettingsTab,
|
||||
}));
|
||||
}, [setSelectedSettingsTab, selectedSettingsTab, tabsConfig]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(callback) => (value: unknown) => {
|
||||
setHasPendingChanges(true);
|
||||
callback(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onCancelClick = useCallback(() => {
|
||||
resetSettings();
|
||||
setHasPendingChanges(false);
|
||||
}, [resetSettings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiPageTemplate.Header pageTitle="Settings" tabs={tabs} paddingSize="none" />
|
||||
<EuiPageTemplate.Section
|
||||
paddingSize="l"
|
||||
css={css`
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
`}
|
||||
>
|
||||
{selectedSettingsTab === CONVERSATIONS_TAB && (
|
||||
<ConversationSettings
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
defaultConnector={defaultConnector}
|
||||
conversationSettings={conversationSettings}
|
||||
setConversationsSettingsBulkActions={handleChange(
|
||||
setConversationsSettingsBulkActions
|
||||
)}
|
||||
conversationsSettingsBulkActions={conversationsSettingsBulkActions}
|
||||
setConversationSettings={handleChange(setConversationSettings)}
|
||||
allSystemPrompts={systemPromptSettings}
|
||||
selectedConversation={selectedConversation}
|
||||
isDisabled={selectedConversation == null}
|
||||
assistantStreamingEnabled={assistantStreamingEnabled}
|
||||
setAssistantStreamingEnabled={handleChange(setUpdatedAssistantStreamingEnabled)}
|
||||
onSelectedConversationChange={onHandleSelectedConversationChange}
|
||||
http={http}
|
||||
isFlyoutMode={isFlyoutMode}
|
||||
/>
|
||||
)}
|
||||
{selectedSettingsTab === QUICK_PROMPTS_TAB && (
|
||||
<QuickPromptSettings
|
||||
quickPromptSettings={quickPromptSettings}
|
||||
onSelectedQuickPromptChange={onHandleSelectedQuickPromptChange}
|
||||
selectedQuickPrompt={selectedQuickPrompt}
|
||||
setUpdatedQuickPromptSettings={handleChange(setUpdatedQuickPromptSettings)}
|
||||
/>
|
||||
)}
|
||||
{selectedSettingsTab === SYSTEM_PROMPTS_TAB && (
|
||||
<SystemPromptSettings
|
||||
conversationSettings={conversationSettings}
|
||||
defaultConnector={defaultConnector}
|
||||
systemPromptSettings={systemPromptSettings}
|
||||
onSelectedSystemPromptChange={onHandleSelectedSystemPromptChange}
|
||||
selectedSystemPrompt={selectedSystemPrompt}
|
||||
setConversationSettings={handleChange(setConversationSettings)}
|
||||
setConversationsSettingsBulkActions={handleChange(
|
||||
setConversationsSettingsBulkActions
|
||||
)}
|
||||
conversationsSettingsBulkActions={conversationsSettingsBulkActions}
|
||||
setUpdatedSystemPromptSettings={handleChange(setUpdatedSystemPromptSettings)}
|
||||
/>
|
||||
)}
|
||||
{selectedSettingsTab === ANONYMIZATION_TAB && (
|
||||
<AnonymizationSettings
|
||||
defaultPageSize={5}
|
||||
anonymizationFields={updatedAnonymizationData}
|
||||
anonymizationFieldsBulkActions={anonymizationFieldsBulkActions}
|
||||
setAnonymizationFieldsBulkActions={handleChange(setAnonymizationFieldsBulkActions)}
|
||||
setUpdatedAnonymizationData={handleChange(setUpdatedAnonymizationData)}
|
||||
/>
|
||||
)}
|
||||
{selectedSettingsTab === KNOWLEDGE_BASE_TAB && (
|
||||
<KnowledgeBaseSettings
|
||||
knowledgeBase={knowledgeBase}
|
||||
setUpdatedKnowledgeBaseSettings={handleChange(setUpdatedKnowledgeBaseSettings)}
|
||||
/>
|
||||
)}
|
||||
{selectedSettingsTab === EVALUATION_TAB && <EvaluationSettings />}
|
||||
</EuiPageTemplate.Section>
|
||||
{hasPendingChanges && (
|
||||
<EuiPageTemplate.BottomBar paddingSize="s" position="fixed">
|
||||
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
color="text"
|
||||
iconType="cross"
|
||||
data-test-subj="cancel-button"
|
||||
onClick={onCancelClick}
|
||||
>
|
||||
{i18n.CANCEL}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
type="submit"
|
||||
data-test-subj="save-button"
|
||||
onClick={handleSave}
|
||||
iconType="check"
|
||||
fill
|
||||
>
|
||||
{i18n.SAVE}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPageTemplate.BottomBar>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AssistantSettingsManagement.displayName = 'AssistantSettingsNew';
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FindAnonymizationFieldsResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/find_anonymization_fields_route.gen';
|
||||
import { PerformBulkActionRequestBody } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen';
|
||||
import { Conversation, Prompt, QuickPrompt } from '../../../..';
|
||||
|
@ -172,6 +172,22 @@ export const useSettingsUpdater = (
|
|||
assistantTelemetry,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!(
|
||||
anonymizationFieldsBulkActions.create?.length ||
|
||||
anonymizationFieldsBulkActions.update?.length ||
|
||||
anonymizationFieldsBulkActions.delete?.ids?.length
|
||||
)
|
||||
)
|
||||
setUpdatedAnonymizationData(anonymizationFields);
|
||||
}, [
|
||||
anonymizationFields,
|
||||
anonymizationFieldsBulkActions.create?.length,
|
||||
anonymizationFieldsBulkActions.delete?.ids?.length,
|
||||
anonymizationFieldsBulkActions.update?.length,
|
||||
]);
|
||||
|
||||
return {
|
||||
conversationSettings,
|
||||
conversationsSettingsBulkActions,
|
||||
|
|
|
@ -50,14 +50,8 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
|
||||
|
||||
const {
|
||||
data: aiConnectors,
|
||||
isLoading: isLoadingConnectors,
|
||||
isFetching: isFetchingConnectors,
|
||||
refetch: refetchConnectors,
|
||||
} = useLoadConnectors({ http });
|
||||
const { data: aiConnectors, refetch: refetchConnectors } = useLoadConnectors({ http });
|
||||
|
||||
const isLoading = isLoadingConnectors || isFetchingConnectors;
|
||||
const localIsDisabled = isDisabled || !assistantAvailability.hasConnectorsReadPrivilege;
|
||||
|
||||
const addNewConnectorOption = useMemo(() => {
|
||||
|
@ -97,7 +91,7 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
return {
|
||||
value: connector.id,
|
||||
'data-test-subj': connector.id,
|
||||
inputDisplay: displayFancy ? displayFancy(connector.name) : connector.name,
|
||||
inputDisplay: displayFancy?.(connector.name) ?? connector.name,
|
||||
dropdownDisplay: (
|
||||
<React.Fragment key={connector.id}>
|
||||
<strong>{connector.name}</strong>
|
||||
|
@ -165,7 +159,6 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
data-test-subj="connector-selector"
|
||||
disabled={localIsDisabled}
|
||||
hasDividers={true}
|
||||
isLoading={isLoading}
|
||||
isOpen={modalForceOpen}
|
||||
onChange={onChange}
|
||||
options={allConnectorOptions}
|
||||
|
@ -178,7 +171,7 @@ export const ConnectorSelector: React.FC<Props> = React.memo(
|
|||
<AddConnectorModal
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
actionTypes={actionTypes}
|
||||
onClose={cleanupAndCloseModal}
|
||||
onClose={() => setIsConnectorModalVisible(false)}
|
||||
onSaveConnector={onSaveConnector}
|
||||
onSelectActionType={(actionType: ActionType) => setSelectedActionType(actionType)}
|
||||
selectedActionType={selectedActionType}
|
||||
|
|
|
@ -27,7 +27,6 @@ interface Props {
|
|||
isFlyoutMode: boolean;
|
||||
onConnectorIdSelected?: (connectorId: string) => void;
|
||||
onConnectorSelected?: (conversation: Conversation) => void;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const inputContainerClassName = css`
|
||||
|
@ -57,9 +56,7 @@ const placeholderButtonClassName = css`
|
|||
text-overflow: ellipsis;
|
||||
max-width: 400px;
|
||||
font-weight: normal;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 0;
|
||||
padding-top: 2px;
|
||||
padding: 0 14px 0 0;
|
||||
`;
|
||||
|
||||
/**
|
||||
|
@ -71,7 +68,7 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
|
|||
selectedConnectorId,
|
||||
selectedConversation,
|
||||
isFlyoutMode,
|
||||
showLabel = true,
|
||||
|
||||
onConnectorIdSelected,
|
||||
onConnectorSelected,
|
||||
}) => {
|
||||
|
@ -139,13 +136,6 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
|
|||
justifyContent={'flexStart'}
|
||||
responsive={false}
|
||||
>
|
||||
{!isFlyoutMode && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{i18n.INLINE_CONNECTOR_LABEL}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<ConnectorSelector
|
||||
displayFancy={(displayText) => (
|
||||
|
@ -177,18 +167,11 @@ export const ConnectorSelectorInline: React.FC<Props> = React.memo(
|
|||
justifyContent={'flexStart'}
|
||||
responsive={false}
|
||||
>
|
||||
{showLabel && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{i18n.INLINE_CONNECTOR_LABEL}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
{isOpen ? (
|
||||
<ConnectorSelector
|
||||
displayFancy={(displayText) => (
|
||||
<EuiText css={inputDisplayClassName} size="s">
|
||||
<EuiText className={inputDisplayClassName} size="xs">
|
||||
{displayText}
|
||||
</EuiText>
|
||||
)}
|
||||
|
|
|
@ -64,6 +64,7 @@
|
|||
"usageCollection",
|
||||
"lists",
|
||||
"home",
|
||||
"management",
|
||||
"telemetry",
|
||||
"dataViewFieldEditor",
|
||||
"osquery",
|
||||
|
|
|
@ -47,7 +47,6 @@ const HeaderComponent: React.FC<Props> = ({
|
|||
onConnectorSelected={noop}
|
||||
onConnectorIdSelected={onConnectorIdSelected}
|
||||
selectedConnectorId={connectorId}
|
||||
showLabel={false}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
|
|
|
@ -12,7 +12,7 @@ import type { Store, Action } from 'redux';
|
|||
import { Provider as ReduxStoreProvider } from 'react-redux';
|
||||
|
||||
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public';
|
||||
import type { AppMountParameters } from '@kbn/core/public';
|
||||
|
||||
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
|
||||
import { CellActionsProvider } from '@kbn/cell-actions';
|
||||
|
@ -36,18 +36,11 @@ import { AssistantProvider } from '../assistant/provider';
|
|||
interface StartAppComponent {
|
||||
children: React.ReactNode;
|
||||
history: History;
|
||||
onAppLeave: (handler: AppLeaveHandler) => void;
|
||||
store: Store<State, Action>;
|
||||
theme$: AppMountParameters['theme$'];
|
||||
}
|
||||
|
||||
const StartAppComponent: FC<StartAppComponent> = ({
|
||||
children,
|
||||
history,
|
||||
onAppLeave,
|
||||
store,
|
||||
theme$,
|
||||
}) => {
|
||||
const StartAppComponent: FC<StartAppComponent> = ({ children, history, store, theme$ }) => {
|
||||
const services = useKibana().services;
|
||||
const {
|
||||
i18n,
|
||||
|
@ -78,9 +71,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
|
|||
<UpsellingProvider upsellingService={upselling}>
|
||||
<DiscoverInTimelineContextProvider>
|
||||
<AssistantProvider>
|
||||
<PageRouter history={history} onAppLeave={onAppLeave}>
|
||||
{children}
|
||||
</PageRouter>
|
||||
<PageRouter history={history}>{children}</PageRouter>
|
||||
</AssistantProvider>
|
||||
</DiscoverInTimelineContextProvider>
|
||||
</UpsellingProvider>
|
||||
|
@ -107,7 +98,6 @@ const StartApp = memo(StartAppComponent);
|
|||
interface SecurityAppComponentProps {
|
||||
children: React.ReactNode;
|
||||
history: History;
|
||||
onAppLeave: (handler: AppLeaveHandler) => void;
|
||||
services: StartServices;
|
||||
store: Store<State, Action>;
|
||||
theme$: AppMountParameters['theme$'];
|
||||
|
@ -116,7 +106,6 @@ interface SecurityAppComponentProps {
|
|||
const SecurityAppComponent: React.FC<SecurityAppComponentProps> = ({
|
||||
children,
|
||||
history,
|
||||
onAppLeave,
|
||||
services,
|
||||
store,
|
||||
theme$,
|
||||
|
@ -131,7 +120,7 @@ const SecurityAppComponent: React.FC<SecurityAppComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
<CloudProvider>
|
||||
<StartApp history={history} onAppLeave={onAppLeave} store={store} theme$={theme$}>
|
||||
<StartApp history={history} store={store} theme$={theme$}>
|
||||
{children}
|
||||
</StartApp>
|
||||
</CloudProvider>
|
||||
|
|
|
@ -53,22 +53,24 @@ export const GlobalHeader = React.memo(() => {
|
|||
const { href, onClick } = useAddIntegrationsUrl();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderActionMenu((element) => {
|
||||
const mount = toMountPoint(<OutPortal node={portalNode} />, {
|
||||
theme,
|
||||
i18n: kibanaServiceI18n,
|
||||
if (setHeaderActionMenu) {
|
||||
setHeaderActionMenu((element) => {
|
||||
const mount = toMountPoint(<OutPortal node={portalNode} />, {
|
||||
theme,
|
||||
i18n: kibanaServiceI18n,
|
||||
});
|
||||
return mount(element);
|
||||
});
|
||||
return mount(element);
|
||||
});
|
||||
|
||||
return () => {
|
||||
/* Dashboard mounts an edit toolbar, it should be restored when leaving dashboard editing page */
|
||||
if (dashboardViewPath) {
|
||||
return;
|
||||
}
|
||||
portalNode.unmount();
|
||||
setHeaderActionMenu(undefined);
|
||||
};
|
||||
return () => {
|
||||
/* Dashboard mounts an edit toolbar, it should be restored when leaving dashboard editing page */
|
||||
if (dashboardViewPath) {
|
||||
return;
|
||||
}
|
||||
portalNode.unmount();
|
||||
setHeaderActionMenu(undefined);
|
||||
};
|
||||
}
|
||||
}, [portalNode, setHeaderActionMenu, theme, kibanaServiceI18n, dashboardViewPath]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { noop } from 'lodash/fp';
|
||||
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
|
||||
import { TimelineId } from '../../../../../common/types/timeline';
|
||||
import { TimelineWrapper } from '../../../../timelines/wrapper';
|
||||
|
@ -19,7 +20,9 @@ export const Timeline = React.memo(() => {
|
|||
|
||||
const { onAppLeave } = useKibana().services;
|
||||
|
||||
return <TimelineWrapper timelineId={TimelineId.active} onAppLeave={onAppLeave} />;
|
||||
return (
|
||||
<TimelineWrapper timelineId={TimelineId.active} onAppLeave={onAppLeave ? onAppLeave : noop} />
|
||||
);
|
||||
});
|
||||
|
||||
Timeline.displayName = 'Timeline';
|
||||
|
|
|
@ -14,25 +14,20 @@ import { AppRoutes } from './app_routes';
|
|||
export const renderApp = ({
|
||||
element,
|
||||
history,
|
||||
onAppLeave,
|
||||
services,
|
||||
store,
|
||||
usageCollection,
|
||||
subPluginRoutes,
|
||||
theme$,
|
||||
children,
|
||||
}: RenderAppProps): (() => void) => {
|
||||
const ApplicationUsageTrackingProvider =
|
||||
usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment;
|
||||
render(
|
||||
<SecurityApp
|
||||
history={history}
|
||||
onAppLeave={onAppLeave}
|
||||
services={services}
|
||||
store={store}
|
||||
theme$={theme$}
|
||||
>
|
||||
<SecurityApp history={history} services={services} store={store} theme$={theme$}>
|
||||
<ApplicationUsageTrackingProvider>
|
||||
<AppRoutes subPluginRoutes={subPluginRoutes} services={services} />
|
||||
{children ??
|
||||
(subPluginRoutes && <AppRoutes subPluginRoutes={subPluginRoutes} services={services} />)}
|
||||
</ApplicationUsageTrackingProvider>
|
||||
</SecurityApp>,
|
||||
element
|
||||
|
|
|
@ -10,7 +10,6 @@ import type { FC } from 'react';
|
|||
import React, { memo, useEffect } from 'react';
|
||||
import { Router, Routes, Route } from '@kbn/shared-ux-router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { AppLeaveHandler } from '@kbn/core/public';
|
||||
|
||||
import { APP_ID } from '../../common/constants';
|
||||
import { RouteCapture } from '../common/components/endpoint/route_capture';
|
||||
|
@ -23,10 +22,9 @@ import { HomePage } from './home';
|
|||
interface RouterProps {
|
||||
children: React.ReactNode;
|
||||
history: History;
|
||||
onAppLeave: (handler: AppLeaveHandler) => void;
|
||||
}
|
||||
|
||||
const PageRouterComponent: FC<RouterProps> = ({ children, history, onAppLeave }) => {
|
||||
const PageRouterComponent: FC<RouterProps> = ({ children, history }) => {
|
||||
const { cases } = useKibana().services;
|
||||
const CasesContext = cases.ui.getCasesContext();
|
||||
const userCasesPermissions = cases.helpers.canUseCases([APP_ID]);
|
||||
|
|
|
@ -25,11 +25,13 @@ import type { StartServices } from '../types';
|
|||
/**
|
||||
* The React properties used to render `SecurityApp` as well as the `element` to render it into.
|
||||
*/
|
||||
export interface RenderAppProps extends AppMountParameters {
|
||||
export interface RenderAppProps
|
||||
extends Omit<AppMountParameters, 'appBasePath' | 'onAppLeave' | 'setHeaderActionMenu'> {
|
||||
services: StartServices;
|
||||
store: Store<State, Action>;
|
||||
subPluginRoutes: RouteProps[];
|
||||
subPluginRoutes?: RouteProps[];
|
||||
usageCollection?: UsageCollectionSetup;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
import type { State, SubPluginsInitReducer } from '../common/store';
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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, useMemo, useState } from 'react';
|
||||
import { AssistantSettingsManagement } from '@kbn/elastic-assistant/impl/assistant/settings/assistant_settings_management';
|
||||
import type { Conversation } from '@kbn/elastic-assistant';
|
||||
import {
|
||||
mergeBaseWithPersistedConversations,
|
||||
useAssistantContext,
|
||||
useFetchCurrentUserConversations,
|
||||
WELCOME_CONVERSATION_TITLE,
|
||||
} 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 { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
|
||||
|
||||
export const ManagementSettings = React.memo(() => {
|
||||
const isFlyoutMode = useIsExperimentalFeatureEnabled('aiAssistantFlyoutMode');
|
||||
|
||||
const {
|
||||
baseConversations,
|
||||
http,
|
||||
assistantAvailability: { isAssistantEnabled },
|
||||
} = useAssistantContext();
|
||||
|
||||
const onFetchedConversations = useCallback(
|
||||
(conversationsData: FetchConversationsResponse): Record<string, Conversation> =>
|
||||
mergeBaseWithPersistedConversations(baseConversations, conversationsData),
|
||||
[baseConversations]
|
||||
);
|
||||
const { data: conversations } = useFetchCurrentUserConversations({
|
||||
http,
|
||||
onFetch: onFetchedConversations,
|
||||
isAssistantEnabled,
|
||||
});
|
||||
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<string>(
|
||||
WELCOME_CONVERSATION_TITLE
|
||||
);
|
||||
|
||||
const { getDefaultConversation } = useConversation();
|
||||
|
||||
const currentConversation = useMemo(
|
||||
() =>
|
||||
conversations?.[selectedConversationId] ??
|
||||
getDefaultConversation({ cTitle: WELCOME_CONVERSATION_TITLE, isFlyoutMode }),
|
||||
[conversations, getDefaultConversation, selectedConversationId, isFlyoutMode]
|
||||
);
|
||||
|
||||
if (conversations) {
|
||||
return (
|
||||
<AssistantSettingsManagement
|
||||
selectedConversation={currentConversation}
|
||||
setSelectedConversationId={setSelectedConversationId}
|
||||
conversations={conversations}
|
||||
isFlyoutMode={isFlyoutMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
});
|
||||
|
||||
ManagementSettings.displayName = 'ManagementSettings';
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* the plugin (defined in `plugin.tsx`) has many dependencies that can be loaded only when the app is being used.
|
||||
* By loading these later we can reduce the initial bundle size and allow users to delay loading these dependencies until they are needed.
|
||||
*/
|
||||
|
||||
import { ManagementSettings } from './assistant/stack_management/management_settings';
|
||||
|
||||
export { ManagementSettings };
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Subject, combineLatestWith } from 'rxjs';
|
||||
import type * as H from 'history';
|
||||
|
@ -93,7 +94,14 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
): PluginSetup {
|
||||
this.services.setup(core, plugins);
|
||||
|
||||
const { home, triggersActionsUi, usageCollection } = plugins;
|
||||
const { home, triggersActionsUi, usageCollection, management } = plugins;
|
||||
|
||||
const assistantManagementTitle = i18n.translate(
|
||||
'xpack.securitySolution.securityAiAssistantManagement.app.title',
|
||||
{
|
||||
defaultMessage: 'AI Assistant for Security',
|
||||
}
|
||||
);
|
||||
|
||||
if (home) {
|
||||
home.featureCatalogue.registerSolution({
|
||||
|
@ -107,6 +115,54 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
path: APP_PATH,
|
||||
order: 300,
|
||||
});
|
||||
|
||||
home.featureCatalogue.register({
|
||||
id: 'ai_assistant_security',
|
||||
title: assistantManagementTitle,
|
||||
description: i18n.translate(
|
||||
'xpack.securitySolution.securityAiAssistantManagement.app.description',
|
||||
{
|
||||
defaultMessage: 'Manage your AI Assistant for Security.',
|
||||
}
|
||||
),
|
||||
icon: 'sparkles',
|
||||
path: '/app/management/kibana/securityAiAssistantManagement',
|
||||
showOnHomePage: false,
|
||||
category: 'admin',
|
||||
});
|
||||
|
||||
if (management) {
|
||||
management.sections.section.kibana.registerApp({
|
||||
id: 'securityAiAssistantManagement',
|
||||
title: assistantManagementTitle,
|
||||
hideFromSidebar: true,
|
||||
order: 1,
|
||||
mount: async (params) => {
|
||||
// required to show the alert table inside cases
|
||||
const { alertsTableConfigurationRegistry } = plugins.triggersActionsUi;
|
||||
const { registerAlertsTableConfiguration } =
|
||||
await this.lazyRegisterAlertsTableConfiguration();
|
||||
registerAlertsTableConfiguration(alertsTableConfigurationRegistry, this.storage);
|
||||
|
||||
const [coreStart, startPlugins] = await core.getStartServices();
|
||||
const subPlugins = await this.startSubPlugins(this.storage, coreStart, startPlugins);
|
||||
const store = await this.store(coreStart, startPlugins, subPlugins);
|
||||
const services = await this.services.generateServices(coreStart, startPlugins);
|
||||
await this.registerActions(store, params.history, services);
|
||||
|
||||
const { renderApp } = await this.lazyApplicationDependencies();
|
||||
const { ManagementSettings } = await this.lazyAssistantSettingsManagement();
|
||||
|
||||
return renderApp({
|
||||
...params,
|
||||
services,
|
||||
store,
|
||||
usageCollection: plugins.usageCollection,
|
||||
children: <ManagementSettings />,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mount: AppMount = async (params) => {
|
||||
|
@ -468,4 +524,15 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
'./actions'
|
||||
);
|
||||
}
|
||||
|
||||
private lazyAssistantSettingsManagement() {
|
||||
/**
|
||||
* The specially formatted comment in the `import` expression causes the corresponding webpack chunk to be named. This aids us in debugging chunk size issues.
|
||||
* See https://webpack.js.org/api/module-methods/#magic-comments
|
||||
*/
|
||||
return import(
|
||||
/* webpackChunkName: "actions" */
|
||||
'./lazy_assistant_settings_management'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,7 +98,7 @@ export class PluginServices {
|
|||
public async generateServices(
|
||||
coreStart: CoreStart,
|
||||
startPlugins: StartPluginsDependencies,
|
||||
params: AppMountParameters<unknown>
|
||||
params?: AppMountParameters<unknown>
|
||||
): Promise<StartServices> {
|
||||
const { apm } = await import('@elastic/apm-rum');
|
||||
const { SecuritySolutionTemplateWrapper } = await import('./app/home/template_wrapper');
|
||||
|
@ -119,11 +119,11 @@ export class PluginServices {
|
|||
apm,
|
||||
configSettings: this.configSettings,
|
||||
savedObjectsTagging: savedObjectsTaggingOss.getTaggingApi(),
|
||||
setHeaderActionMenu: params.setHeaderActionMenu,
|
||||
...(params?.setHeaderActionMenu ? { setHeaderActionMenu: params.setHeaderActionMenu } : {}),
|
||||
storage: this.storage,
|
||||
sessionStorage: this.sessionStorage,
|
||||
security: startPlugins.security,
|
||||
onAppLeave: params.onAppLeave,
|
||||
...(params?.onAppLeave ? { onAppLeave: params.onAppLeave } : {}),
|
||||
securityLayout: { getPluginWrapper: () => SecuritySolutionTemplateWrapper },
|
||||
contentManagement: startPlugins.contentManagement,
|
||||
telemetry: this.telemetry.start(),
|
||||
|
|
|
@ -52,6 +52,7 @@ import type { ContentManagementPublicStart } from '@kbn/content-management-plugi
|
|||
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
|
||||
|
||||
import type { DiscoverStart } from '@kbn/discover-plugin/public';
|
||||
import type { ManagementSetup } from '@kbn/management-plugin/public';
|
||||
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
|
||||
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
|
||||
import type { UpsellingService } from '@kbn/security-solution-upselling/service';
|
||||
|
@ -93,6 +94,7 @@ export interface SetupPlugins {
|
|||
cloud?: CloudSetup;
|
||||
home?: HomePublicPluginSetup;
|
||||
licensing: LicensingPluginSetup;
|
||||
management: ManagementSetup;
|
||||
security: SecurityPluginSetup;
|
||||
triggersActionsUi: TriggersActionsSetup;
|
||||
usageCollection?: UsageCollectionSetup;
|
||||
|
@ -167,8 +169,8 @@ export type StartServices = CoreStart &
|
|||
sessionStorage: Storage;
|
||||
apm: ApmBase;
|
||||
savedObjectsTagging?: SavedObjectsTaggingApi;
|
||||
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
|
||||
onAppLeave: (handler: AppLeaveHandler) => void;
|
||||
setHeaderActionMenu?: AppMountParameters['setHeaderActionMenu'];
|
||||
onAppLeave?: (handler: AppLeaveHandler) => void;
|
||||
|
||||
/**
|
||||
* This component will be exposed to all lazy loaded plugins, via useKibana hook. It should wrap every plugin route.
|
||||
|
|
|
@ -561,6 +561,9 @@ export class Plugin implements ISecuritySolutionPlugin {
|
|||
assistantAlertsInsights: config.experimentalFeatures.assistantAlertsInsights,
|
||||
assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation,
|
||||
});
|
||||
plugins.elasticAssistant.registerFeatures('management', {
|
||||
assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation,
|
||||
});
|
||||
|
||||
if (this.lists && plugins.taskManager && plugins.fleet) {
|
||||
// Exceptions, Artifacts and Manifests start
|
||||
|
|
|
@ -197,6 +197,7 @@
|
|||
"@kbn/data-service",
|
||||
"@kbn/core-chrome-browser",
|
||||
"@kbn/shared-ux-chrome-navigation",
|
||||
"@kbn/core-ui-settings-browser-mocks"
|
||||
"@kbn/core-ui-settings-browser-mocks",
|
||||
"@kbn/management-plugin"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue