diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 1dbe8477b..095abc358 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -18,43 +18,41 @@ import ( "github.com/rs/zerolog/log" ) -type DeleteMultiplePayload struct { - Endpoints []struct { - ID int `json:"id"` - Name string `json:"name"` - DeleteCluster bool `json:"deleteCluster"` - } `json:"environments"` +type endpointDeleteRequest struct { + ID int `json:"id"` + DeleteCluster bool `json:"deleteCluster"` } -func (payload *DeleteMultiplePayload) Validate(r *http.Request) error { +type endpointDeleteBatchPayload struct { + Endpoints []endpointDeleteRequest `json:"endpoints"` +} + +type endpointDeleteBatchPartialResponse struct { + Deleted []int `json:"deleted"` + Errors []int `json:"errors"` +} + +func (payload *endpointDeleteBatchPayload) Validate(r *http.Request) error { if payload == nil || len(payload.Endpoints) == 0 { - return fmt.Errorf("invalid request payload; you must provide a list of nodes to delete") + return fmt.Errorf("invalid request payload. You must provide a list of environments to delete") } return nil } -type DeleteMultipleResp struct { - Name string `json:"name"` - Err error `json:"err"` -} - // @id EndpointDelete -// @summary Remove an environment(endpoint) -// @description Remove an environment(endpoint). -// @description **Access policy**: administrator +// @summary Remove an environment +// @description Remove the environment associated to the specified identifier and optionally clean-up associated resources. +// @description **Access policy**: Administrator only. // @tags endpoints -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @param id path int true "Environment(Endpoint) identifier" -// @success 204 "Success" -// @failure 400 "Invalid request" -// @failure 403 "Permission denied" -// @failure 404 "Environment(Endpoint) not found" -// @failure 500 "Server error" +// @success 204 "Environment successfully deleted." +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 404 "Unable to find the environment with the specified identifier inside the database." +// @failure 500 "Server error occurred while attempting to delete the environment." // @router /endpoints/{id} [delete] -// @deprecated -// Deprecated: use endpointDeleteMultiple instead. func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { @@ -86,58 +84,61 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * return response.Empty(w) } -// @id EndpointDeleteMultiple -// @summary Remove multiple environment(endpoint)s -// @description Remove multiple environment(endpoint)s. -// @description **Access policy**: administrator +// @id EndpointDeleteBatch +// @summary Remove multiple environments +// @description Remove multiple environments and optionally clean-up associated resources. +// @description **Access policy**: Administrator only. // @tags endpoints -// @security ApiKeyAuth -// @security jwt +// @security ApiKeyAuth || jwt // @accept json // @produce json -// @param body body DeleteMultiplePayload true "List of endpoints to delete" -// @success 204 "Success" -// @failure 400 "Invalid request" -// @failure 403 "Permission denied" -// @failure 404 "Environment(Endpoint) not found" -// @failure 500 "Server error" -// @router /endpoints/remove [post] -func (handler *Handler) endpointDeleteMultiple(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var p DeleteMultiplePayload +// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up assocaited resources (cloud environments only)" +// @success 204 "Environment(s) successfully deleted." +// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed." +// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." +// @failure 403 "Unauthorized access or operation not allowed." +// @failure 500 "Server error occurred while attempting to delete the specified environments." +// @router /endpoints [delete] +func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var p endpointDeleteBatchPayload if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil { return httperror.BadRequest("Invalid request payload", err) } - var resps []DeleteMultipleResp + resp := endpointDeleteBatchPartialResponse{ + Deleted: []int{}, + Errors: []int{}, + } - err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { for _, e := range p.Endpoints { - // Demo endpoints cannot be deleted. if handler.demoService.IsDemoEnvironment(portainer.EndpointID(e.ID)) { - resps = append(resps, DeleteMultipleResp{ - Name: e.Name, - Err: httperrors.ErrNotAvailableInDemo, - }) + resp.Errors = append(resp.Errors, e.ID) + log.Warn().Err(httperrors.ErrNotAvailableInDemo).Msgf("Unable to remove demo environment %d", e.ID) + continue } - // Attempt deletion. - err := handler.deleteEndpoint( - tx, - portainer.EndpointID(e.ID), - e.DeleteCluster, - ) + if err := handler.deleteEndpoint(tx, portainer.EndpointID(e.ID), e.DeleteCluster); err != nil { + resp.Errors = append(resp.Errors, e.ID) + log.Warn().Err(err).Int("environment_id", e.ID).Msg("Unable to remove environment") - resps = append(resps, DeleteMultipleResp{Name: e.Name, Err: err}) + continue + } + + resp.Deleted = append(resp.Deleted, e.ID) } return nil - }) - if err != nil { + }); err != nil { return httperror.InternalServerError("Unable to delete environments", err) } - return response.JSON(w, resps) + if len(resp.Errors) > 0 { + return response.JSONWithStatus(w, resp, http.StatusPartialContent) + } + + return response.Empty(w) } func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error { diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 7ead93762..4964104a6 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -71,8 +71,8 @@ func NewHandler(bouncer security.BouncerService, demoService *demo.Service) *Han bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) h.Handle("/endpoints/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) - h.Handle("/endpoints/remove", - bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteMultiple))).Methods(http.MethodPost) + h.Handle("/endpoints", + bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete) h.Handle("/endpoints/{id}/dockerhub/{registryId}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet) h.Handle("/endpoints/{id}/snapshot", diff --git a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts index 07900cfd6..4f679216c 100644 --- a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts +++ b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts @@ -18,30 +18,32 @@ export function useDeleteEnvironmentsMutation() { deleteCluster?: boolean; }[] ) => { - const resps = await deleteEnvironments(environments); - const successfulDeletions = resps.filter((r) => r.err === null); - const failedDeletions = resps.filter((r) => r.err !== null); - return { successfulDeletions, failedDeletions }; + const resp = await deleteEnvironments(environments); + + if (resp === null) { + return { deleted: environments, errors: [] }; + } + + return { + deleted: environments.filter((e) => + (resp.deleted || []).includes(e.id) + ), + errors: environments.filter((e) => (resp.errors || []).includes(e.id)), + }; }, { ...withError('Unable to delete environment(s)'), - onSuccess: ({ successfulDeletions, failedDeletions }) => { + onSuccess: ({ deleted, errors }) => { queryClient.invalidateQueries(['environments']); // show an error message for each env that failed to delete - failedDeletions.forEach((deletion) => { - notifyError( - `Failed to remove environment`, - new Error(deletion.err ? deletion.err.Message : '') as Error - ); + errors.forEach((e) => { + notifyError(`Failed to remove environment ${e.name}`, undefined); }); // show one summary message for all successful deletes - if (successfulDeletions.length) { + if (deleted.length) { notifySuccess( - `${pluralize( - successfulDeletions.length, - 'Environment' - )} successfully removed`, - successfulDeletions.map((deletion) => deletion.name).join(', ') + `${pluralize(deleted.length, 'Environment')} successfully removed`, + deleted.map((d) => d.name).join(', ') ); } }, @@ -53,10 +55,11 @@ async function deleteEnvironments( environments: { id: EnvironmentId; deleteCluster?: boolean }[] ) { try { - const { data } = await axios.post< - { name: string; err: { Message: string } | null }[] - >(buildUrl(undefined, 'remove'), { - environments, + const { data } = await axios.delete<{ + deleted: EnvironmentId[]; + errors: EnvironmentId[]; + } | null>(buildUrl(), { + data: { endpoints: environments }, }); return data; } catch (e) {