diff --git a/service/lib/agama/dbus/base_tree.rb b/service/lib/agama/dbus/base_tree.rb index db28aa4877..a699f03bf3 100644 --- a/service/lib/agama/dbus/base_tree.rb +++ b/service/lib/agama/dbus/base_tree.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2023] SUSE LLC +# Copyright (c) [2023-2024] SUSE LLC # # All Rights Reserved. # @@ -47,11 +47,16 @@ def initialize(service, root_path, logger: nil) # # @param objects [Array] def objects=(objects) - try_add_objects(objects) try_update_objects(objects) + try_add_objects(objects) try_delete_objects(objects) end + # Unexports the current D-Bus objects of this tree. + def clean + dbus_objects.each { |o| service.unexport(o) } + end + private # @return [::DBus::ObjectServer] diff --git a/service/lib/agama/dbus/storage/device.rb b/service/lib/agama/dbus/storage/device.rb index 4e1b07a206..17ecbe167e 100644 --- a/service/lib/agama/dbus/storage/device.rb +++ b/service/lib/agama/dbus/storage/device.rb @@ -30,8 +30,15 @@ module Storage # # The D-Bus object includes the required interfaces for the storage object that it represents. class Device < BaseObject - # @return [Y2Storage::Device] - attr_reader :storage_device + # sid of the Y2Storage device. + # + # @note A Y2Storage device is a wrapper over a libstorage-ng object. If the source + # devicegraph does not exist anymore (e.g., after reprobing), then the Y2Storage device + # object cannot be used (memory error). The device sid is stored to avoid accessing to + # the old Y2Storage device when updating the represented device, see {#storage_device=}. + # + # @return [Integer] + attr_reader :sid # Constructor # @@ -43,6 +50,7 @@ def initialize(storage_device, path, tree, logger: nil) super(path, logger: logger) @storage_device = storage_device + @sid = storage_device.sid @tree = tree add_interfaces end @@ -54,12 +62,13 @@ def initialize(storage_device, path, tree, logger: nil) # # @param value [Y2Storage::Device] def storage_device=(value) - if value.sid != storage_device.sid + if value.sid != sid raise "Cannot update the D-Bus object because the given device has a different sid: " \ - "#{value} instead of #{storage_device.sid}" + "#{value} instead of #{sid}" end @storage_device = value + @sid = value.sid interfaces_and_properties.each do |interface, properties| dbus_properties_changed(interface, properties, []) @@ -71,6 +80,9 @@ def storage_device=(value) # @return [DevicesTree] attr_reader :tree + # @return [Y2Storage::Device] + attr_reader :storage_device + # Adds the required interfaces according to the storage object. def add_interfaces interfaces = Interfaces::Device.constants diff --git a/service/lib/agama/dbus/storage/devices_tree.rb b/service/lib/agama/dbus/storage/devices_tree.rb index c06501bd1e..000bc17255 100644 --- a/service/lib/agama/dbus/storage/devices_tree.rb +++ b/service/lib/agama/dbus/storage/devices_tree.rb @@ -36,10 +36,19 @@ def path_for(device) ::DBus::ObjectPath.new(File.join(root_path, device.sid.to_s)) end - # Updates the D-Bus tree according to the given devicegraph + # Updates the D-Bus tree according to the given devicegraph. + # + # @note In the devices tree it is important to avoid updating D-Bus nodes. Note that an + # already exported D-Bus object could require to add or remove interfaces (e.g., an + # existing partition needs to add the Filesystem interface after formatting the + # partition). Dynamically adding or removing interfaces is not possible with ruby-dbus + # once the object is exported on D-Bus. + # + # Updating the currently exported D-Bus objects is avoided by calling to {#clean} first. # # @param devicegraph [Y2Storage::Devicegraph] def update(devicegraph) + clean self.objects = devices(devicegraph) end @@ -52,17 +61,17 @@ def create_dbus_object(device) end # @see BaseTree - # @param dbus_object [Device] - # @param device [Y2Storage::Device] - def update_dbus_object(dbus_object, device) - dbus_object.storage_device = device + # + # @note D-Bus objects representing devices cannot be safely updated, see {#update}. + def update_dbus_object(_dbus_object, _device) + nil end # @see BaseTree # @param dbus_object [Device] # @param device [Y2Storage::Device] def dbus_object?(dbus_object, device) - dbus_object.storage_device.sid == device.sid + dbus_object.sid == device.sid end # Devices to be exported. @@ -70,13 +79,16 @@ def dbus_object?(dbus_object, device) # Right now, only the required information for calculating a proposal is exported, that is: # * Potential candidate devices (i.e., disk devices, MDs). # * Partitions of the candidate devices in order to indicate how to find free space. - # - # TODO: export LVM VGs and file systems of directly formatted devices. + # * LVM volume groups and logical volumes. # # @param devicegraph [Y2Storage::Devicegraph] # @return [Array] def devices(devicegraph) - devices = devicegraph.disk_devices + devicegraph.software_raids + devices = devicegraph.disk_devices + + devicegraph.software_raids + + devicegraph.lvm_vgs + + devicegraph.lvm_lvs + devices + partitions_from(devices) end diff --git a/service/lib/agama/dbus/storage/interfaces/device.rb b/service/lib/agama/dbus/storage/interfaces/device.rb index 98f09690c2..6376f6a0c4 100644 --- a/service/lib/agama/dbus/storage/interfaces/device.rb +++ b/service/lib/agama/dbus/storage/interfaces/device.rb @@ -33,9 +33,13 @@ module Device require "agama/dbus/storage/interfaces/device/block" require "agama/dbus/storage/interfaces/device/component" +require "agama/dbus/storage/interfaces/device/device" require "agama/dbus/storage/interfaces/device/drive" require "agama/dbus/storage/interfaces/device/filesystem" +require "agama/dbus/storage/interfaces/device/lvm_lv" +require "agama/dbus/storage/interfaces/device/lvm_vg" require "agama/dbus/storage/interfaces/device/md" require "agama/dbus/storage/interfaces/device/multipath" +require "agama/dbus/storage/interfaces/device/partition" require "agama/dbus/storage/interfaces/device/partition_table" require "agama/dbus/storage/interfaces/device/raid" diff --git a/service/lib/agama/dbus/storage/interfaces/device/block.rb b/service/lib/agama/dbus/storage/interfaces/device/block.rb index b5c0e00017..def101b5de 100644 --- a/service/lib/agama/dbus/storage/interfaces/device/block.rb +++ b/service/lib/agama/dbus/storage/interfaces/device/block.rb @@ -44,11 +44,11 @@ def self.apply?(storage_device) BLOCK_INTERFACE = "org.opensuse.Agama.Storage1.Block" private_constant :BLOCK_INTERFACE - # Name of the block device + # Position of the first block of the region. # - # @return [String] e.g., "/dev/sda" - def block_name - storage_device.name + # @return [Integer] + def block_start + storage_device.start end # Whether the block device is currently active @@ -58,6 +58,13 @@ def block_active storage_device.active? end + # Whether the block device is encrypted. + # + # @return [Boolean] + def block_encrypted + storage_device.encrypted? + end + # Name of the udev by-id links # # @return [Array] @@ -98,9 +105,10 @@ def block_systems def self.included(base) base.class_eval do - dbus_interface BLOCK_INTERFACE do - dbus_reader :block_name, "s", dbus_name: "Name" + dbus_interface BLOCK_INTERFACE do + dbus_reader :block_start, "t", dbus_name: "Start" dbus_reader :block_active, "b", dbus_name: "Active" + dbus_reader :block_encrypted, "b", dbus_name: "Encrypted" dbus_reader :block_udev_ids, "as", dbus_name: "UdevIds" dbus_reader :block_udev_paths, "as", dbus_name: "UdevPaths" dbus_reader :block_size, "t", dbus_name: "Size" diff --git a/service/lib/agama/dbus/storage/interfaces/device/component.rb b/service/lib/agama/dbus/storage/interfaces/device/component.rb index c66422b2b4..a4de9ce3e3 100644 --- a/service/lib/agama/dbus/storage/interfaces/device/component.rb +++ b/service/lib/agama/dbus/storage/interfaces/device/component.rb @@ -84,6 +84,8 @@ def self.included(base) base.class_eval do dbus_interface COMPONENT_INTERFACE do dbus_reader :component_type, "s", dbus_name: "Type" + # The names are provided just in case the device is component of a device that + # is not exported yet (e.g., Bcache devices). dbus_reader :component_device_names, "as", dbus_name: "DeviceNames" dbus_reader :component_devices, "ao", dbus_name: "Devices" end diff --git a/service/lib/agama/dbus/storage/interfaces/device/device.rb b/service/lib/agama/dbus/storage/interfaces/device/device.rb new file mode 100644 index 0000000000..6aef6084b7 --- /dev/null +++ b/service/lib/agama/dbus/storage/interfaces/device/device.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus" +require "y2storage/device_description" + +module Agama + module DBus + module Storage + module Interfaces + module Device + # Interface for a device. + # + # @note This interface is intended to be included by {Agama::DBus::Storage::Device}. + module Device + # Whether this interface should be implemented for the given device. + # + # @note All devices implement this interface. + # + # @param _storage_device [Y2Storage::Device] + # @return [Boolean] + def self.apply?(_storage_device) + true + end + + DEVICE_INTERFACE = "org.opensuse.Agama.Storage1.Device" + private_constant :DEVICE_INTERFACE + + # sid of the device. + # + # @return [Integer] + def device_sid + storage_device.sid + end + + # Name to represent the device. + # + # @return [String] e.g., "/dev/sda". + def device_name + storage_device.display_name || "" + end + + # Description of the device. + # + # @return [String] e.g., "EXT4 Partition". + def device_description + Y2Storage::DeviceDescription.new(storage_device).to_s + end + + def self.included(base) + base.class_eval do + dbus_interface DEVICE_INTERFACE do + dbus_reader :device_sid, "u", dbus_name: "SID" + dbus_reader :device_name, "s", dbus_name: "Name" + dbus_reader :device_description, "s", dbus_name: "Description" + end + end + end + end + end + end + end + end +end diff --git a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb b/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb index 36ee17c2ea..545a179813 100644 --- a/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb +++ b/service/lib/agama/dbus/storage/interfaces/device/filesystem.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "dbus" +require "y2storage/filesystem_label" module Agama module DBus @@ -44,6 +45,15 @@ def self.apply?(storage_device) FILESYSTEM_INTERFACE = "org.opensuse.Agama.Storage1.Filesystem" private_constant :FILESYSTEM_INTERFACE + # SID of the file system. + # + # It is useful to detect whether a file system is new. + # + # @return [Integer] + def filesystem_sid + storage_device.filesystem.sid + end + # File system type. # # @return [String] e.g., "ext4" @@ -51,18 +61,27 @@ def filesystem_type storage_device.filesystem.type.to_s end - # Whether the filesystem contains the directory layout of an ESP partition. + # Mount path of the file system. # - # @return [Boolean] - def filesystem_efi? - storage_device.filesystem.efi? + # @return [String] Empty if not mounted. + def filesystem_mount_path + storage_device.filesystem.mount_path || "" + end + + # Label of the file system. + # + # @return [String] Empty if it has no label. + def filesystem_label + Y2Storage::FilesystemLabel.new(storage_device).to_s end def self.included(base) base.class_eval do - dbus_interface FILESYSTEM_INTERFACE do + dbus_interface FILESYSTEM_INTERFACE do + dbus_reader :filesystem_sid, "u", dbus_name: "SID" dbus_reader :filesystem_type, "s", dbus_name: "Type" - dbus_reader :filesystem_efi?, "b", dbus_name: "EFI" + dbus_reader :filesystem_mount_path, "s", dbus_name: "MountPath" + dbus_reader :filesystem_label, "s", dbus_name: "Label" end end end diff --git a/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb b/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb new file mode 100644 index 0000000000..93757ffdfc --- /dev/null +++ b/service/lib/agama/dbus/storage/interfaces/device/lvm_lv.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus" + +module Agama + module DBus + module Storage + module Interfaces + module Device + # Interface for LVM logical volume. + # + # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if + # needed. + module LvmLv + # Whether this interface should be implemented for the given device. + # + # @note LVM logical volumes implement this interface. + # + # @param storage_device [Y2Storage::Device] + # @return [Boolean] + def self.apply?(storage_device) + storage_device.is?(:lvm_lv) + end + + LOGICAL_VOLUME_INTERFACE = "org.opensuse.Agama.Storage1.LVM.LogicalVolume" + private_constant :LOGICAL_VOLUME_INTERFACE + + # LVM volume group hosting the this logical volume. + # + # @return [Array<::DBus::ObjectPath>] + def lvm_lv_vg + tree.path_for(storage_device.lvm_vg) + end + + def self.included(base) + base.class_eval do + dbus_interface LOGICAL_VOLUME_INTERFACE do + dbus_reader :lvm_lv_vg, "o", dbus_name: "VolumeGroup" + end + end + end + end + end + end + end + end +end diff --git a/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb b/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb new file mode 100644 index 0000000000..8d219fc6dd --- /dev/null +++ b/service/lib/agama/dbus/storage/interfaces/device/lvm_vg.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus" + +module Agama + module DBus + module Storage + module Interfaces + module Device + # Interface for a LVM Volume Group. + # + # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if + # needed. + module LvmVg + # Whether this interface should be implemented for the given device. + # + # @note LVM Volume Groups implement this interface. + # + # @param storage_device [Y2Storage::Device] + # @return [Boolean] + def self.apply?(storage_device) + storage_device.is?(:lvm_vg) + end + + VOLUME_GROUP_INTERFACE = "org.opensuse.Agama.Storage1.LVM.VolumeGroup" + private_constant :VOLUME_GROUP_INTERFACE + + # Size of the volume group in bytes + # + # @return [Integer] + def lvm_vg_size + storage_device.size.to_i + end + + # D-Bus paths of the objects representing the physical volumes. + # + # @return [Array] + def lvm_vg_pvs + storage_device.lvm_pvs.map { |p| tree.path_for(p.plain_blk_device) } + end + + # D-Bus paths of the objects representing the logical volumes. + # + # @return [Array] + def lvm_vg_lvs + storage_device.lvm_lvs.map { |l| tree.path_for(l) } + end + + def self.included(base) + base.class_eval do + dbus_interface VOLUME_GROUP_INTERFACE do + dbus_reader :lvm_vg_size, "t", dbus_name: "Size" + dbus_reader :lvm_vg_pvs, "ao", dbus_name: "PhysicalVolumes" + dbus_reader :lvm_vg_lvs, "ao", dbus_name: "LogicalVolumes" + end + end + end + end + end + end + end + end +end diff --git a/service/lib/agama/dbus/storage/interfaces/device/partition.rb b/service/lib/agama/dbus/storage/interfaces/device/partition.rb new file mode 100644 index 0000000000..050d836663 --- /dev/null +++ b/service/lib/agama/dbus/storage/interfaces/device/partition.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus" + +module Agama + module DBus + module Storage + module Interfaces + module Device + # Interface for partition. + # + # @note This interface is intended to be included by {Agama::DBus::Storage::Device} if + # needed. + module Partition + # Whether this interface should be implemented for the given device. + # + # @note Partitions implement this interface. + # + # @param storage_device [Y2Storage::Device] + # @return [Boolean] + def self.apply?(storage_device) + storage_device.is?(:partition) + end + + PARTITION_INTERFACE = "org.opensuse.Agama.Storage1.Partition" + private_constant :PARTITION_INTERFACE + + # Device hosting the partition table of this partition. + # + # @return [Array<::DBus::ObjectPath>] + def partition_device + tree.path_for(storage_device.partitionable) + end + + # Whether it is a (valid) EFI System partition + # + # @return [Boolean] + def partition_efi + storage_device.efi_system? + end + + def self.included(base) + base.class_eval do + dbus_interface PARTITION_INTERFACE do + dbus_reader :partition_device, "o", dbus_name: "Device" + dbus_reader :partition_efi, "b", dbus_name: "EFI" + end + end + end + end + end + end + end + end +end diff --git a/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb b/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb index 546ddfd934..97dcb90e25 100644 --- a/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb +++ b/service/lib/agama/dbus/storage/interfaces/device/partition_table.rb @@ -60,11 +60,22 @@ def partition_table_partitions storage_device.partition_table.partitions.map { |p| tree.path_for(p) } end + # Available slots within a partition table, that is, the spaces that can be used to + # create a new partition. + # + # @return [Array] The first block and the size of each slot. + def partition_table_unused_slots + storage_device.partition_table.unused_partition_slots.map do |slot| + [slot.region.start, slot.region.size.to_i] + end + end + def self.included(base) base.class_eval do dbus_interface PARTITION_TABLE_INTERFACE do dbus_reader :partition_table_type, "s", dbus_name: "Type" dbus_reader :partition_table_partitions, "ao", dbus_name: "Partitions" + dbus_reader :partition_table_unused_slots, "a(tt)", dbus_name: "UnusedSlots" end end end diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 188062579c..3d9380569d 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2023] SUSE LLC +# Copyright (c) [2022-2024] SUSE LLC # # All Rights Reserved. # @@ -289,6 +289,7 @@ def register_proposal_callbacks proposal.on_calculate do export_proposal proposal_properties_changed + refresh_staging_devices end end @@ -332,6 +333,11 @@ def refresh_system_devices system_devices_tree.update(devicegraph) end + def refresh_staging_devices + devicegraph = Y2Storage::StorageManager.instance.staging + staging_devices_tree.update(devicegraph) + end + def refresh_iscsi_nodes nodes = backend.iscsi.nodes iscsi_nodes_tree.update(nodes) @@ -348,6 +354,10 @@ def system_devices_tree @system_devices_tree ||= DevicesTree.new(@service, tree_path("system"), logger: logger) end + def staging_devices_tree + @staging_devices_tree ||= DevicesTree.new(@service, tree_path("staging"), logger: logger) + end + def tree_path(tree_root) File.join(PATH, tree_root) end diff --git a/service/lib/agama/dbus/storage/proposal.rb b/service/lib/agama/dbus/storage/proposal.rb index 2d55331f35..591d142451 100644 --- a/service/lib/agama/dbus/storage/proposal.rb +++ b/service/lib/agama/dbus/storage/proposal.rb @@ -155,6 +155,7 @@ def dbus_settings # @return [Hash] def to_dbus_action(action) { + "Device" => action.target_device.sid, "Text" => action.sentence, "Subvol" => action.device_is?(:btrfs_subvolume), "Delete" => action.delete? diff --git a/service/package/gem2rpm.yml b/service/package/gem2rpm.yml index fef1bc79db..9a886072bd 100644 --- a/service/package/gem2rpm.yml +++ b/service/package/gem2rpm.yml @@ -38,8 +38,7 @@ Requires: yast2-iscsi-client >= 4.5.7 Requires: yast2-network Requires: yast2-proxy - # ProposalSettings#swap_reuse - Requires: yast2-storage-ng >= 5.0.3 + Requires: yast2-storage-ng >= 5.0.8 Requires: yast2-users %ifarch s390 s390x Requires: yast2-s390 >= 4.6.4 diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 5f51032451..0f8d5e6c71 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Tue Mar 19 14:09:54 UTC 2024 - José Iván López González + +- Extend the storage D-Bus API: export LVM volume groups and + logical volumes, export staging devices, add Device and Partition + interfaces, export unused slots (gh#openSUSE/agama#1104). + ------------------------------------------------------------------- Tue Feb 27 15:53:46 UTC 2024 - Imobach Gonzalez Sosa diff --git a/service/test/agama/dbus/storage/device_test.rb b/service/test/agama/dbus/storage/device_test.rb index 649131a83f..efb5ffe720 100644 --- a/service/test/agama/dbus/storage/device_test.rb +++ b/service/test/agama/dbus/storage/device_test.rb @@ -19,19 +19,23 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/dbus/storage/device" -require "agama/dbus/storage/devices_tree" -require "dbus" require_relative "../../../test_helper" require_relative "../../storage/storage_helpers" require_relative "./interfaces/device/block_examples" require_relative "./interfaces/device/component_examples" +require_relative "./interfaces/device/device_examples" require_relative "./interfaces/device/drive_examples" require_relative "./interfaces/device/filesystem_examples" +require_relative "./interfaces/device/lvm_lv_examples" +require_relative "./interfaces/device/lvm_vg_examples" require_relative "./interfaces/device/md_examples" require_relative "./interfaces/device/multipath_examples" +require_relative "./interfaces/device/partition_examples" require_relative "./interfaces/device/partition_table_examples" require_relative "./interfaces/device/raid_examples" +require "agama/dbus/storage/device" +require "agama/dbus/storage/devices_tree" +require "dbus" describe Agama::DBus::Storage::Device do include Agama::RSpec::StorageHelpers @@ -65,6 +69,10 @@ let(:device) { devicegraph.find_by_name("/dev/sda") } + it "defines the Device interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") + end + it "defines the Drive interface" do expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Drive") end @@ -79,6 +87,10 @@ let(:device) { devicegraph.dm_raids.first } + it "defines the Device interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") + end + it "defines the Drive interface" do expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Drive") end @@ -97,6 +109,10 @@ let(:device) { devicegraph.md_raids.first } + it "defines the Device interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") + end + it "does not define the Drive interface" do expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") end @@ -110,6 +126,64 @@ end end + context "when the given device is a LVM volume group" do + let(:scenario) { "trivial_lvm.yml" } + + let(:device) { devicegraph.find_by_name("/dev/vg0") } + + it "defines the Device interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") + end + + it "does not define the Drive interface" do + expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") + end + + it "defines the LVM.VolumeGroup interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.LVM.VolumeGroup") + end + end + + context "when the given device is a LVM logical volume" do + let(:scenario) { "trivial_lvm.yml" } + + let(:device) { devicegraph.find_by_name("/dev/vg0/lv1") } + + it "defines the Device interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") + end + + it "does not define the Drive interface" do + expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") + end + + it "defines the LVM.LogicalVolume interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.LVM.LogicalVolume") + end + end + + context "when the given device is a partition" do + let(:scenario) { "partitioned_md.yml" } + + let(:device) { devicegraph.find_by_name("/dev/sda1") } + + it "defines the Device interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Device") + end + + it "defines the Block interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Block") + end + + it "does not define the Drive interface" do + expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Drive") + end + + it "defines the Partition interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Partition") + end + end + context "when the given device has a partition table" do let(:scenario) { "partitioned_md.yml" } @@ -130,8 +204,30 @@ .to_not include_dbus_interface("org.opensuse.Agama.Storage1.PartitionTable") end end + + context "when the device is formatted" do + let(:scenario) { "multipath-formatted.xml" } + + let(:device) { devicegraph.find_by_name("/dev/mapper/0QEMU_QEMU_HARDDISK_mpath1") } + + it "defines the Filesystem interface" do + expect(subject).to include_dbus_interface("org.opensuse.Agama.Storage1.Filesystem") + end + end + + context "when the device is no formatted" do + let(:scenario) { "partitioned_md.yml" } + + let(:device) { devicegraph.find_by_name("/dev/sda") } + + it "does not define the Filesystem interface" do + expect(subject).to_not include_dbus_interface("org.opensuse.Agama.Storage1.Filesystem") + end + end end + include_examples "Device interface" + include_examples "Drive interface" include_examples "RAID interface" @@ -142,6 +238,12 @@ include_examples "Block interface" + include_examples "LVM.VolumeGroup interface" + + include_examples "LVM.LogicalVolume interface" + + include_examples "Partition interface" + include_examples "PartitionTable interface" include_examples "Filesystem interface" @@ -168,12 +270,6 @@ context "if the given device has the same sid" do let(:new_device) { devicegraph.find_by_name("/dev/sda") } - it "sets the new device" do - subject.storage_device = new_device - - expect(subject.storage_device).to equal(new_device) - end - it "emits a properties changed signal for each interface" do subject.interfaces_and_properties.each_key do |interface| expect(subject).to receive(:dbus_properties_changed).with(interface, anything, anything) diff --git a/service/test/agama/dbus/storage/devices_tree_test.rb b/service/test/agama/dbus/storage/devices_tree_test.rb index 42e0024a33..a876c2f77d 100644 --- a/service/test/agama/dbus/storage/devices_tree_test.rb +++ b/service/test/agama/dbus/storage/devices_tree_test.rb @@ -22,8 +22,8 @@ require_relative "../../../test_helper" require_relative "../../storage/storage_helpers" require "agama/dbus/storage/devices_tree" -require "y2storage" require "dbus" +require "y2storage" describe Agama::DBus::Storage::DevicesTree do include Agama::RSpec::StorageHelpers @@ -87,7 +87,8 @@ mock_storage(devicegraph: scenario) allow(service).to receive(:get_node).with(root_path, anything).and_return(root_node) - allow(root_node).to receive(:descendant_objects).and_return(dbus_objects) + # Returning an empty list for the second call to mock the effect of calling to #clear. + allow(root_node).to receive(:descendant_objects).and_return(dbus_objects, []) allow(service).to receive(:export) allow(service).to receive(:unexport) @@ -102,57 +103,48 @@ let(:devicegraph) { Y2Storage::StorageManager.instance.probed } - context "if a device is not exported yet" do - let(:dbus_objects) { [] } + let(:dbus_objects) { [dbus_object1, dbus_object2] } + let(:dbus_object1) { Agama::DBus::Storage::Device.new(sda, subject.path_for(sda), subject) } + let(:dbus_object2) { Agama::DBus::Storage::Device.new(sdb, subject.path_for(sdb), subject) } + let(:sda) { devicegraph.find_by_name("/dev/sda") } + let(:sdb) { devicegraph.find_by_name("/dev/sdb") } - it "exports a D-Bus object" do - sda = devicegraph.find_by_name("/dev/sda") - sdb = devicegraph.find_by_name("/dev/sdb") - md0 = devicegraph.find_by_name("/dev/md0") - sda1 = devicegraph.find_by_name("/dev/sda1") - sda2 = devicegraph.find_by_name("/dev/sda2") - md0p1 = devicegraph.find_by_name("/dev/md0p1") - - expect(service).to export_object("#{root_path}/#{sda.sid}") - expect(service).to export_object("#{root_path}/#{sdb.sid}") - expect(service).to export_object("#{root_path}/#{md0.sid}") - expect(service).to export_object("#{root_path}/#{sda1.sid}") - expect(service).to export_object("#{root_path}/#{sda2.sid}") - expect(service).to export_object("#{root_path}/#{md0p1.sid}") - expect(service).to_not receive(:export) + it "unexports the current D-Bus objects" do + expect(service).to unexport_object("#{root_path}/#{sda.sid}") + expect(service).to unexport_object("#{root_path}/#{sdb.sid}") - subject.update(devicegraph) - end + subject.update(devicegraph) end - context "if a device is already exported" do - let(:dbus_objects) { [dbus_object1] } - let(:dbus_object1) { Agama::DBus::Storage::Device.new(sda, subject.path_for(sda), subject) } - let(:sda) { devicegraph.find_by_name("/dev/sda") } - - it "does not export a D-Bus object" do - expect(service).to_not export_object("#{root_path}/#{sda.sid}") - - subject.update(devicegraph) - end - - it "updates the D-Bus object" do - expect(dbus_object1.storage_device).to equal(sda) + it "exports disk devices and partitions" do + md0 = devicegraph.find_by_name("/dev/md0") + sda1 = devicegraph.find_by_name("/dev/sda1") + sda2 = devicegraph.find_by_name("/dev/sda2") + md0p1 = devicegraph.find_by_name("/dev/md0p1") + + expect(service).to export_object("#{root_path}/#{sda.sid}") + expect(service).to export_object("#{root_path}/#{sdb.sid}") + expect(service).to export_object("#{root_path}/#{md0.sid}") + expect(service).to export_object("#{root_path}/#{sda1.sid}") + expect(service).to export_object("#{root_path}/#{sda2.sid}") + expect(service).to export_object("#{root_path}/#{md0p1.sid}") + expect(service).to_not receive(:export) + + subject.update(devicegraph) + end - subject.update(devicegraph) + context "if there are LVM volume groups" do + let(:scenario) { "trivial_lvm.yml" } - expect(dbus_object1.storage_device).to_not equal(sda) - expect(dbus_object1.storage_device.sid).to equal(sda.sid) - end - end + let(:dbus_objects) { [] } - context "if an exported D-Bus object does not represent any of the current devices" do - let(:dbus_objects) { [dbus_object1] } - let(:dbus_object1) { Agama::DBus::Storage::Device.new(sdd, subject.path_for(sdd), subject) } - let(:sdd) { instance_double(Y2Storage::Disk, sid: 1, is?: false, filesystem: false) } + it "exports the LVM volume groups and the logical volumes" do + vg0 = devicegraph.find_by_name("/dev/vg0") + lv1 = devicegraph.find_by_name("/dev/vg0/lv1") - it "unexports the D-Bus object" do - expect(service).to unexport_object("#{root_path}/1") + expect(service).to receive(:export) + expect(service).to export_object("#{root_path}/#{vg0.sid}") + expect(service).to export_object("#{root_path}/#{lv1.sid}") subject.update(devicegraph) end diff --git a/service/test/agama/dbus/storage/interfaces/device/block_examples.rb b/service/test/agama/dbus/storage/interfaces/device/block_examples.rb index 11590a3390..ba3ec052a9 100644 --- a/service/test/agama/dbus/storage/interfaces/device/block_examples.rb +++ b/service/test/agama/dbus/storage/interfaces/device/block_examples.rb @@ -27,9 +27,13 @@ let(:device) { devicegraph.find_by_name("/dev/sda") } - describe "#block_name" do - it "returns the name of the device" do - expect(subject.block_name).to eq("/dev/sda") + describe "#block_start" do + before do + allow(device).to receive(:start).and_return(345) + end + + it "returns the first block of the region" do + expect(subject.block_start).to eq(345) end end @@ -43,6 +47,12 @@ end end + describe "#block_encrypted" do + it "returns whether the device is encrypted" do + expect(subject.block_encrypted).to eq(false) + end + end + describe "#block_udev_ids" do before do allow(device).to receive(:udev_ids).and_return(udev_ids) diff --git a/service/test/agama/dbus/storage/interfaces/device/device_examples.rb b/service/test/agama/dbus/storage/interfaces/device/device_examples.rb new file mode 100644 index 0000000000..2afc123152 --- /dev/null +++ b/service/test/agama/dbus/storage/interfaces/device/device_examples.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../../test_helper" +require "y2storage/device_description" + +shared_examples "Device interface" do + describe "Device D-Bus interface" do + let(:scenario) { "partitioned_md.yml" } + + let(:device) { devicegraph.find_by_name("/dev/sda") } + + describe "#device_sid" do + before do + allow(device).to receive(:sid).and_return(123) + end + + it "returns the SID of the device" do + expect(subject.device_sid).to eq(123) + end + end + + describe "#device_name" do + it "returns the name of the device" do + expect(subject.device_name).to eq("/dev/sda") + end + end + + describe "#device_description" do + before do + allow(Y2Storage::DeviceDescription).to receive(:new).with(device).and_return(description) + end + + let(:description) { instance_double(Y2Storage::DeviceDescription, to_s: "test") } + + it "returns the description of the device" do + expect(subject.device_description).to eq("test") + end + end + end +end diff --git a/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb b/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb index c581bd7668..71694a3ab7 100644 --- a/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb +++ b/service/test/agama/dbus/storage/interfaces/device/filesystem_examples.rb @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require_relative "../../../../../test_helper" +require "y2storage/filesystem_label" shared_examples "Filesystem interface" do describe "Filesystem D-Bus interface" do @@ -27,15 +28,49 @@ let(:device) { devicegraph.find_by_name("/dev/mapper/0QEMU_QEMU_HARDDISK_mpath1") } + describe "#filesystem_sid" do + it "returns the file system SID" do + expect(subject.filesystem_sid).to eq(45) + end + end + describe "#filesystem_type" do it "returns the file system type" do expect(subject.filesystem_type).to eq("ext4") end end - describe "#filesystem_efi?" do - it "returns whether the file system is an EFI" do - expect(subject.filesystem_efi?).to eq(false) + describe "#filesystem_mount_path" do + context "if the file system is mounted" do + before do + device.filesystem.mount_path = "/test" + end + + it "returns the mount path" do + expect(subject.filesystem_mount_path).to eq("/test") + end + end + + context "if the file system is not mounted" do + before do + device.filesystem.mount_path = "" + end + + it "returns empty string" do + expect(subject.filesystem_mount_path).to eq("") + end + end + end + + describe "#filesystem_label" do + before do + allow(Y2Storage::FilesystemLabel).to receive(:new).with(device).and_return(label) + end + + let(:label) { instance_double(Y2Storage::FilesystemLabel, to_s: "photos") } + + it "returns the label of the file system" do + expect(subject.filesystem_label).to eq("photos") end end end diff --git a/service/test/agama/dbus/storage/interfaces/device/lvm_lv_examples.rb b/service/test/agama/dbus/storage/interfaces/device/lvm_lv_examples.rb new file mode 100644 index 0000000000..9f96a71ecb --- /dev/null +++ b/service/test/agama/dbus/storage/interfaces/device/lvm_lv_examples.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../../test_helper" + +shared_examples "LVM.LogicalVolume interface" do + describe "LVM.LogicalVolume D-Bus interface" do + let(:scenario) { "trivial_lvm.yml" } + + let(:device) { devicegraph.find_by_name("/dev/vg0/lv1") } + + describe "#lvm_lv_vg" do + it "returns the path of the host volume group" do + vg0 = devicegraph.find_by_name("/dev/vg0") + + expect(subject.lvm_lv_vg).to eq(tree.path_for(vg0)) + end + end + end +end diff --git a/service/test/agama/dbus/storage/interfaces/device/lvm_vg_examples.rb b/service/test/agama/dbus/storage/interfaces/device/lvm_vg_examples.rb new file mode 100644 index 0000000000..2e716acde5 --- /dev/null +++ b/service/test/agama/dbus/storage/interfaces/device/lvm_vg_examples.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../../test_helper" + +shared_examples "LVM.VolumeGroup interface" do + describe "LVM.VolumeGroup D-Bus interface" do + let(:scenario) { "trivial_lvm.yml" } + + let(:device) { devicegraph.find_by_name("/dev/vg0") } + + describe "#lvm_vg_size" do + before do + allow(device).to receive(:size).and_return(size) + end + + let(:size) { Y2Storage::DiskSize.new(1024) } + + it "returns the size in bytes" do + expect(subject.lvm_vg_size).to eq(1024) + end + end + + describe "#lvm_vg_pvs" do + it "returns the D-Bus path of the physical volumes" do + sda1 = devicegraph.find_by_name("/dev/sda1") + + expect(subject.lvm_vg_pvs).to contain_exactly(tree.path_for(sda1)) + end + end + + describe "#lvm_vg_lvs" do + it "returns the D-Bus path of the logical volumes" do + lv1 = devicegraph.find_by_name("/dev/vg0/lv1") + + expect(subject.lvm_vg_lvs).to contain_exactly(tree.path_for(lv1)) + end + end + end +end diff --git a/service/test/agama/dbus/storage/interfaces/device/partition_examples.rb b/service/test/agama/dbus/storage/interfaces/device/partition_examples.rb new file mode 100644 index 0000000000..f99b71d523 --- /dev/null +++ b/service/test/agama/dbus/storage/interfaces/device/partition_examples.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../../../test_helper" + +shared_examples "Partition interface" do + describe "Partition D-Bus interface" do + let(:scenario) { "partitioned_md.yml" } + + let(:device) { devicegraph.find_by_name("/dev/sda1") } + + describe "#partition_device" do + it "returns the path of the host device" do + sda = devicegraph.find_by_name("/dev/sda") + + expect(subject.partition_device).to eq(tree.path_for(sda)) + end + end + + describe "#partition_efi" do + before do + allow(device).to receive(:efi_system?).and_return(true) + end + + it "returns whether it is an EFI partition" do + expect(subject.partition_efi).to eq(true) + end + end + end +end diff --git a/service/test/agama/dbus/storage/interfaces/device/partition_table_examples.rb b/service/test/agama/dbus/storage/interfaces/device/partition_table_examples.rb index 1af30111ab..22fb9efae1 100644 --- a/service/test/agama/dbus/storage/interfaces/device/partition_table_examples.rb +++ b/service/test/agama/dbus/storage/interfaces/device/partition_table_examples.rb @@ -20,6 +20,9 @@ # find current contact information at www.suse.com. require_relative "../../../../../test_helper" +require "y2storage/disk_size" +require "y2storage/partition_tables/partition_slot" +require "y2storage/region" shared_examples "PartitionTable interface" do describe "PartitionTable D-Bus interface" do @@ -39,5 +42,34 @@ expect(subject.partition_table_partitions).to contain_exactly(tree.path_for(md0p1)) end end + + describe "#partition_table_unused_slots" do + before do + allow(device).to receive(:partition_table).and_return(partition_table) + allow(partition_table).to receive(:unused_partition_slots).and_return(unused_slots) + end + + let(:partition_table) { device.partition_table } + + let(:unused_slots) do + [ + instance_double(Y2Storage::PartitionTables::PartitionSlot, region: region1), + instance_double(Y2Storage::PartitionTables::PartitionSlot, region: region2) + ] + end + + let(:region1) do + instance_double(Y2Storage::Region, start: 234, size: Y2Storage::DiskSize.new(1024)) + end + + let(:region2) do + instance_double(Y2Storage::Region, start: 987, size: Y2Storage::DiskSize.new(2048)) + end + + it "returns the information about the unused slots" do + md0p1 = devicegraph.find_by_name("/dev/md0p1") + expect(subject.partition_table_unused_slots).to contain_exactly([234, 1024], [987, 2048]) + end + end end end diff --git a/service/test/agama/dbus/storage/proposal_test.rb b/service/test/agama/dbus/storage/proposal_test.rb index 790014eeaa..fc61302129 100644 --- a/service/test/agama/dbus/storage/proposal_test.rb +++ b/service/test/agama/dbus/storage/proposal_test.rb @@ -257,14 +257,18 @@ let(:action1) do instance_double(Y2Storage::CompoundAction, - sentence: "test1", device_is?: false, delete?: false) + sentence: "test1", target_device: device1, device_is?: false, delete?: false) end let(:action2) do instance_double(Y2Storage::CompoundAction, - sentence: "test2", device_is?: true, delete?: true) + sentence: "test2", target_device: device2, device_is?: true, delete?: true) end + let(:device1) { instance_double(Y2Storage::Device, sid: 1) } + + let(:device2) { instance_double(Y2Storage::Device, sid: 2) } + it "returns a list with a hash for each action" do expect(subject.actions.size).to eq(2) expect(subject.actions).to all(be_a(Hash)) @@ -272,12 +276,14 @@ action1, action2 = subject.actions expect(action1).to eq({ + "Device" => 1, "Text" => "test1", "Subvol" => false, "Delete" => false }) expect(action2).to eq({ + "Device" => 2, "Text" => "test2", "Subvol" => true, "Delete" => true diff --git a/service/test/fixtures/trivial_lvm.yml b/service/test/fixtures/trivial_lvm.yml new file mode 100644 index 0000000000..ff7aec2699 --- /dev/null +++ b/service/test/fixtures/trivial_lvm.yml @@ -0,0 +1,24 @@ +--- +- disk: + name: /dev/sda + size: 200 GiB + partition_table: gpt + partitions: + + - partition: + size: 100 GiB + name: /dev/sda1 + id: lvm + +- lvm_vg: + vg_name: vg0 + lvm_pvs: + - lvm_pv: + blk_device: /dev/sda1 + + lvm_lvs: + - lvm_lv: + size: 100 GiB + lv_name: lv1 + file_system: btrfs + mount_point: / diff --git a/web/.eslintignore b/web/.eslintignore index 8faa0e3fd2..fb9357ef5e 100644 --- a/web/.eslintignore +++ b/web/.eslintignore @@ -1,2 +1,3 @@ node_modules/* src/lib/* +src/**/test-data/* diff --git a/web/cspell.json b/web/cspell.json index 9c7619f4a9..3a2944fcda 100644 --- a/web/cspell.json +++ b/web/cspell.json @@ -5,7 +5,8 @@ "ignorePaths": [ "src/lib/cockpit.js", "src/lib/cockpit-po-plugin.js", - "src/manifest.json" + "src/manifest.json", + "src/**/test-data/*" ], "import": [ "@cspell/dict-css/cspell-ext.json", diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 87fbb37d54..13a4e5d7df 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Tue Mar 19 14:15:30 UTC 2024 - José Iván López González + +- In storage page, replace Planned Actions section by a new Result + section, unify File Systems and Settings sections, and move + Find Space section to a popup (gh#openSUSE/agama#1104). + ------------------------------------------------------------------- Fri Mar 1 10:56:35 UTC 2024 - José Iván López González diff --git a/web/src/assets/styles/blocks.scss b/web/src/assets/styles/blocks.scss index e568ae24db..43b336bf31 100644 --- a/web/src/assets/styles/blocks.scss +++ b/web/src/assets/styles/blocks.scss @@ -403,6 +403,143 @@ ul[data-type="agama/list"][role="grid"] { } } +[data-type="agama/tag"] { + font-size: var(--fs-small); + + &[data-variant="teal"] { + color: var(--color-teal); + } + + &[data-variant="orange"] { + color: var(--color-orange); + } + + &[data-variant="gray-highlight"] { + padding: var(--spacer-smaller); + color: var(--color-gray-darkest); + background: var(--color-gray); + border: 1px solid var(--color-gray-dark); + border-radius: 5px; + margin-inline-start: var(--spacer-smaller); + } +} + +table[data-type="agama/tree-table"] { + th:first-child { + block-size: fit-content; + padding-inline-end: var(--spacer-normal); + } + + th.fit-content { + block-size: fit-content; + overflow: visible; + text-overflow: unset; + } + + /** + * Temporary PF/Table overrides for small devices + **/ + @media (width <= 768px) { + &.pf-m-tree-view-grid-md.pf-v5-c-table tr[aria-level="1"] td { + padding-inline-start: var(--spacer-medium); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr[aria-level="2"] th { + padding-inline-start: calc(var(--spacer-large) * 1.1); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr[aria-level="2"] td { + padding-inline-start: calc(var(--spacer-large) * 1.4); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr).pf-m-tree-view-details-expanded { + padding-block-end: var(--spacer-smaller); + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td:empty, + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td *:empty, + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td:has(> *:empty) { + display: none; + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tr:where(.pf-v5-c-table__tr) td:has(> *:not(:empty)) { + display: inherit; + } + + &.pf-m-tree-view-grid-md.pf-v5-c-table tbody:where(.pf-v5-c-table__tbody) tr:where(.pf-v5-c-table__tr)::before { + inset-inline-start: 0; + } + + &.pf-v5-c-table.pf-m-compact tr:where(.pf-v5-c-table__tr):not(.pf-v5-c-table__expandable-row) > *:last-child { + padding-inline-end: 8px; + } + + tbody th:first-child { + font-size: var(--fs-large); + padding-block-start: var(--spacer-small); + } + } +} + +table.proposal-result { + tr.dimmed-row { + background-color: #fff; + opacity: 0.8; + background: repeating-linear-gradient( -45deg, #fcfcff, #fcfcff 3px, #fff 3px, #fff 10px ); + + td { + color: var(--color-gray-dimmed); + padding-block: 0; + } + + } + + /** + * Temporary hack because the collapse/expand callback was not given to the + * tree table + **/ + th button { + display: none; + } + + tbody th .pf-v5-c-table__tree-view-main { + padding-inline-start: var(--pf-v5-c-table--m-compact--cell--first-last-child--PaddingLeft); + cursor: auto; + } + + tbody tr[aria-level="2"] th .pf-v5-c-table__tree-view-main { + padding-inline-start: calc( + var(--pf-v5-c-table--m-compact--cell--first-last-child--PaddingLeft) + var(--spacer-large) + ); + } + /** End of temporary hack */ + + @media (width > 768px) { + th.details-column { + padding-inline-start: calc(60px + var(--spacer-smaller) * 2); + } + + td.details-column { + display: grid; + gap: var(--spacer-smaller); + grid-template-columns: 60px 1fr; + + :first-child { + text-align: end; + } + } + + th.sizes-column, + td.sizes-column { + text-align: end; + + div.split { + justify-content: flex-end; + } + } + } +} + // compact lists in popover .pf-v5-c-popover li + li { margin: 0; @@ -486,7 +623,24 @@ ul[data-type="agama/list"][role="grid"] { h4 { color: var(--accent-color); - margin-block-end: var(--spacer-smaller); + } + + h4 ~ * { + margin-block-start: var(--spacer-small); + } +} + +section [data-type="agama/reminder"] { + margin-inline: 0; +} + +[data-type="agama/reminder"][data-variant="subtle"] { + --accent-color: var(--color-primary); + padding-block: 0; + border-inline-start-width: 1px; + + h4 { + font-size: var(--fs-normal); } } diff --git a/web/src/assets/styles/variables.scss b/web/src/assets/styles/variables.scss index b25bfcbad3..490429fe54 100644 --- a/web/src/assets/styles/variables.scss +++ b/web/src/assets/styles/variables.scss @@ -51,8 +51,12 @@ --color-gray: #f2f2f2; --color-gray-dark: #efefef; // Fog --color-gray-darker: #999; + --color-gray-darkest: #333; --color-gray-dimmed: #888; --color-gray-dimmest: #666; + --color-teal: #279c9c; + --color-blue: #0d4ea6; + --color-orange: #e86427; --color-link: #0c322c; --color-link-hover: #30ba78; diff --git a/web/src/client/storage.js b/web/src/client/storage.js index 488862d9a1..4c2b2153bd 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -31,6 +31,7 @@ const STORAGE_IFACE = "org.opensuse.Agama.Storage1"; const STORAGE_JOBS_NAMESPACE = "/org/opensuse/Agama/Storage1/jobs"; const STORAGE_JOB_IFACE = "org.opensuse.Agama.Storage1.Job"; const STORAGE_SYSTEM_NAMESPACE = "/org/opensuse/Agama/Storage1/system"; +const STORAGE_STAGING_NAMESPACE = "/org/opensuse/Agama/Storage1/staging"; const PROPOSAL_IFACE = "org.opensuse.Agama.Storage1.Proposal"; const PROPOSAL_CALCULATOR_IFACE = "org.opensuse.Agama.Storage1.Proposal.Calculator"; const ISCSI_INITIATOR_IFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator"; @@ -46,6 +47,103 @@ const ZFCP_CONTROLLER_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Controller"; const ZFCP_DISKS_NAMESPACE = "/org/opensuse/Agama/Storage1/zfcp_disks"; const ZFCP_DISK_IFACE = "org.opensuse.Agama.Storage1.ZFCP.Disk"; +/** + * @typedef {object} StorageDevice + * @property {number} sid - Storage ID + * @property {string} name - Device name + * @property {string} description - Device description + * @property {boolean} isDrive - Whether the device is a drive + * @property {string} type - Type of device (e.g., "disk", "raid", "multipath", "dasd", "md") + * @property {string} [vendor] + * @property {string} [model] + * @property {string[]} [driver] + * @property {string} [bus] + * @property {string} [busId] - DASD Bus ID (only for "dasd" type) + * @property {string} [transport] + * @property {boolean} [sdCard] + * @property {boolean} [dellBOOS] + * @property {string[]} [devices] - RAID devices (only for "raid" and "md" types) + * @property {string[]} [wires] - Multipath wires (only for "multipath" type) + * @property {string} [level] - MD RAID level (only for "md" type) + * @property {string} [uuid] + * @property {number} [start] - First block of the region (only for block devices) + * @property {boolean} [active] + * @property {boolean} [encrypted] - Whether the device is encrypted (only for block devices) + * @property {boolean} [isEFI] - Whether the device is an EFI partition (only for partition) + * @property {number} [size] + * @property {number} [recoverableSize] + * @property {string[]} [systems] - Name of the installed systems + * @property {string[]} [udevIds] + * @property {string[]} [udevPaths] + * @property {PartitionTable} [partitionTable] + * @property {Filesystem} [filesystem] + * @property {Component} [component] - When it is used as component of other devices + * @property {StorageDevice[]} [physicalVolumes] - Only for LVM VGs + * @property {StorageDevice[]} [logicalVolumes] - Only for LVM VGs + * + * @typedef {object} PartitionTable + * @property {string} type + * @property {StorageDevice[]} partitions + * @property {PartitionSlot[]} unusedSlots + * @property {number} unpartitionedSize - Total size not assigned to any partition + * + * @typedef {object} PartitionSlot + * @property {number} start + * @property {number} size + * + * @typedef {object} Component + * @property {string} type + * @property {string[]} deviceNames + * + * @typedef {object} Filesystem + * @property {number} sid + * @property {string} type + * @property {string} [mountPath] + * + * @typedef {object} ProposalResult + * @property {ProposalSettings} settings + * @property {Action[]} actions + * + * @typedef {object} Action + * @property {number} device + * @property {string} text + * @property {boolean} subvol + * @property {boolean} delete + * + * @typedef {object} ProposalSettings + * @property {string} bootDevice + * @property {string} encryptionPassword + * @property {string} encryptionMethod + * @property {boolean} lvm + * @property {string} spacePolicy + * @property {SpaceAction[]} spaceActions + * @property {string[]} systemVGDevices + * @property {Volume[]} volumes + * @property {StorageDevice[]} installationDevices + * + * @typedef {object} SpaceAction + * @property {string} device + * @property {string} action + * + * @typedef {object} Volume + * @property {string} mountPath + * @property {string} fsType + * @property {number} minSize + * @property {number} [maxSize] + * @property {boolean} autoSize + * @property {boolean} snapshots + * @property {boolean} transactional + * @property {VolumeOutline} outline + * + * @typedef {object} VolumeOutline + * @property {boolean} required + * @property {string[]} fsTypes + * @property {boolean} supportAutoSize + * @property {boolean} snapshotsConfigurable + * @property {boolean} snapshotsAffectSizes + * @property {string[]} sizeRelevantVolumes + */ + /** * Enum for the encryption method values * @@ -105,44 +203,15 @@ class DevicesManager { * Gets all the exported devices * * @returns {Promise} - * - * @typedef {object} StorageDevice - * @property {string} sid - Internal id that is used as D-Bus object basename - * @property {boolean} isDrive - Whether the device is a drive - * @property {string} type - Type of device ("disk", "raid", "multipath", "dasd", "md") - * @property {string} [vendor] - * @property {string} [model] - * @property {string[]} [driver] - * @property {string} [bus] - * @property {string} [busId] - DASD Bus ID (only for "dasd" type) - * @property {string} [transport] - * @property {boolean} [sdCard] - * @property {boolean} [dellBOOS] - * @property {string[]} [devices] - RAID devices (only for "raid" and "md" types) - * @property {string[]} [wires] - Multipath wires (only for "multipath" type) - * @property {string} [level] - MD RAID level (only for "md" type) - * @property {string} [uuid] - * @property {boolean} [active] - * @property {string} [name] - Block device name - * @property {number} [size] - * @property {number} [recoverableSize] - * @property {string[]} [systems] - Name of the installed systems - * @property {string[]} [udevIds] - * @property {string[]} [udevPaths] - * @property {PartitionTable} [partitionTable] - * @property {Filesystem} [filesystem] - * - * @typedef {object} PartitionTable - * @property {string} type - * @property {StorageDevice[]} partitions - * @property {number} unpartitionedSize - Total size not assigned to any partition - * - * @typedef {object} Filesystem - * @property {string} type - * @property {boolean} isEFI */ async getDevices() { const buildDevice = (path, dbusDevices) => { + const addDeviceProperties = (device, dbusProperties) => { + device.sid = dbusProperties.SID.v; + device.name = dbusProperties.Name.v; + device.description = dbusProperties.Description.v; + }; + const addDriveProperties = (device, dbusProperties) => { device.isDrive = true; device.type = dbusProperties.Type.v; @@ -173,7 +242,8 @@ class DevicesManager { const addBlockProperties = (device, blockProperties) => { device.active = blockProperties.Active.v; - device.name = blockProperties.Name.v; + device.encrypted = blockProperties.Encrypted.v; + device.start = blockProperties.Start.v; device.size = blockProperties.Size.v; device.recoverableSize = blockProperties.RecoverableSize.v; device.systems = blockProperties.Systems.v; @@ -181,19 +251,41 @@ class DevicesManager { device.udevPaths = blockProperties.UdevPaths.v; }; + const addPartitionProperties = (device, partitionProperties) => { + device.type = "partition"; + device.isEFI = partitionProperties.EFI.v; + }; + + const addLvmVgProperties = (device, lvmVgProperties) => { + device.type = "lvmVg"; + device.size = lvmVgProperties.Size.v; + device.physicalVolumes = lvmVgProperties.PhysicalVolumes.v.map(d => buildDevice(d, dbusDevices)); + device.logicalVolumes = lvmVgProperties.LogicalVolumes.v.map(d => buildDevice(d, dbusDevices)); + }; + + const addLvmLvProperties = (device) => { + device.type = "lvmLv"; + }; + const addPtableProperties = (device, ptableProperties) => { + const buildPartitionSlot = ([start, size]) => ({ start, size }); const partitions = ptableProperties.Partitions.v.map(p => buildDevice(p, dbusDevices)); device.partitionTable = { type: ptableProperties.Type.v, partitions, - unpartitionedSize: device.size - partitions.reduce((s, p) => s + p.size, 0) + unpartitionedSize: device.size - partitions.reduce((s, p) => s + p.size, 0), + unusedSlots: ptableProperties.UnusedSlots.v.map(buildPartitionSlot) }; }; const addFilesystemProperties = (device, filesystemProperties) => { + const buildMountPath = path => path.length > 0 ? path : undefined; + const buildLabel = label => label.length > 0 ? label : undefined; device.filesystem = { + sid: filesystemProperties.SID.v, type: filesystemProperties.Type.v, - isEFI: filesystemProperties.EFI.v + mountPath: buildMountPath(filesystemProperties.MountPath.v), + label: buildLabel(filesystemProperties.Label.v) }; }; @@ -206,6 +298,8 @@ class DevicesManager { const device = { sid: path.split("/").pop(), + name: "", + description: "", isDrive: false, type: "" }; @@ -213,6 +307,9 @@ class DevicesManager { const dbusDevice = dbusDevices[path]; if (!dbusDevice) return device; + const deviceProperties = dbusDevice["org.opensuse.Agama.Storage1.Device"]; + if (deviceProperties !== undefined) addDeviceProperties(device, deviceProperties); + const driveProperties = dbusDevice["org.opensuse.Agama.Storage1.Drive"]; if (driveProperties !== undefined) addDriveProperties(device, driveProperties); @@ -228,6 +325,15 @@ class DevicesManager { const blockProperties = dbusDevice["org.opensuse.Agama.Storage1.Block"]; if (blockProperties !== undefined) addBlockProperties(device, blockProperties); + const partitionProperties = dbusDevice["org.opensuse.Agama.Storage1.Partition"]; + if (partitionProperties !== undefined) addPartitionProperties(device, partitionProperties); + + const lvmVgProperties = dbusDevice["org.opensuse.Agama.Storage1.LVM.VolumeGroup"]; + if (lvmVgProperties !== undefined) addLvmVgProperties(device, lvmVgProperties); + + const lvmLvProperties = dbusDevice["org.opensuse.Agama.Storage1.LVM.LogicalVolume"]; + if (lvmLvProperties !== undefined) addLvmLvProperties(device); + const ptableProperties = dbusDevice["org.opensuse.Agama.Storage1.PartitionTable"]; if (ptableProperties !== undefined) addPtableProperties(device, ptableProperties); @@ -270,42 +376,6 @@ class ProposalManager { }; } - /** - * @typedef {object} ProposalSettings - * @property {string} bootDevice - * @property {string} encryptionPassword - * @property {string} encryptionMethod - * @property {boolean} lvm - * @property {string} spacePolicy - * @property {SpaceAction[]} spaceActions - * @property {string[]} systemVGDevices - * @property {Volume[]} volumes - * @property {StorageDevice[]} installationDevices - * - * @typedef {object} SpaceAction - * @property {string} device - * @property {string} action - * - * @typedef {object} Volume - * @property {string} mountPath - * @property {string} fsType - * @property {number} minSize - * @property {number} [maxSize] - * @property {boolean} autoSize - * @property {boolean} snapshots - * @property {boolean} transactional - * @property {VolumeOutline} outline - * - * @typedef {object} VolumeOutline - * @property {boolean} required - * @property {string[]} fsTypes - * @property {boolean} supportAutoSize - * @property {boolean} adjustByRam - * @property {boolean} snapshotsConfigurable - * @property {boolean} snapshotsAffectSizes - * @property {string[]} sizeRelevantVolumes - */ - /** * Gets the list of available devices * @@ -314,7 +384,7 @@ class ProposalManager { async getAvailableDevices() { const findDevice = (devices, path) => { const sid = path.split("/").pop(); - const device = devices.find(d => d.sid === sid); + const device = devices.find(d => d.sid === Number(sid)); if (device === undefined) console.log("D-Bus object not found: ", path); @@ -362,15 +432,6 @@ class ProposalManager { * Gets the values of the current proposal * * @return {Promise} - * - * @typedef {object} ProposalResult - * @property {ProposalSettings} settings - * @property {Action[]} actions - * - * @typedef {object} Action - * @property {string} text - * @property {boolean} subvol - * @property {boolean} delete */ async getResult() { const proxy = await this.proposalProxy(); @@ -389,6 +450,7 @@ class ProposalManager { const buildAction = dbusAction => { return { + device: dbusAction.Device.v, text: dbusAction.Text.v, subvol: dbusAction.Subvol.v, delete: dbusAction.Delete.v @@ -1469,6 +1531,7 @@ class StorageBaseClient { constructor(address = undefined) { this.client = new DBusClient(StorageBaseClient.SERVICE, address); this.system = new DevicesManager(this.client, STORAGE_SYSTEM_NAMESPACE); + this.staging = new DevicesManager(this.client, STORAGE_STAGING_NAMESPACE); this.proposal = new ProposalManager(this.client, this.system); this.iscsi = new ISCSIManager(StorageBaseClient.SERVICE, address); this.dasd = new DASDManager(StorageBaseClient.SERVICE, address); diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index 6b797556a2..1604c04449 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -33,10 +33,10 @@ const cockpitCallbacks = {}; let managedObjects = {}; -// Define devices +// System devices const sda = { - sid: "59", + sid: 59, isDrive: true, type: "disk", vendor: "Micron", @@ -49,7 +49,10 @@ const sda = { sdCard: true, active: true, name: "/dev/sda", + description: "", size: 1024, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], @@ -57,33 +60,41 @@ const sda = { }; const sda1 = { - sid: "60", + sid: 60, isDrive: false, - type: "", + type: "partition", active: true, name: "/dev/sda1", + description: "", size: 512, + start: 123, + encrypted: false, recoverableSize: 128, systems : [], udevIds: [], - udevPaths: [] + udevPaths: [], + isEFI: false }; const sda2 = { - sid: "61", + sid: 61, isDrive: false, - type: "", + type: "partition", active: true, name: "/dev/sda2", + description: "", size: 256, + start: 1789, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], - udevPaths: [] + udevPaths: [], + isEFI: false }; const sdb = { - sid: "62", + sid: 62, isDrive: true, type: "disk", vendor: "Samsung", @@ -96,7 +107,10 @@ const sdb = { sdCard: false, active: true, name: "/dev/sdb", + description: "", size: 2048, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], @@ -104,7 +118,7 @@ const sdb = { }; const sdc = { - sid: "63", + sid: 63, isDrive: true, type: "disk", vendor: "Disk", @@ -117,7 +131,10 @@ const sdc = { sdCard: false, active: true, name: "/dev/sdc", + description: "", size: 2048, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], @@ -125,7 +142,7 @@ const sdc = { }; const sdd = { - sid: "64", + sid: 64, isDrive: true, type: "disk", vendor: "Disk", @@ -138,7 +155,10 @@ const sdd = { sdCard: false, active: true, name: "/dev/sdd", + description: "", size: 2048, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], @@ -146,7 +166,7 @@ const sdd = { }; const sde = { - sid: "65", + sid: 65, isDrive: true, type: "disk", vendor: "Disk", @@ -159,7 +179,10 @@ const sde = { sdCard: false, active: true, name: "/dev/sde", + description: "", size: 2048, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], @@ -167,23 +190,26 @@ const sde = { }; const md0 = { - sid: "66", + sid: 66, isDrive: false, type: "md", level: "raid0", uuid: "12345:abcde", active: true, name: "/dev/md0", + description: "EXT4 RAID", size: 2048, + start: 0, + encrypted: false, recoverableSize: 0, systems : ["openSUSE Leap 15.2"], udevIds: [], udevPaths: [], - filesystem: { type: "ext4", isEFI: false } + filesystem: { sid: 100, type: "ext4", mountPath: "/test", label: "system" } }; const raid = { - sid: "67", + sid: 67, isDrive: true, type: "raid", vendor: "Dell", @@ -196,7 +222,10 @@ const raid = { sdCard: false, active: true, name: "/dev/mapper/isw_ddgdcbibhd_244", + description: "", size: 2048, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], @@ -204,7 +233,7 @@ const raid = { }; const multipath = { - sid: "68", + sid: 68, isDrive: true, type: "multipath", vendor: "", @@ -217,7 +246,10 @@ const multipath = { sdCard: false, active: true, name: "/dev/mapper/36005076305ffc73a00000000000013b4", + description: "", size: 2048, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], @@ -225,7 +257,7 @@ const multipath = { }; const dasd = { - sid: "69", + sid: 69, isDrive: true, type: "dasd", vendor: "IBM", @@ -238,7 +270,76 @@ const dasd = { sdCard: false, active: true, name: "/dev/dasda", + description: "", size: 2048, + start: 0, + encrypted: false, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: [] +}; + +const sdf = { + sid: 70, + isDrive: true, + type: "disk", + vendor: "Disk", + model: "", + driver: [], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdf", + description: "", + size: 2048, + start: 0, + encrypted: false, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: [] +}; + +const sdf1 = { + sid: 71, + isDrive: false, + type: "partition", + active: true, + name: "/dev/sdf1", + description: "PV of vg0", + size: 512, + start: 1024, + encrypted: true, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: [], + isEFI: false +}; + +const lvmVg = { + sid: 72, + isDrive: false, + type: "lvmVg", + name: "/dev/vg0", + description: "LVM", + size: 512 +}; + +const lvmLv1 = { + sid: 73, + isDrive: false, + type: "lvmLv", + active: true, + name: "/dev/vg0/lv1", + description: "", + size: 512, + start: 0, + encrypted: false, recoverableSize: 0, systems : [], udevIds: [], @@ -250,7 +351,8 @@ const dasd = { sda.partitionTable = { type: "gpt", partitions: [sda1, sda2], - unpartitionedSize: 256 + unpartitionedSize: 256, + unusedSlots: [{ start: 1234, size: 256 }] }; sda1.component = { @@ -283,13 +385,60 @@ sde.component = { deviceNames: ["/dev/mapper/36005076305ffc73a00000000000013b4"] }; +sdf.partitionTable = { + type: "gpt", + partitions: [sdf1], + unpartitionedSize: 1536, + unusedSlots: [] +}; + +sdf1.component = { + type: "physical_volume", + deviceNames: ["/dev/vg0"] +}; + md0.devices = [sda1, sda2]; raid.devices = [sdb, sdc]; multipath.wires = [sdd, sde]; -const systemDevices = { sda, sda1, sda2, sdb, sdc, sdd, sde, md0, raid, multipath, dasd }; +lvmVg.logicalVolumes = [lvmLv1]; +lvmVg.physicalVolumes = [sdf1]; + +const systemDevices = { + sda, sda1, sda2, sdb, sdc, sdd, sde, md0, raid, multipath, dasd, sdf, sdf1, lvmVg, lvmLv1 +}; + +// Staging devices +// +// Using a single device because most of the checks are already done with system devices. + +const sdbStaging = { + sid: 62, + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + description: "", + size: 2048, + start: 0, + encrypted: false, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +const stagingDevices = { sdb: sdbStaging }; const contexts = { withoutProposal: () => { @@ -358,6 +507,7 @@ const contexts = { ], Actions: [ { + Device: { t: "u", v: 2 }, Text: { t: "s", v: "Mount /dev/sdb1 as root" }, Subvol: { t: "b", v: false }, Delete: { t: "b", v: false } @@ -479,6 +629,11 @@ const contexts = { }, withSystemDevices: () => { managedObjects["/org/opensuse/Agama/Storage1/system/59"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 59 }, + Name: { t: "s", v: "/dev/sda" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "disk" }, Vendor: { t: "s", v: "Micron" }, @@ -491,8 +646,9 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/sda" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 1024 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"] }, @@ -503,14 +659,24 @@ const contexts = { Partitions: { t: "as", v: ["/org/opensuse/Agama/Storage1/system/60", "/org/opensuse/Agama/Storage1/system/61"] - } + }, + UnusedSlots: { t: "a(tt)", v: [[1234, 256]] } } }; managedObjects["/org/opensuse/Agama/Storage1/system/60"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 60 }, + Name: { t: "s", v: "/dev/sda1" }, + Description: { t: "s", v: "" } + }, + "org.opensuse.Agama.Storage1.Partition": { + EFI: { t: "b", v: false } + }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/sda1" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 512 }, + Start: { t: "t", v: 123 }, RecoverableSize: { t: "x", v: 128 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -523,10 +689,19 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/61"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 61 }, + Name: { t: "s", v: "/dev/sda2" }, + Description: { t: "s", v: "" } + }, + "org.opensuse.Agama.Storage1.Partition": { + EFI: { t: "b", v: false } + }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/sda2" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 256 }, + Start: { t: "t", v: 1789 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -539,6 +714,11 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/62"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 62 }, + Name: { t: "s", v: "/dev/sdb" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "disk" }, Vendor: { t: "s", v: "Samsung" }, @@ -551,8 +731,9 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/sdb" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -565,6 +746,11 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/63"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 63 }, + Name: { t: "s", v: "/dev/sdc" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "disk" }, Vendor: { t: "s", v: "Disk" }, @@ -577,8 +763,9 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/sdc" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -591,6 +778,11 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/64"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 64 }, + Name: { t: "s", v: "/dev/sdd" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "disk" }, Vendor: { t: "s", v: "Disk" }, @@ -603,8 +795,9 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/sdd" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -617,6 +810,11 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/65"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 65 }, + Name: { t: "s", v: "/dev/sde" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "disk" }, Vendor: { t: "s", v: "Disk" }, @@ -629,8 +827,9 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/sde" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -643,6 +842,11 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/66"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 66 }, + Name: { t: "s", v: "/dev/md0" }, + Description: { t: "s", v: "EXT4 RAID" } + }, "org.opensuse.Agama.Storage1.MD": { Level: { t: "s", v: "raid0" }, UUID: { t: "s", v: "12345:abcde" }, @@ -653,19 +857,27 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/md0" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: ["openSUSE Leap 15.2"] }, UdevIds: { t: "as", v: [] }, UdevPaths: { t: "as", v: [] } }, "org.opensuse.Agama.Storage1.Filesystem": { + SID: { t: "u", v: 100 }, Type: { t: "s", v: "ext4" }, - EFI: { t: "b", v: false } + MountPath: { t: "s", v: "/test" }, + Label: { t: "s", v: "system" } } }; managedObjects["/org/opensuse/Agama/Storage1/system/67"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 67 }, + Name: { t: "s", v: "/dev/mapper/isw_ddgdcbibhd_244" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "raid" }, Vendor: { t: "s", v: "Dell" }, @@ -684,8 +896,9 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/mapper/isw_ddgdcbibhd_244" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -693,6 +906,11 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/68"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 68 }, + Name: { t: "s", v: "/dev/mapper/36005076305ffc73a00000000000013b4" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "multipath" }, Vendor: { t: "s", v: "" }, @@ -711,8 +929,9 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/mapper/36005076305ffc73a00000000000013b4" }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, @@ -720,6 +939,11 @@ const contexts = { } }; managedObjects["/org/opensuse/Agama/Storage1/system/69"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 69 }, + Name: { t: "s", v: "/dev/dasda" }, + Description: { t: "s", v: "" } + }, "org.opensuse.Agama.Storage1.Drive": { Type: { t: "s", v: "dasd" }, Vendor: { t: "s", v: "IBM" }, @@ -732,12 +956,135 @@ const contexts = { }, "org.opensuse.Agama.Storage1.Block": { Active: { t: "b", v: true }, - Name: { t: "s", v: "/dev/dasda" }, + Encrypted: { t: "b", v: false }, + Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, + RecoverableSize: { t: "x", v: 0 }, + Systems: { t: "as", v: [] }, + UdevIds: { t: "as", v: [] }, + UdevPaths: { t: "as", v: [] } + } + }; + managedObjects["/org/opensuse/Agama/Storage1/system/70"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 70 }, + Name: { t: "s", v: "/dev/sdf" }, + Description: { t: "s", v: "" } + }, + "org.opensuse.Agama.Storage1.Drive": { + Type: { t: "s", v: "disk" }, + Vendor: { t: "s", v: "Disk" }, + Model: { t: "s", v: "" }, + Driver: { t: "as", v: [] }, + Bus: { t: "s", v: "IDE" }, + BusId: { t: "s", v: "" }, + Transport: { t: "s", v: "" }, + Info: { t: "a{sv}", v: { DellBOSS: { t: "b", v: false }, SDCard: { t: "b", v: false } } }, + }, + "org.opensuse.Agama.Storage1.Block": { + Active: { t: "b", v: true }, + Encrypted: { t: "b", v: false }, Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, + RecoverableSize: { t: "x", v: 0 }, + Systems: { t: "as", v: [] }, + UdevIds: { t: "as", v: [] }, + UdevPaths: { t: "as", v: [] } + }, + "org.opensuse.Agama.Storage1.PartitionTable": { + Type: { t: "s", v: "gpt" }, + Partitions: { + t: "as", + v: ["/org/opensuse/Agama/Storage1/system/71"] + }, + UnusedSlots: { t: "a(tt)", v: [] } + } + }; + managedObjects["/org/opensuse/Agama/Storage1/system/71"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 71 }, + Name: { t: "s", v: "/dev/sdf1" }, + Description: { t: "s", v: "PV of vg0" } + }, + "org.opensuse.Agama.Storage1.Partition": { + EFI: { t: "b", v: false } + }, + "org.opensuse.Agama.Storage1.Block": { + Active: { t: "b", v: true }, + Encrypted: { t: "b", v: true }, + Size: { t: "x", v: 512 }, + Start: { t: "t", v: 1024 }, + RecoverableSize: { t: "x", v: 0 }, + Systems: { t: "as", v: [] }, + UdevIds: { t: "as", v: [] }, + UdevPaths: { t: "as", v: [] } + }, + "org.opensuse.Agama.Storage1.Component": { + Type: { t: "s", v: "physical_volume" }, + DeviceNames: { t: "as", v: ["/dev/vg0"] }, + Devices: { t: "ao", v: ["/org/opensuse/Agama/Storage1/system/72"] } + } + }; + managedObjects["/org/opensuse/Agama/Storage1/system/72"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 72 }, + Name: { t: "s", v: "/dev/vg0" }, + Description: { t: "s", v: "LVM" } + }, + "org.opensuse.Agama.Storage1.LVM.VolumeGroup": { + Type: { t: "s", v: "physical_volume" }, + Size: { t: "x", v: 512 }, + PhysicalVolumes: { t: "ao", v: ["/org/opensuse/Agama/Storage1/system/71"] }, + LogicalVolumes: { t: "ao", v: ["/org/opensuse/Agama/Storage1/system/73"] } + } + }; + managedObjects["/org/opensuse/Agama/Storage1/system/73"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 73 }, + Name: { t: "s", v: "/dev/vg0/lv1" }, + Description: { t: "s", v: "" } + }, + "org.opensuse.Agama.Storage1.Block": { + Active: { t: "b", v: true }, + Encrypted: { t: "b", v: false }, + Size: { t: "x", v: 512 }, + Start: { t: "t", v: 0 }, RecoverableSize: { t: "x", v: 0 }, Systems: { t: "as", v: [] }, UdevIds: { t: "as", v: [] }, UdevPaths: { t: "as", v: [] } + }, + "org.opensuse.Agama.Storage1.LVM.LogicalVolume": { + VolumeGroup: { t: "o", v: "/org/opensuse/Agama/Storage1/system/72" } + } + }; + }, + withStagingDevices: () => { + managedObjects["/org/opensuse/Agama/Storage1/staging/62"] = { + "org.opensuse.Agama.Storage1.Device": { + SID: { t: "u", v: 62 }, + Name: { t: "s", v: "/dev/sdb" }, + Description: { t: "s", v: "" } + }, + "org.opensuse.Agama.Storage1.Drive": { + Type: { t: "s", v: "disk" }, + Vendor: { t: "s", v: "Samsung" }, + Model: { t: "s", v: "Samsung Evo 8 Pro" }, + Driver: { t: "as", v: ["ahci"] }, + Bus: { t: "s", v: "IDE" }, + BusId: { t: "s", v: "" }, + Transport: { t: "s", v: "" }, + Info: { t: "a{sv}", v: { DellBOSS: { t: "b", v: false }, SDCard: { t: "b", v: false } } }, + }, + "org.opensuse.Agama.Storage1.Block": { + Active: { t: "b", v: true }, + Encrypted: { t: "b", v: false }, + Size: { t: "x", v: 2048 }, + Start: { t: "t", v: 0 }, + RecoverableSize: { t: "x", v: 0 }, + Systems: { t: "as", v: [] }, + UdevIds: { t: "as", v: [] }, + UdevPaths: { t: "as", v: ["pci-0000:00-19"] } } }; } @@ -973,6 +1320,33 @@ describe("#system", () => { }); }); +describe("#staging", () => { + describe("#getDevices", () => { + describe("when there are devices", () => { + beforeEach(() => { + contexts.withStagingDevices(); + client = new StorageClient(); + }); + + it("returns the staging devices", async () => { + const devices = await client.staging.getDevices(); + expect(devices).toEqual(Object.values(stagingDevices)); + }); + }); + + describe("when there are not devices", () => { + beforeEach(() => { + client = new StorageClient(); + }); + + it("returns an empty list", async () => { + const devices = await client.staging.getDevices(); + expect(devices).toEqual([]); + }); + }); + }); +}); + describe("#proposal", () => { describe("#getAvailableDevices", () => { beforeEach(() => { @@ -1172,7 +1546,7 @@ describe("#proposal", () => { ); expect(actions).toStrictEqual([ - { text: "Mount /dev/sdb1 as root", subvol: false, delete: false } + { device: 2, text: "Mount /dev/sdb1 as root", subvol: false, delete: false } ]); }); }); diff --git a/web/src/components/core/Reminder.jsx b/web/src/components/core/Reminder.jsx index 997e870a91..cd4e0943ad 100644 --- a/web/src/components/core/Reminder.jsx +++ b/web/src/components/core/Reminder.jsx @@ -62,7 +62,8 @@ const ReminderTitle = ({ children }) => { * @param {object} props * @param {string} [props.icon] - The name of desired icon. * @param {JSX.Element|string} [props.title] - The content for the title. - * @param {string} [props.role="status"] - The reminder's role, "status" by + * @param {string} [props.role="status"] - The reminder's role, "status" by default. + * @param {("subtle")} [props.variant] - The reminder's variant, none by default. * default. See {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role} * @param {JSX.Element} [props.children] - The content for the description. */ @@ -70,10 +71,11 @@ export default function Reminder ({ icon, title, role = "status", + variant, children }) { return ( -
+
{title} diff --git a/web/src/components/core/Reminder.test.jsx b/web/src/components/core/Reminder.test.jsx index 24527cf2d0..41ebfaf300 100644 --- a/web/src/components/core/Reminder.test.jsx +++ b/web/src/components/core/Reminder.test.jsx @@ -37,6 +37,12 @@ describe("Reminder", () => { within(reminder).getByText("Example"); }); + it("renders a region with given data-variant, if any", () => { + plainRender(Example); + const reminder = screen.getByRole("alert"); + expect(reminder).toHaveAttribute("data-variant", "subtle"); + }); + it("renders given title", () => { plainRender( Kindly reminder}> diff --git a/web/src/components/core/Tag.jsx b/web/src/components/core/Tag.jsx new file mode 100644 index 0000000000..c910d07295 --- /dev/null +++ b/web/src/components/core/Tag.jsx @@ -0,0 +1,41 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React from "react"; + +/** + * Simple component that helps wrapped content stand out visually. The variant + * prop determines what kind of enhancement is applied. + * @component + * + * @param {object} props + * @param {("simple"|"teal"|"orange"|"gray-highlight")} [props.variant="simple"] + * @param {React.ReactNode} props.children + */ +export default function Tag ({ variant = "simple", children }) { + return ( + + {children} + + ); +} diff --git a/web/src/components/core/Tag.test.jsx b/web/src/components/core/Tag.test.jsx new file mode 100644 index 0000000000..e4a9739abf --- /dev/null +++ b/web/src/components/core/Tag.test.jsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { Tag } from "~/components/core"; + +describe("Tag", () => { + it("renders its children in a node with data-type='agama/tag' attribute", () => { + plainRender(New); + const node = screen.getByText("New"); + expect(node).toHaveAttribute("data-type", "agama/tag"); + }); +}); diff --git a/web/src/components/core/TreeTable.jsx b/web/src/components/core/TreeTable.jsx new file mode 100644 index 0000000000..5f7612adb8 --- /dev/null +++ b/web/src/components/core/TreeTable.jsx @@ -0,0 +1,128 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React from "react"; +import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/react-table'; + +/** + * @typedef {import("@patternfly/react-table").TableProps} TableProps + */ + +/** + * @typedef {object} TreeTableColumn + * @property {string} title + * @property {(any) => React.ReactNode} content + * @property {string} [classNames] + */ + +/** + * @typedef {object} TreeTableBaseProps + * @property {TreeTableColumn[]} columns=[] + * @property {object[]} items=[] + * @property {(any) => array} [itemChildren] + * @property {(any) => string} [rowClassNames] + */ + +/** + * Table built on top of PF/Table + * @component + * + * FIXME: omitting `ref` here to avoid a TypeScript error but keep component as + * typed as possible. Further investigation is needed. + * + * @typedef {TreeTableBaseProps & Omit} TreeTableProps + * + * @param {TreeTableProps} props + */ +export default function TreeTable({ + columns = [], + items = [], + itemChildren = () => [], + rowClassNames = () => "", + ...tableProps +}) { + const renderColumns = (item, treeRow) => { + return columns.map((c, cIdx) => { + const props = { + dataLabel: c.title, + className: c.classNames + }; + + if (cIdx === 0) props.treeRow = treeRow; + + return ( + {c.content(item)} + ); + }); + }; + + const renderRows = (items, level) => { + if (items?.length <= 0) return; + + return ( + items.map((item, itemIdx) => { + const children = itemChildren(item); + + const treeRow = { + props: { + isExpanded: true, + isDetailsExpanded: true, + "aria-level": level, + "aria-posinset": itemIdx + 1, + "aria-setsize": children?.length || 0 + } + }; + + const rowProps = { + row: { props: treeRow.props }, + className: rowClassNames(item) + }; + + return ( + + {renderColumns(item, treeRow)} + { renderRows(children, level + 1)} + + ); + }) + ); + }; + + return ( + + + + { columns.map((c, i) => ) } + + + + { renderRows(items, 1) } + +
{c.title}
+ ); +} diff --git a/web/src/components/core/TreeTable.test.jsx b/web/src/components/core/TreeTable.test.jsx new file mode 100644 index 0000000000..c1c858da16 --- /dev/null +++ b/web/src/components/core/TreeTable.test.jsx @@ -0,0 +1,24 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +describe("TreeTable", () => { + it.todo("add examples for testing core/TreeTable component"); +}); diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index 6bdac86248..f6448c03d3 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -58,3 +58,5 @@ export { default as DevelopmentInfo } from "./DevelopmentInfo"; export { default as Selector } from "./Selector"; export { default as OptionsPicker } from "./OptionsPicker"; export { default as Reminder } from "./Reminder"; +export { default as Tag } from "./Tag"; +export { default as TreeTable } from "./TreeTable"; diff --git a/web/src/components/storage/DevicesManager.js b/web/src/components/storage/DevicesManager.js new file mode 100644 index 0000000000..5f70318564 --- /dev/null +++ b/web/src/components/storage/DevicesManager.js @@ -0,0 +1,215 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import { compact, uniq } from "~/utils"; + +/** + * @typedef {import ("~/client/storage").Action} Action + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + */ + +/** + * Class for managing storage devices. + */ +export default class DevicesManager { + /** + * @param {StorageDevice[]} system - Devices representing the current state of the system. + * @param {StorageDevice[]} staging - Devices representing the target state of the system. + * @param {Action[]} actions - Actions to perform from system to staging. + */ + constructor(system, staging, actions) { + this.system = system; + this.staging = staging; + this.actions = actions; + } + + /** + * System device with the given SID. + * + * @param {Number} sid + * @returns {StorageDevice|undefined} + */ + systemDevice(sid) { + return this.#device(sid, this.system); + } + + /** + * Staging device with the given SID. + * + * @param {Number} sid + * @returns {StorageDevice|undefined} + */ + stagingDevice(sid) { + return this.#device(sid, this.staging); + } + + /** + * Whether the given device exists in system. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + existInSystem(device) { + return this.#exist(device, this.system); + } + + /** + * Whether the given device exists in staging. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + existInStaging(device) { + return this.#exist(device, this.staging); + } + + /** + * Whether the given device is going to be formatted. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + hasNewFilesystem(device) { + if (!device.filesystem) return false; + + const systemDevice = this.systemDevice(device.sid); + const systemFilesystemSID = systemDevice?.filesystem?.sid; + + return device.filesystem.sid !== systemFilesystemSID; + } + + /** + * Whether the given device is going to be shrunk. + * + * @param {StorageDevice} device + * @returns {Boolean} + */ + isShrunk(device) { + return this.shrinkSize(device) > 0; + } + + /** + * Amount of bytes the given device is going to be shrunk. + * + * @param {StorageDevice} device + * @returns {Number} + */ + shrinkSize(device) { + const systemDevice = this.systemDevice(device.sid); + const stagingDevice = this.stagingDevice(device.sid); + + if (!systemDevice || !stagingDevice) return 0; + + const amount = systemDevice.size - stagingDevice.size; + return amount > 0 ? amount : 0; + } + + /** + * Disk devices and LVM volume groups used for the installation. + * + * @note The used devices are extracted from the actions. + * + * @returns {StorageDevice[]} + */ + usedDevices() { + const isTarget = (device) => device.isDrive || device.type === "lvmVg"; + + // Check in system devices to detect removals. + const targetSystem = this.system.filter(isTarget); + const targetStaging = this.staging.filter(isTarget); + + const sids = targetSystem.concat(targetStaging) + .filter(d => this.#isUsed(d)) + .map(d => d.sid); + + return compact(uniq(sids).map(sid => this.stagingDevice(sid))); + } + + /** + * Devices deleted. + * + * @note The devices are extracted from the actions. + * + * @returns {StorageDevice[]} + */ + deletedDevices() { + return this.#deleteActionsDevice().filter(d => !d.isDrive); + } + + /** + * Systems deleted. + * + * @returns {string[]} + */ + deletedSystems() { + const systems = this.#deleteActionsDevice() + .filter(d => !d.partitionTable) + .map(d => d.systems) + .flat(); + return compact(systems); + } + + /** + * @param {number} sid + * @param {StorageDevice[]} source + * @returns {StorageDevice|undefined} + */ + #device(sid, source) { + return source.find(d => d.sid === sid); + } + + /** + * @param {StorageDevice} device + * @param {StorageDevice[]} source + * @returns {boolean} + */ + #exist(device, source) { + return this.#device(device.sid, source) !== undefined; + } + + /** + * @param {StorageDevice} device + * @returns {boolean} + */ + #isUsed(device) { + const sids = uniq(compact(this.actions.map(a => a.device))); + + const partitions = device.partitionTable?.partitions || []; + const lvmLvs = device.logicalVolumes || []; + + return sids.includes(device.sid) || + partitions.find(p => this.#isUsed(p)) !== undefined || + lvmLvs.find(l => this.#isUsed(l)) !== undefined; + } + + /** + * @returns {StorageDevice[]} + */ + #deleteActionsDevice() { + const sids = this.actions + .filter(a => a.delete) + .map(a => a.device); + const devices = sids.map(sid => this.systemDevice(sid)); + return compact(devices); + } +} diff --git a/web/src/components/storage/DevicesManager.test.js b/web/src/components/storage/DevicesManager.test.js new file mode 100644 index 0000000000..23636fc106 --- /dev/null +++ b/web/src/components/storage/DevicesManager.test.js @@ -0,0 +1,433 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import DevicesManager from "./DevicesManager"; + +let system; +let staging; +let actions; + +beforeEach(() => { + system = []; + staging = []; + actions = []; +}); + +describe("systemDevice", () => { + beforeEach(() => { + staging = [{ sid: 60, name: "/dev/sda" }]; + }); + + describe("if there is no system device with the given SID", () => { + it("returns undefined", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.systemDevice(60)).toBeUndefined(); + }); + }); + + describe("if there is a system device with the given SID", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sdb" }]; + }); + + it("returns the system device", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.systemDevice(60).name).toEqual("/dev/sdb"); + }); + }); +}); + +describe("stagingDevice", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda" }]; + }); + + describe("if there is no staging device with the given SID", () => { + it("returns undefined", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.stagingDevice(60)).toBeUndefined(); + }); + }); + + describe("if there is a staging device with the given SID", () => { + beforeEach(() => { + staging = [{ sid: 60, name: "/dev/sdb" }]; + }); + + it("returns the staging device", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.stagingDevice(60).name).toEqual("/dev/sdb"); + }); + }); +}); + +describe("existInSystem", () => { + beforeEach(() => { + system = [{ sid: 61, name: "/dev/sda2" }]; + staging = [{ sid: 60, name: "/dev/sda1" }]; + }); + + describe("if the given device does not exist in system", () => { + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInSystem({ sid: 60 })).toEqual(false); + }); + }); + + describe("if the given device exists in system", () => { + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInSystem({ sid: 61 })).toEqual(true); + }); + }); +}); + +describe("existInStaging", () => { + beforeEach(() => { + system = [{ sid: 61, name: "/dev/sda2" }]; + staging = [{ sid: 60, name: "/dev/sda1" }]; + }); + + describe("if the given device does not exist in staging", () => { + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInStaging({ sid: 61 })).toEqual(false); + }); + }); + + describe("if the given device exists in staging", () => { + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.existInStaging({ sid: 60 })).toEqual(true); + }); + }); +}); + +describe("hasNewFilesystem", () => { + describe("if the given device has no file system", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + staging = [{ sid: 60, name: "/dev/sda1" }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.hasNewFilesystem(device)).toEqual(false); + }); + }); + + describe("if the given device has no new file system", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + staging = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.hasNewFilesystem(device)).toEqual(false); + }); + }); + + describe("if the given device has a new file system", () => { + beforeEach(() => { + system = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 61 } }]; + staging = [{ sid: 60, name: "/dev/sda1", filesystem: { sid: 62 } }]; + }); + + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.hasNewFilesystem(device)).toEqual(true); + }); + }); +}); + +describe("isShrunk", () => { + describe("if the device is new", () => { + beforeEach(() => { + system = []; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the device does not exist anymore", () => { + beforeEach(() => { + system = [{ sid: 60, size: 2048 }]; + staging = []; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.systemDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the size is kept", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 1024 }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the size is more than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns false", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(false); + }); + }); + + describe("if the size is less than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 512 }]; + }); + + it("returns true", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.isShrunk(device)).toEqual(true); + }); + }); +}); + +describe("shrinkSize", () => { + describe("if the device is new", () => { + beforeEach(() => { + system = []; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the device does not exist anymore", () => { + beforeEach(() => { + system = [{ sid: 60, size: 2048 }]; + staging = []; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.systemDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the size is kept", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 1024 }]; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the size is more than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 2048 }]; + }); + + it("returns 0", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(0); + }); + }); + + describe("if the size is less than initially", () => { + beforeEach(() => { + system = [{ sid: 60, size: 1024 }]; + staging = [{ sid: 60, size: 512 }]; + }); + + it("returns the shrink amount", () => { + const manager = new DevicesManager(system, staging, actions); + const device = manager.stagingDevice(60); + expect(manager.shrinkSize(device)).toEqual(512); + }); + }); +}); + +describe("usedDevices", () => { + beforeEach(() => { + system = [ + { sid: 60, isDrive: false }, + { sid: 61, isDrive: true }, + { sid: 62, isDrive: true, partitionTable: { partitions: [{ sid: 67 }] } }, + { sid: 63, isDrive: true, partitionTable: { partitions: [] } }, + { sid: 64, isDrive: false, type: "lvmVg", logicalVolumes: [] }, + { sid: 65, isDrive: false, type: "lvmVg", logicalVolumes: [] }, + { sid: 66, isDrive: false, type: "lvmVg", logicalVolumes: [{ sid: 68 }] } + ]; + staging = [ + { sid: 60, isDrive: false }, + // Partition removed + { sid: 62, isDrive: true, partitionTable: { partitions: [] } }, + // Partition added + { sid: 63, isDrive: true, partitionTable: { partitions: [{ sid: 69 }] } }, + { sid: 64, isDrive: false, type: "lvmVg", logicalVolumes: [] }, + // Logical volume added + { sid: 65, isDrive: false, type: "lvmVg", logicalVolumes: [{ sid: 70 }, { sid: 71 }] }, + // Logical volume removed + { sid: 66, isDrive: false, type: "lvmVg", logicalVolumes: [] } + ]; + }); + + describe("if there are no actions", () => { + beforeEach(() => { + actions = []; + }); + + it("returns an empty list", () => { + const manager = new DevicesManager(system, staging, actions); + expect(manager.usedDevices()).toEqual([]); + }); + }); + + describe("if there are actions", () => { + beforeEach(() => { + actions = [ + // This device is ignored because it is neither a drive nor a LVM VG. + { device: 60 }, + // This device was removed. + { device: 61 }, + // This partition was removed (belongs to device 62). + { device: 67 }, + // This logical volume was removed (belongs to device 66). + { device: 68 }, + // This partition was added (belongs to device 63). + { device: 69 }, + // This logical volume was added (belongs to device 65). + { device: 70 }, + // This logical volume was added (belongs to device 65). + { device: 71 } + ]; + }); + + it("does not include removed disk devices or LVM volume groups", () => { + const manager = new DevicesManager(system, staging, actions); + const sids = manager.usedDevices().map(d => d.sid) + .sort(); + expect(sids).not.toContain(61); + }); + + it("includes all disk devices and LVM volume groups affected by the actions", () => { + const manager = new DevicesManager(system, staging, actions); + const sids = manager.usedDevices().map(d => d.sid) + .sort(); + expect(sids).toEqual([62, 63, 65, 66]); + }); + }); +}); + +describe("deletedDevices", () => { + beforeEach(() => { + system = [ + { sid: 60 }, + { sid: 62 }, + { sid: 63 }, + { sid: 64 }, + { sid: 65, isDrive: true } + ]; + actions = [ + { device: 60, delete: true }, + // This device does not exist in system. + { device: 61, delete: true }, + { device: 62, delete: false }, + { device: 63, delete: true }, + { device: 65, delete: true } + ]; + }); + + it("includes all deleted devices", () => { + const manager = new DevicesManager(system, staging, actions); + const sids = manager.deletedDevices().map(d => d.sid) + .sort(); + expect(sids).toEqual([60, 63]); + }); +}); + +describe("deletedSystems", () => { + beforeEach(() => { + system = [ + { sid: 60, systems: ["Windows XP"] }, + { sid: 62, systems: ["Ubuntu"] }, + { + sid: 63, + systems: ["openSUSE Leap", "openSUSE Tumbleweed"], + partitionTable: { + partitions: [{ sid: 65 }, { sid: 66 }] + } + }, + { sid: 64 }, + { sid: 65, systems: ["openSUSE Leap"] }, + { sid: 66, systems: ["openSUSE Tumbleweed"] } + ]; + actions = [ + { device: 60, delete: true }, + // This device does not exist in system. + { device: 61, delete: true }, + { device: 62, delete: false }, + { device: 63, delete: true }, + { device: 65, delete: true }, + { device: 66, delete: true } + ]; + }); + + it("includes all deleted systems", () => { + const manager = new DevicesManager(system, staging, actions); + const systems = manager.deletedSystems(); + expect(systems.length).toEqual(3); + expect(systems).toContain("Windows XP"); + expect(systems).toContain("openSUSE Leap"); + expect(systems).toContain("openSUSE Tumbleweed"); + }); +}); diff --git a/web/src/components/storage/ProposalActionsSection.jsx b/web/src/components/storage/ProposalActionsDialog.jsx similarity index 54% rename from web/src/components/storage/ProposalActionsSection.jsx rename to web/src/components/storage/ProposalActionsDialog.jsx index 8d35563afd..053f9d852c 100644 --- a/web/src/components/storage/ProposalActionsSection.jsx +++ b/web/src/components/storage/ProposalActionsDialog.jsx @@ -20,21 +20,12 @@ */ import React, { useState } from "react"; -import { - List, - ListItem, - ExpandableSection, - Skeleton, -} from "@patternfly/react-core"; +import { List, ListItem, ExpandableSection, } from "@patternfly/react-core"; import { sprintf } from "sprintf-js"; - import { _, n_ } from "~/i18n"; -import { If, Section } from "~/components/core"; import { partition } from "~/utils"; +import { If, Popup } from "~/components/core"; -// TODO: would be nice adding an aria-description to these lists, but aria-description still in -// draft yet and aria-describedby should be used... which id not ideal right now -// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-description const ActionsList = ({ actions }) => { // Some actions (e.g., deleting a LV) are reported as several actions joined by a line break const actionItems = (action, id) => { @@ -53,15 +44,21 @@ const ActionsList = ({ actions }) => { }; /** - * Renders the list of actions to perform in the system + * Renders a dialog with the given list of actions * @component * * @param {object} props - * @param {object[]} [props.actions=[]] + * @param {object[]} [props.actions=[]] - The actions to perform in the system. + * @param {boolean} [props.isOpen=false] - Whether the dialog is visible or not. + * @param {function} props.onClose - Whether the dialog is visible or not. */ -const ProposalActions = ({ actions = [] }) => { +export default function ProposalActionsDialog({ actions = [], isOpen = false, onClose }) { const [isExpanded, setIsExpanded] = useState(false); + if (typeof onClose !== 'function') { + console.error("Missing ProposalActionsDialog#onClose callback"); + } + if (actions.length === 0) return null; const [generalActions, subvolActions] = partition(actions, a => !a.subvol); @@ -72,66 +69,32 @@ const ProposalActions = ({ actions = [] }) => { : sprintf(n_("Show %d subvolume action", "Show %d subvolume actions", subvolActions.length), subvolActions.length); return ( - <> - - {subvolActions.length > 0 && ( - setIsExpanded(!isExpanded)} - toggleText={toggleText} - className="expandable-actions" - > - - - )} - - ); -}; - -/** - * @todo Create a component for rendering a customized skeleton - */ -const ActionsSkeleton = () => { - return ( - <> - - - - - - - ); -}; - -/** - * Section with the actions to perform in the system - * @component - * - * @param {object} props - * @param {object[]} [props.actions=[]] - * @param {string[]} [props.errors=[]] - * @param {boolean} [props.isLoading=false] - Whether the section content should be rendered as loading - */ -export default function ProposalActionsSection({ actions = [], errors = [], isLoading = false }) { - if (isLoading) errors = []; - - return ( -
+ } - else={} + condition={subvolActions.length > 0} + then={ + setIsExpanded(!isExpanded)} + toggleText={toggleText} + className="expandable-actions" + > + + + } /> -
+ + {_("Close")} + + ); } diff --git a/web/src/components/storage/ProposalActionsDialog.test.jsx b/web/src/components/storage/ProposalActionsDialog.test.jsx new file mode 100644 index 0000000000..cde3a95c51 --- /dev/null +++ b/web/src/components/storage/ProposalActionsDialog.test.jsx @@ -0,0 +1,144 @@ +/* + * Copyright (c) [2022-2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within, waitForElementToBeRemoved } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProposalActionsDialog } from "~/components/storage"; + +const actions = [ + { text: 'Create GPT on /dev/vdc', subvol: false, delete: false }, + { text: 'Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition', subvol: false, delete: false }, + { text: 'Create encrypted partition /dev/vdc2 (29.99 GiB) as LVM physical volume', subvol: false, delete: false }, + { text: 'Create volume group system0 (29.98 GiB) with /dev/mapper/cr_vdc2 (29.99 GiB)', subvol: false, delete: false }, + { text: 'Create LVM logical volume /dev/system0/root (20.00 GiB) on volume group system0 for / with btrfs', subvol: false, delete: false }, +]; + +const subvolumeActions = [ + { text: 'Create subvolume @ on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/var on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/usr/local on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/srv on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/root on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/opt on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/home on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/boot/writable on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/boot/grub2/x86_64-efi on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, + { text: 'Create subvolume @/boot/grub2/i386-pc on /dev/system0/root (20.00 GiB)', subvol: true, delete: false } +]; + +const destructiveAction = { text: 'Delete ext4 on /dev/vdc', subvol: false, delete: true }; + +const onCloseFn = jest.fn(); + +it("renders nothing by default", () => { + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); +}); + +it("renders nothing when isOpen=false", () => { + const { container } = plainRender( + + ); + expect(container).toBeEmptyDOMElement(); +}); + +describe("when isOpen", () => { + it("renders nothing if there are no actions", () => { + plainRender(); + + expect(screen.queryAllByText(/Delete/)).toEqual([]); + expect(screen.queryAllByText(/Create/)).toEqual([]); + expect(screen.queryAllByText(/Show/)).toEqual([]); + }); + + describe("and there are actions", () => { + it("renders a dialog with the list of actions", () => { + plainRender(); + + const dialog = screen.getByRole("dialog", { name: "Planned Actions" }); + const actionsList = within(dialog).getByRole("list"); + const actionsListItems = within(actionsList).getAllByRole("listitem"); + expect(actionsListItems.map(i => i.textContent)).toEqual(actions.map(a => a.text)); + }); + + it("triggers the onClose callback when user clicks the Close button", async () => { + const { user } = plainRender(); + const closeButton = screen.getByRole("button", { name: "Close" }); + + await user.click(closeButton); + + expect(onCloseFn).toHaveBeenCalled(); + }); + + describe("when there is a destructive action", () => { + it("emphasizes the action", () => { + plainRender( + + ); + + // https://stackoverflow.com/a/63080940 + const actionItems = screen.getAllByRole("listitem"); + const destructiveActionItem = actionItems.find(item => item.textContent === destructiveAction.text); + + expect(destructiveActionItem).toHaveClass("proposal-action--delete"); + }); + }); + + describe("when there are subvolume actions", () => { + it("does not render the subvolume actions", () => { + plainRender( + + ); + + // For now, we know that there are two lists and the subvolume list is the second one. + // The test could be simplified once we have aria-descriptions for the lists. + const [genericList, subvolList] = screen.getAllByRole("list", { hidden: true }); + expect(genericList).not.toBeNull(); + expect(subvolList).not.toBeNull(); + const subvolItems = within(subvolList).queryAllByRole("listitem"); + expect(subvolItems).toEqual([]); + }); + + it("renders the subvolume actions after clicking on 'show subvolumes'", async () => { + const { user } = plainRender( + + ); + + const link = screen.getByText(/Show.*subvolume actions/); + + expect(screen.getAllByRole("list").length).toEqual(1); + + await user.click(link); + + waitForElementToBeRemoved(link); + screen.getByText(/Hide.*subvolume actions/); + + // For now, we know that there are two lists and the subvolume list is the second one. + // The test could be simplified once we have aria-descriptions for the lists. + const [, subvolList] = screen.getAllByRole("list"); + const subvolItems = within(subvolList).getAllByRole("listitem"); + + expect(subvolItems.map(i => i.textContent)).toEqual(subvolumeActions.map(a => a.text)); + }); + }); + }); +}); diff --git a/web/src/components/storage/ProposalActionsSection.test.jsx b/web/src/components/storage/ProposalActionsSection.test.jsx deleted file mode 100644 index 9864391d0d..0000000000 --- a/web/src/components/storage/ProposalActionsSection.test.jsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, within, waitForElementToBeRemoved } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { ProposalActionsSection } from "~/components/storage"; - -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - - return { - ...original, - Skeleton: () =>
PFSkeleton
- }; -}); - -const actions = [ - { text: 'Create GPT on /dev/vdc', subvol: false, delete: false }, - { text: 'Create partition /dev/vdc1 (8.00 MiB) as BIOS Boot Partition', subvol: false, delete: false }, - { text: 'Create encrypted partition /dev/vdc2 (29.99 GiB) as LVM physical volume', subvol: false, delete: false }, - { text: 'Create volume group system0 (29.98 GiB) with /dev/mapper/cr_vdc2 (29.99 GiB)', subvol: false, delete: false }, - { text: 'Create LVM logical volume /dev/system0/root (20.00 GiB) on volume group system0 for / with btrfs', subvol: false, delete: false }, -]; - -const subvolumeActions = [ - { text: 'Create subvolume @ on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/var on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/usr/local on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/srv on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/root on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/opt on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/home on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/boot/writable on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/boot/grub2/x86_64-efi on /dev/system0/root (20.00 GiB)', subvol: true, delete: false }, - { text: 'Create subvolume @/boot/grub2/i386-pc on /dev/system0/root (20.00 GiB)', subvol: true, delete: false } -]; - -const destructiveAction = { text: 'Delete ext4 on /dev/vdc', subvol: false, delete: true }; - -it("renders skeleton while loading", () => { - plainRender(); - - screen.getAllByText(/PFSkeleton/); -}); - -it("renders nothing when there is no actions", () => { - plainRender(); - - expect(screen.queryAllByText(/Delete/)).toEqual([]); - expect(screen.queryAllByText(/Create/)).toEqual([]); - expect(screen.queryAllByText(/Show/)).toEqual([]); -}); - -describe("when there are actions", () => { - it("renders an explanatory text", () => { - plainRender(); - - screen.getByText(/Actions to create/); - }); - - it("renders the list of actions", () => { - plainRender(); - - const actionsList = screen.getByRole("list"); - const actionsListItems = within(actionsList).getAllByRole("listitem"); - expect(actionsListItems.map(i => i.textContent)).toEqual(actions.map(a => a.text)); - }); - - describe("when there is a destructive action", () => { - it("emphasizes the action", () => { - plainRender(); - - // https://stackoverflow.com/a/63080940 - const actionItems = screen.getAllByRole("listitem"); - const destructiveActionItem = actionItems.find(item => item.textContent === destructiveAction.text); - - expect(destructiveActionItem).toHaveClass("proposal-action--delete"); - }); - }); - - describe("when there are subvolume actions", () => { - it("does not render the subvolume actions", () => { - plainRender(); - - // For now, we know that there are two lists and the subvolume list is the second one. - // The test could be simplified once we have aria-descriptions for the lists. - const [genericList, subvolList] = screen.getAllByRole("list", { hidden: true }); - expect(genericList).not.toBeNull(); - expect(subvolList).not.toBeNull(); - const subvolItems = within(subvolList).queryAllByRole("listitem"); - expect(subvolItems).toEqual([]); - }); - - it("renders the subvolume actions after clicking on 'show subvolumes'", async () => { - const { user } = plainRender( - - ); - - const link = screen.getByText(/Show.*subvolume actions/); - - expect(screen.getAllByRole("list").length).toEqual(1); - - await user.click(link); - - waitForElementToBeRemoved(link); - screen.getByText(/Hide.*subvolume actions/); - - // For now, we know that there are two lists and the subvolume list is the second one. - // The test could be simplified once we have aria-descriptions for the lists. - const [, subvolList] = screen.getAllByRole("list"); - const subvolItems = within(subvolList).getAllByRole("listitem"); - - expect(subvolItems.map(i => i.textContent)).toEqual(subvolumeActions.map(a => a.text)); - }); - }); -}); diff --git a/web/src/components/storage/ProposalDeviceSection.jsx b/web/src/components/storage/ProposalDeviceSection.jsx index df37eaf304..a39f4e333a 100644 --- a/web/src/components/storage/ProposalDeviceSection.jsx +++ b/web/src/components/storage/ProposalDeviceSection.jsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Form, @@ -101,7 +101,7 @@ const InstallationDeviceField = ({ isLoading = false, onChange = noop }) => { - const [device, setDevice] = useState(devices.find(d => d.name === current)); + const [device, setDevice] = useState(); const [isFormOpen, setIsFormOpen] = useState(false); const openForm = () => setIsFormOpen(true); @@ -114,6 +114,10 @@ const InstallationDeviceField = ({ onChange(selectedDevice); }; + useEffect(() => { + setDevice(devices.find(d => d.name === current)); + }, [current, devices, setDevice]); + /** * Renders a button that allows changing selected device * @@ -292,7 +296,7 @@ const LVMField = ({ isLoading = false, onChange: onChangeProp = noop }) => { - const [isChecked, setIsChecked] = useState(isCheckedProp); + const [isChecked, setIsChecked] = useState(); const [isFormOpen, setIsFormOpen] = useState(false); const [isFormValid, setIsFormValid] = useState(true); @@ -312,6 +316,10 @@ const LVMField = ({ onChangeProp({ vgDevices }); }; + useEffect(() => { + setIsChecked(isCheckedProp); + }, [isCheckedProp, setIsChecked]); + const description = _("Configuration of the system volume group. All the file systems will be \ created in a logical volume of the system volume group."); diff --git a/web/src/components/storage/ProposalFileSystemsSection.jsx b/web/src/components/storage/ProposalFileSystemsSection.jsx deleted file mode 100644 index 0b0e5f632d..0000000000 --- a/web/src/components/storage/ProposalFileSystemsSection.jsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { _ } from "~/i18n"; -import { Section } from "~/components/core"; -import { ProposalVolumes } from "~/components/storage"; -import { noop } from "~/utils"; - -/** - * @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings - * @typedef {import ("~/client/storage").ProposalManager.Volume} Volume - */ - -/** - * Section for editing the proposal file systems - * @component - * - * @callback onChangeFn - * @param {object} settings - * - * @param {object} props - * @param {ProposalSettings} props.settings - * @param {Volume[]} [props.volumeTemplates=[]] - * @param {boolean} [props.isLoading=false] - * @param {onChangeFn} [props.onChange=noop] - * - */ -export default function ProposalFileSystemsSection({ - settings, - volumeTemplates = [], - isLoading = false, - onChange = noop -}) { - const { volumes = [] } = settings; - - const changeVolumes = (volumes) => { - onChange({ volumes }); - }; - - // Templates for already existing mount points are filtered out - const usefulTemplates = () => { - const mountPaths = volumes.map(v => v.mountPath); - return volumeTemplates.filter(t => ( - t.mountPath.length > 0 && !mountPaths.includes(t.mountPath) - )); - }; - - const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; - - return ( -
- -
- ); -} diff --git a/web/src/components/storage/ProposalFileSystemsSection.test.jsx b/web/src/components/storage/ProposalFileSystemsSection.test.jsx deleted file mode 100644 index 0b1493a5ff..0000000000 --- a/web/src/components/storage/ProposalFileSystemsSection.test.jsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { ProposalFileSystemsSection } from "~/components/storage"; - -const props = { - settings: {}, - isLoading: false, - onChange: jest.fn() -}; - -describe("ProposalFileSystemsSection", () => { - it("renders a section holding file systems related stuff", () => { - plainRender(); - screen.getByRole("region", { name: "File systems" }); - screen.getByRole("grid", { name: /mount points/ }); - }); - - it("requests a volume change when onChange callback is triggered", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: "Actions" }); - - await user.click(button); - - const menu = screen.getByRole("menu"); - const reset = within(menu).getByRole("menuitem", { name: /Reset/ }); - - await user.click(reset); - - expect(props.onChange).toHaveBeenCalledWith( - { volumes: expect.any(Array) } - ); - }); -}); diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx index f39a4d9db6..f17568c08b 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -26,13 +26,11 @@ import { useInstallerClient } from "~/context/installer"; import { toValidationError, useCancellablePromise } from "~/utils"; import { Page } from "~/components/core"; import { - ProposalActionsSection, ProposalPageMenu, - ProposalSettingsSection, - ProposalSpacePolicySection, ProposalDeviceSection, - ProposalFileSystemsSection, - ProposalTransactionalInfo + ProposalTransactionalInfo, + ProposalSettingsSection, + ProposalResultSection } from "~/components/storage"; import { IDLE } from "~/client/status"; @@ -42,6 +40,8 @@ const initialState = { volumeTemplates: [], encryptionMethods: [], settings: {}, + system: [], + staging: [], actions: [], errors: [] }; @@ -81,6 +81,11 @@ const reducer = (state, action) => { return { ...state, settings }; } + case "UPDATE_DEVICES": { + const { system, staging } = action.payload; + return { ...state, system, staging }; + } + case "UPDATE_ERRORS": { const { errors } = action.payload; return { ...state, errors }; @@ -121,6 +126,12 @@ export default function ProposalPage() { return await cancellablePromise(client.proposal.getResult()); }, [client, cancellablePromise]); + const loadDevices = useCallback(async () => { + const system = await cancellablePromise(client.system.getDevices()) || []; + const staging = await cancellablePromise(client.staging.getDevices()) || []; + return { system, staging }; + }, [client, cancellablePromise]); + const loadErrors = useCallback(async () => { const issues = await cancellablePromise(client.getErrors()); return issues.map(toValidationError); @@ -152,11 +163,14 @@ export default function ProposalPage() { const result = await loadProposalResult(); if (result !== undefined) dispatch({ type: "UPDATE_RESULT", payload: { result } }); + const devices = await loadDevices(); + dispatch({ type: "UPDATE_DEVICES", payload: devices }); + const errors = await loadErrors(); dispatch({ type: "UPDATE_ERRORS", payload: { errors } }); if (result !== undefined) dispatch({ type: "STOP_LOADING" }); - }, [calculateProposal, cancellablePromise, client, loadAvailableDevices, loadEncryptionMethods, loadErrors, loadProposalResult, loadVolumeTemplates]); + }, [calculateProposal, cancellablePromise, client, loadAvailableDevices, loadDevices, loadEncryptionMethods, loadErrors, loadProposalResult, loadVolumeTemplates]); const calculate = useCallback(async (settings) => { dispatch({ type: "START_LOADING" }); @@ -166,11 +180,14 @@ export default function ProposalPage() { const result = await loadProposalResult(); dispatch({ type: "UPDATE_RESULT", payload: { result } }); + const devices = await loadDevices(); + dispatch({ type: "UPDATE_DEVICES", payload: devices }); + const errors = await loadErrors(); dispatch({ type: "UPDATE_ERRORS", payload: { errors } }); dispatch({ type: "STOP_LOADING" }); - }, [calculateProposal, loadErrors, loadProposalResult]); + }, [calculateProposal, loadDevices, loadErrors, loadProposalResult]); useEffect(() => { load().catch(console.error); @@ -199,50 +216,33 @@ export default function ProposalPage() { calculate(newSettings).catch(console.error); }; - const PageContent = () => { - return ( - <> - - - - - - - - ); - }; - return ( - // TRANSLATORS: page title + // TRANSLATORS: Storage page title - + + + + ); } diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index 3b8464f84b..7bd4f44a31 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -84,7 +84,13 @@ const storageMock = { getProductMountPoints: jest.fn().mockResolvedValue([]), getResult: jest.fn().mockResolvedValue(undefined), defaultVolume: jest.fn(mountPath => Promise.resolve({ mountPath })), - calculate: jest.fn().mockResolvedValue(0) + calculate: jest.fn().mockResolvedValue(0), + }, + system: { + getDevices: jest.fn().mockResolvedValue([vda, vdb]) + }, + staging: { + getDevices: jest.fn().mockResolvedValue([vda]) }, getErrors: jest.fn().mockResolvedValue([]), isDeprecated: jest.fn().mockResolvedValue(false), @@ -123,12 +129,12 @@ it("loads the proposal data", async () => { await screen.findByText(/\/dev\/vda/); }); -it("renders the settings, find space and actions sections", async () => { +it("renders the device, settings, find space and result sections", async () => { installerRender(); + await screen.findByText(/Device/); await screen.findByText(/Settings/); - await screen.findByText(/Find Space/); - await screen.findByText(/Planned Actions/); + await screen.findByText(/Result/); }); describe("when the storage devices become deprecated", () => { diff --git a/web/src/components/storage/ProposalResultSection.jsx b/web/src/components/storage/ProposalResultSection.jsx new file mode 100644 index 0000000000..675bdbe48b --- /dev/null +++ b/web/src/components/storage/ProposalResultSection.jsx @@ -0,0 +1,298 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React, { useState } from "react"; +import { Button, Skeleton } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; +import { _, n_ } from "~/i18n"; +import { deviceChildren, deviceSize } from "~/components/storage/utils"; +import DevicesManager from "~/components/storage/DevicesManager"; +import { If, Section, Reminder, Tag, TreeTable } from "~/components/core"; +import { ProposalActionsDialog } from "~/components/storage"; + +/** + * @typedef {import ("~/client/storage").Action} Action + * @typedef {import ("~/client/storage").StorageDevice} StorageDevice + * @typedef {import("~/client/mixins").ValidationError} ValidationError + */ + +/** + * Renders information about planned actions, allowing to check all of them and warning with a + * summary about the deletion ones, if any. + * @component + * + * @param {object} props + * @param {Action[]} props.actions + * @param {string[]} props.systems + */ +const DeletionsInfo = ({ actions, systems }) => { + const total = actions.length; + + if (total === 0) return; + + // TRANSLATORS: %d will be replaced by the amount of destructive actions + const warningTitle = sprintf(n_( + "There is %d destructive action planned", + "There are %d destructive actions planned", + total + ), total); + + // FIXME: Use the Intl.ListFormat instead of the `join(", ")` used below. + // Most probably, a `listFormat` or similar wrapper should live in src/i18n.js or so. + // Read https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat + return ( + + 0} + then={ +

+ { + // TRANSLATORS: This is part of a sentence to hint the user about affected systems. + // Eg. "Affecting Windows 11, openSUSE Leap 15, and Ubuntu 22.04" + } + {_("Affecting")} {systems.join(", ")} +

+ } + /> +
+ ); +}; + +/** + * Renders needed UI elements to allow user check the proposal planned actions + * @component + * + * @param {object} props + * @param {Action[]} props.actions + */ +const ActionsInfo = ({ actions }) => { + const [showActions, setShowActions] = useState(false); + const onOpen = () => setShowActions(true); + const onClose = () => setShowActions(false); + + return ( + <> + + + + ); +}; + +/** + * Renders a TreeTable rendering the devices proposal result. + * @component + * + * @param {object} props + * @param {DevicesManager} props.devicesManager + */ +const DevicesTreeTable = ({ devicesManager }) => { + const renderDeviceName = (item) => { + let name = item.sid && item.name; + // NOTE: returning a fragment here to avoid a weird React complaint when using a PF/Table + + // treeRow props. + if (!name) return <>; + + if (["partition", "lvmLv"].includes(item.type)) + name = name.split("/").pop(); + + return ( +
+ {name} +
+ ); + }; + + const renderNewLabel = (item) => { + if (!item.sid) return; + + // FIXME New PVs over a disk is not detected as new. + if (!devicesManager.existInSystem(item) || devicesManager.hasNewFilesystem(item)) + return {_("New")}; + }; + + const renderContent = (item) => { + if (!item.sid) + return _("Unused space"); + if (!item.partitionTable && item.systems?.length > 0) + return item.systems.join(", "); + + return item.description; + }; + + const renderFilesystemLabel = (item) => { + const label = item.filesystem?.label; + if (label) return {label}; + }; + + const renderPTableType = (item) => { + // TODO: Create a map for partition table types and use an here. + const type = item.partitionTable?.type; + if (type) return {type.toUpperCase()}; + }; + + const renderDetails = (item) => { + return ( + <> +
{renderNewLabel(item)}
+
{renderContent(item)} {renderFilesystemLabel(item)} {renderPTableType(item)}
+ + ); + }; + + const renderResizedLabel = (item) => { + if (!item.sid || !devicesManager.isShrunk(item)) return; + + const sizeBefore = devicesManager.systemDevice(item.sid).size; + + return ( + + { + // TRANSLATORS: Label to indicate the device size before resizing, where %s is replaced by + // the original size (e.g., 3.00 GiB). + sprintf(_("Before %s"), deviceSize(sizeBefore)) + } + + ); + }; + + const renderSize = (item) => { + return ( +
+ {renderResizedLabel(item)} + {deviceSize(item.size)} +
+ ); + }; + + const renderMountPoint = (item) => item.sid && {item.filesystem?.mountPath}; + + return ( + deviceChildren(d)} + rowClassNames={(item) => { + if (!item.sid) return "dimmed-row"; + }} + className="proposal-result" + /> + ); +}; + +/** + * @todo Create a component for rendering a customized skeleton + */ +const ResultSkeleton = () => { + return ( + <> + + + + + ); +}; + +/** + * Content of the section. + * @component + * + * @param {object} props + * @param {StorageDevice[]} props.system + * @param {StorageDevice[]} props.staging + * @param {Action[]} props.actions + * @param {ValidationError[]} props.errors + */ +const SectionContent = ({ system, staging, actions, errors }) => { + if (errors.length) return; + + const devicesManager = new DevicesManager(system, staging, actions); + + return ( + <> + a.delete && !a.subvol)} + systems={devicesManager.deletedSystems()} + /> + + + + ); +}; + +/** + * Section holding the proposal result and actions to perform in the system + * @component + * + * @param {object} props + * @param {StorageDevice[]} [props.system=[]] + * @param {StorageDevice[]} [props.staging=[]] + * @param {Action[]} [props.actions=[]] + * @param {ValidationError[]} [props.errors=[]] - Validation errors + * @param {boolean} [props.isLoading=false] - Whether the section content should be rendered as loading + */ +export default function ProposalResultSection({ + system = [], + staging = [], + actions = [], + errors = [], + isLoading = false +}) { + if (isLoading) errors = []; + const totalActions = actions.length; + + // TRANSLATORS: The description for the Result section in storage proposal + // page. %d will be replaced by the number of proposal actions. + const description = sprintf(n_( + "During installation, %d action will be performed to configure the system as displayed below", + "During installation, %d actions will be performed to configure the system as displayed below", + totalActions + ), totalActions); + + return ( +
+ } + else={ + + } + /> +
+ ); +} diff --git a/web/src/components/storage/ProposalResultSection.test.jsx b/web/src/components/storage/ProposalResultSection.test.jsx new file mode 100644 index 0000000000..95f098f10e --- /dev/null +++ b/web/src/components/storage/ProposalResultSection.test.jsx @@ -0,0 +1,133 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProposalResultSection } from "~/components/storage"; +import { devices, actions } from "./test-data/full-result-example"; + +const errorMessage = "Something went wrong, proposal not possible"; +const errors = [{ severity: 0, message: errorMessage }]; +const defaultProps = { system: devices.system, staging: devices.staging, actions }; + +describe("ProposalResultSection", () => { + describe("when there are errors (proposal was not possible)", () => { + it("renders given errors", () => { + plainRender(); + expect(screen.queryByText(errorMessage)).toBeInTheDocument(); + }); + + it("does not render a warning for delete actions", () => { + plainRender(); + expect(screen.queryByText(/Warning alert:/)).toBeNull(); + }); + + it("does not render a treegrid node", () => { + plainRender(); + expect(screen.queryByRole("treegrid")).toBeNull(); + }); + + it("does not render the link for opening the planned actions dialog", () => { + plainRender(); + expect(screen.queryByRole("button", { name: /planned actions/ })).toBeNull(); + }); + }); + + describe("when there are no errors (proposal was possible)", () => { + it("does not render a warning when there are not delete actions", () => { + const props = { + ...defaultProps, + actions: defaultProps.actions.filter(a => !a.delete) + }; + + plainRender(); + expect(screen.queryByText(/Warning alert:/)).toBeNull(); + }); + + it("renders a reminder when there are delete actions", () => { + plainRender(); + const reminder = screen.getByRole("status"); + within(reminder).getByText(/4 destructive/); + }); + + it("renders the affected systems in the deletion reminder, if any", () => { + // NOTE: simulate the deletion of vdc2 (sid: 79) for checking that + // affected systems are rendered in the warning summary + const props = { + ...defaultProps, + actions: [{ device: 79, delete: true }] + }; + + plainRender(); + // FIXME: below line reveals that warning wrapper deserves a role or + // something + const reminder = screen.getByRole("status"); + within(reminder).getByText(/openSUSE/); + }); + + it("renders a treegrid including all relevant information about final result", () => { + plainRender(); + const treegrid = screen.getByRole("treegrid"); + /** + * Expected rows for full-result-example + * -------------------------------------------------- + * "/dev/vdc Disk GPT 30 GiB" + * "vdc1 New BIOS Boot Partition 8 MiB" + * "vdc3 swap New Swap Partition 1.5 GiB" + * "Unused space 3.49 GiB" + * "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" + * "Unused space 1 GiB" + * "vdc4 Linux Before 2 GiB 1.5 GiB" + * "vdc5 / New Btrfs Partition 17.5 GiB" + * + * Device Mount point Details Size + * ------------------------------------------------------------------------- + * /dev/vdc Disk GPT 30 GiB + * vdc1 New BIOS Boot Partition 8 MiB + * vdc3 swap New Swap Partition 1.5 GiB + * Unused space 3.49 GiB + * vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB + * Unused space 1 GiB + * vdc4 Linux Before 2 GiB 1.5 GiB + * vdc5 / New Btrfs Partition 17.5 GiB + * ------------------------------------------------------------------------- + */ + within(treegrid).getByRole("row", { name: "/dev/vdc Disk GPT 30 GiB" }); + within(treegrid).getByRole("row", { name: "vdc1 New BIOS Boot Partition 8 MiB" }); + within(treegrid).getByRole("row", { name: "vdc3 swap New Swap Partition 1.5 GiB" }); + within(treegrid).getByRole("row", { name: "Unused space 3.49 GiB" }); + within(treegrid).getByRole("row", { name: "vdc2 openSUSE Leap 15.2, Fedora 10.30 5 GiB" }); + within(treegrid).getByRole("row", { name: "Unused space 1 GiB" }); + within(treegrid).getByRole("row", { name: "vdc4 Linux Before 2 GiB 1.5 GiB" }); + within(treegrid).getByRole("row", { name: "vdc5 / New Btrfs Partition 17.5 GiB" }); + }); + + it("renders a button for opening the planned actions dialog", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: /planned actions/ }); + + await user.click(button); + + screen.getByRole("dialog", { name: "Planned Actions" }); + }); + }); +}); diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index abc6830308..969d862b1f 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -24,6 +24,7 @@ import { Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-cor import { _ } from "~/i18n"; import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core"; +import { ProposalVolumes, ProposalSpacePolicyField } from "~/components/storage"; import { Icon } from "~/components/layout"; import { noop } from "~/utils"; import { hasFS } from "~/components/storage/utils"; @@ -283,6 +284,8 @@ const EncryptionField = ({ export default function ProposalSettingsSection({ settings, encryptionMethods = [], + volumeTemplates = [], + isLoading = false, onChange = noop }) { const changeEncryption = ({ password, method }) => { @@ -302,8 +305,26 @@ export default function ProposalSettingsSection({ onChange({ volumes: settings.volumes }); }; + const changeVolumes = (volumes) => { + onChange({ volumes }); + }; + + const changeSpacePolicy = (policy, actions) => { + onChange({ spacePolicy: policy, spaceActions: actions }); + }; + const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; + const { volumes = [] } = settings; + + // Templates for already existing mount points are filtered out + const usefulTemplates = () => { + const mountPaths = volumes.map(v => v.mountPath); + return volumeTemplates.filter(t => ( + t.mountPath.length > 0 && !mountPaths.includes(t.mountPath) + )); + }; + return ( <>
@@ -319,6 +340,20 @@ export default function ProposalSettingsSection({ isLoading={settings.encryptionPassword === undefined} onChange={changeEncryption} /> + +
); diff --git a/web/src/components/storage/ProposalSettingsSection.test.jsx b/web/src/components/storage/ProposalSettingsSection.test.jsx index 3077cd7903..29f7fc4723 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.jsx +++ b/web/src/components/storage/ProposalSettingsSection.test.jsx @@ -36,7 +36,10 @@ jest.mock("@patternfly/react-core", () => { let props; beforeEach(() => { - props = {}; + props = { + settings: {}, + onChange: jest.fn() + }; }); const rootVolume = { mountPath: "/", fsType: "Btrfs", outline: { snapshotsConfigurable: true } }; @@ -65,6 +68,28 @@ describe("if snapshots are not configurable", () => { }); }); +it("renders a section holding file systems related stuff", () => { + plainRender(); + screen.getByRole("grid", { name: "Table with mount points" }); + screen.getByRole("grid", { name: /mount points/ }); +}); + +it("requests a volume change when onChange callback is triggered", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button", { name: "Actions" }); + + await user.click(button); + + const menu = screen.getByRole("menu"); + const reset = within(menu).getByRole("menuitem", { name: /Reset/ }); + + await user.click(reset); + + expect(props.onChange).toHaveBeenCalledWith( + { volumes: expect.any(Array) } + ); +}); + describe("Encryption field", () => { describe("if encryption password setting is not set yet", () => { beforeEach(() => { diff --git a/web/src/components/storage/ProposalSpacePolicySection.jsx b/web/src/components/storage/ProposalSpacePolicyField.jsx similarity index 54% rename from web/src/components/storage/ProposalSpacePolicySection.jsx rename to web/src/components/storage/ProposalSpacePolicyField.jsx index c08135c0e5..17455afb4b 100644 --- a/web/src/components/storage/ProposalSpacePolicySection.jsx +++ b/web/src/components/storage/ProposalSpacePolicyField.jsx @@ -19,13 +19,13 @@ * find current contact information at www.suse.com. */ -import React, { useEffect } from "react"; -import { FormSelect, FormSelectOption } from "@patternfly/react-core"; +import React, { useEffect, useState } from "react"; +import { Button, Form, FormSelect, FormSelectOption, Skeleton } from "@patternfly/react-core"; -import { _, N_ } from "~/i18n"; +import { _, N_, n_ } from "~/i18n"; import { deviceSize } from '~/components/storage/utils'; -import { If, OptionsPicker, Section, SectionSkeleton } from "~/components/core"; -import { noop, useLocalStorage } from "~/utils"; +import { If, OptionsPicker, Popup, SectionSkeleton } from "~/components/core"; +import { noop } from "~/utils"; import { sprintf } from "sprintf-js"; import { Table, Thead, Tr, Th, Tbody, Td, TreeRowWrapper } from '@patternfly/react-table'; @@ -48,31 +48,57 @@ const SPACE_POLICIES = [ { id: "delete", label: N_("Delete current content"), - description: N_("All partitions will be removed and any data in the disks will be lost.") + description: N_("All partitions will be removed and any data in the disks will be lost."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space deleting all content[...]" + N_("deleting all content of the installation device"), + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space deleting all content[...]" + N_("deleting all content of the %d selected disks") + ] }, { id: "resize", label: N_("Shrink existing partitions"), - description: N_("The data is kept, but the current partitions will be resized as needed.") + description: N_("The data is kept, but the current partitions will be resized as needed."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space shrinking partitions[...]" + N_("shrinking partitions of the installation device"), + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space shrinking partitions[...]" + N_("shrinking partitions of the %d selected disks") + ] }, { id: "keep", label: N_("Use available space"), - description: N_("The data is kept. Only the space not assigned to any partition will be used.") + description: N_("The data is kept. Only the space not assigned to any partition will be used."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space without modifying any partition". + N_("without modifying any partition") + ] }, { id: "custom", label: N_("Custom"), - description: N_("Select what to do with each partition.") + description: N_("Select what to do with each partition."), + summaryLabels: [ + // TRANSLATORS: This is presented next to the label "Find space", so the whole sentence + // would read as "Find space performing a custom set of actions". + N_("performing a custom set of actions") + ] } ]; // Names of the columns for the policy actions. const columnNames = { device: N_("Used device"), - content: N_("Current content"), + content: N_("Details"), size: N_("Size"), - details: N_("Details"), + details: N_("Size details"), action: N_("Action") }; @@ -84,63 +110,23 @@ const columnNames = { * @param {StorageDevice} props.device */ const DeviceDescriptionColumn = ({ device }) => { - return ( - <> -
{device.name}
- {`${device.vendor} ${device.model}`}
} - /> - - ); + if (device.isDrive || device.type === "lvmVg") return device.name; + + return device.name.split("/").pop(); }; /** - * Column content with information about the current content of the device. + * Column content with details about the device. * @component * * @param {object} props * @param {StorageDevice} props.device */ const DeviceContentColumn = ({ device }) => { - const PartitionTableContent = () => { - return ( -
- {/* TRANSLATORS: %s is replaced by partition table type (e.g., GPT) */} - {sprintf(_("%s partition table"), device.partitionTable.type.toUpperCase())} -
- ); - }; - - const BlockContent = () => { - const renderContent = () => { - const systems = device.systems; - if (systems.length > 0) return systems.join(", "); - - const filesystem = device.filesystem; - if (filesystem?.isEFI) return _("EFI system partition"); - if (filesystem) { - // TRANSLATORS: %s is replaced by a file system type (e.g., btrfs). - return sprintf(_("%s file system"), filesystem?.type); - } - - const component = device.component; - switch (component?.type) { - case "physical_volume": - // TRANSLATORS: %s is replaced by a LVM volume group name (e.g., /dev/vg0). - return sprintf(_("LVM physical volume of %s"), component.deviceNames[0]); - case "md_device": - // TRANSLATORS: %s is replaced by a RAID name (e.g., /dev/md0). - return sprintf(_("Member of RAID %s"), component.deviceNames[0]); - default: - return _("Not identified"); - } - }; - - return
{renderContent()}
; - }; + const systems = device.systems; + if (systems.length > 0) return systems.join(", "); - return (device.partitionTable ? : ); + return device.description; }; /** @@ -151,7 +137,7 @@ const DeviceContentColumn = ({ device }) => { * @param {StorageDevice} props.device */ const DeviceSizeColumn = ({ device }) => { - return
{deviceSize(device.size)}
; + return deviceSize(device.size); }; /** @@ -166,26 +152,16 @@ const DeviceDetailsColumn = ({ device }) => { if (device.filesystem) return null; const unused = device.partitionTable?.unpartitionedSize || 0; - - return ( -
- {/* TRANSLATORS: %s is replaced by a disk size (e.g., 20 GiB) */} - {sprintf(_("%s unused"), deviceSize(unused))} -
- ); + // TRANSLATORS: %s is replaced by a disk size (e.g., 20 GiB) + return sprintf(_("%s unused"), deviceSize(unused)); }; const RecoverableSize = () => { const size = device.recoverableSize; - if (size === 0) return null; - return ( -
- {/* TRANSLATORS: %s is replaced by a disk size (e.g., 2 GiB) */} - {sprintf(_("Shrinkable by %s"), deviceSize(device.recoverableSize))} -
- ); + // TRANSLATORS: %s is replaced by a disk size (e.g., 2 GiB) + return sprintf(_("Shrinkable by %s"), deviceSize(device.recoverableSize)); }; return ( @@ -250,7 +226,8 @@ const DeviceActionColumn = ({ device, action, isDisabled = false, onChange = noo */ const DeviceRow = ({ device, - settings, + policy, + actions, rowIndex, level = 1, setSize = 0, @@ -260,6 +237,18 @@ const DeviceRow = ({ onCollapse = noop, onChange = noop }) => { + // Generates the action value according to the policy. + const action = () => { + if (policy.id === "custom") + return actions.find(a => a.device === device.name)?.action || "keep"; + + const policyAction = { delete: "force_delete", resize: "resize", keep: "keep" }; + return policyAction[policy.id]; + }; + + const isDisabled = policy.id !== "custom"; + const showAction = !device.partitionTable; + const treeRow = { onCollapse, rowIndex, @@ -273,31 +262,29 @@ const DeviceRow = ({ } }; - const spaceAction = settings.spaceActions.find(a => a.device === device.name); - const isDisabled = settings.spacePolicy !== "custom"; - const showAction = !device.partitionTable; - return ( - + {/* eslint-disable agama-i18n/string-literals */} + - - - - + + + + } /> + {/* eslint-enable agama-i18n/string-literals */} ); }; @@ -310,31 +297,30 @@ const DeviceRow = ({ * @param {ProposalSettings} props.settings * @param {(action: SpaceAction) => void} [props.onChange] */ -const SpaceActionsTable = ({ settings, onChange = noop }) => { - const [expandedDevices, setExpandedDevices] = useLocalStorage("storage-space-actions-expanded", []); - const [autoExpanded, setAutoExpanded] = useLocalStorage("storage-space-actions-auto-expanded", false); +const SpaceActionsTable = ({ policy, actions, devices, onChange = noop }) => { + const [expandedDevices, setExpandedDevices] = useState([]); + const [autoExpanded, setAutoExpanded] = useState(false); useEffect(() => { - const policy = settings.spacePolicy; - const devices = settings.installationDevices.map(d => d.name); - let currentExpanded = devices.filter(d => expandedDevices.includes(d)); + const devNames = devices.map(d => d.name); + let currentExpanded = devNames.filter(d => expandedDevices.includes(d)); - if (policy === "custom" && !autoExpanded) { - currentExpanded = [...devices]; + if (policy.id === "custom" && !autoExpanded) { + currentExpanded = [...devNames]; setAutoExpanded(true); - } else if (policy !== "custom" && autoExpanded) { + } else if (policy.id !== "custom" && autoExpanded) { setAutoExpanded(false); } if (currentExpanded.sort().toString() !== expandedDevices.sort().toString()) { setExpandedDevices(currentExpanded); } - }, [autoExpanded, expandedDevices, setAutoExpanded, setExpandedDevices, settings]); + }, [autoExpanded, expandedDevices, setAutoExpanded, setExpandedDevices, policy, devices]); const renderRows = () => { const rows = []; - settings.installationDevices?.forEach((device, index) => { + devices?.forEach((device, index) => { const isExpanded = expandedDevices.includes(device.name); const children = device.partitionTable?.partitions; @@ -348,7 +334,8 @@ const SpaceActionsTable = ({ settings, onChange = noop }) => { { { const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => { return ( + {/* eslint-disable agama-i18n/string-literals */} {SPACE_POLICIES.map((policy) => { return ( onChange(policy.id)} + title={_(policy.label)} + body={_(policy.description)} + onClick={() => onChange(policy)} isSelected={currentPolicy?.id === policy.id} /> ); })} + {/* eslint-enable agama-i18n/string-literals */} ); }; @@ -428,45 +418,137 @@ const SpacePolicyPicker = ({ currentPolicy, onChange = noop }) => { * @param {boolean} [isLoading=false] * @param {(settings: ProposalSettings) => void} [onChange] */ -export default function ProposalSpacePolicySection({ - settings, +const SpacePolicyForm = ({ + id, + currentPolicy, + currentActions, + devices, isLoading = false, - onChange = noop -}) { - const changeSpacePolicy = (policy) => { - onChange({ spacePolicy: policy }); - }; + onSubmit = noop +}) => { + const [policy, setPolicy] = useState(currentPolicy); + const [actions, setActions] = useState(currentActions); + const [customUsed, setCustomUsed] = useState(false); - const changeSpaceActions = (spaceAction) => { - const spaceActions = settings.spaceActions.filter(a => a.device !== spaceAction.device); + // The selectors for the space action have to be initialized always to the same value + // (e.g., "keep") when the custom policy is selected for first time. The following two useEffect + // ensures that. + + // Stores whether the custom policy has been used. + useEffect(() => { + if (policy.id === "custom" && !customUsed) setCustomUsed(true); + }, [policy, customUsed, setCustomUsed]); + + // Resets actions (i.e., sets everything to "keep") if the custom policy has not been used yet. + useEffect(() => { + if (policy.id !== "custom" && !customUsed) setActions([]); + }, [policy, customUsed, setActions]); + + const changeActions = (spaceAction) => { + const spaceActions = actions.filter(a => a.device !== spaceAction.device); if (spaceAction.action !== "keep") spaceActions.push(spaceAction); - onChange({ spaceActions }); + setActions(spaceActions); }; - const currentPolicy = SPACE_POLICIES.find(p => p.id === settings.spacePolicy) || SPACE_POLICIES[0]; + const submitForm = (e) => { + e.preventDefault(); + if (policy !== undefined) onSubmit(policy, actions); + }; return ( -
+
} else={ <> - + 0} - then={} + condition={devices.length > 0} + then={ + + } /> } /> -
+ + ); +}; + +const SpacePolicyButton = ({ policy, devices, onClick = noop }) => { + const Text = () => { + // eslint-disable-next-line agama-i18n/string-literals + if (policy.summaryLabels.length === 1) return _(policy.summaryLabels[0]); + + // eslint-disable-next-line agama-i18n/string-literals + return sprintf(n_(policy.summaryLabels[0], policy.summaryLabels[1], devices.length), devices.length); + }; + + return ; +}; + +export default function ProposalSpacePolicyField({ + policy, + actions = [], + devices = [], + isLoading = false, + onChange = noop +}) { + const spacePolicy = SPACE_POLICIES.find(p => p.id === policy) || SPACE_POLICIES[0]; + const [isFormOpen, setIsFormOpen] = useState(false); + + const openForm = () => setIsFormOpen(true); + const closeForm = () => setIsFormOpen(false); + + const acceptForm = (spacePolicy, actions) => { + closeForm(); + onChange(spacePolicy.id, actions); + }; + + if (isLoading) { + return ; + } + + const description = _("Allocating the file systems might need to find free space \ +in the devices listed below. Choose how to do it."); + + return ( +
+ {/* TRANSLATORS: To be completed with the rest of a sentence like "deleting all content" */} + {_("Find space")} + + +
+ +
+ + + {_("Accept")} + + + +
+
); } diff --git a/web/src/components/storage/ProposalSpacePolicyField.test.jsx b/web/src/components/storage/ProposalSpacePolicyField.test.jsx new file mode 100644 index 0000000000..a8b5bc9bc8 --- /dev/null +++ b/web/src/components/storage/ProposalSpacePolicyField.test.jsx @@ -0,0 +1,359 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { plainRender, resetLocalStorage } from "~/test-utils"; +import { ProposalSpacePolicyField } from "~/components/storage"; + +const sda = { + sid: "59", + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + recoverableSize: 0, + systems : [], + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], +}; + +const sda1 = { + sid: "60", + isDrive: false, + type: "", + active: true, + name: "/dev/sda1", + size: 512, + recoverableSize: 128, + systems : [], + udevIds: [], + udevPaths: [] +}; + +const sda2 = { + sid: "61", + isDrive: false, + type: "", + active: true, + name: "/dev/sda2", + size: 512, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: [] +}; + +sda.partitionTable = { + type: "gpt", + partitions: [sda1, sda2], + unpartitionedSize: 512 +}; + +const sdb = { + sid: "62", + isDrive: true, + type: "disk", + vendor: "Samsung", + model: "Samsung Evo 8 Pro", + driver: ["ahci"], + bus: "IDE", + busId: "", + transport: "", + dellBOSS: false, + sdCard: false, + active: true, + name: "/dev/sdb", + size: 2048, + recoverableSize: 0, + systems : [], + udevIds: [], + udevPaths: ["pci-0000:00-19"] +}; + +let policy; +let devices; +let actions; + +const openPopup = async (props = {}) => { + const allProps = { policy, devices, actions, ...props }; + const { user } = plainRender(); + const button = screen.getByRole("button"); + + await user.click(button); + const dialog = screen.getByRole("dialog", { name: "Find Space" }); + return { user, dialog }; +}; + +const expandRow = async (user, dialog, name) => { + const row = within(dialog).getByRole("row", { name }); + const toggler = within(row).getByRole("button", { name: /expand/i }); + await user.click(toggler); +}; + +const checkSpaceActions = async (deviceActions) => { + deviceActions.forEach(({ name, action }) => { + const row = screen.getByRole("row", { name }); + const selector = within(row).getByRole("combobox", { name }); + within(selector).getByRole("option", { name: action, selected: true }); + }); +}; + +beforeEach(() => { + devices = [sda, sdb]; + policy = "keep"; + actions = [ + { device: "/dev/sda1", action: "force_delete" }, + { device: "/dev/sda2", action: "resize" } + ]; + + resetLocalStorage(); +}); + +describe("ProposalSpacePolicyField", () => { + it("renders a button for opening the space policy dialog", async () => { + const { user } = plainRender(); + const button = screen.getByRole("button"); + + await user.click(button); + + screen.getByRole("dialog", { name: "Find Space" }); + }); + + it("renders the button with a text according to given policy", () => { + const { rerender } = plainRender(); + screen.getByRole("button", { name: /deleting/ }); + rerender(); + screen.getByRole("button", { name: /shrinking/ }); + }); + + describe("within the dialog", () => { + it("renders the space policy picker", async () => { + const { dialog } = await openPopup(); + const picker = within(dialog).getByRole("listbox"); + within(picker).getByRole("option", { name: /delete/i }); + within(picker).getByRole("option", { name: /resize/i }); + within(picker).getByRole("option", { name: /available/i }); + within(picker).getByRole("option", { name: /custom/i }); + }); + + describe("when there are no installation devices", () => { + beforeEach(() => { + devices = []; + }); + + it("does not render the policy actions", async () => { + const { dialog } = await openPopup(); + const actionsTree = within(dialog).queryByRole("treegrid", { name: "Actions to find space" }); + expect(actionsTree).toBeNull(); + }); + }); + + describe("when there are installation devices", () => { + it("renders the policy actions", async () => { + const { dialog } = await openPopup(); + within(dialog).getByRole("treegrid", { name: "Actions to find space" }); + }); + }); + + describe.each([ + { id: 'delete', nameRegexp: /delete/i }, + { id: 'resize', nameRegexp: /shrink/i }, + { id: 'keep', nameRegexp: /the space not assigned/i } + ])("when space policy is '$id'", ({ id, nameRegexp }) => { + beforeEach(() => { + policy = id; + }); + + it("only renders '$id' option as selected", async () => { + const { dialog } = await openPopup(); + const picker = within(dialog).getByRole("listbox"); + within(picker).getByRole("option", { name: nameRegexp, selected: true }); + expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); + }); + + it("does not allow to modify the space actions", async () => { + const { dialog } = await openPopup(); + // NOTE: HTML `disabled` attribute removes the element from the a11y tree. + // That's why the test is using `hidden: true` here to look for disabled actions. + // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled + // https://testing-library.com/docs/queries/byrole/#hidden + // TODO: use a more inclusive way to disable the actions. + // https://css-tricks.com/making-disabled-buttons-more-inclusive/ + const spaceActions = within(dialog).getAllByRole("combobox", { name: /Space action selector/, hidden: true }); + expect(spaceActions.length).toEqual(3); + }); + }); + + describe("when space policy is 'custom'", () => { + beforeEach(() => { + policy = "custom"; + }); + + it("only renders 'custom' option as selected", async () => { + const { dialog } = await openPopup(); + const picker = within(dialog).getByRole("listbox"); + within(picker).getByRole("option", { name: /custom/i, selected: true }); + expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); + }); + + it("allows to modify the space actions", async () => { + const { dialog } = await openPopup(); + const spaceActions = within(dialog).getAllByRole("combobox", { name: /Space action selector/ }); + expect(spaceActions.length).toEqual(3); + }); + }); + + describe("DeviceActionColumn", () => { + it("renders the space actions selector for devices without partition table", async () => { + const { dialog } = await openPopup(); + const sdaRow = within(dialog).getByRole("row", { name: /sda/ }); + const sdaActionsSelector = within(sdaRow).queryByRole("combobox", { name: "Space action selector for /dev/sda" }); + // sda has partition table, the selector shouldn't be found + expect(sdaActionsSelector).toBeNull(); + const sdbRow = screen.getByRole("row", { name: /sdb/ }); + // sdb does not have partition table, selector should be there + within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); + }); + + it("does not renders the 'resize' option for drives", async () => { + const { dialog } = await openPopup(); + const sdbRow = within(dialog).getByRole("row", { name: /sdb/ }); + const spaceActionsSelector = within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); + const resizeOption = within(spaceActionsSelector).queryByRole("option", { name: /resize/ }); + expect(resizeOption).toBeNull(); + }); + + it("renders the 'resize' option for devices other than drives", async () => { + const { user, dialog } = await openPopup(); + const sdaRow = within(dialog).getByRole("row", { name: /sda/ }); + const sdaToggler = within(sdaRow).getByRole("button", { name: /expand/i }); + await user.click(sdaToggler); + const sda1Row = screen.getByRole("row", { name: /sda1/ }); + const spaceActionsSelector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); + within(spaceActionsSelector).getByRole("option", { name: /resize/ }); + }); + + describe("when space policy is 'delete'", () => { + beforeEach(() => { + policy = "delete"; + }); + + it("renders as selected the delete option", async () => { + const { user, dialog } = await openPopup(); + await expandRow(user, dialog, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /delete/i }, + { name: /sda2/, action: /delete/i } + ]); + }); + }); + + describe("when space policy is 'resize'", () => { + beforeEach(() => { + policy = "resize"; + }); + + it("renders as selected the resize option", async () => { + const { user, dialog } = await openPopup(); + await expandRow(user, dialog, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /resize/i }, + { name: /sda2/, action: /resize/i } + ]); + }); + }); + + describe("when space policy is 'keep'", () => { + beforeEach(() => { + policy = "keep"; + }); + + it("renders as selected the keep option", async () => { + const { user, dialog } = await openPopup(); + await expandRow(user, dialog, /sda/); + await checkSpaceActions([ + { name: /sda1/, action: /not modify/i }, + { name: /sda2/, action: /not modify/i } + ]); + }); + }); + + describe("when space policy is 'custom'", () => { + beforeEach(() => { + policy = "custom"; + }); + + it("renders as selected the option matching the given device space action", async () => { + await openPopup(); + await checkSpaceActions([ + { name: /sda1/, action: /delete/i }, + { name: /sda2/, action: /resize/i } + ]); + }); + }); + }); + }); + + it("triggers the onChange callback when user accepts the dialog", async () => { + const onChangeFn = jest.fn(); + const { user, dialog } = await openPopup({ onChange: onChangeFn }); + + // Select 'custom' + const picker = within(dialog).getByRole("listbox"); + await user.selectOptions( + picker, + within(picker).getByRole("option", { name: /custom/i }) + ); + + // Select custom actions + const sda1Row = within(dialog).getByRole("row", { name: /sda1/ }); + const sda1Select = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); + await user.selectOptions( + sda1Select, + within(sda1Select).getByRole("option", { name: /delete/i }) + ); + const sda2Row = within(dialog).getByRole("row", { name: /sda2/ }); + const sda2Select = within(sda2Row).getByRole("combobox", { name: "Space action selector for /dev/sda2" }); + await user.selectOptions( + sda2Select, + within(sda2Select).getByRole("option", { name: /resize/i }) + ); + + // Accept the result + const acceptButton = within(dialog).getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + + expect(onChangeFn).toHaveBeenCalledWith( + "custom", + expect.arrayContaining([{ action: "resize", device: "/dev/sda2" }, { action: "force_delete", device: "/dev/sda1" }]) + ); + }); +}); diff --git a/web/src/components/storage/ProposalSpacePolicySection.test.jsx b/web/src/components/storage/ProposalSpacePolicySection.test.jsx deleted file mode 100644 index 07f842ab8b..0000000000 --- a/web/src/components/storage/ProposalSpacePolicySection.test.jsx +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender, resetLocalStorage } from "~/test-utils"; -import { ProposalSpacePolicySection } from "~/components/storage"; - -const sda = { - sid: "59", - isDrive: true, - type: "disk", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - busId: "", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/sda", - size: 1024, - recoverableSize: 0, - systems : [], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -const sda1 = { - sid: "60", - isDrive: false, - type: "", - active: true, - name: "/dev/sda1", - size: 512, - recoverableSize: 128, - systems : [], - udevIds: [], - udevPaths: [] -}; - -const sda2 = { - sid: "61", - isDrive: false, - type: "", - active: true, - name: "/dev/sda2", - size: 512, - recoverableSize: 0, - systems : [], - udevIds: [], - udevPaths: [] -}; - -sda.partitionTable = { - type: "gpt", - partitions: [sda1, sda2], - unpartitionedSize: 512 -}; - -const sdb = { - sid: "62", - isDrive: true, - type: "disk", - vendor: "Samsung", - model: "Samsung Evo 8 Pro", - driver: ["ahci"], - bus: "IDE", - busId: "", - transport: "", - dellBOSS: false, - sdCard: false, - active: true, - name: "/dev/sdb", - size: 2048, - recoverableSize: 0, - systems : [], - udevIds: [], - udevPaths: ["pci-0000:00-19"] -}; - -let settings; - -beforeEach(() => { - settings = { - installationDevices: [sda, sdb], - spacePolicy: "keep", - spaceActions: [ - { device: "/dev/sda1", action: "force_delete" }, - { device: "/dev/sda2", action: "resize" } - ], - }; - - resetLocalStorage(); -}); - -describe("ProposalSpacePolicySection", () => { - it("renders the space policy picker", () => { - plainRender(); - const picker = screen.getByRole("listbox"); - within(picker).getByRole("option", { name: /delete/i }); - within(picker).getByRole("option", { name: /resize/i }); - within(picker).getByRole("option", { name: /available/i }); - within(picker).getByRole("option", { name: /custom/i }); - }); - - it("triggers the onChange callback when user changes selected space policy", async () => { - const onChangeFn = jest.fn(); - const { user } = plainRender(); - const picker = screen.getByRole("listbox"); - await user.selectOptions( - picker, - within(picker).getByRole("option", { name: /custom/i }) - ); - expect(onChangeFn).toHaveBeenCalledWith({ spacePolicy: "custom" }); - }); - - describe("when there are no installation devices", () => { - beforeEach(() => { - settings.installationDevices = []; - }); - - it("does not render the policy actions", () => { - plainRender(); - const actions = screen.queryByRole("treegrid", { name: "Actions to find space" }); - expect(actions).toBeNull(); - }); - }); - - describe("when there are installation devices", () => { - it("renders the policy actions", () => { - plainRender(); - screen.getByRole("treegrid", { name: "Actions to find space" }); - }); - }); - - describe.each([ - { id: 'delete', nameRegexp: /delete/i }, - { id: 'resize', nameRegexp: /shrink/i }, - { id: 'keep', nameRegexp: /the space not assigned/i } - ])("when space policy is '$id'", ({ id, nameRegexp }) => { - beforeEach(() => { - settings.spacePolicy = id; - }); - - it("only renders '$id' option as selected", () => { - plainRender(); - const picker = screen.getByRole("listbox"); - within(picker).getByRole("option", { name: nameRegexp, selected: true }); - expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); - }); - - it("does not allow to modify the space actions", () => { - plainRender(); - // NOTE: HTML `disabled` attribute removes the element from the a11y tree. - // That's why the test is using `hidden: true` here to look for disabled actions. - // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled - // https://testing-library.com/docs/queries/byrole/#hidden - // TODO: use a more inclusive way to disable the actions. - // https://css-tricks.com/making-disabled-buttons-more-inclusive/ - const spaceActions = screen.getAllByRole("combobox", { name: /Space action selector/, hidden: true }); - expect(spaceActions.length).toEqual(3); - }); - }); - - describe("when space policy is 'custom'", () => { - beforeEach(() => { - settings.spacePolicy = "custom"; - }); - - it("only renders 'custom' option as selected", () => { - plainRender(); - const picker = screen.getByRole("listbox"); - within(picker).getByRole("option", { name: /custom/i, selected: true }); - expect(within(picker).getAllByRole("option", { selected: false }).length).toEqual(3); - }); - - it("allows to modify the space actions", () => { - plainRender(); - const spaceActions = screen.getAllByRole("combobox", { name: /Space action selector/ }); - expect(spaceActions.length).toEqual(3); - }); - }); - - describe("DeviceActionColumn", () => { - it("renders the space actions selector for devices without partition table", () => { - plainRender(); - const sdaRow = screen.getByRole("row", { name: /sda/ }); - const sdaActionsSelector = within(sdaRow).queryByRole("combobox", { name: "Space action selector for /dev/sda" }); - // sda has partition table, the selector shouldn't be found - expect(sdaActionsSelector).toBeNull(); - const sdbRow = screen.getByRole("row", { name: /sdb/ }); - // sdb does not have partition table, selector should be there - within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); - }); - - it("does not renders the 'resize' option for drives", () => { - plainRender(); - const sdbRow = screen.getByRole("row", { name: /sdb/ }); - const spaceActionsSelector = within(sdbRow).getByRole("combobox", { name: "Space action selector for /dev/sdb" }); - const resizeOption = within(spaceActionsSelector).queryByRole("option", { name: /resize/ }); - expect(resizeOption).toBeNull(); - }); - - it("renders the 'resize' option for devices other than drives", async () => { - const { user } = plainRender(); - const sdaRow = screen.getByRole("row", { name: /sda/ }); - const sdaToggler = within(sdaRow).getByRole("button", { name: /expand/i }); - await user.click(sdaToggler); - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const spaceActionsSelector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); - within(spaceActionsSelector).getByRole("option", { name: /resize/ }); - }); - - it("renders as selected the option matching the given device space action", async () => { - const { user } = plainRender(); - const sdaRow = screen.getByRole("row", { name: /sda/ }); - const sdaToggler = within(sdaRow).getByRole("button", { name: /expand/i }); - await user.click(sdaToggler); - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const sda1SpaceActionsSelector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); - within(sda1SpaceActionsSelector).getByRole("option", { name: /delete/i, selected: true }); - const sda2Row = screen.getByRole("row", { name: /sda2/ }); - const sda2SpaceActionsSelector = within(sda2Row).getByRole("combobox", { name: "Space action selector for /dev/sda2" }); - within(sda2SpaceActionsSelector).getByRole("option", { name: /resize/i, selected: true }); - }); - - it("triggers the onChange callback when user changes space action", async () => { - const onChangeFn = jest.fn(); - const { user } = plainRender( - - ); - - const sda1Row = screen.getByRole("row", { name: /sda1/ }); - const selector = within(sda1Row).getByRole("combobox", { name: "Space action selector for /dev/sda1" }); - await user.selectOptions( - selector, - within(selector).getByRole("option", { name: /resize/i, selected: false }) - ); - expect(onChangeFn).toHaveBeenCalledWith( - expect.objectContaining({ - spaceActions: expect.arrayContaining([{ action: "resize", device: "/dev/sda1" }]) - }) - ); - }); - }); -}); diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/ProposalVolumes.jsx index c7254ed6b5..a3e2f873ae 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/ProposalVolumes.jsx @@ -408,6 +408,9 @@ export default function ProposalVolumes({ <> + + {_("File systems to create in your system")} + { const parseToBytes = (size) => { if (!size || size === undefined || size === "") return 0; - const value = xbytes.parseSize(size, { iec: true }) || parseInt(size); + const value = xbytes.parseSize(size.toString(), { iec: true }) || parseInt(size.toString()); // Avoid decimals resulting from the conversion. D-Bus iface only accepts integer return Math.trunc(value); @@ -140,6 +142,33 @@ const deviceLabel = (device) => { return size ? `${name}, ${deviceSize(size)}` : name; }; +/** + * Sorted list of children devices (i.e., partitions and unused slots or logical volumes). + * @function + * + * @note This method could be directly provided by the device object. For now, the method is kept + * here because the elements considered as children (e.g., partitions + unused slots) is not a + * semantic storage concept but a helper for UI components. + * + * @param {StorageDevice} device + * @returns {(StorageDevice|PartitionSlot)[]} + */ +const deviceChildren = (device) => { + const partitionTableChildren = (partitionTable) => { + const { partitions, unusedSlots } = partitionTable; + const children = partitions.concat(unusedSlots); + return children.sort((a, b) => a.start < b.start ? -1 : 1); + }; + + const lvmVgChildren = (lvmVg) => { + return lvmVg.logicalVolumes.sort((a, b) => a.name < b.name ? -1 : 1); + }; + + if (device.partitionTable) return partitionTableChildren(device.partitionTable); + if (device.type === "lvmVg") return lvmVgChildren(device); + return []; +}; + /** * Checks if volume uses given fs. This method works same as in backend * case insensitive. @@ -193,6 +222,7 @@ export { SIZE_METHODS, SIZE_UNITS, deviceLabel, + deviceChildren, deviceSize, parseToBytes, splitSize, diff --git a/web/src/components/storage/utils.test.js b/web/src/components/storage/utils.test.js index e5eb3cf0aa..c144b123a0 100644 --- a/web/src/components/storage/utils.test.js +++ b/web/src/components/storage/utils.test.js @@ -22,6 +22,7 @@ import { deviceSize, deviceLabel, + deviceChildren, parseToBytes, splitSize, hasFS, @@ -49,6 +50,66 @@ describe("deviceLabel", () => { }); }); +describe("deviceChildren", () => { + let device; + + describe("if the device has partition table", () => { + beforeEach(() => { + device = { + sid: 60, + partitionTable: { + partitions: [ + { sid: 61 }, + { sid: 62 }, + ], + unusedSlots: [ + { start: 1, size: 1024 }, + { start: 2345, size: 512 } + ] + } + }; + }); + + it("returns the partitions and unused slots", () => { + const children = deviceChildren(device); + expect(children.length).toEqual(4); + device.partitionTable.partitions.forEach(p => expect(children).toContainEqual(p)); + device.partitionTable.unusedSlots.forEach(s => expect(children).toContainEqual(s)); + }); + }); + + describe("if the device is a LVM volume group", () => { + beforeEach(() => { + device = { + sid: 60, + type: "lvmVg", + logicalVolumes: [ + { sid: 61 }, + { sid: 62 }, + { sid: 63 } + ] + }; + }); + + it("returns the logical volumes", () => { + const children = deviceChildren(device); + expect(children.length).toEqual(3); + device.logicalVolumes.forEach(l => expect(children).toContainEqual(l)); + }); + }); + + describe("if the device has neither partition table nor logical volumes", () => { + beforeEach(() => { + device = { sid: 60 }; + }); + + it("returns an empty list", () => { + const children = deviceChildren(device); + expect(children.length).toEqual(0); + }); + }); +}); + describe("parseToBytes", () => { it("returns bytes from given input", () => { expect(parseToBytes(1024)).toEqual(1024); diff --git a/web/src/utils.js b/web/src/utils.js index 5969268cf1..f9645ab238 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -66,6 +66,28 @@ const partition = (collection, filter) => { return [pass, fail]; }; +/** + * Generates a new array without null and undefined values. + * @function + * + * @param {Array} collection + * @returns {Array} + */ +function compact(collection) { + return collection.filter(e => e !== null && e !== undefined); +} + +/** + * Generates a new array without duplicates. + * @function + * + * @param {Array} collection + * @returns {Array} + */ +function uniq(collection) { + return [...new Set(collection)]; +} + /** * Simple utility function to help building className conditionally * @@ -355,6 +377,8 @@ export { noop, isObject, partition, + compact, + uniq, classNames, useCancellablePromise, useLocalStorage, diff --git a/web/src/utils.test.js b/web/src/utils.test.js index 8be48355cb..5d340828f8 100644 --- a/web/src/utils.test.js +++ b/web/src/utils.test.js @@ -20,7 +20,7 @@ */ import { - classNames, partition, noop, toValidationError, + classNames, partition, compact, uniq, noop, toValidationError, localConnection, remoteConnection, isObject } from "./utils"; @@ -41,6 +41,22 @@ describe("partition", () => { }); }); +describe("compact", () => { + it("removes null and undefined values", () => { + expect(compact([])).toEqual([]); + expect(compact([undefined, null, "", 0, 1, NaN, false, true])) + .toEqual(["", 0, 1, NaN, false, true]); + }); +}); + +describe("uniq", () => { + it("removes duplicated values", () => { + expect(uniq([])).toEqual([]); + expect(uniq([undefined, null, null, 0, 1, NaN, false, true, false, "test"])) + .toEqual([undefined, null, 0, 1, NaN, false, true, "test"]); + }); +}); + describe("classNames", () => { it("join given arguments, ignoring falsy values", () => { expect(classNames(