diff --git a/x-pack/plugins/enterprise_search/common/types/connectors.ts b/x-pack/plugins/enterprise_search/common/types/connectors.ts new file mode 100644 index 0000000000000..9af170e4dbdcd --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/types/connectors.ts @@ -0,0 +1,10 @@ +/* + * 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 interface DeleteConnectorResponse { + acknowledge: boolean; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/delete_connector_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/delete_connector_api_logic.ts new file mode 100644 index 0000000000000..a427b634c3b6e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/delete_connector_api_logic.ts @@ -0,0 +1,44 @@ +/* + * 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 { DeleteConnectorResponse } from '../../../../../common/types/connectors'; + +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface DeleteConnectorApiLogicArgs { + connectorId: string; + shouldDeleteIndex: boolean; +} + +export interface DeleteConnectorApiLogicResponse { + acknowledged: boolean; +} + +export const deleteConnector = async ({ + connectorId, + shouldDeleteIndex = false, +}: DeleteConnectorApiLogicArgs) => { + return await HttpLogic.values.http.delete( + `/internal/enterprise_search/connectors/${connectorId}`, + { + query: { + shouldDeleteIndex, + }, + } + ); +}; + +export const DeleteConnectorApiLogic = createApiLogic( + ['delete_connector_api_logic'], + deleteConnector +); + +export type DeleteConnectorApiLogicActions = Actions< + DeleteConnectorApiLogicArgs, + DeleteConnectorResponse +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx index 0d797329093c8..3519fc9e16086 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx @@ -42,6 +42,7 @@ import { ConnectorStats } from './connector_stats'; import { ConnectorsLogic } from './connectors_logic'; import { ConnectorsTable } from './connectors_table'; import { CrawlerEmptyState } from './crawler_empty_state'; +import { DeleteConnectorModal } from './delete_connector_modal'; export const baseBreadcrumbs = [ i18n.translate('xpack.enterpriseSearch.content.connectors.breadcrumb', { @@ -53,7 +54,8 @@ export interface ConnectorsProps { isCrawler: boolean; } export const Connectors: React.FC = ({ isCrawler }) => { - const { fetchConnectors, onPaginate, setIsFirstRequest } = useActions(ConnectorsLogic); + const { fetchConnectors, onPaginate, setIsFirstRequest, openDeleteModal } = + useActions(ConnectorsLogic); const { data, isLoading, searchParams, isEmpty, connectors } = useValues(ConnectorsLogic); const { errorConnectingMessage } = useValues(HttpLogic); const [searchQuery, setSearchValue] = useState(''); @@ -69,151 +71,158 @@ export const Connectors: React.FC = ({ isCrawler }) => { return !isLoading && isEmpty && !isCrawler ? ( ) : ( - { - KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_PATH); - }} - > - - , - { - KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_NATIVE_PATH); - }} - > - {i18n.translate('xpack.enterpriseSearch.connectors.newNativeConnectorButtonLabel', { - defaultMessage: 'New Native Connector', - })} - , - { - KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_CLIENTS_PATH); - }} - > - {i18n.translate( - 'xpack.enterpriseSearch.connectors.newConnectorsClientButtonLabel', - { defaultMessage: 'New Connector Client' } - )} - , - ] - : [ - { - KibanaLogic.values.navigateToUrl( - generateEncodedPath(NEW_INDEX_METHOD_PATH, { - type: INGESTION_METHOD_IDS.CRAWLER, - }) - ); - }} - > - {i18n.translate('xpack.enterpriseSearch.connectors.newCrawlerButtonLabel', { - defaultMessage: 'New web crawler', - })} - , - ], - }} - > - {Boolean(errorConnectingMessage) && ( - <> - - - - )} - - - - - {isEmpty && isCrawler ? ( - - ) : ( - <> - - -

- {!isCrawler ? ( - - ) : ( - + <> + + { + KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_PATH); + }} + > + + , + { + KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_NATIVE_PATH); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.connectors.newNativeConnectorButtonLabel', + { + defaultMessage: 'New Native Connector', + } )} -

-
-
- - setSearchValue(event.queryText)} - /> - - + , + { + KibanaLogic.values.navigateToUrl(NEW_INDEX_SELECT_CONNECTOR_CLIENTS_PATH); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.connectors.newConnectorsClientButtonLabel', + { defaultMessage: 'New Connector Client' } + )} + , + ] + : [ + { + KibanaLogic.values.navigateToUrl( + generateEncodedPath(NEW_INDEX_METHOD_PATH, { + type: INGESTION_METHOD_IDS.CRAWLER, + }) + ); + }} + > + {i18n.translate('xpack.enterpriseSearch.connectors.newCrawlerButtonLabel', { + defaultMessage: 'New web crawler', + })} + , + ], + }} + > + {Boolean(errorConnectingMessage) && ( + <> + + )} -
-
+ + + + + {isEmpty && isCrawler ? ( + + ) : ( + <> + + +

+ {!isCrawler ? ( + + ) : ( + + )} +

+
+
+ + setSearchValue(event.queryText)} + /> + + + + )} +
+ + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_logic.ts index d4b6925ddade3..332b20d84928e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_logic.ts @@ -12,6 +12,10 @@ import { Connector } from '@kbn/search-connectors/types'; import { Status } from '../../../../../common/types/api'; import { Meta } from '../../../../../common/types/pagination'; +import { + DeleteConnectorApiLogic, + DeleteConnectorApiLogicActions, +} from '../../api/connector/delete_connector_api_logic'; import { FetchConnectorsApiLogic, FetchConnectorsApiLogicActions, @@ -21,6 +25,10 @@ export type ConnectorViewItem = Connector & { docsCount?: number }; export interface ConnectorsActions { apiError: FetchConnectorsApiLogicActions['apiError']; apiSuccess: FetchConnectorsApiLogicActions['apiSuccess']; + closeDeleteModal(): void; + deleteConnector: DeleteConnectorApiLogicActions['makeRequest']; + deleteError: DeleteConnectorApiLogicActions['apiError']; + deleteSuccess: DeleteConnectorApiLogicActions['apiSuccess']; fetchConnectors({ fetchCrawlersOnly, from, @@ -39,11 +47,26 @@ export interface ConnectorsActions { }; makeRequest: FetchConnectorsApiLogicActions['makeRequest']; onPaginate(newPageIndex: number): { newPageIndex: number }; + openDeleteModal( + connectorName: string, + connectorId: string, + indexName: string | null + ): { + connectorId: string; + connectorName: string; + indexName: string | null; + }; setIsFirstRequest(): void; } export interface ConnectorsValues { connectors: ConnectorViewItem[]; data: typeof FetchConnectorsApiLogic.values.data; + deleteModalConnectorId: string; + deleteModalConnectorName: string; + deleteModalIndexName: string | null; + deleteStatus: typeof DeleteConnectorApiLogic.values.status; + isDeleteLoading: boolean; + isDeleteModalVisible: boolean; isEmpty: boolean; isFetchConnectorsDetailsLoading: boolean; isFirstRequest: boolean; @@ -60,6 +83,7 @@ export interface ConnectorsValues { export const ConnectorsLogic = kea>({ actions: { + closeDeleteModal: true, fetchConnectors: ({ fetchCrawlersOnly, from, size, searchQuery }) => ({ fetchCrawlersOnly, from, @@ -67,13 +91,32 @@ export const ConnectorsLogic = kea ({ newPageIndex }), + openDeleteModal: (connectorName, connectorId, indexName) => ({ + connectorId, + connectorName, + indexName, + }), setIsFirstRequest: true, }, connect: { - actions: [FetchConnectorsApiLogic, ['makeRequest', 'apiSuccess', 'apiError']], - values: [FetchConnectorsApiLogic, ['data', 'status']], + actions: [ + DeleteConnectorApiLogic, + ['apiError as deleteError', 'apiSuccess as deleteSuccess', 'makeRequest as deleteConnector'], + FetchConnectorsApiLogic, + ['makeRequest', 'apiSuccess', 'apiError'], + ], + values: [ + DeleteConnectorApiLogic, + ['status as deleteStatus'], + FetchConnectorsApiLogic, + ['data', 'status'], + ], }, - listeners: ({ actions }) => ({ + listeners: ({ actions, values }) => ({ + deleteSuccess: () => { + actions.closeDeleteModal(); + actions.makeRequest(values.searchParams); + }, fetchConnectors: async (input, breakpoint) => { await breakpoint(150); actions.makeRequest(input); @@ -81,6 +124,34 @@ export const ConnectorsLogic = kea ({ + deleteModalConnectorId: [ + '', + { + closeDeleteModal: () => '', + openDeleteModal: (_, { connectorId }) => connectorId, + }, + ], + deleteModalConnectorName: [ + '', + { + closeDeleteModal: () => '', + openDeleteModal: (_, { connectorName }) => connectorName, + }, + ], + deleteModalIndexName: [ + null, + { + closeDeleteModal: () => null, + openDeleteModal: (_, { indexName }) => indexName, + }, + ], + isDeleteModalVisible: [ + false, + { + closeDeleteModal: () => false, + openDeleteModal: () => true, + }, + ], isFirstRequest: [ true, { @@ -128,6 +199,10 @@ export const ConnectorsLogic = kea [selectors.deleteStatus], + (deleteStatus) => Status.LOADING === deleteStatus, + ], isEmpty: [ () => [selectors.data], (data) => diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx index 3f58137b5dbd9..2c675c362b00e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx @@ -41,6 +41,7 @@ interface ConnectorsTableProps { items: ConnectorViewItem[]; meta?: Meta; onChange: (criteria: CriteriaWithPagination) => void; + onDelete: (connectorName: string, connectorId: string, indexName: string | null) => void; } export const ConnectorsTable: React.FC = ({ items, @@ -53,6 +54,7 @@ export const ConnectorsTable: React.FC = ({ }, onChange, isLoading, + onDelete, }) => { const { navigateToUrl } = useValues(KibanaLogic); const columns: Array> = [ @@ -122,6 +124,23 @@ export const ConnectorsTable: React.FC = ({ }, { actions: [ + { + description: 'Delete this connector', + icon: 'trash', + isPrimary: false, + name: (connector) => + i18n.translate( + 'xpack.enterpriseSearch.content.connectors.connectorTable.column.actions.deleteIndex', + { + defaultMessage: 'Delete connector {connectorName}', + values: { connectorName: connector.name }, + } + ), + onClick: (connector) => { + onDelete(connector.name, connector.id, connector.index_name); + }, + type: 'icon', + }, { description: i18n.translate( 'xpack.enterpriseSearch.content.connectors.connectorTable.columns.actions.viewIndex', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/delete_connector_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/delete_connector_modal.tsx new file mode 100644 index 0000000000000..1a4236f59a798 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/delete_connector_modal.tsx @@ -0,0 +1,158 @@ +/* + * 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, { useState, useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiCheckbox, + EuiConfirmModal, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTextColor, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import { ConnectorsLogic } from './connectors_logic'; + +export const DeleteConnectorModal: React.FC = () => { + const { closeDeleteModal, deleteConnector } = useActions(ConnectorsLogic); + const { + deleteModalConnectorId: connectorId, + deleteModalConnectorName: connectorName, + deleteModalIndexName, + isDeleteLoading, + isDeleteModalVisible, + } = useValues(ConnectorsLogic); + + const [inputConnectorName, setInputConnectorName] = useState(''); + const [shouldDeleteIndex, setShouldDeleteIndex] = useState(false); + + useEffect(() => { + setShouldDeleteIndex(false); + setInputConnectorName(''); + }, [isDeleteModalVisible]); + + return isDeleteModalVisible ? ( + { + closeDeleteModal(); + }} + onConfirm={() => { + deleteConnector({ + connectorId, + shouldDeleteIndex, + }); + }} + cancelButtonText={ + isDeleteLoading + ? i18n.translate( + 'xpack.enterpriseSearch.content.connectors.deleteModal.closeButton.title', + { + defaultMessage: 'Close', + } + ) + : i18n.translate( + 'xpack.enterpriseSearch.content.connectors.deleteModal.cancelButton.title', + { + defaultMessage: 'Cancel', + } + ) + } + confirmButtonText={i18n.translate( + 'xpack.enterpriseSearch.content.connectors.deleteModal.confirmButton.title', + { + defaultMessage: 'Delete index', + } + )} + defaultFocusedButton="confirm" + buttonColor="danger" + confirmButtonDisabled={inputConnectorName.trim() !== connectorName} + isLoading={isDeleteLoading} + > +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.connectors.deleteModal.delete.description', + { + defaultMessage: 'You are about to delete this connector:', + } + )} +

+

+

    +
  • + +
  • +
+

+

+ + + {connectorName} + + ), + }} + /> + +

+ {deleteModalIndexName && ( + <> + setShouldDeleteIndex(!shouldDeleteIndex)} + /> + + + )} + + + setInputConnectorName(e.target.value)} + value={inputConnectorName} + /> + + +
+ ) : ( + <> + ); +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts index cc071d41ccd47..e1b5b399dc3a0 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts @@ -8,6 +8,8 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { + deleteConnectorById, + fetchConnectorById, fetchConnectors, fetchSyncJobsByConnectorId, putUpdateNative, @@ -22,11 +24,14 @@ import { import { ConnectorStatus, FilteringRule, SyncJobType } from '@kbn/search-connectors'; import { cancelSyncs } from '@kbn/search-connectors/lib/cancel_syncs'; +import { isResourceNotFoundException } from '@kbn/search-connectors/utils/identify_exceptions'; import { ErrorCode } from '../../../common/types/error_codes'; import { addConnector } from '../../lib/connectors/add_connector'; import { startSync } from '../../lib/connectors/start_sync'; +import { deleteAccessControlIndex } from '../../lib/indices/delete_access_control_index'; import { fetchIndexCounts } from '../../lib/indices/fetch_index_counts'; +import { deleteIndexPipelines } from '../../lib/pipelines/delete_pipelines'; import { getDefaultPipeline } from '../../lib/pipelines/get_default_pipeline'; import { updateDefaultPipeline } from '../../lib/pipelines/update_default_pipeline'; import { updateConnectorPipeline } from '../../lib/pipelines/update_pipeline'; @@ -37,6 +42,7 @@ import { elasticsearchErrorHandler } from '../../utils/elasticsearch_error_handl import { isAccessControlDisabledException, isExpensiveQueriesNotAllowedException, + isIndexNotFoundException, } from '../../utils/identify_exceptions'; export function registerConnectorRoutes({ router, log }: RouteDependencies) { @@ -539,4 +545,66 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { }); }) ); + + router.delete( + { + path: '/internal/enterprise_search/connectors/{connectorId}', + validate: { + params: schema.object({ + connectorId: schema.string(), + }), + query: schema.object({ + shouldDeleteIndex: schema.maybe(schema.boolean()), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + const { connectorId } = request.params; + const { shouldDeleteIndex } = request.query; + + let connectorResponse; + let indexNameToDelete; + try { + if (shouldDeleteIndex) { + const connector = await fetchConnectorById(client.asCurrentUser, connectorId); + indexNameToDelete = connector?.value.index_name; + } + connectorResponse = await deleteConnectorById(client.asCurrentUser, connectorId); + if (indexNameToDelete) { + await deleteIndexPipelines(client, indexNameToDelete); + await deleteAccessControlIndex(client, indexNameToDelete); + await client.asCurrentUser.indices.delete({ index: indexNameToDelete }); + } + } catch (error) { + if (isResourceNotFoundException(error)) { + return createError({ + errorCode: ErrorCode.RESOURCE_NOT_FOUND, + message: i18n.translate( + 'xpack.enterpriseSearch.server.routes.connectors.resource_not_found_error', + { + defaultMessage: 'Connector with id {connectorId} is not found.', + values: { connectorId }, + } + ), + response, + statusCode: 404, + }); + } + + if (isIndexNotFoundException(error)) { + return createError({ + errorCode: ErrorCode.INDEX_NOT_FOUND, + message: 'Could not find index', + response, + statusCode: 404, + }); + } + + throw error; + } + + return response.ok({ body: connectorResponse }); + }) + ); }