[Security Solution] Connector selector onboarding (#203742)

## Summary

Summarize your PR. If it involves visual changes include a screenshot or
gif.


https://github.com/user-attachments/assets/6d7527d1-dc8d-4f3a-9b03-cfd0022701d2



### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Agustina Nahir Ruidiaz 2025-01-24 13:47:41 +01:00 committed by GitHub
parent e394abf6e9
commit 86666bf790
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 740 additions and 304 deletions

1
.github/CODEOWNERS vendored
View file

@ -965,6 +965,7 @@ x-pack/solutions/search/plugins/search_playground @elastic/search-kibana
x-pack/solutions/search/plugins/search_solution/search_navigation @elastic/search-kibana
x-pack/solutions/search/plugins/search_synonyms @elastic/search-kibana
x-pack/solutions/search/plugins/serverless_search @elastic/search-kibana
x-pack/solutions/security/packages/connectors @elastic/security-threat-hunting-explore
x-pack/solutions/security/packages/data_table @elastic/security-threat-hunting-investigations
x-pack/solutions/security/packages/data-stream-adapter @elastic/security-threat-hunting
x-pack/solutions/security/packages/distribution_bar @elastic/kibana-cloud-security-posture

View file

@ -822,6 +822,7 @@
"@kbn/security-plugin-types-public": "link:x-pack/platform/packages/shared/security/plugin_types_public",
"@kbn/security-plugin-types-server": "link:x-pack/platform/packages/shared/security/plugin_types_server",
"@kbn/security-role-management-model": "link:x-pack/platform/packages/private/security/role_management_model",
"@kbn/security-solution-connectors": "link:x-pack/solutions/security/packages/connectors",
"@kbn/security-solution-distribution-bar": "link:x-pack/solutions/security/packages/distribution_bar",
"@kbn/security-solution-ess": "link:x-pack/solutions/security/plugins/security_solution_ess",
"@kbn/security-solution-features": "link:x-pack/solutions/security/packages/features",

View file

@ -1632,6 +1632,8 @@
"@kbn/security-plugin-types-server/*": ["x-pack/platform/packages/shared/security/plugin_types_server/*"],
"@kbn/security-role-management-model": ["x-pack/platform/packages/private/security/role_management_model"],
"@kbn/security-role-management-model/*": ["x-pack/platform/packages/private/security/role_management_model/*"],
"@kbn/security-solution-connectors": ["x-pack/solutions/security/packages/connectors"],
"@kbn/security-solution-connectors/*": ["x-pack/solutions/security/packages/connectors/*"],
"@kbn/security-solution-distribution-bar": ["x-pack/solutions/security/packages/distribution_bar"],
"@kbn/security-solution-distribution-bar/*": ["x-pack/solutions/security/packages/distribution_bar/*"],
"@kbn/security-solution-ess": ["x-pack/solutions/security/plugins/security_solution_ess"],

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { ConnectorSelector } from './src/connector_selector';
export type { ConnectorSelectorProps } from './src/connector_selector';

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
roots: ['<rootDir>/x-pack/solutions/security/packages/connectors'],
rootDir: '../../../../..',
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-browser",
"id": "@kbn/security-solution-connectors",
"owner": [
"@elastic/security-threat-hunting-explore"
],
"group": "security",
"visibility": "private"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/security-solution-connectors",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View file

@ -0,0 +1,41 @@
/*
* 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 { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
export const useConnectorSelectorStyles = () => {
const { euiTheme } = useEuiTheme();
return {
placeholder: css`
color: ${euiTheme.colors.primary};
margin-right: ${euiTheme.size.xs};
`,
optionDisplay: css`
margin-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
`,
offset: css`
width: 24px;
`,
inputContainer: css`
.euiSuperSelectControl {
border: none;
box-shadow: none;
background: none;
padding-left: 0;
}
.euiFormControlLayoutIcons {
right: 14px;
top: 2px;
}
`,
};
};

View file

@ -0,0 +1,145 @@
/*
* 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 {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiSuperSelect,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { some } from 'lodash';
import * as i18n from './translations';
import { useConnectorSelectorStyles } from './connector_selector.styles';
import { ADD_NEW_CONNECTOR } from './constants';
export interface ConnectorDetails {
id: string;
name: string;
description: string;
}
export interface ConnectorSelectorProps {
connectors: ConnectorDetails[];
onChange: (connectorId: string) => void;
selectedId?: string;
onNewConnectorClicked?: () => void;
isDisabled?: boolean;
}
export const ConnectorSelector = React.memo<ConnectorSelectorProps>(
({ connectors, onChange, selectedId, onNewConnectorClicked, isDisabled }) => {
const styles = useConnectorSelectorStyles();
const { euiTheme } = useEuiTheme();
const addNewConnectorOption = useMemo(() => {
return {
value: ADD_NEW_CONNECTOR,
inputDisplay: i18n.ADD_NEW_CONNECTOR,
dropdownDisplay: (
<EuiFlexGroup gutterSize="none" key={ADD_NEW_CONNECTOR}>
<EuiFlexItem grow={true}>
<EuiButtonEmpty
data-test-subj="addNewConnectorButton"
href="#"
isDisabled={isDisabled}
iconType="plus"
size="xs"
>
{i18n.ADD_NEW_CONNECTOR}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{/* Right offset to compensate for 'selected' icon of EuiSuperSelect since native footers aren't supported*/}
<div css={styles.offset} />
</EuiFlexItem>
</EuiFlexGroup>
),
};
}, [isDisabled, styles.offset]);
const connectorExists = useMemo(
() => some(connectors, ['id', selectedId]),
[connectors, selectedId]
);
const mappedConnectorOptions = connectors.map((connector) => ({
value: connector.id,
'data-test-subj': connector.id,
inputDisplay: (
<EuiText css={styles.optionDisplay} size="s" color={euiTheme.colors.primary}>
{connector.name}
</EuiText>
),
dropdownDisplay: (
<React.Fragment key={connector.id}>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="none" alignItems="center">
<EuiFlexItem grow={false} data-test-subj={`connector-${connector.name}`}>
<strong>{connector.name}</strong>
<EuiText size="xs" color="subdued">
<p>{connector.description}</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>
),
}));
const allConnectorOptions = useMemo(
() =>
onNewConnectorClicked
? [...mappedConnectorOptions, addNewConnectorOption]
: [...mappedConnectorOptions],
[onNewConnectorClicked, mappedConnectorOptions, addNewConnectorOption]
);
const onChangeConnector = useCallback(
(connectorId: string) => {
if (connectorId === ADD_NEW_CONNECTOR) {
onNewConnectorClicked?.();
return;
}
onChange(connectorId);
},
[onChange, onNewConnectorClicked]
);
return (
<div css={styles.inputContainer}>
{!connectorExists && !connectors.length ? (
<EuiButtonEmpty
data-test-subj="addNewConnectorButton"
iconType="plusInCircle"
isDisabled={isDisabled}
size="xs"
onClick={() => onNewConnectorClicked?.()}
>
{i18n.ADD_CONNECTOR}
</EuiButtonEmpty>
) : (
<EuiSuperSelect
aria-label={i18n.CONNECTOR_SELECTOR_TITLE}
css={styles.placeholder}
compressed={true}
data-test-subj="connector-selector"
disabled={isDisabled}
hasDividers={true}
onChange={onChangeConnector}
options={allConnectorOptions}
valueOfSelected={selectedId}
placeholder={i18n.CONNECTOR_SELECTOR_PLACEHOLDER}
popoverProps={{ panelMinWidth: 400, anchorPosition: 'downRight' }}
/>
)}
</div>
);
}
);
ConnectorSelector.displayName = 'ConnectorSelector';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export const ADD_NEW_CONNECTOR = 'ADD_NEW_CONNECTOR';

View file

@ -0,0 +1,36 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CONNECTOR_SELECTOR_TITLE = i18n.translate(
'securitySolutionPackages.connectors.connectorSelector.ariaLabel',
{
defaultMessage: 'Connector Selector',
}
);
export const ADD_NEW_CONNECTOR = i18n.translate(
'securitySolutionPackages.connectors.connectorSelector.newConnectorOptions',
{
defaultMessage: 'Add new Connector...',
}
);
export const ADD_CONNECTOR = i18n.translate(
'securitySolutionPackages.connectors.connectorSelector.addConnectorButtonLabel',
{
defaultMessage: 'Add connector',
}
);
export const CONNECTOR_SELECTOR_PLACEHOLDER = i18n.translate(
'securitySolutionPackages.connectors.connectorSelectorInline.connectorPlaceholder',
{
defaultMessage: 'Select a connector',
}
);

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop",
"@testing-library/jest-dom",
"@testing-library/react",
]
},
"include": ["**/*.ts", "**/*.tsx"],
"kbn_references": [
"@kbn/i18n",
],
"exclude": ["target/**/*"]
}

View file

@ -19,6 +19,7 @@ const LocalStorageKey = {
selectedIntegrationTabId: 'securitySolution.onboarding.selectedIntegrationTabId',
selectedCardItemId: 'securitySolution.onboarding.selectedCardItem',
integrationSearchTerm: 'securitySolution.onboarding.integrationSearchTerm',
assistantConnectorId: 'securitySolution.onboarding.assistantCard.connectorId',
} as const;
/**
@ -80,3 +81,9 @@ export const useStoredIntegrationSearchTerm = (spaceId: string) =>
`${LocalStorageKey.integrationSearchTerm}.${spaceId}`,
null
);
/**
* Stores the integration search term per space
*/
export const useStoredAssistantConnectorId = (spaceId: string) =>
useDefinedLocalStorage<string | null>(`${LocalStorageKey.assistantConnectorId}.${spaceId}`, null);

View file

@ -8,15 +8,25 @@
import React, { useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink } from '@elastic/eui';
import { css } from '@emotion/css';
import { useAssistantContext, type Conversation } from '@kbn/elastic-assistant';
import { useCurrentConversation } from '@kbn/elastic-assistant/impl/assistant/use_current_conversation';
import { useDataStreamApis } from '@kbn/elastic-assistant/impl/assistant/use_data_stream_apis';
import { getDefaultConnector } from '@kbn/elastic-assistant/impl/assistant/helpers';
import { getGenAiConfig } from '@kbn/elastic-assistant/impl/connectorland/helpers';
import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation';
import { CenteredLoadingSpinner } from '../../../../../common/components/centered_loading_spinner';
import { OnboardingCardId } from '../../../../constants';
import type { OnboardingCardComponent } from '../../../../types';
import * as i18n from './translations';
import { useStoredAssistantConnectorId } from '../../../hooks/use_stored_state';
import { useOnboardingContext } from '../../../onboarding_context';
import { OnboardingCardContentPanel } from '../common/card_content_panel';
import { ConnectorCards } from '../common/connectors/connector_cards';
import { CardCallOut } from '../common/card_callout';
import { CardSubduedText } from '../common/card_subdued_text';
import type { AssistantCardMetadata } from './types';
import { MissingPrivilegesCallOut } from '../common/connectors/missing_privileges';
import type { AIConnector } from '../common/connectors/types';
export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
isCardComplete,
@ -25,6 +35,9 @@ export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
checkComplete,
isCardAvailable,
}) => {
const { spaceId } = useOnboardingContext();
const { connectors, canExecuteConnectors, canCreateConnectors } = checkCompleteMetadata ?? {};
const isIntegrationsCardComplete = useMemo(
() => isCardComplete(OnboardingCardId.integrations),
[isCardComplete]
@ -39,9 +52,99 @@ export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
setExpandedCardId(OnboardingCardId.integrations, { scroll: true });
}, [setExpandedCardId]);
const connectors = checkCompleteMetadata?.connectors;
const canExecuteConnectors = checkCompleteMetadata?.canExecuteConnectors;
const canCreateConnectors = checkCompleteMetadata?.canCreateConnectors;
const [selectedConnectorId, setSelectedConnectorId] = useStoredAssistantConnectorId(spaceId);
const defaultConnector = useMemo(() => getDefaultConnector(connectors), [connectors]);
const { setApiConfig } = useConversation();
const {
http,
assistantAvailability: { isAssistantEnabled },
baseConversations,
getLastConversationId,
} = useAssistantContext();
const {
allSystemPrompts,
conversations,
isFetchedCurrentUserConversations,
isFetchedPrompts,
refetchCurrentUserConversations,
} = useDataStreamApis({ http, baseConversations, isAssistantEnabled });
const { currentConversation, handleOnConversationSelected } = useCurrentConversation({
allSystemPrompts,
conversations,
defaultConnector,
refetchCurrentUserConversations,
conversationId: getLastConversationId(),
mayUpdateConversations:
isFetchedCurrentUserConversations &&
isFetchedPrompts &&
Object.keys(conversations).length > 0,
});
const onConversationChange = useCallback(
(updatedConversation: Conversation) => {
handleOnConversationSelected({
cId: updatedConversation.id,
cTitle: updatedConversation.title,
});
},
[handleOnConversationSelected]
);
const onConnectorSelected = useCallback(
async (connector: AIConnector) => {
const connectorId = connector.id;
const config = getGenAiConfig(connector);
const apiProvider = config?.apiProvider;
const model = config?.defaultModel;
if (currentConversation != null) {
const conversation = await setApiConfig({
conversation: currentConversation,
apiConfig: {
...currentConversation.apiConfig,
actionTypeId: connector.actionTypeId,
connectorId,
// With the inline component, prefer config args to handle 'new connector' case
provider: apiProvider,
model,
},
});
if (conversation && onConversationChange != null) {
onConversationChange(conversation);
}
}
if (selectedConnectorId != null) {
setSelectedConnectorId(connectorId);
}
},
[
currentConversation,
selectedConnectorId,
setApiConfig,
onConversationChange,
setSelectedConnectorId,
]
);
if (!checkCompleteMetadata) {
return (
<OnboardingCardContentPanel>
<CenteredLoadingSpinner />
</OnboardingCardContentPanel>
);
}
const onNewConnectorSaved = (connectorId: string) => {
checkComplete();
setSelectedConnectorId(connectorId);
};
return (
<OnboardingCardContentPanel>
@ -77,7 +180,9 @@ export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
<ConnectorCards
canCreateConnectors={canCreateConnectors}
connectors={connectors}
onConnectorSaved={checkComplete}
onNewConnectorSaved={onNewConnectorSaved}
selectedConnectorId={selectedConnectorId}
onConnectorSelected={onConnectorSelected}
/>
)}
</EuiFlexItem>

View file

@ -18,7 +18,7 @@ export const ASSISTANT_CARD_DESCRIPTION = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.description',
{
defaultMessage:
'Choose and configure any AI provider available to use with Elastic AI Assistant.',
'The Elastic AI connector is currently configured, powered by OpenAI gpt 4.0 for optimal performance for migrating SIEM rules. However,any AI service provider can be configured. Read more about AI provider performance and other Elastic powered by AI features.',
}
);

View file

@ -6,42 +6,39 @@
*/
import React, { useCallback } from 'react';
import { type AIConnector } from '@kbn/elastic-assistant/impl/connectorland/connector_selector';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiLoadingSpinner,
EuiText,
EuiBadge,
EuiSpacer,
EuiCallOut,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { useKibana } from '../../../../../../common/lib/kibana';
import {
CreateConnectorPopover,
type CreateConnectorPopoverProps,
} from './create_connector_popover';
import { ConnectorSetup } from './connector_setup';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiCallOut } from '@elastic/eui';
import { css } from '@emotion/react';
import type { AIConnector } from './types';
import * as i18n from './translations';
import { MissingPrivilegesDescription } from './missing_privileges';
import { ConnectorSetup } from './connector_setup';
import { ConnectorSelectorPanel } from './connector_selector_panel';
interface ConnectorCardsProps
extends CreateConnectorPopoverProps,
Omit<ConnectorListProps, 'connectors'> {
interface ConnectorCardsProps {
onNewConnectorSaved: (connectorId: string) => void;
canCreateConnectors?: boolean;
connectors?: AIConnector[]; // make connectors optional to handle loading state
selectedConnectorId?: string | null;
onConnectorSelected: (connector: AIConnector) => void;
}
export const ConnectorCards = React.memo<ConnectorCardsProps>(
({
connectors,
onConnectorSaved,
onNewConnectorSaved,
canCreateConnectors,
selectedConnectorId,
setSelectedConnectorId,
onConnectorSelected,
}) => {
const onNewConnectorStoredSave = useCallback(
(newConnector: AIConnector) => {
onNewConnectorSaved(newConnector.id);
// default select the new connector created
onConnectorSelected(newConnector);
},
[onConnectorSelected, onNewConnectorSaved]
);
if (!connectors) {
return <EuiLoadingSpinner />;
}
@ -59,95 +56,26 @@ export const ConnectorCards = React.memo<ConnectorCardsProps>(
return (
<>
{hasConnectors ? (
<>
<ConnectorList
connectors={connectors}
selectedConnectorId={selectedConnectorId}
setSelectedConnectorId={setSelectedConnectorId}
/>
<EuiSpacer />
<CreateConnectorPopover
canCreateConnectors={canCreateConnectors}
onConnectorSaved={onConnectorSaved}
/>
</>
) : (
<ConnectorSetup onConnectorSaved={onConnectorSaved} />
)}
<EuiFlexGroup
css={css`
height: 160px;
`}
>
{hasConnectors && (
<EuiFlexItem>
<ConnectorSelectorPanel
selectedConnectorId={selectedConnectorId}
connectors={connectors}
onConnectorSelected={onConnectorSelected}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<ConnectorSetup onConnectorSaved={onNewConnectorStoredSave} />
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
);
ConnectorCards.displayName = 'ConnectorCards';
interface ConnectorListProps {
connectors: AIConnector[];
selectedConnectorId?: string | null;
setSelectedConnectorId?: (id: string) => void;
}
const ConnectorList = React.memo<ConnectorListProps>(
({ connectors, selectedConnectorId, setSelectedConnectorId }) => {
const { euiTheme } = useEuiTheme();
const { actionTypeRegistry } = useKibana().services.triggersActionsUi;
const onConnectorClick = useCallback(
(id: string) => {
setSelectedConnectorId?.(id);
},
[setSelectedConnectorId]
);
const selectedCss = `border: 2px solid ${euiTheme.colors.primary};`;
return (
<EuiFlexGroup
wrap
gutterSize="s"
className={css`
padding: ${euiTheme.size.s} 0;
max-height: 350px;
overflow-y: auto;
`}
>
{connectors.map((connector) => (
<EuiFlexItem
key={connector.id}
grow={false}
className={css`
width: 30%;
`}
>
<EuiPanel
hasShadow={false}
hasBorder
paddingSize="m"
onClick={setSelectedConnectorId ? () => onConnectorClick(connector.id) : undefined}
css={css`
${selectedConnectorId === connector.id ? selectedCss : ''}
`}
color={selectedConnectorId === connector.id ? 'primary' : 'plain'}
>
<EuiFlexGroup direction="row" gutterSize="s" wrap>
<EuiFlexItem
className={css`
min-width: 100%;
`}
>
<EuiText size="s">{connector.name}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">
{actionTypeRegistry.get(connector.actionTypeId).actionTypeTitle}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}
);
ConnectorList.displayName = 'ConnectorList';

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { css } from '@emotion/react';
import { ConnectorSelectorWithIcon } from './connector_selector_with_icon';
import * as i18n from './translations';
import type { AIConnector } from './types';
interface ConnectorSelectorPanelProps {
connectors: AIConnector[];
selectedConnectorId?: string | null;
onConnectorSelected: (connector: AIConnector) => void;
}
export const ConnectorSelectorPanel = React.memo<ConnectorSelectorPanelProps>(
({ connectors, selectedConnectorId, onConnectorSelected }) => {
return (
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup
css={css`
height: 100%;
`}
alignItems="center"
justifyContent="center"
direction="column"
gutterSize="s"
>
<EuiFlexItem grow={false} justifyContent="center">
<EuiText>{i18n.SELECTED_PROVIDER}</EuiText>
</EuiFlexItem>
<EuiFlexItem justifyContent="center">
<ConnectorSelectorWithIcon
selectedConnectorId={selectedConnectorId}
connectors={connectors}
onConnectorSelected={onConnectorSelected}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
);
ConnectorSelectorPanel.displayName = 'ConnectorSelectorPanel';

View file

@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLoadingSpinner } from '@elastic/eui';
import React, { useMemo, useEffect, useCallback } from 'react';
import { useAssistantContext } from '@kbn/elastic-assistant';
import { ConnectorSelector } from '@kbn/security-solution-connectors';
import {
getActionTypeTitle,
getGenAiConfig,
} from '@kbn/elastic-assistant/impl/connectorland/helpers';
import { css } from '@emotion/react';
import type { AIConnector } from './types';
import { useFilteredActionTypes } from './hooks/use_load_action_types';
import * as i18n from './translations';
interface Props {
isDisabled?: boolean;
selectedConnectorId?: string | null;
connectors: AIConnector[];
onConnectorSelected: (connector: AIConnector) => void;
}
/**
* A compact wrapper of the ConnectorSelector with a Selected Icon
*/
export const ConnectorSelectorWithIcon = React.memo<Props>(
({ isDisabled = false, selectedConnectorId, connectors, onConnectorSelected }) => {
const { actionTypeRegistry, assistantAvailability } = useAssistantContext();
const actionTypes = useFilteredActionTypes();
const selectedConnector = useMemo(
() => connectors.find((connector) => connector.id === selectedConnectorId),
[connectors, selectedConnectorId]
);
useEffect(() => {
if (connectors.length === 1) {
onConnectorSelected(connectors[0]);
}
}, [connectors, onConnectorSelected]);
const localIsDisabled = isDisabled || !assistantAvailability.hasConnectorsReadPrivilege;
const connectorOptions = useMemo(
() =>
(connectors ?? []).map((connector) => {
const connectorTypeTitle =
getGenAiConfig(connector)?.apiProvider ??
getActionTypeTitle(actionTypeRegistry.get(connector.actionTypeId));
const connectorDetails = connector.isPreconfigured
? i18n.PRECONFIGURED_CONNECTOR
: connectorTypeTitle;
return {
id: connector.id,
name: connector.name,
description: connectorDetails,
};
}),
[actionTypeRegistry, connectors]
);
const onConnectorSelectionChange = useCallback(
(connectorId: string) => {
const connector = (connectors ?? []).find((c) => c.id === connectorId);
if (connector) {
onConnectorSelected(connector);
}
},
[connectors, onConnectorSelected]
);
if (!actionTypes) {
return <EuiLoadingSpinner />;
}
return (
<EuiFlexGroup
alignItems="center"
css={css`
height: 32px;
`}
direction="column"
justifyContent="center"
responsive={false}
gutterSize="xs"
>
{selectedConnector && (
<EuiFlexItem grow={false}>
<EuiIcon
size="xxl"
color="text"
type={actionTypeRegistry.get(selectedConnector.actionTypeId).iconClass}
/>
</EuiFlexItem>
)}
{selectedConnectorId && (
<EuiFlexItem grow={false}>
<ConnectorSelector
connectors={connectorOptions}
isDisabled={localIsDisabled}
selectedId={selectedConnectorId}
onChange={onConnectorSelectionChange}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
);
ConnectorSelectorWithIcon.displayName = 'ConnectorSelectorWithIcon';

View file

@ -6,142 +6,105 @@
*/
import React, { useCallback, useState } from 'react';
import { type ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import {
useEuiTheme,
EuiFlexGroup,
EuiFlexItem,
EuiListGroup,
EuiIcon,
EuiPanel,
EuiLoadingSpinner,
EuiText,
EuiLink,
EuiTextColor,
EuiButton,
} from '@elastic/eui';
import { css } from '@emotion/css';
import {
ConnectorAddModal,
type ActionConnector,
} from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import type { ActionType } from '@kbn/actions-plugin/common';
import { AddConnectorModal } from '@kbn/elastic-assistant/impl/connectorland/add_connector_modal';
import * as i18n from './translations';
import { useKibana } from '../../../../../../common/lib/kibana';
import { useFilteredActionTypes } from './hooks/use_load_action_types';
const usePanelCss = () => {
const { euiTheme } = useEuiTheme();
return css`
.connectorSelectorPanel {
height: 160px;
&.euiPanel:hover {
background-color: ${euiTheme.colors.backgroundBaseSubdued};
}
}
`;
};
interface ConnectorSetupProps {
onConnectorSaved?: (savedAction: ActionConnector) => void;
onClose?: () => void;
compressed?: boolean;
}
export const ConnectorSetup = React.memo<ConnectorSetupProps>(
({ onConnectorSaved, onClose, compressed = false }) => {
const panelCss = usePanelCss();
const {
http,
triggersActionsUi: { actionTypeRegistry },
notifications: { toasts },
} = useKibana().services;
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
export const ConnectorSetup = React.memo<ConnectorSetupProps>(({ onConnectorSaved, onClose }) => {
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
const onModalClose = useCallback(() => {
setSelectedActionType(null);
onClose?.();
}, [onClose]);
const {
triggersActionsUi: { actionTypeRegistry },
} = useKibana().services;
const actionTypes = useFilteredActionTypes(http, toasts);
const onModalClose = useCallback(() => {
setSelectedActionType(null);
setIsModalVisible(false);
onClose?.();
}, [onClose]);
if (!actionTypes) {
return <EuiLoadingSpinner />;
}
const actionTypes = useFilteredActionTypes();
return (
<>
{compressed ? (
<EuiListGroup
flush
data-test-subj="connectorSetupCompressed"
listItems={actionTypes.map((actionType) => ({
key: actionType.id,
id: actionType.id,
label: actionType.name,
size: 's',
icon: (
<EuiIcon
size="l"
color="text"
type={actionTypeRegistry.get(actionType.id).iconClass}
/>
),
isDisabled: !actionType.enabled,
onClick: () => setSelectedActionType(actionType),
}))}
/>
) : (
<EuiFlexGroup gutterSize="l">
{actionTypes.map((actionType: ActionType) => (
<EuiFlexItem key={actionType.id}>
<EuiLink
color="text"
onClick={() => setSelectedActionType(actionType)}
data-test-subj={`actionType-${actionType.id}`}
className={panelCss}
>
<EuiPanel
hasShadow={false}
hasBorder
paddingSize="m"
className="connectorSelectorPanel"
>
<EuiFlexGroup
direction="column"
alignItems="center"
justifyContent="center"
gutterSize="s"
className={css`
height: 100%;
`}
>
<EuiFlexItem grow={false}>
<EuiIcon
size="xxl"
color="text"
type={actionTypeRegistry.get(actionType.id).iconClass}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTextColor color="default">
<EuiText size="s">{actionType.name}</EuiText>
</EuiTextColor>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiLink>
</EuiFlexItem>
))}
</EuiFlexGroup>
)}
{selectedActionType && (
<ConnectorAddModal
actionTypeRegistry={actionTypeRegistry}
actionType={selectedActionType}
onClose={onModalClose}
postSaveEventHandler={onConnectorSaved}
/>
)}
</>
);
if (!actionTypes) {
return <EuiLoadingSpinner />;
}
);
return (
<>
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup
style={{ height: '100%' }}
direction="column"
justifyContent="center"
alignItems="center"
gutterSize="m"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" justifyContent="center">
{actionTypes.map((actionType: ActionType) => (
<EuiFlexItem grow={false} key={actionType.id}>
<EuiFlexGroup
direction="column"
alignItems="center"
justifyContent="center"
gutterSize="s"
className={css`
height: 100%;
`}
>
<EuiFlexItem grow={false}>
<EuiIcon
size="xxl"
color="text"
type={actionTypeRegistry.get(actionType.id).iconClass}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="createConnectorButton"
iconType="plusInCircle"
iconSide="left"
onClick={() => setIsModalVisible(true)}
isLoading={false}
>
{i18n.CREATE_NEW_CONNECTOR_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
{isModalVisible && onConnectorSaved && (
<AddConnectorModal
actionTypeRegistry={actionTypeRegistry}
actionTypes={actionTypes}
onClose={onModalClose}
onSaveConnector={onConnectorSaved}
onSelectActionType={setSelectedActionType}
selectedActionType={selectedActionType}
/>
)}
</>
);
});
ConnectorSetup.displayName = 'ConnectorSetup';

View file

@ -1,59 +0,0 @@
/*
* 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, useState } from 'react';
import { css } from '@emotion/css';
import { EuiPopover, EuiLink, EuiText } from '@elastic/eui';
import { ConnectorSetup } from './connector_setup';
import * as i18n from './translations';
import { MissingPrivilegesTooltip } from './missing_privileges';
export interface CreateConnectorPopoverProps {
onConnectorSaved: () => void;
canCreateConnectors?: boolean;
}
export const CreateConnectorPopover = React.memo<CreateConnectorPopoverProps>(
({ onConnectorSaved, canCreateConnectors }) => {
const [isOpen, setIsPopoverOpen] = useState(false);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const onButtonClick = useCallback(
() => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen),
[]
);
if (!canCreateConnectors) {
return (
<MissingPrivilegesTooltip>
<EuiLink data-test-subj="createConnectorPopoverButton" onClick={onButtonClick} disabled>
{i18n.ASSISTANT_CARD_CREATE_NEW_CONNECTOR_POPOVER}
</EuiLink>
</MissingPrivilegesTooltip>
);
}
return (
<EuiPopover
className={css`
width: fit-content;
`}
button={
<EuiText size="s">
<EuiLink data-test-subj="createConnectorPopoverButton" onClick={onButtonClick}>
{i18n.ASSISTANT_CARD_CREATE_NEW_CONNECTOR_POPOVER}
</EuiLink>
</EuiText>
}
isOpen={isOpen}
closePopover={closePopover}
data-test-subj="createConnectorPopover"
>
<ConnectorSetup onConnectorSaved={onConnectorSaved} onClose={closePopover} compressed />
</EuiPopover>
);
}
);
CreateConnectorPopover.displayName = 'CreateConnectorPopover';

View file

@ -7,11 +7,11 @@
import { useMemo } from 'react';
import { useLoadActionTypes as loadActionTypes } from '@kbn/elastic-assistant/impl/connectorland/use_load_action_types';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { IToasts } from '@kbn/core-notifications-browser';
import { useAssistantContext } from '@kbn/elastic-assistant';
import { AIActionTypeIds } from '../constants';
export const useFilteredActionTypes = (http: HttpSetup, toasts: IToasts) => {
export const useFilteredActionTypes = () => {
const { http, toasts } = useAssistantContext();
const { data } = loadActionTypes({ http, toasts });
return useMemo(() => data?.filter(({ id }) => AIActionTypeIds.includes(id)), [data]);
};

View file

@ -7,10 +7,16 @@
import { i18n } from '@kbn/i18n';
export const ASSISTANT_CARD_CREATE_NEW_CONNECTOR_POPOVER = i18n.translate(
export const CREATE_NEW_CONNECTOR_BUTTON = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.createNewConnectorPopover',
{
defaultMessage: 'Create new connector',
defaultMessage: 'AI service provider',
}
);
export const SELECTED_PROVIDER = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.selectedProvider',
{
defaultMessage: 'Selected provider',
}
);
@ -21,6 +27,13 @@ export const PRIVILEGES_MISSING_TITLE = i18n.translate(
}
);
export const PRECONFIGURED_CONNECTOR = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.preconfiguredTitle',
{
defaultMessage: 'Preconfigured',
}
);
export const PRIVILEGES_REQUIRED_TITLE = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.requiredPrivileges',
{

View file

@ -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.
*/
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import type { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
export type AIConnector = ActionConnector & {
// related to OpenAI connectors, ex: Azure OpenAI, OpenAI
apiProvider?: OpenAiProviderType;
};

View file

@ -17,6 +17,7 @@ import { ConnectorCards } from '../../common/connectors/connector_cards';
import { CardSubduedText } from '../../common/card_subdued_text';
import type { AIConnectorCardMetadata } from './types';
import { MissingPrivilegesCallOut } from '../../common/connectors/missing_privileges';
import type { AIConnector } from '../../common/connectors/types';
export const AIConnectorCard: OnboardingCardComponent<AIConnectorCardMetadata> = ({
checkCompleteMetadata,
@ -24,13 +25,13 @@ export const AIConnectorCard: OnboardingCardComponent<AIConnectorCardMetadata> =
setComplete,
}) => {
const { siemMigrations } = useKibana().services;
const [storedConnectorId, setStoredConnectorId] = useDefinedLocalStorage<string | null>(
const [storedConnectorId, setStoredConnectorId] = useDefinedLocalStorage<string>(
siemMigrations.rules.connectorIdStorage.key,
null
''
);
const setSelectedConnectorId = useCallback(
(connectorId: string) => {
setStoredConnectorId(connectorId);
const setSelectedConnector = useCallback(
(connector: AIConnector) => {
setStoredConnectorId(connector.id);
setComplete(true);
},
[setComplete, setStoredConnectorId]
@ -57,9 +58,9 @@ export const AIConnectorCard: OnboardingCardComponent<AIConnectorCardMetadata> =
<ConnectorCards
canCreateConnectors={canCreateConnectors}
connectors={connectors}
onConnectorSaved={checkComplete}
onNewConnectorSaved={checkComplete}
selectedConnectorId={storedConnectorId}
setSelectedConnectorId={setSelectedConnectorId}
onConnectorSelected={setSelectedConnector}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -230,6 +230,7 @@
"@kbn/react-hooks",
"@kbn/index-adapter",
"@kbn/core-http-server-utils",
"@kbn/security-solution-connectors",
"@kbn/core-chrome-browser-mocks",
"@kbn/ai-assistant-icon",
"@kbn/llm-tasks-plugin",

View file

@ -7124,6 +7124,10 @@
version "0.0.0"
uid ""
"@kbn/security-solution-connectors@link:x-pack/solutions/security/packages/connectors":
version "0.0.0"
uid ""
"@kbn/security-solution-distribution-bar@link:x-pack/solutions/security/packages/distribution_bar":
version "0.0.0"
uid ""