diff --git a/app/controllers/api/v2/hosts_bulk_actions_controller.rb b/app/controllers/api/v2/hosts_bulk_actions_controller.rb index 5a2f471f0c2..4c15d89e43c 100644 --- a/app/controllers/api/v2/hosts_bulk_actions_controller.rb +++ b/app/controllers/api/v2/hosts_bulk_actions_controller.rb @@ -5,7 +5,8 @@ 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, :disassociate] + before_action :find_editable_hosts, :only => [:build, :reassign_hostgroup, :change_owner, :disassociate, :change_power_state] + before_action :validate_power_action, :only => [:change_power_state] def_param_group :bulk_host_ids do param :organization_id, :number, :required => true, :desc => N_("ID of the organization") @@ -63,6 +64,56 @@ def build end end + api :PUT, "/hosts/bulk/change_power_state", N_("Change power state") + param_group :bulk_host_ids + param :power, String, :required => true, :desc => N_("Power action to perform (start, stop, poweroff, reboot, reset, soft, cycle)") + def change_power_state + action = params[:power] + + manager = BulkHostsManager.new(hosts: @hosts) + result = manager.change_power_state(action) + + failed_hosts = result[:failed_hosts] || [] + failed_host_ids = result[:failed_host_ids] || [] + unsupported_hosts = result[:unsupported_hosts] || [] + unsupported_host_ids = result[:unsupported_host_ids] || [] + + if failed_hosts.empty? && unsupported_hosts.empty? + render json: { + message: _('The power state of the selected hosts will be set to %s') % _(action), + }, status: :ok + else + total_failed = failed_hosts.size + total_unsupported = unsupported_hosts.size + + parts = [] + if total_failed > 0 + parts << n_( + "Failed to set power state for %s host.", + "Failed to set power state for %s hosts.", + total_failed + ) % total_failed + end + + if total_unsupported > 0 + parts << n_( + "%s host does not support power management.", + "%s hosts do not support power management.", + total_unsupported + ) % total_unsupported + end + + render json: { + error: { + message: parts.join(' '), + failed_host_ids: (failed_host_ids + unsupported_host_ids).uniq, + failed_hosts: failed_hosts, + unsupported_hosts: unsupported_hosts, + }, + }, status: :unprocessable_entity + end + end + api :PUT, "/hosts/bulk/reassign_hostgroups", N_("Reassign hostgroups") param_group :bulk_host_ids param :hostgroup_id, :number, :desc => N_("ID of the hostgroup to reassign the hosts to") @@ -126,7 +177,7 @@ def assign_location def action_permission case params[:action] - when 'build' + when 'build', 'change_power_state' 'edit' else super @@ -173,6 +224,31 @@ def rebuild_config ) end end + + def validate_power_action + action = params[:power] + host_ids = @hosts&.map(&:id) || [] + + return true if action.present? && PowerManager::REAL_ACTIONS.include?(action) + + if action.blank? + render json: { + error: { + message: _("Power action is required"), + failed_host_ids: host_ids, + }, + }, status: :unprocessable_entity + else + render json: { + error: { + message: _("Invalid power action"), + valid_power_actions: PowerManager::REAL_ACTIONS, + failed_host_ids: host_ids, + }, + }, status: :unprocessable_entity + end + false + end end end end diff --git a/app/services/bulk_hosts_manager.rb b/app/services/bulk_hosts_manager.rb index d439dc5ced4..5b60ca4a27e 100644 --- a/app/services/bulk_hosts_manager.rb +++ b/app/services/bulk_hosts_manager.rb @@ -66,4 +66,36 @@ def assign_taxonomy(taxonomy, optimistic_import) raise _("Cannot update %{type} to %{name} because of mismatch in settings") % {type: taxonomy.type.downcase, name: taxonomy.name} end end + + def change_power_state(action) + failed_hosts = [] + unsupported_hosts = [] + + @hosts.each do |host| + unless host.supports_power? + unsupported_hosts << { + id: host.id, + error: _('Power management not available for this host'), + } + next + end + + begin + host.power.send(action.to_sym) + rescue => error + Foreman::Logging.exception("Failed to set power state for #{host}.", error) + failed_hosts << { + id: host.id, + error: error.message, + } + end + end + + { + failed_hosts: failed_hosts, + failed_host_ids: failed_hosts.map { |h| h[:id] }, + unsupported_hosts: unsupported_hosts, + unsupported_host_ids: unsupported_hosts.map { |h| h[:id] }, + } + end end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 70056a54f0d..10ef6ec5331 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" => [:assign_organization, :assign_location, :build, :reassign_hostgroup, :change_owner, :disassociate], + :"api/v2/hosts_bulk_actions" => [:assign_organization, :assign_location, :build, :reassign_hostgroup, :change_owner, :disassociate, :change_power_state], } 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 664e570e54b..ecabef43c02 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -8,6 +8,7 @@ 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/change_power_state', :to => 'hosts_bulk_actions#change_power_state' put 'hosts/bulk/disassociate', :to => 'hosts_bulk_actions#disassociate' match 'hosts/bulk/reassign_hostgroup', :to => 'hosts_bulk_actions#reassign_hostgroup', :via => [:put] diff --git a/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb b/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb index a9722daee55..0823d2180a5 100644 --- a/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb +++ b/test/controllers/api/v2/hosts_bulk_actions_controller_test.rb @@ -26,6 +26,10 @@ def valid_bulk_params(host_ids = @host_ids) } end + def valid_power_params(host_ids = @host_ids, action = 'start') + valid_bulk_params(host_ids).merge(:power => action) + end + test "should change owner with user id" do put :change_owner, params: valid_bulk_params.merge(:owner_id => @user.id_and_type) @@ -103,6 +107,88 @@ def valid_bulk_params(host_ids = @host_ids) end end + context "change_power_state" do + test "successfully changes power state for all hosts" do + Host.any_instance.stubs(:supports_power?).returns(true) + power_mock = mock('power') + Host.any_instance.stubs(:power).returns(power_mock) + power_mock.expects(:send).with(:start).times(@host_ids.size) + + put :change_power_state, params: valid_power_params(@host_ids, 'start') + + assert_response :success + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/The power state of the selected hosts will be set to start/, body['message']) + end + + test "returns failed_host_ids when all hosts fail" do + Host.any_instance.stubs(:supports_power?).returns(true) + power_mock = mock('power') + Host.any_instance.stubs(:power).returns(power_mock) + power_mock.stubs(:send).with(:start).raises(StandardError.new('Power operation failed')) + + put :change_power_state, params: valid_power_params(@host_ids, 'start') + + assert_response :unprocessable_entity + body = ActiveSupport::JSON.decode(@response.body) + assert_match(/Failed to set power state for 3 hosts/, body['error']['message']) + assert_equal @host_ids.sort, body['error']['failed_host_ids'].sort + end + + test "returns error when power param is missing" do + Host.any_instance.stubs(:supports_power?).returns(true) + power_mock = mock('power') + Host.any_instance.stubs(:power).returns(power_mock) + power_mock.stubs(:send).raises(StandardError.new('Power operation failed')) + + put :change_power_state, params: valid_bulk_params(@host_ids) + + assert_response :unprocessable_entity + body = ActiveSupport::JSON.decode(@response.body) + assert_equal "Power action is required", body['error']['message'] + assert_equal @host_ids.sort, body['error']['failed_host_ids'].sort + end + + test "returns error when power action is invalid" do + put :change_power_state, params: valid_power_params(@host_ids, 'invalid') + + assert_response :unprocessable_entity + body = ActiveSupport::JSON.decode(@response.body) + assert_equal "Invalid power action", body['error']['message'] + assert_equal PowerManager::REAL_ACTIONS, body['error']['valid_power_actions'] + assert_equal @host_ids.sort, body['error']['failed_host_ids'].sort + end + + test "handles mixed hosts with no power support, failure, and success" do + Host.any_instance.stubs(:supports_power?) + .returns(false) + .then.returns(true) + .then.returns(true) + + power_fail = mock('power_fail') + power_ok = mock('power_ok') + + Host.any_instance.stubs(:power) + .returns(power_fail) + .then.returns(power_ok) + + power_fail.expects(:send).with(:start).raises(StandardError.new('Power operation failed')) + power_ok.expects(:send).with(:start) + + put :change_power_state, params: valid_power_params(@host_ids, 'start') + + assert_response :unprocessable_entity + body = ActiveSupport::JSON.decode(@response.body) + # Message should contain both failure types + assert_match(/Failed to set power state for 1 host/, body['error']['message']) + assert_match(/1 host does not support power management/, body['error']['message']) + assert_equal 2, body['error']['failed_host_ids'].size + body['error']['failed_host_ids'].each do |id| + assert_includes @host_ids, id + end + end + end + private def set_session_user diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js new file mode 100644 index 00000000000..9220be06d2a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.js @@ -0,0 +1,228 @@ +import React, { useState, useContext } from 'react'; +import { + Modal, + ModalVariant, + Button, + TextContent, + Text, + Select, + SelectOption, + SelectList, + Title, + MenuToggle, + FormGroup, +} from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +import { translate as __, sprintf } from '../../../../common/I18n'; +import './BulkPowerStateModal.scss'; + +import { HostsPowerRefreshContext } from '../../HostsPowerRefreshContext'; +import { POWER_STATES, BULK_POWER_STATE_KEY } from './constants'; +import { bulkChangePowerState } from './actions'; +import { failedHostsToastParams } from '../helpers'; +import { addToast } from '../../../ToastsList/slice'; +import { + HOSTS_API_PATH, + API_REQUEST_KEY, +} from '../../../../routes/Hosts/constants'; +import { foremanUrl } from '../../../../common/helpers'; +import { APIActions } from '../../../../redux/API'; + +const BulkPowerStateModal = ({ + selectedHostsCount, + fetchBulkParams, + isOpen, + closeModal, +}) => { + const [isSelectOpen, setIsSelectOpen] = useState(false); + const [selectedPowerState, setSelectedPowerState] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { bumpRefresh } = useContext(HostsPowerRefreshContext); + const dispatch = useDispatch(); + + const handleSelect = (_event, value) => { + setSelectedPowerState(value); + setIsSelectOpen(false); + }; + + const cleanup = () => { + setIsLoading(false); + closeModal(); + bumpRefresh(); + }; + + const handleSuccess = response => { + dispatch( + addToast({ + type: 'success', + message: response.data.message, + }) + ); + dispatch( + APIActions.get({ + key: API_REQUEST_KEY, + url: foremanUrl(HOSTS_API_PATH), + }) + ); + + cleanup(); + }; + + const handleError = error => { + const apiError = error?.response?.data?.error; + + if (apiError) { + let enhancedError = apiError; + + if (apiError.failed_hosts && apiError.failed_hosts.length > 0) { + const providerErrors = [ + ...new Set(apiError.failed_hosts.map(h => h.error).filter(Boolean)), + ]; + + if (providerErrors.length > 0) { + enhancedError = { + ...apiError, + message: `${apiError.message} ${providerErrors.join(' ')}`, + }; + } + } + + dispatch( + addToast( + failedHostsToastParams({ + ...enhancedError, + key: BULK_POWER_STATE_KEY, + }) + ) + ); + } + + cleanup(); + }; + + const handleSubmit = () => { + setIsLoading(true); + const payload = { + included: { + search: fetchBulkParams(), + }, + power: selectedPowerState, + }; + dispatch(bulkChangePowerState(payload, handleSuccess, handleError)); + }; + + return ( + + {__('Apply')} + , + , + ]} + > + + {/* eslint-disable-next-line spellcheck/spell-checker */} + + {__('Change power state')} + + {selectedHostsCount > 0 && ( + + {sprintf( + selectedHostsCount === 1 + ? __('%s host is selected for power state change') + : __('%s hosts are selected for power state change'), + selectedHostsCount + )} + + )} + + + + + + ); +}; + +BulkPowerStateModal.propTypes = { + selectedHostsCount: PropTypes.number, + fetchBulkParams: PropTypes.func.isRequired, + isOpen: PropTypes.bool, + closeModal: PropTypes.func, +}; + +BulkPowerStateModal.defaultProps = { + selectedHostsCount: 0, + isOpen: false, + closeModal: () => {}, +}; + +export default BulkPowerStateModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.scss b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.scss new file mode 100644 index 00000000000..eed5531551d --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/BulkPowerStateModal.scss @@ -0,0 +1,4 @@ +.bulk-power-state-select-list { + max-height: 200px; + overflow-y: auto; +} diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/actions.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/actions.js new file mode 100644 index 00000000000..fa0d4c88dc6 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/actions.js @@ -0,0 +1,12 @@ +import { APIActions } from '../../../../redux/API'; +import { foremanUrl } from '../../../../common/helpers'; +import { BULK_POWER_STATE_KEY, BULK_POWER_STATE_URL } from './constants'; + +export const bulkChangePowerState = (params, handleSuccess, handleError) => + APIActions.put({ + key: BULK_POWER_STATE_KEY, + url: foremanUrl(BULK_POWER_STATE_URL), + handleSuccess, + handleError, + params, + }); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/constants.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/constants.js new file mode 100644 index 00000000000..411f451e999 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/constants.js @@ -0,0 +1,12 @@ +export const POWER_STATES = [ + { value: 'start', label: 'Start' }, + { value: 'stop', label: 'Stop' }, + { value: 'poweroff', label: 'Power Off' }, + { value: 'reboot', label: 'Reboot' }, + { value: 'reset', label: 'Reset' }, + { value: 'soft', label: 'Reboot (Soft)' }, + { value: 'cycle', label: 'Power Cycle' }, +]; + +export const BULK_POWER_STATE_KEY = 'BULK_POWER_STATE_CHANGE'; +export const BULK_POWER_STATE_URL = '/api/v2/hosts/bulk/change_power_state'; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js new file mode 100644 index 00000000000..b7817b26622 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/powerState/index.js @@ -0,0 +1,24 @@ +import React, { useContext } from 'react'; +import { ForemanActionsBarContext } from '../../../../components/HostDetails/ActionsBar'; +import { useForemanModal } from '../../../../components/ForemanModal/ForemanModalHooks'; +import BulkPowerStateModal from './BulkPowerStateModal'; + +const BulkPowerStateModalScene = () => { + const { fetchBulkParams, selectedCount = 0 } = useContext( + ForemanActionsBarContext + ); + const { modalOpen, setModalClosed } = useForemanModal({ + id: 'bulk-power-state-modal', + }); + + return ( + + ); +}; + +export default BulkPowerStateModalScene; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/Columns/components/HostPowerStatus.js b/webpack/assets/javascripts/react_app/components/HostsIndex/Columns/components/HostPowerStatus.js index 7642a7d9c95..a7a24078177 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/Columns/components/HostPowerStatus.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/Columns/components/HostPowerStatus.js @@ -1,9 +1,8 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { PowerOffIcon as PowerOnIcon, - OffIcon as PowerOffIcon, UnknownIcon, } from '@patternfly/react-icons'; import { Icon } from '@patternfly/react-core'; @@ -11,9 +10,11 @@ import { useAPI } from '../../../../common/hooks/API/APIHooks'; import SkeletonLoader from '../../../common/SkeletonLoader'; import { selectAPIResponse } from '../../../../redux/API/APISelectors'; import { STATUS } from '../../../../constants'; +import { HostsPowerRefreshContext } from '../../HostsPowerRefreshContext'; const HostPowerStatus = ({ hostName }) => { - const key = `HOST_POWER_STATUS_${hostName}`; + const { refreshId } = useContext(HostsPowerRefreshContext); + const key = `HOST_POWER_STATUS_${hostName}_${refreshId}`; const existingResponse = useSelector(state => selectAPIResponse(state, key)); const useExistingResponse = !!existingResponse.state; const response = useAPI( @@ -36,6 +37,8 @@ const HostPowerStatus = ({ hostName }) => { const moveItALittleUp = { position: 'relative', top: '-0.1em' }; const green = 'var(--pf-v5-global--palette--green-300)'; const disabledGray = 'var(--pf-v5-global--disabled-color--200)'; + const red = 'var(--pf-v5-global--palette--red-300)'; + switch (state) { case 'on': powerIcon = ( @@ -48,9 +51,9 @@ const HostPowerStatus = ({ hostName }) => { break; case 'off': powerIcon = ( - + - + ); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/HostsPowerRefreshContext.js b/webpack/assets/javascripts/react_app/components/HostsIndex/HostsPowerRefreshContext.js new file mode 100644 index 00000000000..81d89469a22 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/HostsPowerRefreshContext.js @@ -0,0 +1,6 @@ +import React from 'react'; + +export const HostsPowerRefreshContext = React.createContext({ + refreshId: 0, + bumpRefresh: () => {}, +}); diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index f468ae3cb92..1b9a50ce39c 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -57,9 +57,11 @@ import BulkBuildHostModal from './BulkActions/buildHosts'; import BulkReassignHostgroupModal from './BulkActions/reassignHostGroup'; import BulkChangeOwnerModal from './BulkActions/changeOwner'; import BulkDisassociateModal from './BulkActions/disassociate'; +import BulkPowerStateModal from './BulkActions/powerState/index'; import { foremanUrl } from '../../common/helpers'; import Slot from '../common/Slot'; import forceSingleton from '../../common/forceSingleton'; +import { HostsPowerRefreshContext } from './HostsPowerRefreshContext'; import './index.scss'; import { STATUS } from '../../constants'; import { RowSelectTd } from '../PF4/TableIndexPage/RowSelectTd'; @@ -85,6 +87,8 @@ const HostsIndex = () => { getColumnData({ tableName: 'hosts' }) ); const [allJsLoaded, setAllJsLoaded] = useState(false); + const [powerRefreshId, setPowerRefreshId] = useState(0); + const bumpPowerRefresh = () => setPowerRefreshId(prev => prev + 1); const { searchParam: urlSearchQuery = '', page: urlPage, @@ -251,6 +255,11 @@ const HostsIndex = () => { id: 'bulk-disassociate-modal', }) ); + dispatch( + addModal({ + id: 'bulk-power-state-modal', + }) + ); }, [dispatch]); const { setModalOpen: setOrganizationModalOpen } = useForemanModal({ @@ -271,6 +280,9 @@ const HostsIndex = () => { const { setModalOpen: setDisassociateModalOpen } = useForemanModal({ id: 'bulk-disassociate-modal', }); + const { setModalOpen: setPowerStateModalOpen } = useForemanModal({ + id: 'bulk-power-state-modal', + }); const dropdownItems = [ { > {__('Disassociate hosts')} , + + {__('Change power state')} + , { const { perPage: _, ...paramsWithoutPerPage } = params; return ( - - - setAPIOptions({ - ...apiOptions, - params: { search: urlSearchQuery }, - }) - } - columns={columns} - errorMessage={ - status === STATUS.ERROR && errorMessage ? errorMessage : null - } - isPending={status === STATUS.PENDING} + - {results?.map((result, rowIndex) => { - const rowActions = getActions(result); - return ( - - {} - {columnNamesKeys.map(k => ( - + ); + })} +
- {columns[k].wrapper ? columns[k].wrapper(result) : result[k]} + + setAPIOptions({ + ...apiOptions, + params: { search: urlSearchQuery }, + }) + } + columns={columns} + errorMessage={ + status === STATUS.ERROR && errorMessage ? errorMessage : null + } + isPending={status === STATUS.PENDING} + > + {results?.map((result, rowIndex) => { + const rowActions = getActions(result); + return ( + + { + + } + {columnNamesKeys.map(k => ( + + ))} + - ))} - - - ); - })} -
+ {columns[k].wrapper + ? columns[k].wrapper(result) + : result[k]} + + {rowActions.length ? ( + + ) : null} - {rowActions.length ? ( - - ) : null} -
- - - - - - - - - - +
+ + + + + + + + + + +
+ ); };