diff --git a/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts b/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts index ae2b285c91c53..ed4bd0a42d38e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts @@ -9,3 +9,5 @@ export const UIM_APP_NAME = 'ingest_pipelines'; export const UIM_PIPELINES_LIST_LOAD = 'pipelines_list_load'; export const UIM_PIPELINE_CREATE = 'pipeline_create'; export const UIM_PIPELINE_UPDATE = 'pipeline_update'; +export const UIM_PIPELINE_DELETE = 'pipeline_delete'; +export const UIM_PIPELINE_DELETE_MANY = 'pipeline_delete_many'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/index.tsx b/x-pack/plugins/ingest_pipelines/public/application/index.tsx index 914a4b3c57e70..778ce0c873e66 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/index.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/index.tsx @@ -6,6 +6,7 @@ import React, { ReactNode } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; +import { NotificationsSetup } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { App } from './app'; @@ -16,6 +17,7 @@ export interface AppServices { metric: UiMetricService; documentation: DocumentationService; api: ApiService; + notifications: NotificationsSetup; } export const renderApp = ( diff --git a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts index 51db91295ed42..9b950a54096c3 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts @@ -28,6 +28,7 @@ export async function mountManagementSection( metric: uiMetricService, documentation: documentationService, api: apiService, + notifications: coreSetup.notifications, }; return renderApp(element, I18nContext, services); diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx new file mode 100644 index 0000000000000..c7736a6c19ba1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useKibana } from '../../../shared_imports'; + +export const PipelineDeleteModal = ({ + pipelinesToDelete, + callback, +}: { + pipelinesToDelete: string[]; + callback: (data?: { hasDeletedPipelines: boolean }) => void; +}) => { + const { services } = useKibana(); + + const numPipelinesToDelete = pipelinesToDelete.length; + + const handleDeletePipelines = () => { + services.api + .deletePipelines(pipelinesToDelete) + .then(({ data: { itemsDeleted, errors }, error }) => { + const hasDeletedPipelines = itemsDeleted && itemsDeleted.length; + + if (hasDeletedPipelines) { + const successMessage = + itemsDeleted.length === 1 + ? i18n.translate( + 'xpack.ingestPipelines.deleteModal.successDeleteSingleNotificationMessageText', + { + defaultMessage: "Deleted pipeline '{pipelineName}'", + values: { pipelineName: pipelinesToDelete[0] }, + } + ) + : i18n.translate( + 'xpack.ingestPipelines.deleteModal.successDeleteMultipleNotificationMessageText', + { + defaultMessage: + 'Deleted {numSuccesses, plural, one {# pipeline} other {# pipelines}}', + values: { numSuccesses: itemsDeleted.length }, + } + ); + + callback({ hasDeletedPipelines }); + services.notifications.toasts.addSuccess(successMessage); + } + + if (error || errors?.length) { + const hasMultipleErrors = errors?.length > 1 || (error && pipelinesToDelete.length > 1); + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.ingestPipelines.deleteModal.multipleErrorsNotificationMessageText', + { + defaultMessage: 'Error deleting {count} pipelines', + values: { + count: errors?.length || pipelinesToDelete.length, + }, + } + ) + : i18n.translate('xpack.ingestPipelines.deleteModal.errorNotificationMessageText', { + defaultMessage: "Error deleting pipeline '{name}'", + values: { name: (errors && errors[0].name) || pipelinesToDelete[0] }, + }); + services.notifications.toasts.addDanger(errorMessage); + } + }); + }; + + const handleOnCancel = () => { + callback(); + }; + + return ( + + + } + onCancel={handleOnCancel} + onConfirm={handleDeletePipelines} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + <> +

+ +

+ +
    + {pipelinesToDelete.map(name => ( +
  • {name}
  • + ))} +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details.tsx index 798b9153a1644..2258966cfeb5f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details.tsx @@ -25,7 +25,7 @@ import { PipelineDetailsJsonBlock } from './details_json_block'; export interface Props { pipeline: Pipeline; onEditClick: (pipelineName: string) => void; - onDeleteClick: () => void; + onDeleteClick: (pipelineName: string[]) => void; onClose: () => void; } @@ -116,7 +116,7 @@ export const PipelineDetails: FunctionComponent = ({ - + onDeleteClick([pipeline.name])}> {i18n.translate('xpack.ingestPipelines.list.pipelineDetails.deleteButtonLabel', { defaultMessage: 'Delete', })} diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index 311c1c9d4c9e7..ca4892fe281c2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -29,11 +29,13 @@ import { UIM_PIPELINES_LIST_LOAD } from '../../constants'; import { EmptyList } from './empty_list'; import { PipelineTable } from './table'; import { PipelineDetails } from './details'; +import { PipelineDeleteModal } from './delete_modal'; export const PipelinesList: React.FunctionComponent = ({ history }) => { const { services } = useKibana(); const [selectedPipeline, setSelectedPipeline] = useState(undefined); + const [pipelinesToDelete, setPipelinesToDelete] = useState([]); // Track component loaded useEffect(() => { @@ -63,7 +65,7 @@ export const PipelinesList: React.FunctionComponent = ({ hi {}} + onDeletePipelineClick={setPipelinesToDelete} onViewPipelineClick={setSelectedPipeline} pipelines={data} /> @@ -128,10 +130,23 @@ export const PipelinesList: React.FunctionComponent = ({ hi setSelectedPipeline(undefined)} - onDeleteClick={() => {}} + onDeleteClick={setPipelinesToDelete} onEditClick={editPipeline} /> )} + {pipelinesToDelete?.length > 0 ? ( + { + if (deleteResponse?.hasDeletedPipelines) { + // reload pipelines list + sendRequest(); + } + setPipelinesToDelete([]); + setSelectedPipeline(undefined); + }} + pipelinesToDelete={pipelinesToDelete} + /> + ) : null} ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx index 45f539007cde3..01b05eace3b60 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx @@ -3,8 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiInMemoryTable, EuiLink, EuiButton } from '@elastic/eui'; import { BASE_PATH } from '../../../../common/constants'; @@ -13,8 +14,8 @@ import { Pipeline } from '../../../../common/types'; export interface Props { pipelines: Pipeline[]; onReloadClick: () => void; - onEditPipelineClick: (pipeineName: string) => void; - onDeletePipelineClick: (pipeline: Pipeline) => void; + onEditPipelineClick: (pipelineName: string) => void; + onDeletePipelineClick: (pipelineName: string[]) => void; onViewPipelineClick: (pipeline: Pipeline) => void; } @@ -25,9 +26,32 @@ export const PipelineTable: FunctionComponent = ({ onDeletePipelineClick, onViewPipelineClick, }) => { + const [selection, setSelection] = useState([]); + return ( 0 ? ( + onDeletePipelineClick(selection.map(pipeline => pipeline.name))} + color="danger" + > + + + ) : ( + undefined + ), toolsRight: [ = ({ name: i18n.translate('xpack.ingestPipelines.list.table.nameColumnTitle', { defaultMessage: 'Name', }), - render: (name: any, pipeline) => ( + render: (name: string, pipeline) => ( onViewPipelineClick(pipeline)}>{name} ), }, @@ -98,7 +122,7 @@ export const PipelineTable: FunctionComponent = ({ type: 'icon', icon: 'trash', color: 'danger', - onClick: onDeletePipelineClick, + onClick: ({ name }) => onDeletePipelineClick([name]), }, ], }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts index 48b925b02eeb4..42a157705baa7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts @@ -15,7 +15,12 @@ import { useRequest as _useRequest, } from '../../shared_imports'; import { UiMetricService } from './ui_metric'; -import { UIM_PIPELINE_CREATE, UIM_PIPELINE_UPDATE } from '../constants'; +import { + UIM_PIPELINE_CREATE, + UIM_PIPELINE_UPDATE, + UIM_PIPELINE_DELETE, + UIM_PIPELINE_DELETE_MANY, +} from '../constants'; export class ApiService { private client: HttpSetup | undefined; @@ -87,6 +92,17 @@ export class ApiService { return result; } + + public async deletePipelines(names: string[]) { + const result = this.sendRequest({ + path: `${API_BASE_PATH}/${names.map(name => encodeURIComponent(name)).join(',')}`, + method: 'delete', + }); + + this.trackUiMetric(names.length > 1 ? UIM_PIPELINE_DELETE_MANY : UIM_PIPELINE_DELETE); + + return result; + } } export const apiService = new ApiService(); diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts new file mode 100644 index 0000000000000..4664b49a08a50 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; + +const paramsSchema = schema.object({ + names: schema.string(), +}); + +export const registerDeleteRoute = ({ router, license }: RouteDependencies): void => { + router.delete( + { + path: `${API_BASE_PATH}/{names}`, + validate: { + params: paramsSchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.dataClient; + const { names } = req.params; + const pipelineNames = names.split(','); + + const response: { itemsDeleted: string[]; errors: any[] } = { + itemsDeleted: [], + errors: [], + }; + + await Promise.all( + pipelineNames.map(pipelineName => { + return callAsCurrentUser('ingest.deletePipeline', { id: pipelineName }) + .then(() => response.itemsDeleted.push(pipelineName)) + .catch(e => + response.errors.push({ + name: pipelineName, + error: e, + }) + ); + }) + ); + + return res.ok({ body: response }); + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts index 0d40d17205eed..9992f56512c01 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts @@ -9,3 +9,5 @@ export { registerGetRoutes } from './get'; export { registerCreateRoute } from './create'; export { registerUpdateRoute } from './update'; + +export { registerDeleteRoute } from './delete'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/index.ts index d217fb937778c..419525816f217 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/index.ts @@ -6,12 +6,18 @@ import { RouteDependencies } from '../types'; -import { registerGetRoutes, registerCreateRoute, registerUpdateRoute } from './api'; +import { + registerGetRoutes, + registerCreateRoute, + registerUpdateRoute, + registerDeleteRoute, +} from './api'; export class ApiRoutes { setup(dependencies: RouteDependencies) { registerGetRoutes(dependencies); registerCreateRoute(dependencies); registerUpdateRoute(dependencies); + registerDeleteRoute(dependencies); } } diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index 41f285938c003..a1773a052ede2 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -185,5 +185,104 @@ export default function({ getService }: FtrProviderContext) { }); }); }); + + describe('Delete', () => { + const PIPELINE = { + description: 'test pipeline description', + processors: [ + { + script: { + source: 'ctx._type = null', + }, + }, + ], + version: 1, + }; + + it('should delete a pipeline', async () => { + // Create pipeline to be deleted + const PIPELINE_ID = 'test_delete_pipeline'; + createPipeline({ body: PIPELINE, id: PIPELINE_ID }); + + const uri = `${API_BASE_PATH}/${PIPELINE_ID}`; + + const { body } = await supertest + .delete(uri) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(body).to.eql({ + itemsDeleted: [PIPELINE_ID], + errors: [], + }); + }); + + it('should delete multiple pipelines', async () => { + // Create pipelines to be deleted + const PIPELINE_ONE_ID = 'test_delete_pipeline_1'; + const PIPELINE_TWO_ID = 'test_delete_pipeline_2'; + createPipeline({ body: PIPELINE, id: PIPELINE_ONE_ID }); + createPipeline({ body: PIPELINE, id: PIPELINE_TWO_ID }); + + const uri = `${API_BASE_PATH}/${PIPELINE_ONE_ID},${PIPELINE_TWO_ID}`; + + const { + body: { itemsDeleted, errors }, + } = await supertest + .delete(uri) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(errors).to.eql([]); + + // The itemsDeleted array order isn't guaranteed, so we assert against each pipeline name instead + [PIPELINE_ONE_ID, PIPELINE_TWO_ID].forEach(pipelineName => { + expect(itemsDeleted.includes(pipelineName)).to.be(true); + }); + }); + + it('should return an error for any pipelines not sucessfully deleted', async () => { + const PIPELINE_DOES_NOT_EXIST = 'pipeline_does_not_exist'; + + // Create pipeline to be deleted + const PIPELINE_ONE_ID = 'test_delete_pipeline_1'; + createPipeline({ body: PIPELINE, id: PIPELINE_ONE_ID }); + + const uri = `${API_BASE_PATH}/${PIPELINE_ONE_ID},${PIPELINE_DOES_NOT_EXIST}`; + + const { body } = await supertest + .delete(uri) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(body).to.eql({ + itemsDeleted: [PIPELINE_ONE_ID], + errors: [ + { + name: PIPELINE_DOES_NOT_EXIST, + error: { + msg: '[resource_not_found_exception] pipeline [pipeline_does_not_exist] is missing', + path: '/_ingest/pipeline/pipeline_does_not_exist', + query: {}, + statusCode: 404, + response: JSON.stringify({ + error: { + root_cause: [ + { + type: 'resource_not_found_exception', + reason: 'pipeline [pipeline_does_not_exist] is missing', + }, + ], + type: 'resource_not_found_exception', + reason: 'pipeline [pipeline_does_not_exist] is missing', + }, + status: 404, + }), + }, + }, + ], + }); + }); + }); }); }