From 40300387d9abc60a0deda057cf78a87eddfd6d93 Mon Sep 17 00:00:00 2001 From: Lucy Fu Date: Wed, 2 Jul 2025 13:42:05 -0400 Subject: [PATCH] Fixes #38416 - Hosts bulk action: Assign organization and location --- .../api/v2/hosts_bulk_actions_controller.rb | 38 +++ .../foreman/controller/taxonomy_multiple.rb | 19 +- app/services/bulk_hosts_manager.rb | 15 ++ config/initializers/f_foreman_permissions.rb | 2 +- config/routes/api/v2.rb | 2 + test/controllers/hosts_controller_test.rb | 4 +- .../BulkAssignTaxonomyConstants.js | 8 + .../assignTaxonomy/BulkAssignTaxonomyModal.js | 252 ++++++++++++++++++ .../assignTaxonomy/TaxonomySelect.css | 9 + .../assignTaxonomy/TaxonomySelect.js | 106 ++++++++ .../BulkActions/assignTaxonomy/actions.js | 52 ++++ .../BulkActions/assignTaxonomy/index.js | 45 ++++ .../react_app/components/HostsIndex/index.js | 69 ++++- 13 files changed, 599 insertions(+), 22 deletions(-) create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyConstants.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.css create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/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 872c0d1e92..501229ab9f 100644 --- a/app/controllers/api/v2/hosts_bulk_actions_controller.rb +++ b/app/controllers/api/v2/hosts_bulk_actions_controller.rb @@ -94,6 +94,34 @@ def disassociate "Updated hosts: Disassociated from compute resource", @hosts.count)}) end + api :PUT, "/hosts/bulk/assign_organization", N_("Assign organization") + param_group :bulk_host_ids + param :id, :number, :required => true, :desc => N_("The organization ID to assign the hosts to") + param :mismatch_setting, :bool, :required => true, :desc => N_("Fix organization on mismatch") + def assign_organization + without_taxonomy do + find_editable_hosts + taxonomy = Organization.find(params[:id]) + BulkHostsManager.new(hosts: @hosts).assign_taxonomy(taxonomy, params[:mismatch_setting]) + message = _("Organization is set to %s") % taxonomy.name + process_response(true, { :message => n_("Updated host: #{message}", "Updated hosts: #{message}", @hosts.count)}) + end + end + + api :PUT, "/hosts/bulk/assign_location", N_("Assign location") + param_group :bulk_host_ids + param :id, :number, :required => true, :desc => N_("The location ID to assign the hosts to") + param :mismatch_setting, :bool, :required => true, :desc => N_("Fix location on mismatch") + def assign_location + without_taxonomy do + find_editable_hosts + taxonomy = Location.find(params[:id]) + BulkHostsManager.new(hosts: @hosts).assign_taxonomy(taxonomy, params[:mismatch_setting]) + message = _("Location is set to %s") % taxonomy.name + process_response(true, { :message => n_("Updated host: #{message}", "Updated hosts: #{message}", @hosts.count)}) + end + end + protected def action_permission @@ -115,6 +143,16 @@ def find_editable_hosts find_bulk_hosts(:edit_hosts, params) end + def without_taxonomy + context = Foreman::ThreadSession::Context.get + Foreman::ThreadSession::Context.set(user: context[:user]) + yield + rescue => e + render_error(:custom_error, :status => :unprocessable_entity, :locals => { :message => e.message}) + ensure + Foreman::ThreadSession::Context.set(**context) if context + end + def rebuild_config all_fails = BulkHostsManager.new(hosts: @hosts).rebuild_configuration failed_host_ids = all_fails.flat_map { |_key, values| values&.map(&:id) } diff --git a/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb b/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb index f812a0dcef..94e2f0efc2 100644 --- a/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb +++ b/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb @@ -36,21 +36,12 @@ def update_multiple_taxonomies(type) end taxonomy = Taxonomy.find_by_id(id) - - if params[type][:optimistic_import] == 'yes' - @hosts.update_all("#{type}_id".to_sym => taxonomy.id) - # hosts location needs to be updated before import missing ids - taxonomy.import_missing_ids - else - if taxonomy.need_to_be_selected_ids.count == 0 - @hosts.update_all("#{type}_id".to_sym => taxonomy.id) - else - error "Cannot update #{taxonomy.type} to #{taxonomy.name} because of mismatch in settings" - redirect_back_or_to helpers.current_hosts_path - return - end + begin + BulkHostsManager.new(hosts: @hosts).assign_taxonomy(taxonomy, params[type][:optimistic_import] == 'yes') + success "Updated hosts: Changed #{type.to_s.classify}" + rescue => e + error e.message end - success "Updated hosts: Changed #{type.to_s.classify}" redirect_back_or_to helpers.current_hosts_path end end diff --git a/app/services/bulk_hosts_manager.rb b/app/services/bulk_hosts_manager.rb index 5ebadc1d47..d439dc5ced 100644 --- a/app/services/bulk_hosts_manager.rb +++ b/app/services/bulk_hosts_manager.rb @@ -51,4 +51,19 @@ def disassociate host.disassociate! end end + + # @param [Boolean] optimistic_import + # either fix on mismatch or fail on mismatch + def assign_taxonomy(taxonomy, optimistic_import) + tax_type = taxonomy.type.downcase + if optimistic_import + @hosts.update_all("#{tax_type}_id".to_sym => taxonomy.id) + # hosts location needs to be updated before import missing ids + taxonomy.import_missing_ids + elsif taxonomy.need_to_be_selected_ids.empty? + @hosts.update_all("#{tax_type}_id".to_sym => taxonomy.id) + else + raise _("Cannot update %{type} to %{name} because of mismatch in settings") % {type: taxonomy.type.downcase, name: taxonomy.name} + end + end end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 4925c1dbe6..67d83d5ccf 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, :disassociate], + :"api/v2/hosts_bulk_actions" => [:assign_organization, :assign_location, :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 e66d0abc82..664e570e54 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -4,6 +4,8 @@ # new v2 routes that point to v2 scope "(:apiv)", :module => :v2, :defaults => {:apiv => 'v2'}, :apiv => /v2/, :constraints => ApiConstraints.new(:version => 2, :default => true) do match 'hosts/bulk', :to => 'hosts_bulk_actions#bulk_destroy', :via => [:delete] + put 'hosts/bulk/assign_organization', :to => 'hosts_bulk_actions#assign_organization' + put 'hosts/bulk/assign_location', :to => 'hosts_bulk_actions#assign_location' 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' diff --git a/test/controllers/hosts_controller_test.rb b/test/controllers/hosts_controller_test.rb index 149b83b96f..139e4c01c6 100644 --- a/test/controllers/hosts_controller_test.rb +++ b/test/controllers/hosts_controller_test.rb @@ -812,7 +812,7 @@ def test_unset_manage :host_ids => Host.pluck('hosts.id'), }, session: set_session_user assert_redirected_to new_hosts_index_page_path - assert flash[:error] == "Cannot update Location to Location 1 because of mismatch in settings" + assert_equal "Cannot update location to Location 1 because of mismatch in settings", flash[:error] end test "update multiple location does not update location of hosts if fails on pessimistic import" do @request.env['HTTP_REFERER'] = current_hosts_path @@ -871,7 +871,7 @@ def test_unset_manage :host_ids => Host.pluck('hosts.id'), }, session: set_session_user assert_redirected_to new_hosts_index_page_path - assert_equal "Cannot update Organization to Organization 1 because of mismatch in settings", flash[:error] + assert_equal "Cannot update organization to Organization 1 because of mismatch in settings", flash[:error] end test "update multiple organization does not update organization of hosts if fails on pessimistic import" do @request.env['HTTP_REFERER'] = current_hosts_path diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyConstants.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyConstants.js new file mode 100644 index 0000000000..bfcf75fd00 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyConstants.js @@ -0,0 +1,8 @@ +export const BULK_ASSIGN_ORGANIZATION_KEY = 'BULK_ASSIGN_ORGANIZATION'; +export const BULK_ASSIGN_LOCATION_KEY = 'BULK_ASSIGN_LOCATION'; +export const ORGANIZATION_KEY = 'ORGANIZATION'; +export const LOCATION_KEY = 'LOCATION'; +export const MODAL_TYPES = { + ORGANIZATION: 'ORGANIZATION', + LOCATION: 'LOCATION', +}; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js new file mode 100644 index 0000000000..5bc8273709 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js @@ -0,0 +1,252 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { + Modal, + Button, + MenuToggle, + SelectOption, + TextContent, + Text, + TreeView, +} from '@patternfly/react-core'; +import { addToast } from '../../../ToastsList/slice'; +import { translate as __ } from '../../../../common/I18n'; +import { STATUS } from '../../../../constants'; +import { + selectAPIStatus, + selectAPIResponse, +} from '../../../../redux/API/APISelectors'; +import { + bulkAssignOrganization, + bulkAssignLocation, + fetchOrganizations, + fetchLocations, +} from './actions'; +import { + BULK_ASSIGN_ORGANIZATION_KEY, + BULK_ASSIGN_LOCATION_KEY, + ORGANIZATION_KEY, + LOCATION_KEY, + MODAL_TYPES, +} from './BulkAssignTaxonomyConstants'; +import { foremanUrl } from '../../../../common/helpers'; +import { APIActions } from '../../../../redux/API'; +import { + HOSTS_API_PATH, + API_REQUEST_KEY, +} from '../../../../routes/Hosts/constants'; +import TaxonomySelect from './TaxonomySelect'; + +export const BulkAssignOrganizationModal = props => ( + +); +export const BulkAssignLocationModal = props => ( + +); + +const BulkAssignTaxonomyModal = ({ + isOpen, + closeModal, + selectAllHostsMode, + selectedCount, + fetchBulkParams, + modalType, +}) => { + const org = modalType === MODAL_TYPES.ORGANIZATION; + const taxType = org ? 'organization' : 'location'; + const dispatch = useDispatch(); + const [taxId, setTaxId] = useState(''); + const [selectOpen, setSelectOpen] = useState(false); + const [fixRadioChecked, setFixRadioChecked] = useState(true); + const taxResults = useSelector(state => + org + ? selectAPIResponse(state, ORGANIZATION_KEY) + : selectAPIResponse(state, LOCATION_KEY) + ); + const status = useSelector(state => + org + ? selectAPIStatus(state, ORGANIZATION_KEY) + : selectAPIStatus(state, LOCATION_KEY) + ); + const hostUpdateStatus = useSelector(state => + org + ? selectAPIStatus(state, BULK_ASSIGN_ORGANIZATION_KEY) + : selectAPIStatus(state, BULK_ASSIGN_LOCATION_KEY) + ); + const handleModalClose = () => { + setTaxId(''); + setFixRadioChecked(true); + closeModal(); + }; + + useEffect(() => { + org ? dispatch(fetchOrganizations()) : dispatch(fetchLocations()); + }, [dispatch, org]); + + const onToggleClick = () => { + setSelectOpen(!selectOpen); + }; + const toggle = toggleRef => ( + + {getSelectedLabel(taxId, taxResults)} + + ); + + const handleSelect = (event, selection) => { + setTaxId(selection); + setSelectOpen(false); + }; + + const getSelectedLabel = (id, taxonomy) => + taxonomy.results.find(t => t.id === id)?.name; + + const handleError = error => { + const { + response: { + data: { + error: { message }, + }, + }, + } = error; + dispatch(addToast({ type: 'danger', message })); + handleModalClose(); + }; + + const handleSuccess = response => { + dispatch( + addToast({ + type: 'success', + message: response.data.message, + }) + ); + dispatch( + APIActions.get({ + key: API_REQUEST_KEY, + url: foremanUrl(HOSTS_API_PATH), + }) + ); + handleModalClose(); + }; + + const handleSave = () => { + const requestBody = { + included: { + search: fetchBulkParams(), + }, + id: taxId, + mismatch_setting: fixRadioChecked, + }; + + org + ? dispatch( + bulkAssignOrganization(requestBody, handleSuccess, handleError) + ) + : dispatch(bulkAssignLocation(requestBody, handleSuccess, handleError)); + }; + + const translatedTaxType = org ? __('organization') : __('location'); + const modalText = ( + + ); + + const modalActions = [ + , + , + ]; + + const selectedTreeViewData = [ + { + name: __('Selected hosts'), + id: 'selected-hosts-tree-view-title', + customBadgeContent: selectAllHostsMode ? 'All' : selectedCount, + }, + ]; + + return ( + + + {modalText} + + {taxResults && status === STATUS.RESOLVED && ( + setSelectOpen(isSelectOpen)} + toggle={toggle} + radioChecked={fixRadioChecked} + setRadioChecked={setFixRadioChecked} + > + {taxResults.results?.map(tax => ( + + {tax.name} + + ))} + + )} +
+ +
+
+ ); +}; + +BulkAssignTaxonomyModal.propTypes = { + isOpen: PropTypes.bool, + closeModal: PropTypes.func, + selectedCount: PropTypes.number.isRequired, + selectAllHostsMode: PropTypes.bool.isRequired, + fetchBulkParams: PropTypes.func.isRequired, + modalType: PropTypes.string.isRequired, +}; + +BulkAssignTaxonomyModal.defaultProps = { + isOpen: false, + closeModal: () => {}, +}; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.css b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.css new file mode 100644 index 0000000000..4bb967746e --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.css @@ -0,0 +1,9 @@ +div#mismatch-radio-container { + display: flex; + align-items: baseline; + margin-top: 1em; +} +div .pf-v5-c-radio__input { + position: relative; + top: -0.2em; +} diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js new file mode 100644 index 0000000000..a9a2aa1bcb --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Bullseye, Radio, Select, Tooltip } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import { translate as __ } from '../../../../common/I18n'; +import './TaxonomySelect.css'; + +const TaxonomySelect = ({ + headerText, + taxonomy, + children, + radioChecked, + setRadioChecked, + ...selectProps +}) => { + const taxonomyType = + taxonomy === 'organization' ? __('organization') : __('location'); + const mismatchFixTip = ( + + } + > + + + ); + const mismatchFailTip = ( + + } + > + + + ); + + return ( +
+

{headerText}

+ + +
+ setRadioChecked(checked)} + name={`radioFixOnMismatch${taxonomy}`} + label={__('Fix on mismatch')} + id={`radio-fix-on-mismatch-${taxonomy}`} + ouiaId={`radio-fix-on-mismatch-${taxonomy}`} + /> + {mismatchFixTip} + setRadioChecked(!checked)} + name={`radioFailOnMismatch${taxonomy}`} + label={__('Fail on mismatch')} + id={`radio-fail-on-mismatch-${taxonomy}`} + ouiaId={`radio-fail-on-mismatch-${taxonomy}`} + style={{ marginLeft: '50px' }} + /> + {mismatchFailTip} +
+
+
+ ); +}; + +TaxonomySelect.propTypes = { + headerText: PropTypes.string, + taxonomy: PropTypes.string, + children: PropTypes.node, + radioChecked: PropTypes.bool, + setRadioChecked: PropTypes.func, +}; + +TaxonomySelect.defaultProps = { + headerText: '', + taxonomy: '', + children: [], + radioChecked: false, + setRadioChecked: undefined, +}; + +export default TaxonomySelect; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js new file mode 100644 index 0000000000..9bafd1f45a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js @@ -0,0 +1,52 @@ +import { APIActions } from '../../../../redux/API'; +import { foremanUrl } from '../../../../common/helpers'; +import { + BULK_ASSIGN_ORGANIZATION_KEY, + BULK_ASSIGN_LOCATION_KEY, + ORGANIZATION_KEY, + LOCATION_KEY, +} from './BulkAssignTaxonomyConstants'; + +export const bulkAssignOrganization = (params, handleSuccess, handleError) => { + const url = foremanUrl(`/api/v2/hosts/bulk/assign_organization`); + return APIActions.put({ + key: BULK_ASSIGN_ORGANIZATION_KEY, + url, + handleSuccess, + handleError, + params, + }); +}; + +export const bulkAssignLocation = (params, handleSuccess, handleError) => { + const url = foremanUrl(`/api/v2/hosts/bulk/assign_location`); + return APIActions.put({ + key: BULK_ASSIGN_LOCATION_KEY, + url, + handleSuccess, + handleError, + params, + }); +}; + +export const fetchOrganizations = () => { + const url = foremanUrl('/api/v2/organizations'); + return APIActions.get({ + key: ORGANIZATION_KEY, + url, + params: { + per_page: 'all', + }, + }); +}; + +export const fetchLocations = () => { + const url = foremanUrl('/api/v2/locations'); + return APIActions.get({ + key: LOCATION_KEY, + url, + params: { + per_page: 'all', + }, + }); +}; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js new file mode 100644 index 0000000000..0707b6a037 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js @@ -0,0 +1,45 @@ +import React, { useContext } from 'react'; +import { ForemanActionsBarContext } from '../../../../components/HostDetails/ActionsBar'; +import { useForemanModal } from '../../../../components/ForemanModal/ForemanModalHooks'; +import { + BulkAssignOrganizationModal, + BulkAssignLocationModal, +} from './BulkAssignTaxonomyModal'; + +export const BulkAssignOrganizationModalScene = () => { + const { selectAllHostsMode, selectedCount, fetchBulkParams } = useContext( + ForemanActionsBarContext + ); + const { modalOpen, setModalClosed } = useForemanModal({ + id: 'bulk-assign-organization-modal', + }); + return ( + + ); +}; + +export const BulkAssignLocationModalScene = () => { + const { selectAllHostsMode, selectedCount, fetchBulkParams } = useContext( + ForemanActionsBarContext + ); + const { modalOpen, setModalClosed } = useForemanModal({ + id: 'bulk-assign-location-modal', + }); + return ( + + ); +}; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index 8eccb63248..483d13855c 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -5,10 +5,13 @@ import { Tr, Td, ActionsColumn } from '@patternfly/react-table'; import { ToolbarItem, Divider, - MenuItem, Flex, FlexItem, Button, + Menu, + MenuItem, + MenuContent, + MenuList, Split, SplitItem, TextContent, @@ -44,6 +47,10 @@ import { useForemanHostsPageUrl, } from '../../Root/Context/ForemanContext'; import { bulkDeleteHosts } from './BulkActions/bulkDelete'; +import { + BulkAssignOrganizationModalScene as BulkAssignOrganizationModal, + BulkAssignLocationModalScene as BulkAssignLocationModal, +} from './BulkActions/assignTaxonomy'; import BulkBuildHostModal from './BulkActions/buildHosts'; import BulkReassignHostgroupModal from './BulkActions/reassignHostGroup'; import BulkChangeOwnerModal from './BulkActions/changeOwner'; @@ -209,6 +216,16 @@ const HostsIndex = () => { }; useEffect(() => { + dispatch( + addModal({ + id: 'bulk-assign-organization-modal', + }) + ); + dispatch( + addModal({ + id: 'bulk-assign-location-modal', + }) + ); dispatch( addModal({ id: 'bulk-build-hosts-modal', @@ -231,6 +248,12 @@ const HostsIndex = () => { ); }, [dispatch]); + const { setModalOpen: setOrganizationModalOpen } = useForemanModal({ + id: 'bulk-assign-organization-modal', + }); + const { setModalOpen: setLocationModalOpen } = useForemanModal({ + id: 'bulk-assign-location-modal', + }); const { setModalOpen: setHgModalOpen } = useForemanModal({ id: 'bulk-reassign-hg-modal', }); @@ -254,12 +277,46 @@ const HostsIndex = () => { {__('Build management')} , setMenuOpen(false)} + > + + + + {__('Host group')} + + + {__('Organization')} + + + {__('Location')} + + + + + } > - {__('Change host group')} + {__('Change associations')} , { fetchBulkParams, }} > + +