diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index bcfd77ecb..1755c29bc 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -2,6 +2,7 @@ package endpoints import ( "errors" + "fmt" "net/http" "slices" "strconv" @@ -17,6 +18,26 @@ 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"` +} + +func (payload *DeleteMultiplePayload) 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 nil +} + +type DeleteMultipleResp struct { + Name string `json:"name"` + Err error `json:"err"` +} + // @id EndpointDelete // @summary Remove an environment(endpoint) // @description Remove an environment(endpoint). @@ -31,6 +52,7 @@ import ( // @failure 404 "Environment(Endpoint) not found" // @failure 500 "Server error" // @router /endpoints/{id} [delete] +// @deprecated func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { @@ -62,6 +84,53 @@ 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 +// @tags endpoints +// @security ApiKeyAuth +// @security 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 + err := request.DecodeAndValidateJSONPayload(r, &p) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + var resps []DeleteMultipleResp + 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, + }) + continue + } + + // Attempt deletion. + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return handler.deleteEndpoint( + tx, + portainer.EndpointID(e.ID), + e.DeleteCluster, + ) + }) + resps = append(resps, DeleteMultipleResp{Name: e.Name, Err: err}) + } + return response.JSON(w, resps) +} + func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error { endpoint, err := tx.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if tx.IsErrObjectNotFound(err) { diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 23bff58af..7ead93762 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -71,6 +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/{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/ListView.tsx b/app/react/portainer/environments/ListView/ListView.tsx index 94f01b591..f3801d083 100644 --- a/app/react/portainer/environments/ListView/ListView.tsx +++ b/app/react/portainer/environments/ListView/ListView.tsx @@ -1,8 +1,6 @@ import { useStore } from 'zustand'; -import _ from 'lodash'; import { environmentStore } from '@/react/hooks/current-environment-store'; -import { notifySuccess } from '@/portainer/services/notifications'; import { PageHeader } from '@@/PageHeader'; import { confirmDelete } from '@@/modals/confirm'; @@ -44,15 +42,11 @@ export function ListView() { } deletionMutation.mutate( - environments.map((e) => e.Id), - { - onSuccess() { - notifySuccess( - 'Environments successfully removed', - _.map(environments, 'Name').join(', ') - ); - }, - } + environments.map((e) => ({ + id: e.Id, + deleteCluster: false, + name: e.Name, + })) ); } } diff --git a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts index 8c6df6c99..07900cfd6 100644 --- a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts +++ b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts @@ -1,12 +1,9 @@ import { useMutation, useQueryClient } from 'react-query'; -import { promiseSequence } from '@/portainer/helpers/promise-utils'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { - mutationOptions, - withError, - withInvalidate, -} from '@/react-tools/react-query'; +import { withError } from '@/react-tools/react-query'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { pluralize } from '@/portainer/helpers/strings'; import { buildUrl } from '../environment.service/utils'; import { EnvironmentId } from '../types'; @@ -14,22 +11,54 @@ import { EnvironmentId } from '../types'; export function useDeleteEnvironmentsMutation() { const queryClient = useQueryClient(); return useMutation( - (environments: EnvironmentId[]) => - promiseSequence( - environments.map( - (environmentId) => () => deleteEnvironment(environmentId) - ) - ), - mutationOptions( - withError('Unable to delete environment(s)'), - withInvalidate(queryClient, [['environments']]) - ) + async ( + environments: { + id: EnvironmentId; + name: string; + 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 }; + }, + { + ...withError('Unable to delete environment(s)'), + onSuccess: ({ successfulDeletions, failedDeletions }) => { + 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 + ); + }); + // show one summary message for all successful deletes + if (successfulDeletions.length) { + notifySuccess( + `${pluralize( + successfulDeletions.length, + 'Environment' + )} successfully removed`, + successfulDeletions.map((deletion) => deletion.name).join(', ') + ); + } + }, + } ); } -async function deleteEnvironment(id: EnvironmentId) { +async function deleteEnvironments( + environments: { id: EnvironmentId; deleteCluster?: boolean }[] +) { try { - await axios.delete(buildUrl(id)); + const { data } = await axios.post< + { name: string; err: { Message: string } | null }[] + >(buildUrl(undefined, 'remove'), { + environments, + }); + return data; } catch (e) { throw parseAxiosError(e as Error, 'Unable to delete environment'); }