diff --git a/app/controllers/api/v2/bulk_snapshots_controller.rb b/app/controllers/api/v2/bulk_snapshots_controller.rb
index 8446f5a..6c8afed 100644
--- a/app/controllers/api/v2/bulk_snapshots_controller.rb
+++ b/app/controllers/api/v2/bulk_snapshots_controller.rb
@@ -3,12 +3,27 @@
module Api
module V2
class BulkSnapshotsController < V2::BaseController
- SNAPSHOT_MODES = %w[full include_ram quiesce].freeze
+ include ::Api::V2::BulkHostsExtension
- before_action :validate_hosts, :validate_snapshot, :validate_mode, only: :create
+ SNAPSHOT_MODES = ForemanSnapshotManagement::Extensions::BulkHostsManager::SNAPSHOT_MODES
+
+ before_action :validate_snapshot, :validate_mode, :find_snapshotable_hosts, only: :create
api :POST, '/snapshots/bulk/create_snapshot', N_('Create snapshots for multiple hosts')
- param :host_ids, Array, of: Integer, desc: N_('Array of host IDs to create snapshots for'), required: true
+
+ param :organization_id, :number, required: true, desc: N_('ID of the organization')
+
+ param :included, Hash, required: true, action_aware: true do
+ param :search, String, required: false,
+ desc: N_('Search string for hosts to perform an action on')
+ param :ids, Array, required: false,
+ desc: N_('List of host ids to perform an action on')
+ end
+
+ param :excluded, Hash, required: true, action_aware: true do
+ param :ids, Array, required: false,
+ desc: N_('List of host ids to exclude and not run an action on')
+ end
param :snapshot, Hash, desc: N_('Snapshot parameters'), required: true do
param :name, String, desc: N_('Name of the snapshot'), required: true
@@ -21,17 +36,21 @@ class BulkSnapshotsController < V2::BaseController
required: false
def create
- include_ram, quiesce = resolve_mode(params[:mode])
-
- results = @hosts.map do |host|
- process_host(host, @snapshot_name, @snapshot_description, include_ram, quiesce)
- end
+ results = ::BulkHostsManager.new(hosts: @hosts).create_snapshots(
+ name: @snapshot_name,
+ description: @snapshot_description,
+ mode: params[:mode]
+ )
render_results(results)
end
private
+ def find_snapshotable_hosts
+ find_bulk_hosts(:create_snapshots, params)
+ end
+
def validate_mode
mode = params[:mode]
return true if mode.blank? || SNAPSHOT_MODES.include?(mode)
@@ -46,44 +65,6 @@ def validate_mode
false
end
- def validate_hosts
- host_ids = params[:host_ids]
-
- if host_ids.blank?
- render(
- json: { error: _('host_ids parameter is required and cannot be empty') },
- status: :unprocessable_entity
- )
- return false
- end
-
- unless host_ids.is_a?(Array)
- render(
- json: { error: _('host_ids must be an array') },
- status: :unprocessable_entity
- )
- return false
- end
-
- @requested_host_ids = host_ids.map(&:to_i)
- resource_base = Host.authorized("#{action_permission}_snapshots".to_sym, Host).friendly
- @hosts = resource_base.where(id: @requested_host_ids)
-
- found_ids = @hosts.pluck(:id)
- missing_ids = @requested_host_ids - found_ids
-
- return true if missing_ids.empty?
-
- render(
- json: {
- error: _('Some host_ids are invalid'),
- invalid_ids: missing_ids,
- },
- status: :unprocessable_entity
- )
- false
- end
-
def validate_snapshot
snap = params[:snapshot] || {}
@snapshot_name = snap[:name].to_s.strip
@@ -101,13 +82,7 @@ def validate_snapshot
def render_results(results)
failed_hosts = results.select { |r| r[:status] == 'failed' }
failed_count = failed_hosts.length
-
- status =
- if failed_count.zero?
- :ok
- else
- :unprocessable_entity
- end
+ status = failed_count.zero? ? :ok : :unprocessable_entity
render(
json: {
@@ -121,66 +96,6 @@ def render_results(results)
)
end
- def resolve_mode(mode)
- case mode
- when 'include_ram'
- [true, false]
- when 'quiesce'
- [false, true]
- else
- [false, false]
- end
- end
-
- def process_host(host, name, description, include_ram, quiesce)
- snapshot = ForemanSnapshotManagement::Snapshot.new(
- name: name,
- description: description,
- include_ram: include_ram,
- quiesce: quiesce,
- host: host
- )
-
- if snapshot.create
- {
- host_id: host.id,
- host_name: host.name,
- status: 'success',
- }
- else
- errors = snapshot.errors.full_messages
- errors << quiesce_error_message if quiesce
-
- {
- host_id: host.id,
- host_name: host.name,
- status: 'failed',
- errors: errors,
- }
- end
- rescue StandardError => e
- Foreman::Logging.exception(
- "Failed to create snapshot for host #{host.name} (ID: #{host.id})",
- e
- )
- {
- host_id: host.id,
- host_name: host.name,
- status: 'failed',
- errors: [_('Snapshot creation failed: %s') % e.message],
- }
- end
-
- def quiesce_error_message
- _(
- 'Unable to create VMWare Snapshot with Quiesce. Check Power and VMWare Tools status.'
- )
- end
-
- def action_permission
- :create
- end
-
def resource_class
::ForemanSnapshotManagement::Snapshot
end
diff --git a/app/models/foreman_snapshot_management/extensions/bulk_hosts_manager.rb b/app/models/foreman_snapshot_management/extensions/bulk_hosts_manager.rb
new file mode 100644
index 0000000..05cc182
--- /dev/null
+++ b/app/models/foreman_snapshot_management/extensions/bulk_hosts_manager.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+module ForemanSnapshotManagement
+ module Extensions
+ module BulkHostsManager
+ extend ActiveSupport::Concern
+
+ SNAPSHOT_MODES = %w[full include_ram quiesce].freeze
+ QUIESCE_ERROR_MESSAGE =
+ _('Unable to create VMWare Snapshot with Quiesce. Check Power and VMWare Tools status.').freeze
+
+ def create_snapshots(name:, description:, mode:)
+ include_ram, quiesce = resolve_mode(mode)
+
+ @hosts.map do |host|
+ process_snapshot_for_host(
+ host: host,
+ name: name,
+ description: description,
+ include_ram: include_ram,
+ quiesce: quiesce
+ )
+ end
+ end
+
+ private
+
+ def resolve_mode(mode)
+ case mode
+ when 'include_ram'
+ [true, false]
+ when 'quiesce'
+ [false, true]
+ else
+ [false, false]
+ end
+ end
+
+ def process_snapshot_for_host(host:, name:, description:, include_ram:, quiesce:)
+ validation_error = validate_host(host)
+ return validation_error if validation_error
+
+ create_snapshot_for_host(host, name, description, include_ram, quiesce)
+ rescue ActiveRecord::RecordNotFound => e
+ handle_record_not_found(host, e)
+ rescue StandardError => e
+ handle_snapshot_error(host, e)
+ end
+
+ def validate_host(host)
+ if host.compute_resource.nil?
+ return {
+ host_id: host.id,
+ host_name: host.name,
+ status: 'failed',
+ errors: [_('Host has no compute resource, cannot create snapshot.')],
+ }
+ end
+
+ return if host.uuid.present?
+
+ {
+ host_id: host.id,
+ host_name: host.name,
+ status: 'failed',
+ errors: [_('Host has no UUID, cannot create snapshot.')],
+ }
+ end
+
+ def create_snapshot_for_host(host, name, description, include_ram, quiesce)
+ snapshot = ::ForemanSnapshotManagement::Snapshot.new(
+ name: name,
+ description: description,
+ include_ram: include_ram,
+ quiesce: quiesce,
+ host: host
+ )
+
+ if snapshot.create
+ { host_id: host.id, host_name: host.name, status: 'success' }
+ else
+ errors = snapshot.errors.full_messages
+ errors << QUIESCE_ERROR_MESSAGE if quiesce
+ { host_id: host.id, host_name: host.name, status: 'failed', errors: errors }
+ end
+ end
+
+ def handle_record_not_found(host, exception)
+ ::Foreman::Logging.exception(
+ "Failed to create snapshot for host #{host.name} (ID: #{host.id})",
+ exception
+ )
+
+ cr_name = host.compute_resource&.name
+ cr_name = "#{cr_name} " if cr_name.present?
+
+ msg =
+ _('VM details could not be retrieved from compute resource %sThe VM may be missing/deleted or inaccessible.') % cr_name
+
+ {
+ host_id: host.id,
+ host_name: host.name,
+ status: 'failed',
+ errors: [msg],
+ }
+ end
+
+ def handle_snapshot_error(host, exception)
+ ::Foreman::Logging.exception(
+ "Failed to create snapshot for host #{host.name} (ID: #{host.id})",
+ exception
+ )
+
+ {
+ host_id: host.id,
+ host_name: host.name,
+ status: 'failed',
+ errors: [_('Snapshot creation failed: %s') % exception.message],
+ }
+ end
+ end
+ end
+end
diff --git a/lib/foreman_snapshot_management/engine.rb b/lib/foreman_snapshot_management/engine.rb
index 3d02de0..a177794 100644
--- a/lib/foreman_snapshot_management/engine.rb
+++ b/lib/foreman_snapshot_management/engine.rb
@@ -91,6 +91,8 @@ class Engine < ::Rails::Engine
# Include concerns in this config.to_prepare block
config.to_prepare do
begin
+ ::BulkHostsManager.include ForemanSnapshotManagement::Extensions::BulkHostsManager
+
::ForemanFogProxmox::Proxmox.prepend ForemanSnapshotManagement::ProxmoxExtensions
# Load Fog extensions
diff --git a/test/controllers/api/v2/bulk_snapshots_controller_test.rb b/test/controllers/api/v2/bulk_snapshots_controller_test.rb
index 34a1429..0a1a8a0 100644
--- a/test/controllers/api/v2/bulk_snapshots_controller_test.rb
+++ b/test/controllers/api/v2/bulk_snapshots_controller_test.rb
@@ -55,7 +55,15 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
setup { ::Fog.mock! }
teardown { ::Fog.unmock! }
- context 'view user without create permissions' do
+ def bulk_params(ids:)
+ {
+ :organization_id => tax_organization.id,
+ :included => { :ids => ids },
+ :excluded => { :ids => [] },
+ }
+ end
+
+ context 'user without edit_hosts permissions (bulk selection gate)' do
setup do
host1
host2
@@ -67,11 +75,10 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
User.current = @orig_user
end
- test 'should refute create bulk snapshots without permission' do
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => { :name => 'test' },
- }
+ test 'should forbid bulk snapshot create when user cannot bulk-select hosts' do
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :name => 'test' }
+ )
assert_response :forbidden
end
end
@@ -83,6 +90,8 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
host_without_compute
@orig_user = User.current
User.current = manager_user
+
+ ForemanSnapshotManagement::Snapshot.any_instance.stubs(:create).returns(true)
end
teardown do
@@ -90,97 +99,44 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
end
test 'should return 422 when snapshot parameter is missing' do
- post :create, params: { :host_ids => [host1.id, host2.id] }
+ post :create, params: bulk_params(ids: [host1.id, host2.id])
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_includes body['error'], 'snapshot.name is required'
end
test 'should return 422 when snapshot name is missing' do
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => { :description => 'test description' },
- }
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :description => 'test description' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_includes body['error'], 'snapshot.name is required'
end
test 'should return 422 when snapshot name is empty string' do
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => { :name => '' },
- }
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :name => '' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_includes body['error'], 'snapshot.name is required'
end
test 'should return 422 when snapshot name is only whitespace' do
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => { :name => ' ' },
- }
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :name => ' ' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_includes body['error'], 'snapshot.name is required'
end
- test 'should return error when host_ids parameter is missing' do
- post :create, params: { :snapshot => { :name => 'test' } }
- assert_response :unprocessable_entity
- body = ActiveSupport::JSON.decode(@response.body)
- assert_includes body['error'], 'host_ids'
- end
-
- test 'should return error when host_ids is empty array' do
- post :create, params: {
- :host_ids => [],
- :snapshot => { :name => 'test' },
- }
- assert_response :unprocessable_entity
- body = ActiveSupport::JSON.decode(@response.body)
- assert_includes body['error'], 'host_ids'
- end
-
- test 'should return error when host_ids contains non-existent IDs' do
- post :create, params: {
- :host_ids => [99_999, 88_888],
- :snapshot => { :name => 'test' },
- }
- assert_response :unprocessable_entity
- body = ActiveSupport::JSON.decode(@response.body)
- assert_includes body['error'], 'host_ids'
- end
-
- test 'should return error when some host_ids are invalid' do
- post :create, params: {
- :host_ids => [host1.id, 99_999],
- :snapshot => { :name => 'test' },
- }
-
- assert_response :unprocessable_entity
- body = ActiveSupport::JSON.decode(@response.body)
- assert_includes body['error'], 'Some host_ids are invalid'
- assert_equal [99_999], body['invalid_ids']
- end
-
- test 'should return error when host_ids is not an array' do
- post :create, params: {
- :host_ids => 'not-an-array',
- :snapshot => { :name => 'test' },
- }
- assert_response :unprocessable_entity
- body = ActiveSupport::JSON.decode(@response.body)
- assert_includes body['error'], 'host_ids'
- end
-
test 'should create snapshots with include_ram mode' do
- post :create, params: {
- :host_ids => [host1.id],
+ post :create, params: bulk_params(ids: [host1.id]).merge(
:snapshot => { :name => 'test_with_ram' },
- :mode => 'include_ram',
- }
+ :mode => 'include_ram'
+ )
assert_response :ok
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['total']
@@ -188,11 +144,10 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
end
test 'should create snapshots with full mode' do
- post :create, params: {
- :host_ids => [host1.id],
+ post :create, params: bulk_params(ids: [host1.id]).merge(
:snapshot => { :name => 'test_without_ram' },
- :mode => 'full',
- }
+ :mode => 'full'
+ )
assert_response :ok
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['total']
@@ -200,11 +155,10 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
end
test 'should create snapshots with quiesce mode' do
- post :create, params: {
- :host_ids => [host1.id],
+ post :create, params: bulk_params(ids: [host1.id]).merge(
:snapshot => { :name => 'test_quiesce' },
- :mode => 'quiesce',
- }
+ :mode => 'quiesce'
+ )
assert_response :ok
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['total']
@@ -212,23 +166,17 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
end
test 'should return 422 when mode is invalid' do
- post :create, params: {
- :host_ids => [host1.id],
+ post :create, params: bulk_params(ids: [host1.id]).merge(
:snapshot => { :name => 'invalid_mode_snapshot' },
- :mode => 'invalid_mode',
- }
-
+ :mode => 'invalid_mode'
+ )
assert_response :unprocessable_entity
end
test 'should successfully create snapshot for single host' do
- post :create, params: {
- :host_ids => [host1.id],
- :snapshot => {
- :name => 'successful_snapshot',
- :description => 'Test description',
- },
- }
+ post :create, params: bulk_params(ids: [host1.id]).merge(
+ :snapshot => { :name => 'successful_snapshot', :description => 'Test description' }
+ )
assert_response :ok
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['total']
@@ -241,41 +189,31 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
end
test 'should successfully create snapshots for multiple hosts' do
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => {
- :name => 'bulk_snapshot',
- :description => 'Bulk test',
- },
- }
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :name => 'bulk_snapshot', :description => 'Bulk test' }
+ )
assert_response :ok
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 2, body['total']
assert_equal 2, body['success_count']
assert_equal 0, body['failed_count']
-
statuses = body['results'].map { |r| r['status'] }
assert_equal ['success', 'success'], statuses
end
test 'should include snapshot description in request' do
- post :create, params: {
- :host_ids => [host1.id],
- :snapshot => {
- :name => 'test',
- :description => 'Detailed description here',
- },
- }
+ post :create, params: bulk_params(ids: [host1.id]).merge(
+ :snapshot => { :name => 'test', :description => 'Detailed description here' }
+ )
assert_response :ok
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['success_count']
end
test 'should handle snapshot without description' do
- post :create, params: {
- :host_ids => [host1.id],
- :snapshot => { :name => 'test' },
- }
+ post :create, params: bulk_params(ids: [host1.id]).merge(
+ :snapshot => { :name => 'test' }
+ )
assert_response :ok
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['success_count']
@@ -287,10 +225,9 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
OpenStruct.new(full_messages: ['Snapshot creation failed'])
)
- post :create, params: {
- :host_ids => [host1.id],
- :snapshot => { :name => 'failing_snapshot' },
- }
+ post :create, params: bulk_params(ids: [host1.id]).merge(
+ :snapshot => { :name => 'failing_snapshot' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['total']
@@ -305,10 +242,9 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
StandardError.new('Unexpected error')
)
- post :create, params: {
- :host_ids => [host1.id],
- :snapshot => { :name => 'exception_snapshot' },
- }
+ post :create, params: bulk_params(ids: [host1.id]).merge(
+ :snapshot => { :name => 'exception_snapshot' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['total']
@@ -323,10 +259,9 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
OpenStruct.new(full_messages: ['Error message'])
)
- post :create, params: {
- :host_ids => [host1.id],
- :snapshot => { :name => 'test' },
- }
+ post :create, params: bulk_params(ids: [host1.id]).merge(
+ :snapshot => { :name => 'test' }
+ )
body = ActiveSupport::JSON.decode(@response.body)
assert_nil body['results'].first['errors']
@@ -335,15 +270,14 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
end
test 'should handle mixed success and failure results' do
- ForemanSnapshotManagement::Snapshot.any_instance.stubs(:create).returns(true).then.returns(false)
+ ForemanSnapshotManagement::Snapshot.any_instance.stubs(:create).returns(true, false)
ForemanSnapshotManagement::Snapshot.any_instance.stubs(:errors).returns(
OpenStruct.new(full_messages: ['Failed for this host'])
)
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => { :name => 'mixed_results' },
- }
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :name => 'mixed_results' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 2, body['total']
@@ -355,26 +289,23 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
test 'should return 422 when any host fails' do
ForemanSnapshotManagement::Snapshot.any_instance.stubs(:create).returns(false)
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => { :name => 'test' },
- }
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :name => 'test' }
+ )
assert_response :unprocessable_entity
end
test 'should return 200 when all hosts succeed' do
- post :create, params: {
- :host_ids => [host1.id, host2.id],
- :snapshot => { :name => 'test' },
- }
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
+ :snapshot => { :name => 'test' }
+ )
assert_response :ok
end
test 'should handle host without compute resource' do
- post :create, params: {
- :host_ids => [host_without_compute.id],
- :snapshot => { :name => 'test' },
- }
+ post :create, params: bulk_params(ids: [host_without_compute.id]).merge(
+ :snapshot => { :name => 'test' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, body['total']
@@ -383,10 +314,9 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
end
test 'should handle mix of hosts with and without compute resources' do
- post :create, params: {
- :host_ids => [host1.id, host_without_compute.id],
- :snapshot => { :name => 'test' },
- }
+ post :create, params: bulk_params(ids: [host1.id, host_without_compute.id]).merge(
+ :snapshot => { :name => 'test' }
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
assert_equal 2, body['total']
@@ -399,11 +329,10 @@ class Api::V2::BulkSnapshotsControllerTest < ActionController::TestCase
OpenStruct.new(full_messages: ['Underlying quiesce failure'])
)
- post :create, params: {
- :host_ids => [host1.id, host2.id],
+ post :create, params: bulk_params(ids: [host1.id, host2.id]).merge(
:snapshot => { :name => 'test_quiesce_fail' },
- :mode => 'quiesce',
- }
+ :mode => 'quiesce'
+ )
assert_response :unprocessable_entity
body = ActiveSupport::JSON.decode(@response.body)
diff --git a/webpack/components/SnapshotManagement/SnapshotManagementActions.js b/webpack/components/SnapshotManagement/SnapshotManagementActions.js
index f4566e0..b0b51dd 100644
--- a/webpack/components/SnapshotManagement/SnapshotManagementActions.js
+++ b/webpack/components/SnapshotManagement/SnapshotManagementActions.js
@@ -213,6 +213,54 @@ export const snapshotRollbackAction = (host, rowData) => async dispatch => {
}
};
+const buildHostsSearchHref = query =>
+ `/new/hosts?search=${encodeURIComponent(query)}`;
+
+const failedHostsToastParams = ({ message, failedHostIds, key }) => {
+ const { FAILURE } = actionTypeGenerator(key);
+
+ const toastParams = {
+ type: 'danger',
+ message,
+ key: FAILURE,
+ };
+
+ if (failedHostIds?.length) {
+ const query = `id ^ (${failedHostIds.join(',')})`;
+ toastParams.link = {
+ children: __('Failed hosts'),
+ href: buildHostsSearchHref(query),
+ };
+ }
+
+ return toastParams;
+};
+
+const buildEnhancedErrorMessage = ({
+ successCount,
+ failedCount,
+ failedHosts,
+}) => {
+ const base = sprintf(
+ successCount === 1
+ ? __('%d snapshot succeeded, %d failed.')
+ : __('%d snapshots succeeded, %d failed.'),
+ successCount,
+ failedCount
+ );
+
+ const uniqueErrors = [
+ ...new Set(
+ (failedHosts || []).flatMap(h => h.errors || []).filter(Boolean)
+ ),
+ ];
+
+ const extra = uniqueErrors.length
+ ? ` ${uniqueErrors.slice(0, 2).join(' ')}`
+ : '';
+ return `${base}${extra}`;
+};
+
export const bulkCreateSnapshotsAction = payload => async dispatch => {
const { REQUEST, SUCCESS, FAILURE } = actionTypeGenerator(
SNAPSHOT_BULK_CREATE
@@ -231,28 +279,34 @@ export const bulkCreateSnapshotsAction = payload => async dispatch => {
})
);
+ if (payload.hostId) {
+ dispatch(loadSnapshotList(payload.hostId));
+ }
+
return dispatch({ type: SUCCESS, payload, response: data });
} catch (error) {
- const data = error?.response?.data;
- const failedCount = data?.['failed_count'] || 0;
- const successCount = data?.['success_count'] || 0;
+ const data = error?.response?.data || {};
+
+ const failedHosts = data.failed_hosts || [];
+ const failedHostIds = failedHosts.map(h => h.host_id).filter(Boolean);
+
+ const failedCount = data.failed_count || failedHosts.length || 0;
+ const successCount = data.success_count || 0;
+
+ const message = buildEnhancedErrorMessage({
+ successCount,
+ failedCount,
+ failedHosts,
+ });
dispatch(
- addToast({
- type: 'error',
- message: sprintf(
- successCount === 1
- ? __(
- '%d snapshot succeeded, %d failed. Check production logs for details.'
- )
- : __(
- '%d snapshots succeeded, %d failed. Check production logs for details.'
- ),
- successCount,
- failedCount
- ),
- key: FAILURE,
- })
+ addToast(
+ failedHostsToastParams({
+ message,
+ failedHostIds,
+ key: SNAPSHOT_BULK_CREATE,
+ })
+ )
);
return dispatch({
diff --git a/webpack/components/SnapshotManagement/components/BulkActions/BulkSnapshotModalScene/BulkSnapshotModalScene.js b/webpack/components/SnapshotManagement/components/BulkActions/BulkSnapshotModalScene/BulkSnapshotModalScene.js
index fad8936..9779dc7 100644
--- a/webpack/components/SnapshotManagement/components/BulkActions/BulkSnapshotModalScene/BulkSnapshotModalScene.js
+++ b/webpack/components/SnapshotManagement/components/BulkActions/BulkSnapshotModalScene/BulkSnapshotModalScene.js
@@ -4,14 +4,24 @@ import WrappedSnapshotFormModal from '../../SnapshotFormModal';
import useSnapshotSubmit from '../../hooks/useSnapshotSubmit';
const BulkSnapshotModalScene = () => {
- const { selectedResults = [], selectedCount = 0 } =
- useContext(ForemanActionsBarContext) || {};
+ const {
+ selectedResults = [],
+ selectedCount = 0,
+ fetchBulkParams,
+ exclusionSet,
+ selectAllMode,
+ response = {},
+ } = useContext(ForemanActionsBarContext) || {};
const { handleSubmit } = useSnapshotSubmit();
return (
);
diff --git a/webpack/components/SnapshotManagement/components/SnapshotForm/SnapshotForm.js b/webpack/components/SnapshotManagement/components/SnapshotForm/SnapshotForm.js
index 0630b9d..53d7e5d 100644
--- a/webpack/components/SnapshotManagement/components/SnapshotForm/SnapshotForm.js
+++ b/webpack/components/SnapshotManagement/components/SnapshotForm/SnapshotForm.js
@@ -30,7 +30,11 @@ const SnapshotForm = ({
onSubmit,
selectedHosts,
setModalClosed,
+ fetchBulkParams,
host,
+ selectAllMode,
+ exclusionSet,
+ allHosts,
}) => {
let nameValidation = Yup.string().max(80, 'Too Long!');
if (capabilities.limitSnapshotNameFormat)
@@ -55,9 +59,13 @@ const SnapshotForm = ({
const [snapshotMode, setSnapshotMode] = useState('');
const [isSelectOpen, setIsSelectOpen] = useState(false);
+ const hostsToCheck = selectAllMode
+ ? allHosts.filter(h => !exclusionSet.has(h.id))
+ : selectedHosts;
+
const allSelectedSupportQuiesce =
- selectedHosts?.length > 0 &&
- selectedHosts.every(h =>
+ hostsToCheck?.length > 0 &&
+ hostsToCheck.every(h =>
h.capabilities?.includes('snapshot_include_quiesce')
);
@@ -101,25 +109,15 @@ const SnapshotForm = ({
},
};
- const hostsIds =
- Array.isArray(selectedHosts) && selectedHosts.length > 0
- ? selectedHosts.map(h => h.id)
- : [];
-
const singleHostId = host?.id;
- let ids = [];
- if (hostsIds.length > 0) {
- ids = hostsIds;
- } else if (singleHostId) {
- ids = [singleHostId];
- }
-
- if (ids.length === 0) {
- actions.setSubmitting(false);
- return;
- }
- const payload = { host_ids: ids, ...submitValues };
+ const payload = {
+ included: {
+ search: host ? `id = ${singleHostId}` : fetchBulkParams(),
+ },
+ ...(singleHostId && { hostId: singleHostId }),
+ ...submitValues,
+ };
await onSubmit(payload, actions);
} catch (error) {
if (actions.setStatus) {
@@ -298,6 +296,10 @@ SnapshotForm.propTypes = {
quiesceOption: PropTypes.bool,
}),
}),
+ fetchBulkParams: PropTypes.func.isRequired,
+ selectAllMode: PropTypes.bool,
+ exclusionSet: PropTypes.instanceOf(Set),
+ allHosts: PropTypes.array,
};
SnapshotForm.defaultProps = {
@@ -314,6 +316,9 @@ SnapshotForm.defaultProps = {
},
selectedHosts: [],
host: null,
+ selectAllMode: false,
+ exclusionSet: new Set(),
+ allHosts: [],
};
export default SnapshotForm;
diff --git a/webpack/components/SnapshotManagement/components/SnapshotForm/__tests__/SnapshotForm.test.js b/webpack/components/SnapshotManagement/components/SnapshotForm/__tests__/SnapshotForm.test.js
index a30345b..215c9f5 100644
--- a/webpack/components/SnapshotManagement/components/SnapshotForm/__tests__/SnapshotForm.test.js
+++ b/webpack/components/SnapshotManagement/components/SnapshotForm/__tests__/SnapshotForm.test.js
@@ -2,16 +2,20 @@ import { testComponentSnapshotsWithFixtures } from 'react-redux-test-utils';
import SnapshotForm from '../SnapshotForm';
+const fetchBulkParams = () => null;
+
const fixtures = {
- render: { hostId: 42, onSubmit: () => null },
+ render: { hostId: 42, onSubmit: () => null, fetchBulkParams },
'render with limitSnapshotNameFormat capability': {
hostId: 42,
onSubmit: () => null,
+ fetchBulkParams,
capabilities: { limitSnapshotNameFormat: true },
},
'render with optional Props': {
hostId: 42,
onSubmit: () => null,
+ fetchBulkParams,
initialValues: {
name: 'Snapshot1',
description: 'Hello World',
diff --git a/webpack/components/SnapshotManagement/components/SnapshotFormModal/SnapshotFormModal.js b/webpack/components/SnapshotManagement/components/SnapshotFormModal/SnapshotFormModal.js
index 0e54974..58b01bc 100644
--- a/webpack/components/SnapshotManagement/components/SnapshotFormModal/SnapshotFormModal.js
+++ b/webpack/components/SnapshotManagement/components/SnapshotFormModal/SnapshotFormModal.js
@@ -11,6 +11,7 @@ const SnapshotFormModal = ({
selectedHostsCount,
setModalClosed,
host,
+ fetchBulkParams,
...props
}) => {
const displayCount = selectedHostsCount === 0 ? 1 : selectedHostsCount;
@@ -50,6 +51,7 @@ const SnapshotFormModal = ({
setModalClosed={setModalClosed}
selectedHosts={selectedHosts}
host={host}
+ fetchBulkParams={fetchBulkParams}
{...props}
/>
)}
@@ -76,6 +78,7 @@ SnapshotFormModal.propTypes = {
quiesceOption: PropTypes.bool,
}),
}),
+ fetchBulkParams: PropTypes.func.isRequired,
};
SnapshotFormModal.defaultProps = {
diff --git a/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/SnapshotFormModal.test.js b/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/SnapshotFormModal.test.js
index ec9dd9f..f0c0b4c 100644
--- a/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/SnapshotFormModal.test.js
+++ b/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/SnapshotFormModal.test.js
@@ -9,8 +9,13 @@ import SnapshotFormModal from '../SnapshotFormModal';
// const mockStore = configureStore([]);
const setModalClosed = () => null;
+const fetchBulkParams = () => null;
const fixtures = {
- normal: { host: { id: 42, name: 'deep.thought' }, setModalClosed },
+ normal: {
+ host: { id: 42, name: 'deep.thought' },
+ setModalClosed,
+ fetchBulkParams,
+ },
};
describe('SnapshotFormModal', () => {
diff --git a/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/__snapshots__/SnapshotFormModal.test.js.snap b/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/__snapshots__/SnapshotFormModal.test.js.snap
index fc32887..fe2a4e1 100644
--- a/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/__snapshots__/SnapshotFormModal.test.js.snap
+++ b/webpack/components/SnapshotManagement/components/SnapshotFormModal/__tests__/__snapshots__/SnapshotFormModal.test.js.snap
@@ -30,6 +30,7 @@ exports[`SnapshotFormModal renders normal 1`] = `