From 09b6a9a1a59bde955721c93fb3dc6654e1986d75 Mon Sep 17 00:00:00 2001 From: Lucy Fu Date: Thu, 29 May 2025 12:21:38 -0400 Subject: [PATCH] Fixes #38456 - Hosts bulk action: Disassociate hosts --- .../api/v2/hosts_bulk_actions_controller.rb | 10 +- app/controllers/hosts_controller.rb | 4 +- app/services/bulk_hosts_manager.rb | 6 + config/initializers/f_foreman_permissions.rb | 2 +- config/routes/api/v2.rb | 1 + .../disassociate/BulkDisassociateModal.js | 199 ++++++++++++++++++ .../BulkActions/disassociate/actions.js | 16 ++ .../BulkActions/disassociate/index.js | 29 +++ .../react_app/components/HostsIndex/index.js | 18 ++ 9 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/actions.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js diff --git a/app/controllers/api/v2/hosts_bulk_actions_controller.rb b/app/controllers/api/v2/hosts_bulk_actions_controller.rb index 5501070e2f..872c0d1e92 100644 --- a/app/controllers/api/v2/hosts_bulk_actions_controller.rb +++ b/app/controllers/api/v2/hosts_bulk_actions_controller.rb @@ -5,7 +5,7 @@ class HostsBulkActionsController < V2::BaseController include Api::V2::BulkHostsExtension before_action :find_deletable_hosts, :only => [:bulk_destroy] - before_action :find_editable_hosts, :only => [:build, :reassign_hostgroup, :change_owner] + before_action :find_editable_hosts, :only => [:build, :reassign_hostgroup, :change_owner, :disassociate] def_param_group :bulk_host_ids do param :organization_id, :number, :required => true, :desc => N_("ID of the organization") @@ -86,6 +86,14 @@ def change_owner process_response(true, { :message => n_("Updated host: changed owner", "Updated hosts: changed owner", @hosts.count)}) end + api :PUT, "/hosts/bulk/disassociate", N_("Disassociate compute resources") + param_group :bulk_host_ids + def disassociate + BulkHostsManager.new(hosts: @hosts).disassociate + process_response(true, { :message => n_("Updated host: Disassociated from compute resource", + "Updated hosts: Disassociated from compute resource", @hosts.count)}) + end + protected def action_permission diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index 9906a4aa49..1d92fe35b9 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -551,9 +551,7 @@ def multiple_disassociate end def update_multiple_disassociate - @hosts.each do |host| - host.disassociate! - end + BulkHostsManager.new(hosts: @hosts).disassociate success _('Updated hosts: Disassociated from VM') redirect_back_or_to helpers.current_hosts_path end diff --git a/app/services/bulk_hosts_manager.rb b/app/services/bulk_hosts_manager.rb index c9c585a608..5ebadc1d47 100644 --- a/app/services/bulk_hosts_manager.rb +++ b/app/services/bulk_hosts_manager.rb @@ -45,4 +45,10 @@ def change_owner(owner_id) host.save(:validate => false) end end + + def disassociate + @hosts.each do |host| + host.disassociate! + end + end end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 99c6f5c047..4925c1dbe6 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -271,7 +271,7 @@ :"api/v2/hosts" => [:update, :disassociate, :forget_status], :"api/v2/interfaces" => [:create, :update, :destroy], :"api/v2/compute_resources" => [:associate], - :"api/v2/hosts_bulk_actions" => [:build, :reassign_hostgroup, :change_owner], + :"api/v2/hosts_bulk_actions" => [:build, :reassign_hostgroup, :change_owner, :disassociate], } map.permission :destroy_hosts, {:hosts => [:destroy, :multiple_actions, :reset_multiple, :multiple_destroy, :submit_multiple_destroy], :"api/v2/hosts" => [:destroy], diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 010b08f808..e66d0abc82 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -6,6 +6,7 @@ match 'hosts/bulk', :to => 'hosts_bulk_actions#bulk_destroy', :via => [:delete] match 'hosts/bulk/build', :to => 'hosts_bulk_actions#build', :via => [:put] match 'hosts/bulk/change_owner', :to => 'hosts_bulk_actions#change_owner', :via => [:put] + put 'hosts/bulk/disassociate', :to => 'hosts_bulk_actions#disassociate' match 'hosts/bulk/reassign_hostgroup', :to => 'hosts_bulk_actions#reassign_hostgroup', :via => [:put] resources :architectures, :except => [:new, :edit] do diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js new file mode 100644 index 0000000000..17a51be23d --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/BulkDisassociateModal.js @@ -0,0 +1,199 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +import { + Modal, + Alert, + Button, + TextContent, + Text, + TreeView, +} from '@patternfly/react-core'; +import { addToast } from '../../../ToastsList/slice'; +import { foremanUrl } from '../../../../common/helpers'; +import { translate as __ } from '../../../../common/I18n'; +import { BULK_DISASSOCIATE_KEY, bulkDisassociate } from './actions'; +import { APIActions } from '../../../../redux/API'; +import { + HOSTS_API_PATH, + API_REQUEST_KEY, +} from '../../../../routes/Hosts/constants'; +import { failedHostsToastParams } from '../helpers'; + +const BulkDisassociateModal = ({ + isOpen, + closeModal, + selectAllHostsMode, + selectedCount, + selectedResults, + fetchBulkParams, +}) => { + const dispatch = useDispatch(); + const hostsWithComputeResource = selectedResults?.filter( + h => h.compute_resource_id && h.uuid + ); + const hostsWithoutComputeResource = selectedResults?.filter( + h => !hostsWithComputeResource.includes(h) + ); + const selectedResultsEmpty = selectedResults?.length === 0; + + const selectedTreeViewData = [ + { + name: __('Selected hosts'), + id: 'selected-hosts-tree-view-title', + customBadgeContent: selectAllHostsMode ? 'All' : selectedCount, + }, + ]; + const applicableTreeViewData = [ + { + name: __('Hosts associated to compute resources'), + id: 'applicable-hosts-tree-view-title', + customBadgeContent: hostsWithComputeResource?.length, + }, + ]; + const excludedTreeViewData = [ + { + name: __('Excluded hosts'), + id: 'excluded-hosts-tree-view-title', + customBadgeContent: hostsWithoutComputeResource?.length, + }, + ]; + + const handleError = response => { + closeModal(); + dispatch( + addToast( + failedHostsToastParams({ + ...response.data.error, + key: BULK_DISASSOCIATE_KEY, + }) + ) + ); + }; + + const handleSuccess = response => { + dispatch( + addToast({ + type: 'success', + message: response.data.message, + }) + ); + dispatch( + APIActions.get({ + key: API_REQUEST_KEY, + url: foremanUrl(HOSTS_API_PATH), + }) + ); + closeModal(); + }; + + const handleConfirm = () => { + const queryString = selectedResultsEmpty + ? fetchBulkParams() + : `id ^ (${hostsWithComputeResource.map(h => h.id).join(',')})`; + const requestBody = { + included: { + search: queryString, + }, + }; + + dispatch(bulkDisassociate(requestBody, handleSuccess, handleError)); + }; + + const modalActions = [ + , + , + ]; + + return ( + + + + {__( + 'This will disassociate the host in Foreman from its compute resource.' + )} +
+ {__( + 'After disassociating, a host can be deleted from Foreman without affecting its virtual machine.' + )} +
+
+ +
+ {selectedResultsEmpty && ( + + )} + {!selectedResultsEmpty && ( + <> + + + + )} +
+
+ ); +}; + +BulkDisassociateModal.propTypes = { + isOpen: PropTypes.bool, + closeModal: PropTypes.func, + selectedResults: PropTypes.array, + fetchBulkParams: PropTypes.func.isRequired, + selectedCount: PropTypes.number.isRequired, + selectAllHostsMode: PropTypes.bool.isRequired, +}; + +BulkDisassociateModal.defaultProps = { + isOpen: false, + closeModal: () => {}, + selectedResults: [], +}; + +export default BulkDisassociateModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/actions.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/actions.js new file mode 100644 index 0000000000..ee3a84b243 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/actions.js @@ -0,0 +1,16 @@ +import { APIActions } from '../../../../redux/API'; +import { foremanUrl } from '../../../../common/helpers'; + +export const BULK_DISASSOCIATE_KEY = 'BULK_DISASSOCIATE'; +export const bulkDisassociate = (params, handleSuccess, handleError) => { + const url = foremanUrl(`/api/v2/hosts/bulk/disassociate`); + return APIActions.put({ + key: BULK_DISASSOCIATE_KEY, + url, + handleSuccess, + handleError, + params, + }); +}; + +export default bulkDisassociate; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js new file mode 100644 index 0000000000..bcc8343b47 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/disassociate/index.js @@ -0,0 +1,29 @@ +import React, { useContext } from 'react'; +import { ForemanActionsBarContext } from '../../../../components/HostDetails/ActionsBar'; +import { useForemanModal } from '../../../../components/ForemanModal/ForemanModalHooks'; +import BulkDisassociateModal from './BulkDisassociateModal'; + +const BulkDisassociateModalScene = () => { + const { + selectAllHostsMode, + selectedCount, + selectedResults, + fetchBulkParams, + } = useContext(ForemanActionsBarContext); + const { modalOpen, setModalClosed } = useForemanModal({ + id: 'bulk-disassociate-modal', + }); + return ( + + ); +}; + +export default BulkDisassociateModalScene; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index 2336263ae5..8eccb63248 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -47,6 +47,7 @@ import { bulkDeleteHosts } from './BulkActions/bulkDelete'; import BulkBuildHostModal from './BulkActions/buildHosts'; import BulkReassignHostgroupModal from './BulkActions/reassignHostGroup'; import BulkChangeOwnerModal from './BulkActions/changeOwner'; +import BulkDisassociateModal from './BulkActions/disassociate'; import { foremanUrl } from '../../common/helpers'; import Slot from '../common/Slot'; import forceSingleton from '../../common/forceSingleton'; @@ -223,6 +224,11 @@ const HostsIndex = () => { id: 'bulk-change-owner-modal', }) ); + dispatch( + addModal({ + id: 'bulk-disassociate-modal', + }) + ); }, [dispatch]); const { setModalOpen: setHgModalOpen } = useForemanModal({ @@ -234,6 +240,9 @@ const HostsIndex = () => { const { setModalOpen: setChangeOwnerModalOpen } = useForemanModal({ id: 'bulk-change-owner-modal', }); + const { setModalOpen: setDisassociateModalOpen } = useForemanModal({ + id: 'bulk-disassociate-modal', + }); const dropdownItems = [ { > {__('Change owner')} , + + {__('Disassociate hosts')} + , ]; const dangerZoneItems = [ @@ -468,6 +485,7 @@ const HostsIndex = () => { +