From 0bb97a84b95618bb50aaf0850880341c47b0e959 Mon Sep 17 00:00:00 2001 From: Marek Hulan Date: Fri, 4 Jul 2025 21:57:16 +0000 Subject: [PATCH 1/3] Fixes #38549 - Add WOL capability to Host The feature adds the ability to wake suspended Host by Wake on LAN. It requires a smart proxy with WOL feature. Typically, when host has a network interface attached to a subnet where we manage the DHCP, the broadcast in such network is available. That's due to the core principle of DHCP protocol. In such network, it's possible to perform WOL, which also relies on broadcast. This patch adds WOL support for Host's primary interface as that's the one we run the DHCP orchestration on. This could be extended in future if we see the need to specify other host interface instead. The Host's API loads the primary interface, checks the presence of the DHCP proxy and it's WOL capability. If it is available, it triggers the relevant API on the smart proxy and displays a toast notification. This new action reuses the same permission we have for BMC (power management of the host). --- app/controllers/api/v2/hosts_controller.rb | 36 ++++- app/services/proxy_api/wol.rb | 20 +++ config/initializers/f_foreman_permissions.rb | 2 +- config/routes/api/v2.rb | 1 + db/seeds.d/110-smart_proxy_features.rb | 2 +- .../api/v2/hosts_controller_test.rb | 123 ++++++++++++++++++ test/unit/proxy_api/wol_test.rb | 69 ++++++++++ 7 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 app/services/proxy_api/wol.rb create mode 100644 test/unit/proxy_api/wol_test.rb diff --git a/app/controllers/api/v2/hosts_controller.rb b/app/controllers/api/v2/hosts_controller.rb index 0dd4a381634..7beb3ed9414 100644 --- a/app/controllers/api/v2/hosts_controller.rb +++ b/app/controllers/api/v2/hosts_controller.rb @@ -280,6 +280,38 @@ def boot render_exception(e, :status => :unprocessable_entity) end + api :PUT, "/hosts/:id/wol", N_("Send Wake on LAN request to host") + param :id, :identifier_dottable, :required => true + + def wol + primary_interface = @host.primary_interface + + unless primary_interface + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('Host has no primary interface') } + end + + unless primary_interface.mac.present? + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('Primary interface has no MAC address') } + end + + unless primary_interface.subnet&.dhcp? + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('Primary interface subnet has no DHCP proxy configured') } + end + + dhcp_proxy = primary_interface.subnet.dhcp + unless dhcp_proxy.has_feature?('WOL') + return render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => _('DHCP proxy does not have WOL feature enabled') } + end + + begin + wol_api = ProxyAPI::Wol.new(:url => dhcp_proxy.url) + result = wol_api.wake(primary_interface.mac) + render :json => { :wol => result }, :status => :ok + rescue => e + render_error :custom_error, :status => :unprocessable_entity, :locals => { :message => e.message } + end + end + api :POST, "/hosts/facts", N_("Upload facts for a host, creating the host if required") param :name, String, :required => true, :desc => N_("hostname of the host") param :facts, Hash, :required => true, :desc => N_("hash containing the facts for the host") @@ -382,6 +414,8 @@ def action_permission :power when 'boot' :ipmi_boot + when 'wol' + :power when 'console' :console when 'disassociate', 'forget_status' @@ -397,7 +431,7 @@ def action_permission def parent_permission(child_permission) case child_permission.to_s - when 'power', 'boot', 'console', 'vm_compute_attributes', 'get_status', 'template', 'enc', 'rebuild_config', 'inherited_parameters' + when 'power', 'boot', 'wol', 'console', 'vm_compute_attributes', 'get_status', 'template', 'enc', 'rebuild_config', 'inherited_parameters' 'view' when 'disassociate' 'edit' diff --git a/app/services/proxy_api/wol.rb b/app/services/proxy_api/wol.rb new file mode 100644 index 00000000000..7c8c15b213c --- /dev/null +++ b/app/services/proxy_api/wol.rb @@ -0,0 +1,20 @@ +module ProxyAPI + class Wol < ProxyAPI::Resource + def initialize(args) + @url = args[:url] + "/wol" + super args + end + + # Send Wake on LAN request + # [+mac+] : MAC address in coloned sextuplet format + # Returns : Boolean status + def wake(mac) + raise "Must define a MAC address" if mac.blank? + + params = { :mac_address => mac } + parse post(params) + rescue => e + raise ProxyException.new(url, e, N_("Unable to send Wake on LAN request for MAC %s"), mac) + end + end +end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 6d29d1a8f11..e1a4c22c466 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -285,7 +285,7 @@ :"api/v2/hosts" => [:rebuild_config], } map.permission :power_hosts, {:hosts => [:power], - :"api/v2/hosts" => [:power, :power_status] } + :"api/v2/hosts" => [:power, :power_status, :wol] } map.permission :console_hosts, {:hosts => [:console] } map.permission :ipmi_boot_hosts, { :hosts => [:ipmi_boot], :"api/v2/hosts" => [:boot] } diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 74ce286d37c..688b9bc67c4 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -319,6 +319,7 @@ put :boot, :on => :member get :power, :on => :member, :action => :power_status put :power, :on => :member + put :wol, :on => :member put :rebuild_config, :on => :member get :inherited_parameters, :on => :member post :facts, :on => :collection diff --git a/db/seeds.d/110-smart_proxy_features.rb b/db/seeds.d/110-smart_proxy_features.rb index f89624b49eb..07c3703be16 100644 --- a/db/seeds.d/110-smart_proxy_features.rb +++ b/db/seeds.d/110-smart_proxy_features.rb @@ -1,6 +1,6 @@ # Proxy features proxy_features = ["Templates", "TFTP", "DNS", "DHCP", "Puppet CA", "BMC", "Realm", "Facts", "Logs", "HTTPBoot", "External IPAM", - "Registration"] + "Registration", "WOL"] proxy_features.each do |input| f = Feature.where(:name => input).first_or_create diff --git a/test/controllers/api/v2/hosts_controller_test.rb b/test/controllers/api/v2/hosts_controller_test.rb index fdcae6d8111..1cec127e39a 100644 --- a/test/controllers/api/v2/hosts_controller_test.rb +++ b/test/controllers/api/v2/hosts_controller_test.rb @@ -1427,4 +1427,127 @@ def setup assert_equal json_response['facet_param'], 'bar' end end + + context 'Wake-on-LAN (WOL) tests' do + setup do + @dhcp_proxy = FactoryBot.create(:dhcp_smart_proxy) + @dhcp_proxy.features << FactoryBot.create(:feature, :name => 'WOL') + @subnet = FactoryBot.create(:subnet_ipv4, :dhcp => @dhcp_proxy) + @host = FactoryBot.create(:host, :managed) + @primary_interface = FactoryBot.create(:nic_primary_and_provision, + :host => @host, + :subnet => @subnet, + :mac => '00:61:23:18:48:a5') + @host.interfaces = [@primary_interface] + @host.save! + User.current = users(:apiadmin) + end + + test 'should send WOL request successfully' do + ProxyAPI::Wol.any_instance.expects(:wake).with('00:61:23:18:48:a5').returns(true) + + put :wol, params: { :id => @host.to_param } + + assert_response :success + response_body = JSON.parse(@response.body) + assert_equal true, response_body['wol'] + end + + test 'should return error when host has no primary interface' do + @host.interfaces = [] + @host.save! + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Host has no primary interface/, response_body['error']['message']) + end + + test 'should return error when primary interface has no MAC address' do + @primary_interface.update_attribute(:mac, nil) + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Primary interface has no MAC address/, response_body['error']['message']) + end + + test 'should return error when primary interface has empty MAC address' do + @primary_interface.update_attribute(:mac, '') + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Primary interface has no MAC address/, response_body['error']['message']) + end + + test 'should return error when primary interface subnet has no DHCP proxy' do + @primary_interface.subnet.update_attribute(:dhcp, nil) + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Primary interface subnet has no DHCP proxy configured/, response_body['error']['message']) + end + + test 'should return error when DHCP proxy does not have WOL feature' do + @dhcp_proxy.features.delete_all + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/DHCP proxy does not have WOL feature enabled/, response_body['error']['message']) + end + + test 'should handle ProxyException with proper error message' do + proxy_exception = ProxyException.new('http://proxy.example.com/wol', + StandardError.new('Network error'), + N_("Unable to send Wake on LAN request for MAC %s"), + '00:61:23:18:48:a5') + ProxyAPI::Wol.any_instance.expects(:wake).with('00:61:23:18:48:a5').raises(proxy_exception) + + put :wol, params: { :id => @host.to_param } + + assert_response :unprocessable_entity + response_body = JSON.parse(@response.body) + assert_match(/Unable to send Wake on LAN request/, response_body['error']['message']) + end + + test 'should return 404 for non-existent host' do + put :wol, params: { :id => 'non-existent-host' } + + assert_response :not_found + end + + context 'permissions' do + setup do + setup_user 'view', 'hosts' + setup_user 'power', 'hosts' + end + + test 'should allow user with power permission to send WOL request' do + ProxyAPI::Wol.any_instance.expects(:wake).with('00:61:23:18:48:a5').returns(true) + + put :wol, params: { :id => @host.to_param }, session: set_session_user.merge(:user => @one.id) + + assert_response :success + response_body = JSON.parse(@response.body) + assert_equal true, response_body['wol'] + end + + test 'should deny user without power permission' do + setup_user 'view', 'hosts' + # Don't add power permission + + put :wol, params: { :id => @host.to_param }, session: set_session_user.merge(:user => @one.id) + + assert_response :forbidden + end + end + end end diff --git a/test/unit/proxy_api/wol_test.rb b/test/unit/proxy_api/wol_test.rb new file mode 100644 index 00000000000..64ca801381b --- /dev/null +++ b/test/unit/proxy_api/wol_test.rb @@ -0,0 +1,69 @@ +require 'test_helper' + +class ProxyApiWolTest < ActiveSupport::TestCase + def setup + @url = "http://dummyproxy.theforeman.org:8443" + @wol_proxy = ProxyAPI::Wol.new({:url => @url}) + end + + test "base url should equal /wol" do + expected = @url + "/wol" + assert_equal(expected, @wol_proxy.url) + end + + test "wake should raise exception when MAC address is nil" do + exception = assert_raise ProxyAPI::ProxyException do + @wol_proxy.wake(nil) + end + assert_includes(exception.message, "Must define a MAC address") + end + + test "wake should handle proxy communication errors" do + mac = "00:11:22:33:44:55" + error = StandardError.new("Connection timeout") + + @wol_proxy.stubs(:post).raises(error) + + exception = assert_raise ProxyAPI::ProxyException do + @wol_proxy.wake(mac) + end + + assert_match(/Unable to send Wake on LAN request for MAC/, exception.message) + assert_match(/#{mac}/, exception.message) + end + + test "wake should handle JSON parsing errors" do + mac = "00:11:22:33:44:55" + expected_params = { :mac_address => mac } + + @wol_proxy.stubs(:post).with(expected_params).returns(fake_rest_client_response("invalid json")) + @wol_proxy.stubs(:parse).raises(JSON::ParserError.new("Invalid JSON")) + + exception = assert_raise ProxyAPI::ProxyException do + @wol_proxy.wake(mac) + end + + assert_match(/Unable to send Wake on LAN request for MAC/, exception.message) + assert_match(/#{mac}/, exception.message) + end + + test "wake should return parsed response on success" do + mac = "00:11:22:33:44:55" + expected_params = { :mac_address => mac } + response_data = { "status" => "success", "result" => true } + + @wol_proxy.stubs(:post).with(expected_params).returns(fake_rest_client_response(response_data)) + @wol_proxy.stubs(:parse).returns(response_data) + + result = @wol_proxy.wake(mac) + assert_equal(response_data, result) + end + + private + + def fake_rest_client_response(data) + response = mock('RestClient::Response') + response.stubs(:body).returns(data.to_json) + response + end +end From aff151ef2962a29ecf1f7286fe67d663430bfd6d Mon Sep 17 00:00:00 2001 From: Marek Hulan Date: Tue, 8 Jul 2025 20:16:45 +0000 Subject: [PATCH 2/3] Refs #38549 - add UI for WOL --- .../__tests__/ActionsBar.fixtures.js | 43 ++ .../ActionsBar/__tests__/ActionsBar.test.js | 40 ++ .../__snapshots__/ActionsBar.test.js.snap | 373 ++++++++++++++++++ .../__snapshots__/actions.test.js.snap | 33 ++ .../ActionsBar/__tests__/actions.test.js | 13 + .../HostDetails/ActionsBar/actions.js | 16 + .../HostDetails/ActionsBar/index.js | 20 +- 7 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.fixtures.js create mode 100644 webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.test.js create mode 100644 webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/ActionsBar.test.js.snap create mode 100644 webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/actions.test.js.snap create mode 100644 webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/actions.test.js diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.fixtures.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.fixtures.js new file mode 100644 index 00000000000..9ae58873ca9 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.fixtures.js @@ -0,0 +1,43 @@ +export const mockHost = { + hostId: 1, + hostFriendlyId: 'test-host.example.com', + hostName: 'test-host.example.com', + computeId: 123, + isBuild: false, +}; + +export const basePermissions = { + destroy_hosts: true, + create_hosts: true, + edit_hosts: true, + build_hosts: true, + power_hosts: true, +}; + +export const noPermissions = { + destroy_hosts: false, + create_hosts: false, + edit_hosts: false, + build_hosts: false, + power_hosts: false, +}; + +export const noPowerPermissions = { + ...basePermissions, + power_hosts: false, +}; + +export const baseProps = { + ...mockHost, + permissions: basePermissions, +}; + +export const noPowerProps = { + ...mockHost, + permissions: noPowerPermissions, +}; + +export const noPermissionProps = { + ...mockHost, + permissions: noPermissions, +}; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.test.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.test.js new file mode 100644 index 00000000000..2c03f0727f0 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/ActionsBar.test.js @@ -0,0 +1,40 @@ +import { shallowRenderComponentWithFixtures } from '../../../../common/testHelpers'; +import ActionsBar from '../index'; +import { + baseProps, + noPowerProps, + noPermissionProps +} from './ActionsBar.fixtures'; + +// Mock the required external dependencies +jest.mock('../../../../../foreman_navigation', () => ({ + visit: jest.fn(), +})); + +jest.mock('../../../../Root/Context/ForemanContext', () => ({ + useForemanSettings: () => ({ destroyVmOnHostDelete: false }), + useForemanHostsPageUrl: () => '/hosts', +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => []), + useDispatch: () => jest.fn(), + connect: jest.fn(() => (component) => component), +})); + +const fixtures = { + 'renders ActionsBar with all permissions (including power)': baseProps, + 'renders ActionsBar without power permissions': noPowerProps, + 'renders ActionsBar without any permissions': noPermissionProps, +}; + +describe('ActionsBar', () => { + describe('rendering', () => { + const components = shallowRenderComponentWithFixtures(ActionsBar, fixtures); + components.forEach(({ description, component }) => { + it(description, () => { + expect(component).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/ActionsBar.test.js.snap b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/ActionsBar.test.js.snap new file mode 100644 index 00000000000..4096f222345 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/ActionsBar.test.js.snap @@ -0,0 +1,373 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionsBar rendering renders ActionsBar with all permissions (including power) 1`] = ` + + + + + + + + + } + isDisabled={false} + onClick={[Function]} + ouiaId="build-dropdown-item" + > + Build + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="clone-dropdown-item" + > + Clone + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="delete-dropdown-item" + > + Delete + , + , + } + isAriaDisabled={false} + onClick={[Function]} + ouiaId="console-dropdown-item" + > + Console + , + } + onClick={[Function]} + ouiaId="fact-dropdown-item" + > + Facts + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="wol-dropdown-item" + > + Wake on LAN + , + , + } + ouiaId="pre-version-dropdown-item" + > + Legacy UI + , + ] + } + isOpen={false} + isPlain={true} + ouiaId="kebab-dropdown" + toggle={ + + } + /> + + + + +`; + +exports[`ActionsBar rendering renders ActionsBar without any permissions 1`] = ` + + + + + + + + + } + isDisabled={true} + onClick={[Function]} + ouiaId="build-dropdown-item" + > + Build + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="clone-dropdown-item" + > + Clone + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="delete-dropdown-item" + > + Delete + , + , + } + isAriaDisabled={false} + onClick={[Function]} + ouiaId="console-dropdown-item" + > + Console + , + } + onClick={[Function]} + ouiaId="fact-dropdown-item" + > + Facts + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="wol-dropdown-item" + > + Wake on LAN + , + , + } + ouiaId="pre-version-dropdown-item" + > + Legacy UI + , + ] + } + isOpen={false} + isPlain={true} + ouiaId="kebab-dropdown" + toggle={ + + } + /> + + + + +`; + +exports[`ActionsBar rendering renders ActionsBar without power permissions 1`] = ` + + + + + + + + + } + isDisabled={false} + onClick={[Function]} + ouiaId="build-dropdown-item" + > + Build + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="clone-dropdown-item" + > + Clone + , + } + isDisabled={false} + onClick={[Function]} + ouiaId="delete-dropdown-item" + > + Delete + , + , + } + isAriaDisabled={false} + onClick={[Function]} + ouiaId="console-dropdown-item" + > + Console + , + } + onClick={[Function]} + ouiaId="fact-dropdown-item" + > + Facts + , + } + isDisabled={true} + onClick={[Function]} + ouiaId="wol-dropdown-item" + > + Wake on LAN + , + , + } + ouiaId="pre-version-dropdown-item" + > + Legacy UI + , + ] + } + isOpen={false} + isPlain={true} + ouiaId="kebab-dropdown" + toggle={ + + } + /> + + + + +`; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/actions.test.js.snap b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/actions.test.js.snap new file mode 100644 index 00000000000..db6f6ea0427 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/__snapshots__/actions.test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionsBar actions wakeOnLan should call Wake on LAN API action 1`] = ` +Array [ + Array [ + Object { + "payload": Object { + "errorToast": [Function], + "key": "1_WOL", + "successToast": [Function], + "url": "/api/hosts/1/wol", + }, + "type": "API_PUT", + }, + ], +] +`; + +exports[`ActionsBar actions wakeOnLan should call Wake on LAN API action with different host 1`] = ` +Array [ + Array [ + Object { + "payload": Object { + "errorToast": [Function], + "key": "42_WOL", + "successToast": [Function], + "url": "/api/hosts/42/wol", + }, + "type": "API_PUT", + }, + ], +] +`; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/actions.test.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/actions.test.js new file mode 100644 index 00000000000..2e8c93ef625 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/__tests__/actions.test.js @@ -0,0 +1,13 @@ +import { testActionSnapshotWithFixtures } from '../../../../common/testHelpers'; +import { wakeOnLan } from '../actions'; + +const fixtures = { + 'should call Wake on LAN API action': () => wakeOnLan(1, 'test-host.example.com'), + 'should call Wake on LAN API action with different host': () => wakeOnLan(42, 'another-host.example.com'), +}; + +describe('ActionsBar actions', () => { + describe('wakeOnLan', () => { + testActionSnapshotWithFixtures(fixtures); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js index c362380c2d3..135e42af681 100644 --- a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/actions.js @@ -110,3 +110,19 @@ export const isHostTurnOn = store => { const { state } = selectAPIResponse(store, POWER_REQURST_KEY); return state === 'on'; }; + +export const wakeOnLan = (hostId, hostName) => dispatch => { + const successToast = () => __('Wake on LAN signal has been sent'); + const errorToast = ({ message }) => + message || __('Sending Wake on LAN signal has failed'); + const url = foremanUrl(`/api/hosts/${hostId}/wol`); + + dispatch( + APIActions.put({ + url, + key: `${hostId}_WOL`, + successToast, + errorToast, + }) + ); +}; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js index c3b4622d4f6..2d020e00f57 100644 --- a/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js +++ b/webpack/assets/javascripts/react_app/components/HostDetails/ActionsBar/index.js @@ -15,12 +15,13 @@ import { UndoIcon, BuildIcon, TerminalIcon, + PlayIcon, } from '@patternfly/react-icons'; import { visit } from '../../../../foreman_navigation'; import { translate as __ } from '../../../common/I18n'; import { selectKebabItems } from './Selectors'; import { foremanUrl } from '../../../common/helpers'; -import { cancelBuild, deleteHost, isHostTurnOn } from './actions'; +import { cancelBuild, deleteHost, isHostTurnOn, wakeOnLan } from './actions'; import { useForemanSettings, useForemanHostsPageUrl, @@ -46,6 +47,7 @@ const ActionsBar = ({ create_hosts: canCreate, edit_hosts: canEdit, build_hosts: canBuild, + power_hosts: canPower, }, }) => { const [kebabIsOpen, setKebab] = useState(false); @@ -62,6 +64,11 @@ const ActionsBar = ({ deleteHost(hostName, computeId, destroyVmOnHostDelete, hostsIndexUrl) ); + const wakeOnLanHandler = () => { + dispatch(wakeOnLan(hostId, hostName)); + setKebab(false); + }; + const isConsoleDisabled = !(computeId && isHostActive); const determineTooltip = () => { if (isConsoleDisabled) { @@ -132,6 +139,16 @@ const ActionsBar = ({ > {__('Facts')} , + } + > + {__('Wake on LAN')} + , , Date: Fri, 11 Jul 2025 16:29:00 +0000 Subject: [PATCH 3/3] Refs #38549 - update the guidance --- test/unit/shared/access_permissions_test_base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/shared/access_permissions_test_base.rb b/test/unit/shared/access_permissions_test_base.rb index d4cfd0ecbbd..56e142bd805 100644 --- a/test/unit/shared/access_permissions_test_base.rb +++ b/test/unit/shared/access_permissions_test_base.rb @@ -27,7 +27,7 @@ def check_routes(app_routes, skipped_actions, skip_patterns: [/^(api\/v2\/)?(dum # Pass if the controller deliberately skips login requirement next if controller < ApplicationController && filters.select { |f| f.filter == :require_login }.empty? - assert_not_empty Foreman::AccessControl.permissions.select { |p| p.actions.include? path }, "permission for #{path} not found, check access_permissions.rb" + assert_not_empty Foreman::AccessControl.permissions.select { |p| p.actions.include? path }, "permission for #{path} not found, check config/initializers/f_foreman_permissions.rb.rb" end end end