From ccdd71800575a30797415771d72d74976f6bbba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 27 Feb 2025 13:12:31 +0000 Subject: [PATCH 001/103] storage: add vgs and lvs to model schema --- .../share/examples/storage/model.json | 28 ++++++++++++++++ .../agama-lib/share/storage.model.schema.json | 33 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/rust/agama-lib/share/examples/storage/model.json b/rust/agama-lib/share/examples/storage/model.json index 7f2155246e..b7a47507fa 100644 --- a/rust/agama-lib/share/examples/storage/model.json +++ b/rust/agama-lib/share/examples/storage/model.json @@ -75,6 +75,34 @@ } } ] + }, + { + "name": "/dev/vdc", + "alias": "lvm", + "spacePolicy": "delete" + } + ], + "volumeGroups": [ + { + "name": "vg0", + "extentSize": 1024, + "targetDevices": ["lvm"], + "logicalVolumes": [ + { + "name": "lv0", + "mountPath": "/data", + "filesystem": { + "default": false, + "type": "ext4" + }, + "size": { + "default": true, + "min": 1111 + }, + "stripes": 8, + "stripeSize": 1204 + } + ] } ] } diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index e1fcf25fda..8eb6034084 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -9,6 +9,10 @@ "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } + }, + "volumeGroups": { + "type": "array", + "items": { "$ref": "#/$defs/volumeGroup" } } }, "$defs": { @@ -79,6 +83,35 @@ "resizeIfNeeded": { "type": "boolean" } } }, + "volumeGroup": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "extentSize": { "type": "integer" }, + "targetDevices": { + "type": "array", + "items": { "$ref": "#/$defs/alias" } + }, + "logicalVolumes": { + "type": "array", + "items": { "$ref": "#/$defs/logicalVolume" } + } + } + }, + "logicalVolume": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "mountPath": { "type": "string" }, + "filesystem": { "$ref": "#/$defs/filesystem" }, + "size": { "$ref": "#/$defs/size" }, + "stripes": { "type": "integer" }, + "stripeSize": { "type": "integer" } + } + }, "alias": { "description": "Alias used to reference a device.", "type": "string" From 8b545a12bbb8c8e25a72164671cbd6fdb4551813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 28 Feb 2025 15:29:08 +0000 Subject: [PATCH 002/103] storage: add conversion to model for LVM --- .../to_model_conversions.rb | 2 + .../to_model_conversions/config.rb | 13 +- .../to_model_conversions/logical_volume.rb | 59 +++++ .../to_model_conversions/volume_group.rb | 60 +++++ service/lib/agama/storage/proposal.rb | 14 +- .../config_conversions/to_model_test.rb | 220 +++++++++++++++++- 6 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb create mode 100644 service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions.rb b/service/lib/agama/storage/config_conversions/to_model_conversions.rb index 1d2c252603..8c4760dfdd 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions.rb @@ -26,9 +26,11 @@ require "agama/storage/config_conversions/to_model_conversions/drive" require "agama/storage/config_conversions/to_model_conversions/encryption" require "agama/storage/config_conversions/to_model_conversions/filesystem" +require "agama/storage/config_conversions/to_model_conversions/logical_volume" require "agama/storage/config_conversions/to_model_conversions/partition" require "agama/storage/config_conversions/to_model_conversions/size" require "agama/storage/config_conversions/to_model_conversions/space_policy" +require "agama/storage/config_conversions/to_model_conversions/volume_group" module Agama module Storage diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index 2634fbe1b4..adc3220280 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -23,6 +23,7 @@ require "agama/storage/config_conversions/to_model_conversions/boot" require "agama/storage/config_conversions/to_model_conversions/encryption" require "agama/storage/config_conversions/to_model_conversions/drive" +require "agama/storage/config_conversions/to_model_conversions/volume_group" module Agama module Storage @@ -41,9 +42,10 @@ def initialize(config) # @see Base#conversions def conversions { - boot: convert_boot, - encryption: convert_encryption, - drives: convert_drives + boot: convert_boot, + encryption: convert_encryption, + drives: convert_drives, + volumeGroups: convert_volume_groups } end @@ -65,6 +67,11 @@ def convert_drives valid_drives.map { |d| ToModelConversions::Drive.new(d).convert } end + # @return [Array] + def convert_volume_groups + config.volume_groups.map { |v| ToModelConversions::VolumeGroup.new(v).convert } + end + # @return [Array] def valid_drives config.drives.select(&:found_device) diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb new file mode 100644 index 0000000000..2c674a4b52 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/with_filesystem" +require "agama/storage/config_conversions/to_model_conversions/with_size" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # LVM logical volume conversion to model according to the JSON schema. + class LogicalVolume < Base + include WithFilesystem + include WithSize + + # @param config [Configs::LogicalVolume] + def initialize(config) + super() + @config = config + end + + private + + # @see Base#conversions + def conversions + { + name: config.name, + alias: config.alias, + mountPath: config.filesystem&.path, + filesystem: convert_filesystem, + size: convert_size, + stripes: config.stripes, + stripeSize: config.stripe_size&.to_i + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb new file mode 100644 index 0000000000..ea66c7cb1f --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/logical_volume" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # LVM volume group conversion to model according to the JSON schema. + class VolumeGroup < Base + include WithFilesystem + + # @param config [Configs::VolumeGroup] + def initialize(config) + super() + @config = config + end + + private + + # @see Base#conversions + def conversions + { + name: config.name, + extentSize: config.extent_size&.to_i, + targetDevices: config.physical_volumes_devices, + logicalVolumes: convert_logical_volumes + } + end + + def convert_logical_volumes + config.logical_volumes.map do |logical_volume| + ToModelConversions::LogicalVolume.new(logical_volume).convert + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 05f5f7593d..abcd501486 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -285,23 +285,25 @@ def config(solved: false) solved ? strategy.config : source_config end + # TODO: extract to a separate class (e.g., ModelSupportChecker) and add more checks, for + # example: the presence of thin pools, partitions without mount path, lvs without mount + # path, etc. + # # Whether the config model supports all features of the given config. # # @param config [Storage::Config] # @return [Boolean] def model_supported?(config) - unsupported_configs = [ - config.volume_groups, + unsupported_devices = [ config.md_raids, config.btrfs_raids, config.nfs_mounts ].flatten - encryptable_configs = [ - config.drives - ].flatten + # Only volume groups with automatically generated pvs are supported + volume_groups_with_pvs = config.volume_groups.select { |v| v.physical_volumes.any? } - unsupported_configs.empty? && encryptable_configs.none?(&:encryption) + unsupported_devices.empty? && volume_groups_with_pvs.empty? end # Calculates a proposal from guided JSON settings. diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index 927f60dbc0..9abcf75dda 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -627,11 +627,12 @@ it "generates the expected JSON" do expect(subject.convert).to eq( { - boot: { + boot: { configure: true, device: { default: true } }, - drives: [] + drives: [], + volumeGroups: [] } ) end @@ -909,5 +910,220 @@ include_examples "#spacePolicy property", drive_result_scope end end + + context "if #volume_groups is configured" do + let(:config_json) do + { volumeGroups: volume_groups } + end + + let(:volume_groups) do + [ + volume_group, + {} + ] + end + + let(:volume_group) { {} } + + it "generates the expected JSON for 'VolumeGroups'" do + volume_groups_model = subject.convert[:volumeGroups] + + expect(volume_groups_model).to eq( + [ + { targetDevices: [], logicalVolumes: [] }, + { targetDevices: [], logicalVolumes: [] } + ] + ) + end + + vg_result_scope = proc { |c| c[:volumeGroups].first } + + context "if #name is not configured for a volume group" do + let(:volume_group) { {} } + include_examples "without name", vg_result_scope + end + + context "if #extent_size is not configured for a volume group" do + let(:volume_group) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:extentSize) + end + end + + context "if #physical_volumes_devices is not configured for a volume group" do + let(:volume_group) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:targetDevices]).to eq([]) + end + end + + context "if #logical_volumes is not configured for a volume group" do + let(:volume_group) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:logicalVolumes]).to eq([]) + end + end + + context "if #name is configured for a volume group" do + let(:volume_group) { { name: "test" } } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:name]).to eq("test") + end + end + + context "if #extent_size is configured for a volume group" do + let(:volume_group) { { extentSize: "1 KiB" } } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:extentSize]).to eq(1.KiB.to_i) + end + end + + context "if #physical_volumes_devices is configured for a volume group" do + let(:volume_group) do + { + physicalVolumes: [{ generate: ["disk1", "disk2"] }] + } + end + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:targetDevices]).to eq(["disk1", "disk2"]) + end + end + + context "if #logical_volumes is configured for a volume group" do + let(:volume_group) do + { + logicalVolumes: [logical_volume, {}] + } + end + + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json[:logicalVolumes].size).to eq(2) + end + + lv_result_scope = proc { |c| vg_result_scope.call(c)[:logicalVolumes].first } + # partition_scope = proc { |c| device_scope.call(c).partitions.first } + + context "if #name is not configured for a logical volume" do + let(:logical_volume) { {} } + include_examples "without name", lv_result_scope + end + + context "if #alias is not configured for a logical volume" do + let(:logical_volume) { {} } + include_examples "without alias", lv_result_scope + end + + context "if #filesystem is not configured for a logical volume" do + let(:logical_volume) { {} } + include_examples "without filesystem", lv_result_scope + end + + context "if #size is not configured for a logical volume" do + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: true, + min: 100.MiB.to_i + } + ) + end + end + + context "if #stripes is not configured for a logical volume" do + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:stripes) + end + end + + context "if #stripe_size is not configured for a logical volume" do + let(:logical_volume) { {} } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:stripeSize) + end + end + + context "if #name is configured for a logical volume" do + let(:logical_volume) { { name: "test" } } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:name]).to eq("test") + end + end + + context "if #alias is configured for a logical volume" do + let(:logical_volume) { { alias: device_alias } } + include_examples "with alias", lv_result_scope + end + + context "if #filesystem is configured for a logical volume" do + let(:logical_volume) { { filesystem: filesystem } } + include_examples "with filesystem", lv_result_scope + end + + context "if #size is not configured for a logical volume" do + let(:logical_volume) do + { + size: { + min: "1 GiB", + max: "5 GiB" + } + } + end + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: false, + min: 1.GiB.to_i, + max: 5.GiB.to_i + } + ) + end + end + + context "if #stripes is configured for a logical volume" do + let(:logical_volume) { { stripes: 8 } } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:stripes]).to eq(8) + end + end + + context "if #stripe_size is configured for a logical volume" do + let(:logical_volume) { { stripeSize: "4 KiB" } } + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json[:stripeSize]).to eq(4.KiB.to_i) + end + end + end + end end end From 851d33df06335028023e263ef2d806b064257a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 3 Mar 2025 06:17:51 +0000 Subject: [PATCH 003/103] feat(storage): make vg name mandatory --- rust/agama-lib/share/storage.schema.json | 1 + .../storage/config_checkers/volume_group.rb | 16 +++++++++++--- .../test/agama/storage/config_checker_test.rb | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/rust/agama-lib/share/storage.schema.json b/rust/agama-lib/share/storage.schema.json index 27ec46f8e3..808433fd67 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -159,6 +159,7 @@ "description": "LVM volume group.", "type": "object", "additionalProperties": false, + "required": ["name"], "properties": { "name": { "description": "Volume group name.", diff --git a/service/lib/agama/storage/config_checkers/volume_group.rb b/service/lib/agama/storage/config_checkers/volume_group.rb index cbcae6025c..9d9f8757c2 100644 --- a/service/lib/agama/storage/config_checkers/volume_group.rb +++ b/service/lib/agama/storage/config_checkers/volume_group.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -41,16 +41,17 @@ def initialize(config, storage_config, product_config) @config = config end - # Volume group config issues. + # Issues from a volume group config. # # @return [Array] def issues [ + name_issue, logical_volumes_issues, physical_volumes_issues, physical_volumes_devices_issues, physical_volumes_encryption_issues - ].flatten + ].compact.flatten end private @@ -58,6 +59,15 @@ def issues # @return [Configs::VolumeGroup] attr_reader :config + # Issue if the volume group name is missing. + # + # @return [Issue, nil] + def name_issue + return if config.name && !config.name.empty? + + error(_("There is a volume group without name")) + end + # Issues from logical volumes. # # @return [Array] diff --git a/service/test/agama/storage/config_checker_test.rb b/service/test/agama/storage/config_checker_test.rb index b675e68b28..542423431a 100644 --- a/service/test/agama/storage/config_checker_test.rb +++ b/service/test/agama/storage/config_checker_test.rb @@ -515,12 +515,27 @@ end end + context "if a volume group has no name" do + let(:config_json) do + { + volumeGroups: [{ name: "test" }, { name: "" }, {}] + } + end + + it "includes the expected issue" do + issues = subject.issues.select { |i| i.description.match?(/without name/) } + expect(issues.size).to eq(2) + expect(issues.map(&:error?)).to all(eq(true)) + end + end + context "if a volume group has logical volumes" do let(:config_json) do { boot: { configure: false }, volumeGroups: [ { + name: "test", logicalVolumes: [ logical_volume, { @@ -591,6 +606,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: ["first-disk", "pv1"] } ] @@ -618,6 +634,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: [ { generate: { @@ -652,6 +669,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: [ { generate: { @@ -745,6 +763,7 @@ ], volumeGroups: [ { + name: "test1", physicalVolumes: [ { generate: { @@ -754,6 +773,7 @@ ] }, { + name: "test2", physicalVolumes: [ { generate: { @@ -763,6 +783,7 @@ ] }, { + name: "test3", physicalVolumes: [ { generate: { @@ -797,6 +818,7 @@ ], volumeGroups: [ { + name: "test", physicalVolumes: ["pv1"] } ] From f2bdb8cce8db05a3b5c997e50fea8a61d42b633f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 5 Mar 2025 12:20:11 +0000 Subject: [PATCH 004/103] fix(storage): drive model includes the searching name - Includes the name used for searching the device when the device is not found. --- .../config_conversions/to_model_conversions/drive.rb | 9 +++++++-- .../agama/storage/config_conversions/to_model_test.rb | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb index 0542d7f4fe..7589812a49 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -45,7 +45,7 @@ def initialize(config) # @see Base#conversions def conversions { - name: config.found_device&.name, + name: convert_name, alias: config.alias, mountPath: config.filesystem&.path, filesystem: convert_filesystem, @@ -54,6 +54,11 @@ def conversions partitions: convert_partitions } end + + # @return [String, nil] + def convert_name + config.found_device&.name || config.search&.name + end end end end diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index 9abcf75dda..da9c07bf5f 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -841,6 +841,7 @@ expect(drives_model).to eq( [ + { name: "/dev/vdd", spacePolicy: "keep", partitions: [] }, { name: "/dev/vda", spacePolicy: "keep", partitions: [] } ] ) From e99fb7361d15d8cb3f4847f085c965b3b7bfa4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 5 Mar 2025 12:26:18 +0000 Subject: [PATCH 005/103] fix(storage): only exclude skipped devices from model - The model includes all the devices, including devices with errors. --- .../to_model_conversions/config.rb | 2 +- .../to_model_conversions/with_partitions.rb | 25 ++----------- .../config_conversions/to_model_test.rb | 35 +++++++++++++++++++ 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index adc3220280..76f7f70db3 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -74,7 +74,7 @@ def convert_volume_groups # @return [Array] def valid_drives - config.drives.select(&:found_device) + config.drives.reject { |d| d.search&.skip_device? } end # TODO: proper support for a base encryption. diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb index d13152bb1e..f7d09c6eb0 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -36,28 +36,7 @@ def convert_partitions # @return [Array] def valid_partitions - config.partitions.select { |p| valid_partition?(p) } - end - - # @param partition_config [Configs::Partition] - # @return [Boolean] - def valid_partition?(partition_config) - valid_new_partition(partition_config) || valid_existing_partition(partition_config) - end - - # @param partition_config [Configs::Partition] - # @return [Boolean] - def valid_new_partition(partition_config) - delete = partition_config.delete? || partition_config.delete_if_needed? - return false if delete - - partition_config.search.nil? || partition_config.search.create_device? - end - - # @param partition_config [Configs::Partition] - # @return [Boolean] - def valid_existing_partition(partition_config) - !partition_config.found_device.nil? + config.partitions.reject { |p| p.search&.skip_device? } end end end diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index da9c07bf5f..71b149c22d 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -173,6 +173,13 @@ model_json = result_scope.call(subject.convert) expect(model_json[:partitions]).to eq( [ + { + delete: true, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 100.MiB.to_i } + }, default_partition_json ] ) @@ -186,6 +193,13 @@ model_json = result_scope.call(subject.convert) expect(model_json[:partitions]).to eq( [ + { + delete: false, + deleteIfNeeded: true, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 100.MiB.to_i } + }, default_partition_json ] ) @@ -846,6 +860,27 @@ ] ) end + + context "and the drive is set to be skipped" do + let(:drive) do + { + search: { + condition: { name: "/dev/vdd" }, + ifNotFound: "skip" + } + } + end + + it "generates the expected JSON for 'drives'" do + drives_model = subject.convert[:drives] + + expect(drives_model).to eq( + [ + { name: "/dev/vda", spacePolicy: "keep", partitions: [] } + ] + ) + end + end end context "if a device is found for a drive" do From 6970c142086b2ea2ba80843c037cc8b2a148ec81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 5 Mar 2025 12:29:04 +0000 Subject: [PATCH 006/103] fix(storage): model always generates a min size - Min size is even generated when a device is not found. --- .../config_conversions/to_model_conversions/size.rb | 9 +++++++-- .../agama/storage/config_conversions/to_model_test.rb | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb index 13ab91151a..b8000f920e 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -39,11 +39,16 @@ def initialize(config) def conversions { default: config.default?, - min: config.min&.to_i, + min: convert_min_size, max: convert_max_size } end + # @return [Integer] + def convert_min_size + config.min&.to_i || 0 + end + # @return [Integer, nil] def convert_max_size max = config.max diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index 71b149c22d..db7317faf9 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -213,6 +213,13 @@ model_json = result_scope.call(subject.convert) expect(model_json[:partitions]).to eq( [ + { + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 0 } + }, default_partition_json ] ) From 4be9c6aca3b137f37b0fb661ae966c557426fa26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 3 Mar 2025 15:49:30 +0000 Subject: [PATCH 007/103] storage: add checker for model support --- .../agama/storage/model_support_checker.rb | 203 +++++++ .../model_support_checker_test.rb | 499 ++++++++++++++++++ 2 files changed, 702 insertions(+) create mode 100644 service/lib/agama/storage/model_support_checker.rb create mode 100644 service/test/agama/storage/config_conversions/model_support_checker_test.rb diff --git a/service/lib/agama/storage/model_support_checker.rb b/service/lib/agama/storage/model_support_checker.rb new file mode 100644 index 0000000000..4eefe3cc31 --- /dev/null +++ b/service/lib/agama/storage/model_support_checker.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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. + +module Agama + module Storage + # Class for checking whether a config is supported by the config model. + # + # Features will be added to the config model little by little. Ideally, this class will + # dissapear once the model supports all the features provided by the config. + class ModelSupportChecker + # @note A solved config is expected. Otherwise some checks cannot be done reliably. + # + # @param config [Storage::Config] + def initialize(config) + @config = config + end + + # Whether the config is completely supported by the config model. + # + # @return [Booelan] + def supported? + return @supported unless @supported.nil? + + @supported = !unsupported_config? + end + + private + + # @return [Storage::Config] + attr_reader :config + + # Whether the config is not supported by the config model. + # + # @return [Boolean] + def unsupported_config? # rubocop:disable Metrics/CyclomaticComplexity + any_unsupported_device? || + any_drive_without_name? || + any_drive_with_encryption? || + any_volume_group_without_name? || + any_volume_group_with_pvs? || + any_partition_without_mount_path? || + any_logical_volume_without_mount_path? || + any_logical_volume_with_encryption? + end + + # Whether there is any device that is not supported by the model. + # + # @return [Boolean] + def any_unsupported_device? + thin_pools = config.logical_volumes.select(&:pool?) + thin_volumes = config.logical_volumes.select(&:thin_volume?) + + [ + config.md_raids, + config.btrfs_raids, + config.nfs_mounts, + thin_pools, + thin_volumes + ].flatten.any? + end + + # Whether there is any mandatory drive without a name. + # + # @return [Boolean] + def any_drive_without_name? + config.drives.any? do |drive| + !drive.found_device && + !drive.search&.skip_device? && + !drive.search&.name + end + end + + # Whether there is any mandatory drive with encryption. + # + # @return [Boolean] + def any_drive_with_encryption? + config.drives.any? { |d| !d.search&.skip_device? && !d.encryption.nil? } + end + + # Whether there is any volume group without a name. + # + # @return [Boolean] + def any_volume_group_without_name? + !config.volume_groups.all?(&:name) + end + + # Only volume groups with automatically generated physical volumes are supported. + # @todo Revisit this check once individual physical volumes are supported by the model. + # + # @return [Boolean] + def any_volume_group_with_pvs? + config.volume_groups.any? { |v| v.physical_volumes.any? } + end + + # Whether there is any logical volume with missing mount path. + # @todo Revisit this check once volume groups can be reused. + # + # @return [Boolean] + def any_logical_volume_without_mount_path? + config.logical_volumes.any? { |p| !p.filesystem&.path } + end + + # Whether there is any logical volume with encryption. + # + # @return [Boolean] + def any_logical_volume_with_encryption? + config.logical_volumes.any?(&:encryption) + end + + # Whether there is any partition with missing mount path. + # @see #need_mount_path? + # + # @return [Boolean] + def any_partition_without_mount_path? + config.partitions.any? { |p| need_mount_path?(p) && !p.filesystem&.path } + end + + # Whether the config represents a partition that requires a mount path. + # + # A mount path is required for all the partitions that are going to be created. For a config + # reusing an existing partition, the mount path is required only if the partition does not + # represent a space policy action (delete or resize). + # + # @todo Revisit this check once individual physical volumes are supported by the model. The + # partitions representing the new physical volumes would not need a mount path. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def need_mount_path?(partition_config) + return true if new_partition?(partition_config) + + reused_partition?(partition_config) && + !delete_action_partition?(partition_config) && + !resize_action_partition?(partition_config) + end + + # Whether the config represents a new partition to be created. + # + # @note The config has to be solved. Otherwise, in some cases it would be impossible to + # determine whether the partition is going to be created or reused. For example, if the + # config has a search and #if_not_found is set to :create. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def new_partition?(partition_config) + partition_config.search.nil? || partition_config.search.create_device? + end + + # Whether the config is reusing an existing partition. + # + # @note The config has to be solved. Otherwise, in some cases it would be impossible to + # determine whether the partition is going to be reused or skipped. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def reused_partition?(partition_config) + !new_partition?(partition_config) && !partition_config.search.skip_device? + end + + # Whether the partition is configured to be deleted or deleted if needed. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def delete_action_partition?(partition_config) + return false unless reused_partition?(partition_config) + + partition_config.delete? || partition_config.delete_if_needed? + end + + # Whether the partition is configured to be resized if needed. + # + # @param partition_config [Configs::Partition] + # @return [Boolean] + def resize_action_partition?(partition_config) + return false unless reused_partition?(partition_config) + + partition_config.filesystem.nil? && + partition_config.encryption.nil? && + partition_config.size && + !partition_config.size.default? && + partition_config.size.min == Y2Storage::DiskSize.zero + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/model_support_checker_test.rb b/service/test/agama/storage/config_conversions/model_support_checker_test.rb new file mode 100644 index 0000000000..75668465e7 --- /dev/null +++ b/service/test/agama/storage/config_conversions/model_support_checker_test.rb @@ -0,0 +1,499 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "../storage_helpers" +require_relative "../../../test_helper" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_solver" +require "agama/storage/model_support_checker" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +describe Agama::Storage::ModelSupportChecker do + include Agama::RSpec::StorageHelpers + + let(:product_data) do + { + "storage" => { + "volumes" => ["/", "swap"], + "volume_templates" => [ + { + "mount_path" => "/", + "filesystem" => "btrfs", + "size" => { + "auto" => true, + "min" => "5 GiB", + "max" => "10 GiB" + }, + "btrfs" => { + "snapshots" => true + }, + "outline" => { + "required" => true, + "snapshots_configurable" => true, + "auto_size" => { + "base_min" => "5 GiB", + "base_max" => "10 GiB" + } + } + }, + { + "mount_path" => "/home", + "filesystem" => "xfs", + "size" => { + "auto" => false, + "min" => "5 GiB" + }, + "outline" => { + "required" => false + } + }, + { + "mount_path" => "swap", + "filesystem" => "swap", + "size" => { + "auto" => true + }, + "outline" => { + "auto_size" => { + "base_min" => "2 GiB", + "base_max" => "4 GiB" + } + } + }, + { + "mount_path" => "", + "filesystem" => "ext4", + "size" => { + "min" => "100 MiB" + } + } + ] + } + } + end + + let(:product_config) { Agama::Config.new(product_data) } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + .tap { |c| Agama::Storage::ConfigSolver.new(product_config, devicegraph).solve(c) } + end + + before do + mock_storage(devicegraph: scenario) + # To speed-up the tests + allow(Y2Storage::EncryptionMethod::TPM_FDE) + .to(receive(:possible?)) + .and_return(true) + end + + subject { described_class.new(config) } + + describe "#supported?" do + let(:scenario) { "disks.yaml" } + + # The drive is not found and it is not searched by name. + context "if there is a drive with unknown name" do + let(:scenario) { "empty-hd-50GiB.yaml" } + + let(:config_json) do + { + drives: [ + {}, + { search: { ifNotFound: if_not_found } } + ] + } + end + + context "and the drive is going to be skipped" do + let(:if_not_found) { "skip" } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the drive is not going to be skipped" do + let(:if_not_found) { "error" } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + end + + context "if there is a drive with encryption" do + let(:config_json) do + { + drives: [ + { + search: { + condition: condition, + ifNotFound: "skip" + }, + encryption: { + luks1: { password: "12345" } + } + } + ] + } + end + + context "and the drive is going to be skipped" do + let(:condition) { { name: "/not/found" } } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the drive is not going to be skipped" do + let(:condition) { nil } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + end + + context "if there is a LVM thin pool" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [ + { pool: true } + ] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM thin volume" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [ + { usedPool: "pool" } + ] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM volume group without name" do + let(:config_json) do + { + volumeGroups: [{}] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM volume group with specific physical volumes" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + physicalVolumes: ["pv1"] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a partition without mount path" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + search: "/dev/vda", + partitions: [ + { + search: search, + delete: delete, + deleteIfNeeded: deleteIfNeeded, + filesystem: filesystem, + encryption: encryption, + size: size + } + ] + } + ] + } + end + + let(:search) { nil } + let(:delete) { nil } + let(:deleteIfNeeded) { nil } + let(:filesystem) { nil } + let(:encryption) { nil } + let(:size) { nil } + + context "and the partition has not a search (new partition)" do + let(:search) { nil } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition has a search" do + let(:search) do + { + condition: condition, + ifNotFound: if_not_found + } + end + + let(:if_not_found) { nil } + + shared_examples "reused partition" do + context "and the partition is set to be deleted" do + let(:delete) { true } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the partition is set to be deleted if needed" do + let(:deleteIfNeeded) { true } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the partition is not set to be deleted" do + let(:delete) { false } + let(:deleteIfNeeded) { false } + + context "and the partition has encryption" do + let(:encryption) do + { luks1: { password: "12345" } } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition has filesystem" do + let(:filesystem) { { type: "xfs" } } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition has a size" do + let(:size) do + { + default: false, + min: 1.GiB, + max: 10.GiB + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "and the partition is only set to be resized if needed" do + let(:encryption) { nil } + let(:filesystem) { nil } + let(:size) do + { + default: false, + min: Y2Storage::DiskSize.zero + } + end + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + end + end + + context "and the partition is found" do + let(:condition) { { name: "/dev/vda1" } } + + include_examples "reused partition" + end + + context "and the partition is not found" do + let(:condition) { { name: "/no/found" } } + + context "and the partition can be skipped" do + let(:if_not_found) { "skip" } + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + + context "and the partition cannot be skipped" do + let(:if_not_found) { "error" } + + include_examples "reused partition" + end + + context "and the partition can be created" do + let(:if_not_found) { "create" } + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + end + end + end + + context "if there is a LVM logical volume without mount path" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [{}] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if there is a LVM logical volume with encryption" do + let(:config_json) do + { + volumeGroups: [ + { + name: "system", + logicalVolumes: [ + { + encryption: { + luks1: { password: "12345" } + } + } + ] + } + ] + } + end + + it "returns false" do + expect(subject.supported?).to eq(false) + end + end + + context "if the config is totally supported" do + let(:scenario) { "disks.yaml" } + + let(:config_json) do + { + drives: [ + { + search: "/dev/vda", + partitions: [ + { search: "/dev/vda1", delete: true }, + { + search: "/dev/vda2", + size: { default: false, min: 0 } + }, + { + encryption: { + luks1: { password: "12345" } + }, + filesystem: { path: "/", type: "btrfs" } + } + ] + }, + { + alias: "pv", + partitions: [ + { search: "*", delete: true } + ] + }, + { + partitions: [ + { search: "*", delete: true }, + { + filesystem: { path: "/home", type: "xfs" } + } + ] + } + ], + volumeGroups: [ + { + name: "data", + physicalVolumes: [{ generate: ["pv"] }], + logicalVolumes: [ + { + filesystem: { path: "/data" }, + size: { min: "10 GiB" } + } + ] + } + ] + } + end + + it "returns true" do + expect(subject.supported?).to eq(true) + end + end + end +end From e70204af8f1e38fd7fc399e23fa211f8adaa8782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 5 Mar 2025 14:44:46 +0000 Subject: [PATCH 008/103] storage: use model support checker --- service/lib/agama/storage/proposal.rb | 16 +----- .../test/agama/dbus/storage/manager_test.rb | 14 ++--- service/test/agama/storage/proposal_test.rb | 51 ++++++++----------- 3 files changed, 30 insertions(+), 51 deletions(-) diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index abcd501486..2e622dee0e 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -24,6 +24,7 @@ require "agama/storage/config_checker" require "agama/storage/config_conversions" require "agama/storage/config_solver" +require "agama/storage/model_support_checker" require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "json" @@ -285,25 +286,12 @@ def config(solved: false) solved ? strategy.config : source_config end - # TODO: extract to a separate class (e.g., ModelSupportChecker) and add more checks, for - # example: the presence of thin pools, partitions without mount path, lvs without mount - # path, etc. - # # Whether the config model supports all features of the given config. # # @param config [Storage::Config] # @return [Boolean] def model_supported?(config) - unsupported_devices = [ - config.md_raids, - config.btrfs_raids, - config.nfs_mounts - ].flatten - - # Only volume groups with automatically generated pvs are supported - volume_groups_with_pvs = config.volume_groups.select { |v| v.physical_volumes.any? } - - unsupported_devices.empty? && volume_groups_with_pvs.empty? + ModelSupportChecker.new(config).supported? end # Calculates a proposal from guided JSON settings. diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index f582c396ea..bd6b664880 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -734,14 +734,14 @@ def serialize(value) it "returns the serialized config model" do expect(subject.recover_model).to eq( serialize({ - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - drives: [ + drives: [ { name: "/dev/sda", alias: "root", @@ -765,7 +765,8 @@ def serialize(value) } ] } - ] + ], + volumeGroups: [] }) ) end @@ -810,14 +811,14 @@ def serialize(value) expect(result).to eq( serialize({ - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - drives: [ + drives: [ { name: "/dev/sda", alias: "sda", @@ -841,7 +842,8 @@ def serialize(value) } ] } - ] + ], + volumeGroups: [] }) ) end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index dfe6246804..db828f00d7 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -358,7 +358,7 @@ def drive(partitions) subject.calculate_from_json(config_json) end - context "and the config contains an encrypted drive" do + context "and the model does not support the config" do let(:config_json) do { storage: { @@ -378,32 +378,12 @@ def drive(partitions) end end - context "and the config contains volume groups" do - let(:config_json) do - { - storage: { - volumeGroups: [ - { - name: "vg0" - } - ] - } - } - end - - it "returns nil" do - expect(subject.model_json).to be_nil - end - end - context "and the config has errors" do let(:config_json) do { storage: { drives: [ - { - search: "unknown" - } + { search: "unknown" } ] } } @@ -412,13 +392,20 @@ def drive(partitions) it "returns the config model" do expect(subject.model_json).to eq( { - boot: { + boot: { configure: true, device: { default: true } }, - drives: [] + drives: [ + { + name: "unknown", + spacePolicy: "keep", + partitions: [] + } + ], + volumeGroups: [] } ) end @@ -448,18 +435,18 @@ def drive(partitions) it "returns the config model" do expect(subject.model_json).to eq( { - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - encryption: { + encryption: { method: "luks1", password: "12345" }, - drives: [ + drives: [ { name: "/dev/sda", alias: "root", @@ -483,7 +470,8 @@ def drive(partitions) } ] } - ] + ], + volumeGroups: [] } ) end @@ -510,14 +498,14 @@ def drive(partitions) result = subject.solve_model(model) expect(result).to eq({ - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - drives: [ + drives: [ { name: "/dev/sda", alias: "sda", @@ -541,7 +529,8 @@ def drive(partitions) } ] } - ] + ], + volumeGroups: [] }) end From 5983925dc38579698fc0c272a0f550d589afc084 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Sat, 1 Mar 2025 23:02:25 +0000 Subject: [PATCH 009/103] web: Extract repeated code to a new DeviceMenu component --- web/src/components/storage/DriveEditor.tsx | 249 +++++------------- .../components/storage/utils/configEditor.tsx | 89 +++++++ 2 files changed, 159 insertions(+), 179 deletions(-) create mode 100644 web/src/components/storage/utils/configEditor.tsx diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 629c06716f..ab34d3ea9c 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React, { useId, useRef, useState } from "react"; +import React from "react"; import { useNavigate, generatePath } from "react-router-dom"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; @@ -33,6 +33,7 @@ import { useDrive } from "~/queries/storage/config-model"; import * as driveUtils from "~/components/storage/utils/drive"; import * as partitionUtils from "~/components/storage/utils/partition"; import { contentDescription } from "~/components/storage/utils/device"; +import { DeviceMenu } from "~/components/storage/utils/configEditor"; import { Icon } from "../layout"; import { MenuHeader } from "~/components/core"; import MenuDeviceDescription from "./MenuDeviceDescription"; @@ -45,15 +46,9 @@ import { Flex, Label, Split, - Menu, - MenuContainer, - MenuContent, MenuItem, MenuItemAction, MenuList, - MenuToggle, - MenuToggleProps, - MenuToggleElement, MenuGroup, } from "@patternfly/react-core"; @@ -61,18 +56,6 @@ import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacin export type DriveEditorProps = { drive: configModel.Drive; driveDevice: StorageDevice }; -export const InlineMenuToggle = React.forwardRef( - (props: MenuToggleProps, ref: React.Ref) => ( - } - innerRef={ref} - variant="plain" - className="agm-inline-menu-toggle" - {...props} - /> - ), -); - // FIXME: Presentation is quite poor const SpacePolicySelectorIntro = ({ device }) => { const main = _("Choose what to with current content"); @@ -97,18 +80,13 @@ const SpacePolicySelectorIntro = ({ device }) => { }; const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); const { setSpacePolicy } = useDrive(drive.name); - const onToggle = () => setIsOpen(!isOpen); const onSpacePolicyChange = (spacePolicy: configModel.SpacePolicy) => { if (spacePolicy === "custom") { return navigate(generatePath(PATHS.findSpace, { id: baseName(drive.name) })); } else { setSpacePolicy(spacePolicy); - setIsOpen(false); } }; @@ -132,31 +110,19 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { }; return ( - - {driveUtils.contentActionsDescription(drive)} - - } - menuRef={menuRef} - menu={ - - - }> - - - {SPACE_POLICIES.map((policy) => ( - - ))} - - - - - } - /> + {driveUtils.contentActionsDescription(drive)}} + activeItemId={currentPolicy.id} + > + }> + + + {SPACE_POLICIES.map((policy) => ( + + ))} + + + ); }; @@ -404,47 +370,22 @@ const RemoveDriveOption = ({ drive }) => { }; const DriveSelector = ({ drive, selected, toggleAriaLabel }) => { - const menuId = useId(); - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const driveHandler = useDrive(drive.name); const onDriveChange = (newDriveName: string) => { driveHandler.switch(newDriveName); - setIsOpen(false); }; - const onToggle = () => setIsOpen(!isOpen); return ( - - {deviceLabel(selected)} - - } - menuRef={menuRef} - menu={ - - - - - - - - - } - // @ts-expect-error - popperProps={{ appendTo: document.body }} - /> + {deviceLabel(selected)}} + ariaLabel={toggleAriaLabel} + activeItemId={selected.sid} + > + + + + + ); }; @@ -519,52 +460,27 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { }; const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { - const menuId = useId(); - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); - const onToggle = () => setIsOpen(!isOpen); return ( - {_("No additional partitions will be created")}} + ariaLabel={toggleAriaLabel} + > + + navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) }))} > - {_("No additional partitions will be created")} - - } - menuRef={menuRef} - menu={ - - - - - navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) })) - } - > - - {_("Add or use partition")} - - - - - - } - /> + + {_("Add or use partition")} + + + + ); }; @@ -615,63 +531,38 @@ const PartitionMenuItem = ({ driveName, mountPath }) => { }; const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { - const menuId = useId(); - const menuRef = useRef(); - const toggleMenuRef = useRef(); - const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); - const onToggle = () => setIsOpen(!isOpen); return ( - {driveUtils.contentDescription(drive)}} + ariaLabel={toggleAriaLabel} + > + + {drive.partitions + .filter((p) => p.mountPath) + .map((partition) => { + return ( + + ); + })} + + navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) }))} > - {driveUtils.contentDescription(drive)} - - } - menuRef={menuRef} - menu={ - - - - {drive.partitions - .filter((p) => p.mountPath) - .map((partition) => { - return ( - - ); - })} - - - navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) })) - } - > - - {_("Add or use partition")} - - - - - - } - /> + + {_("Add or use partition")} + + + + ); }; diff --git a/web/src/components/storage/utils/configEditor.tsx b/web/src/components/storage/utils/configEditor.tsx new file mode 100644 index 0000000000..24e4aed0fe --- /dev/null +++ b/web/src/components/storage/utils/configEditor.tsx @@ -0,0 +1,89 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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, { useId, useRef, useState } from "react"; +import { Icon } from "../../layout"; +import { + Menu, + MenuContainer, + MenuContent, + MenuToggle, + MenuToggleProps, + MenuToggleElement, +} from "@patternfly/react-core"; + +export const InlineMenuToggle = React.forwardRef( + (props: MenuToggleProps, ref: React.Ref) => ( + } + innerRef={ref} + variant="plain" + className="agm-inline-menu-toggle" + {...props} + /> + ), +); + +const DeviceMenu = ({ title, ariaLabel = undefined, activeItemId = undefined, children }) => { + const menuId = useId(); + const menuRef = useRef(); + const toggleMenuRef = useRef(); + const [isOpen, setIsOpen] = useState(false); + const onToggle = () => setIsOpen(!isOpen); + + return ( + + {title} + + } + menuRef={menuRef} + menu={ + setIsOpen(false)} + > + {children} + + } + // @ts-expect-error + popperProps={{ appendTo: document.body }} + /> + ); +}; + +export { DeviceMenu }; From 401648539a6fac218f35f523bc618783b6d1aa0b Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 3 Mar 2025 11:27:19 +0000 Subject: [PATCH 010/103] web: First real implementation of hasPV --- web/src/components/storage/DriveEditor.tsx | 22 +++++++++++----------- web/src/components/storage/utils/drive.tsx | 7 ------- web/src/queries/storage/config-model.ts | 8 ++++++++ 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index ab34d3ea9c..2861a87059 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -130,7 +130,7 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { const driveModel = useDrive(drive.name); if (!driveModel) return; - const { isBoot, isExplicitBoot } = driveModel; + const { isBoot, isExplicitBoot, hasPv } = driveModel; // TODO: Get volume groups associated to the drive. const volumeGroups = []; @@ -142,7 +142,7 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { if (!driveUtils.hasFilesystem(drive)) { // The current device will be the only option to choose from - if (driveUtils.hasPv(drive)) { + if (hasPv) { if (volumeGroups.length > 1) { if (isExplicitBoot) { return _( @@ -195,7 +195,7 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { const name = baseName(drive.name); - if (driveUtils.hasPv(drive)) { + if (hasPv) { if (volumeGroups.length > 1) { if (isExplicitBoot) { return sprintf( @@ -318,13 +318,13 @@ const SearchSelectorOptions = ({ drive, selected, onChange }) => { const driveModel = useDrive(drive.name); if (!driveModel) return; - const { isExplicitBoot } = driveModel; + const { isExplicitBoot, hasPv } = driveModel; // const boot = isExplicitBoot(drive.name); if (driveUtils.hasReuse(drive)) return ; if (!driveUtils.hasFilesystem(drive)) { - if (driveUtils.hasPv(drive) || isExplicitBoot) { + if (hasPv || isExplicitBoot) { return ; } @@ -349,10 +349,10 @@ const RemoveDriveOption = ({ drive }) => { if (!driveModel) return; - const { isExplicitBoot, delete: deleteDrive } = driveModel; + const { isExplicitBoot, hasPv, delete: deleteDrive } = driveModel; if (isExplicitBoot) return; - if (driveUtils.hasPv(drive)) return; + if (hasPv) return; if (driveUtils.hasRoot(drive)) return; return ( @@ -390,11 +390,11 @@ const DriveSelector = ({ drive, selected, toggleAriaLabel }) => { }; const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { - const { isBoot } = useDrive(drive.name); + const { isBoot, hasPv } = useDrive(drive.name); const text = (drive: configModel.Drive): string => { if (driveUtils.hasRoot(drive)) { - if (driveUtils.hasPv(drive)) { + if (hasPv) { if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s to install, host LVM and boot"); @@ -412,7 +412,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { } if (driveUtils.hasFilesystem(drive)) { - if (driveUtils.hasPv(drive)) { + if (hasPv) { if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s for LVM, additional partitions and booting"); @@ -429,7 +429,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { return _("Use %s for additional partitions"); } - if (driveUtils.hasPv(drive)) { + if (hasPv) { if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s to host LVM and boot"); diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index a4c3342444..41d39d0e0e 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -157,14 +157,7 @@ const hasReuse = (drive: configModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath && p.name); }; -// TODO: maybe it should be moved to Drive hook from config-model. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const hasPv = (drive: configModel.Drive): boolean => { - return false; -}; - export { - hasPv, hasReuse, hasFilesystem, hasRoot, diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 22dc392a2b..42fa0801f6 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -85,6 +85,12 @@ function isExplicitBoot(model: configModel.Config, driveName: string): boolean { return !model.boot?.device?.default && driveName === model.boot?.device?.name; } +function driveHasPv(model: configModel.Config, driveAlias: string): boolean { + if (!driveAlias) return false; + + return model.volumeGroups.flatMap((g) => g.targetDevices).includes(driveAlias); +} + function allMountPaths(drive: configModel.Drive): string[] { if (drive.mountPath) return [drive.mountPath]; @@ -411,6 +417,7 @@ export function useEncryption(): EncryptionHook { export type DriveHook = { isBoot: boolean; isExplicitBoot: boolean; + hasPv: boolean; allMountPaths: string[]; configuredExistingPartitions: configModel.Partition[]; switch: (newName: string) => void; @@ -432,6 +439,7 @@ export function useDrive(name: string): DriveHook | null { return { isBoot: isBoot(model, name), isExplicitBoot: isExplicitBoot(model, name), + hasPv: driveHasPv(model, drive.alias), allMountPaths: allMountPaths(drive), configuredExistingPartitions: configuredExistingPartitions(drive), switch: (newName) => mutate(switchDrive(model, name, newName)), From 451637c0b5cdf7371737d84deb4a5a0b47b5fc4d Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 3 Mar 2025 12:00:15 +0000 Subject: [PATCH 011/103] web: Refresh types/config-model.ts --- web/src/api/storage/types/config-model.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index 8a109fb952..7cf4871e7d 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -37,6 +37,7 @@ export interface Config { boot?: Boot; encryption?: Encryption; drives?: Drive[]; + volumeGroups?: VolumeGroup[]; } export interface Boot { configure: boolean; @@ -83,3 +84,17 @@ export interface Size { min: number; max?: number; } +export interface VolumeGroup { + name: string; + extentSize?: number; + targetDevices?: Alias[]; + logicalVolumes?: LogicalVolume[]; +} +export interface LogicalVolume { + name?: string; + mountPath?: string; + filesystem?: Filesystem; + size?: Size; + stripes?: number; + stripeSize?: number; +} From eb08eb0d4225f27d70ca77ceff9fdc0f92fd6d02 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 3 Mar 2025 12:35:02 +0000 Subject: [PATCH 012/103] web: First version of VolumeGroupEditor --- web/src/components/storage/ConfigEditor.tsx | 10 +- web/src/components/storage/DriveEditor.tsx | 10 +- .../components/storage/VolumeGroupEditor.tsx | 95 +++++++++++++++++++ .../components/storage/utils/configEditor.tsx | 14 ++- web/src/queries/storage/config-model.ts | 32 +++++++ 5 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 web/src/components/storage/VolumeGroupEditor.tsx diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index 1e2238f1a9..548577a2a1 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -24,6 +24,7 @@ import React from "react"; import { useDevices } from "~/queries/storage"; import { useConfigModel } from "~/queries/storage/config-model"; import DriveEditor from "~/components/storage/DriveEditor"; +import VolumeGroupEditor from "~/components/storage/VolumeGroupEditor"; import { List, ListItem } from "@patternfly/react-core"; export default function ConfigEditor() { @@ -32,6 +33,13 @@ export default function ConfigEditor() { return ( + {model.volumeGroups.map((vg, i) => { + return ( + + + + ); + })} {model.drives.map((drive, i) => { const device = devices.find((d) => d.name === drive.name); @@ -42,7 +50,7 @@ export default function ConfigEditor() { if (device === undefined) return null; return ( - + ); diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 2861a87059..32c313eba5 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -33,7 +33,7 @@ import { useDrive } from "~/queries/storage/config-model"; import * as driveUtils from "~/components/storage/utils/drive"; import * as partitionUtils from "~/components/storage/utils/partition"; import { contentDescription } from "~/components/storage/utils/device"; -import { DeviceMenu } from "~/components/storage/utils/configEditor"; +import { DeviceHeader, DeviceMenu } from "~/components/storage/utils/configEditor"; import { Icon } from "../layout"; import { MenuHeader } from "~/components/core"; import MenuDeviceDescription from "./MenuDeviceDescription"; @@ -445,17 +445,13 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s"); }; - - const [txt1, txt2] = text(drive).split("%s"); // TRANSLATORS: a disk drive const toggleAriaLabel = _("Drive"); return ( -

- {txt1} + - {txt2} -

+ ); }; diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx new file mode 100644 index 0000000000..ea8dd7bfe8 --- /dev/null +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -0,0 +1,95 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { sprintf } from "sprintf-js"; +import { configModel } from "~/api/storage/types"; +import { useVolumeGroup } from "~/queries/storage/config-model"; +import { DeviceHeader, DeviceMenu } from "~/components/storage/utils/configEditor"; +import { + Card, + CardBody, + CardHeader, + CardTitle, + Flex, + MenuItem, + MenuList, +} from "@patternfly/react-core"; + +import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +export type VolumeGroupEditorProps = { vg: configModel.VolumeGroup }; + +const RemoveVgOption = ({ vg }) => { + const volumeGroup = useVolumeGroup(vg.name); + const drive = volumeGroup.targetDrives[0]; + const desc = sprintf(_("The logical volumes will become partitions at %s"), drive.name); + + return ( + + {_("Do not create")} + + ); +}; + +const EditVgOption = () => { + return ( + + {_("Edit volume group")} + + ); +}; + +const VgMenu = ({ vg }) => { + return ( + {vg.name}}> + + + + + + ); +}; + +const VgHeader = ({ vg }: VolumeGroupEditorProps) => { + return ( + + + + ); +}; + +export default function VolumeGroupEditor({ vg }: VolumeGroupEditorProps) { + return ( + + + + + + + + + + + ); +} diff --git a/web/src/components/storage/utils/configEditor.tsx b/web/src/components/storage/utils/configEditor.tsx index 24e4aed0fe..bb60fccbbd 100644 --- a/web/src/components/storage/utils/configEditor.tsx +++ b/web/src/components/storage/utils/configEditor.tsx @@ -86,4 +86,16 @@ const DeviceMenu = ({ title, ariaLabel = undefined, activeItemId = undefined, ch ); }; -export { DeviceMenu }; +const DeviceHeader = ({ title, children }) => { + const [txt1, txt2] = title.split("%s"); + + return ( +

+ {txt1} + {children} + {txt2} +

+ ); +}; + +export { DeviceHeader, DeviceMenu }; diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 42fa0801f6..41fddcbf6d 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -53,6 +53,14 @@ function findDrive(model: configModel.Config, driveName: string): configModel.Dr return drives.find((d) => d.name === driveName); } +function findVolumeGroup( + model: configModel.Config, + vgName: string, +): configModel.VolumeGroup | undefined { + const vgs = model?.volumeGroups || []; + return vgs.find((g) => g.name === vgName); +} + function removeDrive(model: configModel.Config, driveName: string): configModel.Config { model.drives = model.drives.filter((d) => d.name !== driveName); return model; @@ -91,6 +99,15 @@ function driveHasPv(model: configModel.Config, driveAlias: string): boolean { return model.volumeGroups.flatMap((g) => g.targetDevices).includes(driveAlias); } +function volumeGroupTargetDrives( + model: configModel.Config, + vg: configModel.VolumeGroup, +): configModel.Drive[] { + const aliases = vg.targetDevices; + + return aliases.map((a) => model.drives.find((d) => d.alias === a)).filter((d) => d); +} + function allMountPaths(drive: configModel.Drive): string[] { if (drive.mountPath) return [drive.mountPath]; @@ -455,6 +472,21 @@ export function useDrive(name: string): DriveHook | null { }; } +export type VolumeGroupHook = { + targetDrives: configModel.Drive[]; +}; + +export function useVolumeGroup(name: string): VolumeGroupHook | null { + const model = useConfigModel(); + const vg = findVolumeGroup(model, name); + + if (vg === undefined) return null; + + return { + targetDrives: volumeGroupTargetDrives(model, vg), + }; +} + export type ModelHook = { model: configModel.Config; usedMountPaths: string[]; From c0764ff4846f79e913e7e65085fdfd5d219376fb Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Mon, 3 Mar 2025 16:50:38 +0000 Subject: [PATCH 013/103] web: Add list of logical volumes --- .../components/storage/DriveEditor.test.tsx | 3 +- web/src/components/storage/DriveEditor.tsx | 54 ++++--------------- .../components/storage/VolumeGroupEditor.tsx | 32 +++++++++-- .../components/storage/utils/configEditor.tsx | 45 +++++++++++++++- .../components/storage/utils/partition.tsx | 6 +-- .../components/storage/utils/volumeGroup.tsx | 46 ++++++++++++++++ 6 files changed, 134 insertions(+), 52 deletions(-) create mode 100644 web/src/components/storage/utils/volumeGroup.tsx diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index db24a8c89d..06c2e6b276 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -161,7 +161,6 @@ const drive2: ConfigModel.Drive = { }; const mockDeleteDrive = jest.fn(); -const mockGetPartition = jest.fn(); const mockDeletePartition = jest.fn(); jest.mock("~/queries/storage", () => ({ @@ -176,7 +175,7 @@ jest.mock("~/queries/storage/config-model", () => ({ useConfigModel: () => ({ drives: [drive1, drive2] }), useDrive: () => ({ delete: mockDeleteDrive, - getPartition: mockGetPartition, + getPartition: (path) => drive1.partitions.find((p) => p.mountPath === path), deletePartition: mockDeletePartition, }), })); diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 32c313eba5..3d592a2103 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -25,16 +25,18 @@ import { useNavigate, generatePath } from "react-router-dom"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; import { baseName, deviceLabel, formattedPath, SPACE_POLICIES } from "~/components/storage/utils"; -import { useAvailableDevices, useVolume } from "~/queries/storage"; +import { useAvailableDevices } from "~/queries/storage"; import { configModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDrive } from "~/queries/storage/config-model"; import * as driveUtils from "~/components/storage/utils/drive"; -import * as partitionUtils from "~/components/storage/utils/partition"; import { contentDescription } from "~/components/storage/utils/device"; -import { DeviceHeader, DeviceMenu } from "~/components/storage/utils/configEditor"; -import { Icon } from "../layout"; +import { + DeviceHeader, + DeviceMenu, + MountPathMenuItem, +} from "~/components/storage/utils/configEditor"; import { MenuHeader } from "~/components/core"; import MenuDeviceDescription from "./MenuDeviceDescription"; import { @@ -47,7 +49,6 @@ import { Label, Split, MenuItem, - MenuItemAction, MenuList, MenuGroup, } from "@patternfly/react-core"; @@ -481,48 +482,15 @@ const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { }; const PartitionMenuItem = ({ driveName, mountPath }) => { - const navigate = useNavigate(); const drive = useDrive(driveName); const partition = drive.getPartition(mountPath); - const volume = useVolume(mountPath); - const isRequired = volume.outline?.required || false; - const description = partition ? partitionUtils.typeWithSize(partition) : null; + const editPath = generatePath(PATHS.editPartition, { + id: baseName(driveName), + partitionId: encodeURIComponent(mountPath), + }); return ( - - } - actionId={`edit-${mountPath}`} - aria-label={`Edit ${mountPath}`} - onClick={() => - navigate( - generatePath(PATHS.editPartition, { - id: baseName(driveName), - partitionId: encodeURIComponent(mountPath), - }), - ) - } - /> - {!isRequired && ( - } - actionId={`delete-${mountPath}`} - aria-label={`Delete ${mountPath}`} - onClick={() => drive.deletePartition(mountPath)} - /> - )} - - } - > - {mountPath} - + ); }; diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index ea8dd7bfe8..139485ab47 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -24,8 +24,13 @@ import React from "react"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { configModel } from "~/api/storage/types"; +import { contentDescription } from "~/components/storage/utils/volumeGroup"; import { useVolumeGroup } from "~/queries/storage/config-model"; -import { DeviceHeader, DeviceMenu } from "~/components/storage/utils/configEditor"; +import { + DeviceHeader, + DeviceMenu, + MountPathMenuItem, +} from "~/components/storage/utils/configEditor"; import { Card, CardBody, @@ -72,13 +77,32 @@ const VgMenu = ({ vg }) => { }; const VgHeader = ({ vg }: VolumeGroupEditorProps) => { + const title = vg.logicalVolumes.length + ? _("Create LVM volume group %s") + : _("Empty LVM volume group %s"); + return ( - + ); }; +const LogicalVolumes = ({ vg }) => { + return ( + {contentDescription(vg)}} + ariaLabel={_("Logical volumes")} + > + + {vg.logicalVolumes.map((lv) => { + return ; + })} + + + ); +}; + export default function VolumeGroupEditor({ vg }: VolumeGroupEditorProps) { return ( @@ -88,7 +112,9 @@ export default function VolumeGroupEditor({ vg }: VolumeGroupEditorProps) { - + + + ); diff --git a/web/src/components/storage/utils/configEditor.tsx b/web/src/components/storage/utils/configEditor.tsx index bb60fccbbd..0fdafdf573 100644 --- a/web/src/components/storage/utils/configEditor.tsx +++ b/web/src/components/storage/utils/configEditor.tsx @@ -23,11 +23,16 @@ // @ts-check import React, { useId, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useVolume } from "~/queries/storage"; +import * as partitionUtils from "~/components/storage/utils/partition"; import { Icon } from "../../layout"; import { Menu, MenuContainer, MenuContent, + MenuItem, + MenuItemAction, MenuToggle, MenuToggleProps, MenuToggleElement, @@ -98,4 +103,42 @@ const DeviceHeader = ({ title, children }) => { ); }; -export { DeviceHeader, DeviceMenu }; +const MountPathMenuItem = ({ device, editPath = undefined, deleteFn = undefined }) => { + const navigate = useNavigate(); + const mountPath = device.mountPath; + const volume = useVolume(mountPath); + const isRequired = volume.outline?.required || false; + const description = device ? partitionUtils.typeWithSize(device) : null; + + return ( + + } + actionId={`edit-${mountPath}`} + aria-label={`Edit ${mountPath}`} + onClick={() => editPath && navigate(editPath)} + /> + {!isRequired && ( + } + actionId={`delete-${mountPath}`} + aria-label={`Delete ${mountPath}`} + onClick={() => deleteFn && deleteFn(mountPath)} + /> + )} + + } + > + {mountPath} + + ); +}; + +export { DeviceHeader, DeviceMenu, MountPathMenuItem }; diff --git a/web/src/components/storage/utils/partition.tsx b/web/src/components/storage/utils/partition.tsx index 8dff68878b..98545476a8 100644 --- a/web/src/components/storage/utils/partition.tsx +++ b/web/src/components/storage/utils/partition.tsx @@ -30,7 +30,7 @@ import { configModel } from "~/api/storage/types"; /** * String to identify the drive. */ -const pathWithSize = (partition: configModel.Partition): string => { +const pathWithSize = (partition: configModel.Partition | configModel.LogicalVolume): string => { return sprintf( // TRANSLATORS: %1$s is an already formatted mount path (eg. "/"), // %2$s is a size description (eg. at least 10 GiB) @@ -43,7 +43,7 @@ const pathWithSize = (partition: configModel.Partition): string => { /** * String to identify the type of partition to be created (or used). */ -const typeDescription = (partition: configModel.Partition): string => { +const typeDescription = (partition: configModel.Partition | configModel.LogicalVolume): string => { const fs = filesystemType(partition.filesystem); if (partition.name) { @@ -64,7 +64,7 @@ const typeDescription = (partition: configModel.Partition): string => { /** * Combination of {@link typeDescription} and the size of the target partition. */ -const typeWithSize = (partition: configModel.Partition): string => { +const typeWithSize = (partition: configModel.Partition | configModel.LogicalVolume): string => { return sprintf( // TRANSLATORS: %1$s is a filesystem type description (eg. "Btrfs with snapshots"), // %2$s is a description of the size or the size limits (eg. "at least 10 GiB") diff --git a/web/src/components/storage/utils/volumeGroup.tsx b/web/src/components/storage/utils/volumeGroup.tsx new file mode 100644 index 0000000000..7a4bbd363d --- /dev/null +++ b/web/src/components/storage/utils/volumeGroup.tsx @@ -0,0 +1,46 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { _, n_, formatList } from "~/i18n"; +import { configModel } from "~/api/storage/types"; +import { formattedPath } from "~/components/storage/utils"; +import { sprintf } from "sprintf-js"; + +const contentDescription = (vg: configModel.VolumeGroup): string => { + if (vg.logicalVolumes.length === 0) return _("No logical volumes are defined yet"); + + const mountPaths = vg.logicalVolumes.map((v) => formattedPath(v.mountPath)); + return sprintf( + // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a + // single mount point in the singular case). + n_( + "A new logical volume will be created for %s", + "New logical volumes will be created for %s", + mountPaths.length, + ), + formatList(mountPaths), + ); +}; + +export { contentDescription }; From 552352dce9fc5e353c1aeda108fab4f2cbf1bf6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 6 Mar 2025 12:59:47 +0000 Subject: [PATCH 014/103] web: add initial storage model hook --- web/src/hooks/storage/model.ts | 88 +++++++++++++++++++++++++ web/src/queries/storage/config-model.ts | 2 + web/src/types/storage/model.ts | 38 +++++++++++ 3 files changed, 128 insertions(+) create mode 100644 web/src/hooks/storage/model.ts create mode 100644 web/src/types/storage/model.ts diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts new file mode 100644 index 0000000000..be686feb2c --- /dev/null +++ b/web/src/hooks/storage/model.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { useQuery } from "@tanstack/react-query"; +import { configModelQuery } from "~/queries/storage/config-model"; +import * as apiModel from "~/api/storage/types/config-model"; +import * as model from "~/types/storage/model"; + +function findDrive(modelData: apiModel.Config, alias: string): apiModel.Drive | undefined { + return modelData.drives.find((d) => d.alias === alias); +} + +function buildDrive(driveData: apiModel.Drive): model.Drive { + return { ...driveData }; +} + +function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.LogicalVolume { + return { ...logicalVolumeData }; +} + +function buildVolumeGroup( + volumeGroupData: apiModel.VolumeGroup, + modelData: apiModel.Config, +): model.VolumeGroup { + const buildTargetDevices = (): model.Drive[] => { + const aliases = volumeGroupData.targetDevices || []; + return aliases + .map((a) => findDrive(modelData, a)) + .filter((d) => d) + .map(buildDrive); + }; + + const buildLogicalVolumes = (): model.LogicalVolume[] => { + const logicalVolumesData = volumeGroupData.logicalVolumes || []; + return logicalVolumesData.map(buildLogicalVolume); + }; + + return { + ...volumeGroupData, + targetDevices: buildTargetDevices(), + logicalVolumes: buildLogicalVolumes(), + }; +} + +function buildModel(modelData: apiModel.Config): model.Model { + const buildVolumeGroups = (): model.VolumeGroup[] => { + const volumeGroupsData = modelData.volumeGroups || []; + return volumeGroupsData.map((v) => buildVolumeGroup(v, modelData)); + }; + + return { + volumeGroups: buildVolumeGroups(), + }; +} + +function useModel(): model.Model | null { + const { data } = useQuery(configModelQuery); + return data ? buildModel(data) : null; +} + +function useVolumeGroup(name: string): model.VolumeGroup | null { + const model = useModel(); + const volumeGroup = model?.volumeGroups?.find((v) => v.name === name); + return volumeGroup || null; +} + +export default useModel; + +export { useVolumeGroup }; diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 41fddcbf6d..da8bdbb957 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -509,3 +509,5 @@ export function useModel(): ModelHook { unusedMountPaths: model ? unusedMountPaths(model, volumes) : [], }; } + +export { configModelQuery }; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts new file mode 100644 index 0000000000..4d3e4efdc5 --- /dev/null +++ b/web/src/types/storage/model.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 * as apiModel from "~/api/storage/types/config-model"; + +type Drive = apiModel.Drive; + +type LogicalVolume = apiModel.LogicalVolume; + +interface VolumeGroup extends Omit { + targetDevices: Drive[]; + logicalVolumes: LogicalVolume[]; +} + +type Model = { + volumeGroups: VolumeGroup[]; +}; + +export type { Model, Drive, VolumeGroup, LogicalVolume }; From 8555ca7a56be0e23dc8013b34bce730f7c091b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 6 Mar 2025 14:13:25 +0000 Subject: [PATCH 015/103] web: use model hook --- .../components/storage/VolumeGroupEditor.tsx | 28 ++++++++-------- .../components/storage/utils/volumeGroup.tsx | 4 +-- web/src/queries/storage/config-model.ts | 32 ------------------- 3 files changed, 17 insertions(+), 47 deletions(-) diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 139485ab47..1cdd49522e 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -23,9 +23,10 @@ import React from "react"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { configModel } from "~/api/storage/types"; +import * as apiModel from "~/api/storage/types/config-model"; +import * as model from "~/types/storage/model"; import { contentDescription } from "~/components/storage/utils/volumeGroup"; -import { useVolumeGroup } from "~/queries/storage/config-model"; +import { useVolumeGroup } from "~/hooks/storage/model"; import { DeviceHeader, DeviceMenu, @@ -43,12 +44,9 @@ import { import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; -export type VolumeGroupEditorProps = { vg: configModel.VolumeGroup }; - -const RemoveVgOption = ({ vg }) => { - const volumeGroup = useVolumeGroup(vg.name); - const drive = volumeGroup.targetDrives[0]; - const desc = sprintf(_("The logical volumes will become partitions at %s"), drive.name); +const RemoveVgOption = ({ vg }: { vg: model.VolumeGroup }) => { + const device = vg.targetDevices[0]; + const desc = sprintf(_("The logical volumes will become partitions at %s"), device.name); return ( @@ -65,7 +63,7 @@ const EditVgOption = () => { ); }; -const VgMenu = ({ vg }) => { +const VgMenu = ({ vg }: { vg: model.VolumeGroup }) => { return ( {vg.name}}> @@ -76,7 +74,7 @@ const VgMenu = ({ vg }) => { ); }; -const VgHeader = ({ vg }: VolumeGroupEditorProps) => { +const VgHeader = ({ vg }: { vg: model.VolumeGroup }) => { const title = vg.logicalVolumes.length ? _("Create LVM volume group %s") : _("Empty LVM volume group %s"); @@ -88,7 +86,7 @@ const VgHeader = ({ vg }: VolumeGroupEditorProps) => { ); }; -const LogicalVolumes = ({ vg }) => { +const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { return ( {contentDescription(vg)}} @@ -103,17 +101,21 @@ const LogicalVolumes = ({ vg }) => { ); }; +export type VolumeGroupEditorProps = { vg: apiModel.VolumeGroup }; + export default function VolumeGroupEditor({ vg }: VolumeGroupEditorProps) { + const volumeGroup = useVolumeGroup(vg.name); + return ( - + - + diff --git a/web/src/components/storage/utils/volumeGroup.tsx b/web/src/components/storage/utils/volumeGroup.tsx index 7a4bbd363d..bbb70adb0c 100644 --- a/web/src/components/storage/utils/volumeGroup.tsx +++ b/web/src/components/storage/utils/volumeGroup.tsx @@ -23,11 +23,11 @@ // @ts-check import { _, n_, formatList } from "~/i18n"; -import { configModel } from "~/api/storage/types"; +import * as model from "~/types/storage/model"; import { formattedPath } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; -const contentDescription = (vg: configModel.VolumeGroup): string => { +const contentDescription = (vg: model.VolumeGroup): string => { if (vg.logicalVolumes.length === 0) return _("No logical volumes are defined yet"); const mountPaths = vg.logicalVolumes.map((v) => formattedPath(v.mountPath)); diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index da8bdbb957..f951e5325b 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -53,14 +53,6 @@ function findDrive(model: configModel.Config, driveName: string): configModel.Dr return drives.find((d) => d.name === driveName); } -function findVolumeGroup( - model: configModel.Config, - vgName: string, -): configModel.VolumeGroup | undefined { - const vgs = model?.volumeGroups || []; - return vgs.find((g) => g.name === vgName); -} - function removeDrive(model: configModel.Config, driveName: string): configModel.Config { model.drives = model.drives.filter((d) => d.name !== driveName); return model; @@ -99,15 +91,6 @@ function driveHasPv(model: configModel.Config, driveAlias: string): boolean { return model.volumeGroups.flatMap((g) => g.targetDevices).includes(driveAlias); } -function volumeGroupTargetDrives( - model: configModel.Config, - vg: configModel.VolumeGroup, -): configModel.Drive[] { - const aliases = vg.targetDevices; - - return aliases.map((a) => model.drives.find((d) => d.alias === a)).filter((d) => d); -} - function allMountPaths(drive: configModel.Drive): string[] { if (drive.mountPath) return [drive.mountPath]; @@ -472,21 +455,6 @@ export function useDrive(name: string): DriveHook | null { }; } -export type VolumeGroupHook = { - targetDrives: configModel.Drive[]; -}; - -export function useVolumeGroup(name: string): VolumeGroupHook | null { - const model = useConfigModel(); - const vg = findVolumeGroup(model, name); - - if (vg === undefined) return null; - - return { - targetDrives: volumeGroupTargetDrives(model, vg), - }; -} - export type ModelHook = { model: configModel.Config; usedMountPaths: string[]; From 4c46f937e7d46958759b84a628b27473546f9169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 6 Mar 2025 15:03:18 +0000 Subject: [PATCH 016/103] web: add basic test for config editor --- .../components/storage/ConfigEditor.test.tsx | 112 ++++++++++++++++++ web/src/components/storage/ConfigEditor.tsx | 4 +- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 web/src/components/storage/ConfigEditor.test.tsx diff --git a/web/src/components/storage/ConfigEditor.test.tsx b/web/src/components/storage/ConfigEditor.test.tsx new file mode 100644 index 0000000000..8c2e3e90fd --- /dev/null +++ b/web/src/components/storage/ConfigEditor.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 ConfigEditor from "~/components/storage/ConfigEditor"; +import { StorageDevice } from "~/types/storage"; +import * as apiModel from "~/api/storage/types/config-model"; + +const disk: StorageDevice = { + sid: 60, + type: "disk", + isDrive: true, + description: "", + vendor: "Seagate", + model: "Unknown", + driver: ["ahci", "mmcblk"], + bus: "IDE", + name: "/dev/vda", + size: 1e6, +}; + +const mockUseDevices = jest.fn(); +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useDevices: () => mockUseDevices(), +})); + +const mockUseConfigModel = jest.fn(); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => mockUseConfigModel(), +})); + +jest.mock("./DriveEditor", () => () =>
drive editor
); +jest.mock("./VolumeGroupEditor", () => () =>
volume group editor
); + +beforeEach(() => { + mockUseDevices.mockReturnValue([disk]); +}); + +describe("if no drive is used for installation", () => { + beforeEach(() => { + const modelData: apiModel.Config = {}; + mockUseConfigModel.mockReturnValue(modelData); + }); + + it("does not render the drive editor", () => { + plainRender(); + expect(screen.queryByText("drive editor")).not.toBeInTheDocument(); + }); +}); + +describe("if a drive is used for installation", () => { + beforeEach(() => { + const modelData: apiModel.Config = { + drives: [{ name: "/dev/vda" }], + }; + mockUseConfigModel.mockReturnValue(modelData); + }); + + it("renders the drive editor", () => { + plainRender(); + expect(screen.queryByText("drive editor")).toBeInTheDocument(); + }); +}); + +describe("if no volume group is used for installation", () => { + beforeEach(() => { + const modelData: apiModel.Config = {}; + mockUseConfigModel.mockReturnValue(modelData); + }); + + it("does not render the volume group editor", () => { + plainRender(); + expect(screen.queryByText("volume group editor")).not.toBeInTheDocument(); + }); +}); + +describe("if a volume group is used for installation", () => { + beforeEach(() => { + const modelData: apiModel.Config = { + volumeGroups: [{ name: "/dev/system" }], + }; + mockUseConfigModel.mockReturnValue(modelData); + }); + + it("renders the drive editor", () => { + plainRender(); + expect(screen.queryByText("volume group editor")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index 548577a2a1..cd0335a13c 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -33,14 +33,14 @@ export default function ConfigEditor() { return ( - {model.volumeGroups.map((vg, i) => { + {model.volumeGroups?.map((vg, i) => { return ( ); })} - {model.drives.map((drive, i) => { + {model.drives?.map((drive, i) => { const device = devices.find((d) => d.name === drive.name); /** From e6cf582deed14d8c07d8e233a8df3c8ebb7ed170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 7 Mar 2025 11:32:14 +0000 Subject: [PATCH 017/103] storage: make global encryption to work with LVM --- .../to_model_conversions/config.rb | 32 ++- .../lib/agama/storage/config_solvers/boot.rb | 14 +- service/lib/agama/storage/configs/drive.rb | 7 +- .../agama/storage/configs/logical_volume.rb | 7 +- .../lib/agama/storage/configs/partition.rb | 9 +- .../agama/storage/configs/with_filesystem.rb | 39 +++ .../config_conversions/to_model_test.rb | 239 +++++++++++++----- 7 files changed, 255 insertions(+), 92 deletions(-) create mode 100644 service/lib/agama/storage/configs/with_filesystem.rb diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index 76f7f70db3..0b04adc599 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -88,19 +88,37 @@ def base_encryption root_encryption || first_encryption end - # Encryption from root partition. + # Encryption for root. # - # @return [Configs::Encryption, nil] nil if there is no encryption for root partition. + # @note If root is a logical volume, then the encryption of the automatically generated + # physical volumes is considered. Encryption from logical volumes is ignored. + # + # @return [Configs::Encryption, nil] nil if there is no encryption for root. def root_encryption - root_partition = config.partitions.find { |p| p.filesystem&.root? } - root_partition&.encryption + root_partition&.encryption || root_volume_group&.physical_volumes_encryption end - # Encryption from the first encrypted partition. + # Partition config for root. # - # @return [Configs::Encryption, nil] nil if there is no encrypted partition. + # @return [Configs::Partition, nil] + def root_partition + config.partitions.find(&:root?) + end + + # Volume group config containing a logical volume for root. + # + # @return [Configs::LogicalVolume, nil] + def root_volume_group + config.volume_groups.find { |v| v.logical_volumes.any?(&:root?) } + end + + # Encryption from the first encrypted partition or from the first volume group with + # automatically generated and encrypted physical volumes. + # + # @return [Configs::Encryption, nil] def first_encryption - config.partitions.find(&:encryption)&.encryption + config.partitions.find(&:encryption)&.encryption || + config.volume_groups.find(&:physical_volumes_encryption)&.physical_volumes_encryption end end end diff --git a/service/lib/agama/storage/config_solvers/boot.rb b/service/lib/agama/storage/config_solvers/boot.rb index ad17840237..40096a2e72 100644 --- a/service/lib/agama/storage/config_solvers/boot.rb +++ b/service/lib/agama/storage/config_solvers/boot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -82,7 +82,7 @@ def root_volume_group_config # @param config [Configs::Drive] # @return [Boolean] def root_drive_config?(config) - config.partitions.any? { |p| root_config?(p) } + config.partitions.any?(&:root?) end # Whether the given volume group config contains a root logical volume config. @@ -90,15 +90,7 @@ def root_drive_config?(config) # @param config [Configs::VolumeGroup] # @return [Boolean] def root_volume_group_config?(config) - config.logical_volumes.any? { |l| root_config?(l) } - end - - # Whether the given config if for the root filesystem. - # - # @param config [#filesystem] - # @return [Boolean] - def root_config?(config) - config.filesystem&.root? + config.logical_volumes.any?(&:root?) end # Whether the given drive config can be used to allocate physcial volumes. diff --git a/service/lib/agama/storage/configs/drive.rb b/service/lib/agama/storage/configs/drive.rb index 8c6c3a7bcf..edb3d2f265 100644 --- a/service/lib/agama/storage/configs/drive.rb +++ b/service/lib/agama/storage/configs/drive.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/configs/search" require "agama/storage/configs/with_alias" +require "agama/storage/configs/with_filesystem" require "agama/storage/configs/with_search" module Agama @@ -30,14 +31,12 @@ module Configs # system and that can be used as a regular disk. class Drive include WithAlias + include WithFilesystem include WithSearch # @return [Encryption, nil] attr_accessor :encryption - # @return [Filesystem, nil] - attr_accessor :filesystem - # @return [Y2Storage::PartitionTables::Type, nil] attr_accessor :ptable_type diff --git a/service/lib/agama/storage/configs/logical_volume.rb b/service/lib/agama/storage/configs/logical_volume.rb index 1f1696a5a1..65120bf269 100644 --- a/service/lib/agama/storage/configs/logical_volume.rb +++ b/service/lib/agama/storage/configs/logical_volume.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/configs/size" require "agama/storage/configs/with_alias" +require "agama/storage/configs/with_filesystem" module Agama module Storage @@ -28,6 +29,7 @@ module Configs # Section of the configuration representing a LVM logical volume. class LogicalVolume include WithAlias + include WithFilesystem # @return [String, nil] attr_accessor :name @@ -51,9 +53,6 @@ class LogicalVolume # @return [Encryption, nil] attr_accessor :encryption - # @return [Filesystem, nil] - attr_accessor :filesystem - def initialize @size = Size.new @pool = false diff --git a/service/lib/agama/storage/configs/partition.rb b/service/lib/agama/storage/configs/partition.rb index d615ade999..babbfd0bf7 100644 --- a/service/lib/agama/storage/configs/partition.rb +++ b/service/lib/agama/storage/configs/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,8 +20,9 @@ # find current contact information at www.suse.com. require "agama/storage/configs/size" -require "agama/storage/configs/with_search" require "agama/storage/configs/with_alias" +require "agama/storage/configs/with_filesystem" +require "agama/storage/configs/with_search" module Agama module Storage @@ -49,6 +50,7 @@ def self.new_for_shrink_any_if_needed end include WithAlias + include WithFilesystem include WithSearch # @return [Boolean] @@ -68,9 +70,6 @@ def self.new_for_shrink_any_if_needed # @return [Encryption, nil] attr_accessor :encryption - # @return [Filesystem, nil] - attr_accessor :filesystem - def initialize @size = Size.new @delete = false diff --git a/service/lib/agama/storage/configs/with_filesystem.rb b/service/lib/agama/storage/configs/with_filesystem.rb new file mode 100644 index 0000000000..2f48158a0a --- /dev/null +++ b/service/lib/agama/storage/configs/with_filesystem.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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. + +module Agama + module Storage + module Configs + # Mixin for configs with filesystem. + module WithFilesystem + # @return [Filesystem, nil] + attr_accessor :filesystem + + # Whether the config is for the root filesystem. + # + # @return [Boolean] + def root? + filesystem&.root? + end + end + end + end +end diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index db7317faf9..76661cb653 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -745,87 +745,204 @@ end end - context "if the root partition is encrypted" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { - filesystem: { path: "/" }, - encryption: { - luks1: { password: "12345" } + context "for the global encryption" do + context "if the root partition is encrypted" do + let(:config_json) do + { + drives: [ + { + alias: "vda", + partitions: [ + { + filesystem: { path: "/" }, + encryption: { + luks1: { password: "12345" } + } } - } - ] + ] + } + ], + volumeGroups: [ + { + name: "test", + physicalVolumes: [ + { + generate: { + targetDevices: ["vda"], + encryption: { + luks2: { password: "54321" } + } + } + } + ], + logicalVolumes: [ + { filesystem: { path: "/home" } } + ] + } + ] + } + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks1", + password: "12345" } - ] - } + ) + end end - it "generates the expected JSON for 'encryption'" do - encryption_model = subject.convert[:encryption] - - expect(encryption_model).to eq( + context "if there is a root logical volume" do + let(:config_json) do { - method: "luks1", - password: "12345" + drives: [ + { + alias: "vda", + partitions: [ + { + filesystem: { path: "/home" }, + encryption: { + luks1: { password: "12345" } + } + } + ] + } + ], + volumeGroups: [ + { + name: "test", + physicalVolumes: physicalVolumes, + logicalVolumes: [ + { filesystem: { path: "/" } } + ] + } + ] } - ) - end - end + end - context "if the root partition is not encrypted but other partition is encrypted" do - let(:config_json) do - { - drives: [ - { - partitions: [ - { - filesystem: { path: "/" } - }, - { - encryption: { - luks1: { password: "12345" } + context "and the volume group has automatically generated and encrypted physical volumes" do + let(:physicalVolumes) do + [ + { + generate: { + targetDevices: ["vda"], + encryption: { + luks2: { password: "54321" } } } - ] - } - ] - } - end + } + ] + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] - it "generates the expected JSON for 'encryption'" do - encryption_model = subject.convert[:encryption] + expect(encryption_model).to eq( + { + method: "luks2", + password: "54321" + } + ) + end + end + end - expect(encryption_model).to eq( + context "if there is no encryption for root" do + let(:config_json) do { - method: "luks1", - password: "12345" + drives: [ + { + alias: "vda", + partitions: [ + { + filesystem: { path: "/" } + }, + { + filesystem: { path: "/home" }, + encryption: encryption + } + ] + } + ], + volumeGroups: [ + { + name: "test", + physicalVolumes: physicalVolumes, + logicalVolumes: [ + { filesystem: { path: "swap" } } + ] + } + ] } - ) - end - end + end - context "if there is not an encrypted partition" do - let(:config_json) do - { - drives: [ + let(:physicalVolumes) do + [ { - partitions: [ - { - filesystem: { path: "/" } + generate: { + targetDevices: ["vda"], + encryption: { + luks2: { password: "54321" } } - ] + } } ] - } - end + end - it "generates the expected JSON for 'encryption'" do - encryption_model = subject.convert[:encryption] + context "and there is an encrypted partition" do + let(:encryption) do + { + luks1: { password: "12345" } + } + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks1", + password: "12345" + } + ) + end + end + + context "and there is no encrypted partition" do + let(:encryption) { nil } + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks2", + password: "54321" + } + ) + end - expect(encryption_model).to be_nil + context "if there is no automatically generated and encrypted physical volumes" do + let(:physicalVolumes) do + [ + { + generate: { + targetDevices: ["vda"] + } + } + ] + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to be_nil + end + end + end end end From 8668a57a6c24d476d57302809589bf0bc82f6521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 7 Mar 2025 11:45:41 +0000 Subject: [PATCH 018/103] service: changelog --- service/package/rubygem-agama-yast.changes | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index a7d674c8b1..9b730ece93 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Mar 7 11:37:47 UTC 2025 - José Iván López González + +- Export LVM config to the storage model and add more checks for + the model supported features (gh#agama-project/agama#2089). + ------------------------------------------------------------------- Wed Mar 5 14:50:04 UTC 2025 - Ladislav Slezák @@ -15,7 +21,7 @@ Wed Mar 5 08:09:28 UTC 2025 - Michal Filka - introduced boot_strategy into storage section of product definition yaml file. It allows to control what boot strategy - will be proposed by storage. Currently works only for BLS. + will be proposed by storage. Currently works only for BLS. ------------------------------------------------------------------- Fri Feb 28 13:03:11 UTC 2025 - Imobach Gonzalez Sosa From 0dd659f2c080feda465f70f0dfb596421e8560ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 7 Mar 2025 11:46:20 +0000 Subject: [PATCH 019/103] rust: changelog --- rust/package/agama.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 5adec11d4f..d99d6d0cea 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Fri Mar 7 11:40:56 UTC 2025 - José Iván López González + +- Extend storage model schema with LVM (gh#agama-project/agama#2089). + ------------------------------------------------------------------- Thu Mar 6 12:51:42 UTC 2025 - Imobach Gonzalez Sosa From 7d64c00a82e1be976d8245866cc936ea562d9772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 7 Mar 2025 11:46:40 +0000 Subject: [PATCH 020/103] web: changelog --- web/package/agama-web-ui.changes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 8c1d32cd12..8273339a77 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Fri Mar 7 11:42:33 UTC 2025 - José Iván López González + +- Show LVM config in the storage UI (gh#agama-project/agama#2089). + ------------------------------------------------------------------- Thu Mar 6 08:06:17 UTC 2025 - David Diaz From 2dfb8d240933ca69642a3f2f0247bef746a7b608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 10 Mar 2025 09:18:51 +0000 Subject: [PATCH 021/103] web: rename utils file --- web/src/components/storage/VolumeGroupEditor.tsx | 2 +- .../storage/utils/{volumeGroup.tsx => volume-group.tsx} | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) rename web/src/components/storage/utils/{volumeGroup.tsx => volume-group.tsx} (99%) diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 1cdd49522e..d8568d1968 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -25,7 +25,7 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import * as apiModel from "~/api/storage/types/config-model"; import * as model from "~/types/storage/model"; -import { contentDescription } from "~/components/storage/utils/volumeGroup"; +import { contentDescription } from "~/components/storage/utils/volume-group"; import { useVolumeGroup } from "~/hooks/storage/model"; import { DeviceHeader, diff --git a/web/src/components/storage/utils/volumeGroup.tsx b/web/src/components/storage/utils/volume-group.tsx similarity index 99% rename from web/src/components/storage/utils/volumeGroup.tsx rename to web/src/components/storage/utils/volume-group.tsx index bbb70adb0c..3c58409d71 100644 --- a/web/src/components/storage/utils/volumeGroup.tsx +++ b/web/src/components/storage/utils/volume-group.tsx @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import { _, n_, formatList } from "~/i18n"; import * as model from "~/types/storage/model"; import { formattedPath } from "~/components/storage/utils"; From 44a7bb67b1a7b7e29a9dbdfd0d953e20b58c07ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 10 Mar 2025 09:19:33 +0000 Subject: [PATCH 022/103] web: extract components --- web/src/components/storage/DeviceHeader.tsx | 39 +++++++++ .../configEditor.tsx => DeviceMenu.tsx} | 80 ++++--------------- web/src/components/storage/DriveEditor.tsx | 8 +- .../components/storage/MountPathMenuItem.tsx | 77 ++++++++++++++++++ .../components/storage/VolumeGroupEditor.tsx | 8 +- 5 files changed, 139 insertions(+), 73 deletions(-) create mode 100644 web/src/components/storage/DeviceHeader.tsx rename web/src/components/storage/{utils/configEditor.tsx => DeviceMenu.tsx} (54%) create mode 100644 web/src/components/storage/MountPathMenuItem.tsx diff --git a/web/src/components/storage/DeviceHeader.tsx b/web/src/components/storage/DeviceHeader.tsx new file mode 100644 index 0000000000..f0a9a78bf6 --- /dev/null +++ b/web/src/components/storage/DeviceHeader.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { Content } from "@patternfly/react-core"; + +export type DeviceHeaderProps = { + title: string; + children: React.ReactNode; +}; + +export default function DeviceHeader({ title, children }: DeviceHeaderProps) { + const [txt1, txt2] = title.split("%s"); + + return ( + + {txt1} {children} {txt2} + + ); +} diff --git a/web/src/components/storage/utils/configEditor.tsx b/web/src/components/storage/DeviceMenu.tsx similarity index 54% rename from web/src/components/storage/utils/configEditor.tsx rename to web/src/components/storage/DeviceMenu.tsx index 0fdafdf573..6492b9ddbf 100644 --- a/web/src/components/storage/utils/configEditor.tsx +++ b/web/src/components/storage/DeviceMenu.tsx @@ -20,25 +20,19 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useId, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useVolume } from "~/queries/storage"; -import * as partitionUtils from "~/components/storage/utils/partition"; -import { Icon } from "../../layout"; +import { Icon } from "~/components/layout"; import { Menu, + MenuProps, MenuContainer, MenuContent, - MenuItem, - MenuItemAction, MenuToggle, MenuToggleProps, MenuToggleElement, } from "@patternfly/react-core"; -export const InlineMenuToggle = React.forwardRef( +const InlineMenuToggle = React.forwardRef( (props: MenuToggleProps, ref: React.Ref) => ( } @@ -50,7 +44,19 @@ export const InlineMenuToggle = React.forwardRef( ), ); -const DeviceMenu = ({ title, ariaLabel = undefined, activeItemId = undefined, children }) => { +export type DeviceMenuProps = { + title: string | React.ReactNode; + ariaLabel?: string; + activeItemId?: MenuProps["activeItemId"]; + children: React.ReactNode; +}; + +export default function DeviceMenu({ + title, + ariaLabel = undefined, + activeItemId = undefined, + children, +}: DeviceMenuProps) { const menuId = useId(); const menuRef = useRef(); const toggleMenuRef = useRef(); @@ -89,56 +95,4 @@ const DeviceMenu = ({ title, ariaLabel = undefined, activeItemId = undefined, ch popperProps={{ appendTo: document.body }} /> ); -}; - -const DeviceHeader = ({ title, children }) => { - const [txt1, txt2] = title.split("%s"); - - return ( -

- {txt1} - {children} - {txt2} -

- ); -}; - -const MountPathMenuItem = ({ device, editPath = undefined, deleteFn = undefined }) => { - const navigate = useNavigate(); - const mountPath = device.mountPath; - const volume = useVolume(mountPath); - const isRequired = volume.outline?.required || false; - const description = device ? partitionUtils.typeWithSize(device) : null; - - return ( - - } - actionId={`edit-${mountPath}`} - aria-label={`Edit ${mountPath}`} - onClick={() => editPath && navigate(editPath)} - /> - {!isRequired && ( - } - actionId={`delete-${mountPath}`} - aria-label={`Delete ${mountPath}`} - onClick={() => deleteFn && deleteFn(mountPath)} - /> - )} - - } - > - {mountPath} - - ); -}; - -export { DeviceHeader, DeviceMenu, MountPathMenuItem }; +} diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 3d592a2103..a38baf1175 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -32,11 +32,9 @@ import { STORAGE as PATHS } from "~/routes/paths"; import { useDrive } from "~/queries/storage/config-model"; import * as driveUtils from "~/components/storage/utils/drive"; import { contentDescription } from "~/components/storage/utils/device"; -import { - DeviceHeader, - DeviceMenu, - MountPathMenuItem, -} from "~/components/storage/utils/configEditor"; +import DeviceMenu from "~/components/storage/DeviceMenu"; +import DeviceHeader from "~/components/storage/DeviceHeader"; +import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; import { MenuHeader } from "~/components/core"; import MenuDeviceDescription from "./MenuDeviceDescription"; import { diff --git a/web/src/components/storage/MountPathMenuItem.tsx b/web/src/components/storage/MountPathMenuItem.tsx new file mode 100644 index 0000000000..6f8ef3ab29 --- /dev/null +++ b/web/src/components/storage/MountPathMenuItem.tsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { useNavigate } from "react-router-dom"; +import { useVolume } from "~/queries/storage"; +import * as partitionUtils from "~/components/storage/utils/partition"; +import { Icon } from "~/components/layout"; +import { MenuItem, MenuItemAction } from "@patternfly/react-core"; +import * as apiModel from "~/api/storage/types/config-model"; + +export type MountPathMenuItemProps = { + device: apiModel.Partition | apiModel.LogicalVolume; + editPath?: string; + deleteFn?: (mountPath: string) => void; +}; + +export default function MountPathMenuItem({ + device, + editPath = undefined, + deleteFn = undefined, +}: MountPathMenuItemProps) { + const navigate = useNavigate(); + const mountPath = device.mountPath; + const volume = useVolume(mountPath); + const isRequired = volume.outline?.required || false; + const description = device ? partitionUtils.typeWithSize(device) : null; + + return ( + + } + actionId={`edit-${mountPath}`} + aria-label={`Edit ${mountPath}`} + onClick={() => editPath && navigate(editPath)} + /> + {!isRequired && ( + } + actionId={`delete-${mountPath}`} + aria-label={`Delete ${mountPath}`} + onClick={() => deleteFn && deleteFn(mountPath)} + /> + )} + + } + > + {mountPath} + + ); +} diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index d8568d1968..4cf7c1800c 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -27,11 +27,9 @@ import * as apiModel from "~/api/storage/types/config-model"; import * as model from "~/types/storage/model"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { useVolumeGroup } from "~/hooks/storage/model"; -import { - DeviceHeader, - DeviceMenu, - MountPathMenuItem, -} from "~/components/storage/utils/configEditor"; +import DeviceMenu from "~/components/storage/DeviceMenu"; +import DeviceHeader from "~/components/storage/DeviceHeader"; +import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; import { Card, CardBody, From e98047dc9e085118c6fb9e839b3d09aa6afbf0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Mar 2025 14:26:55 +0000 Subject: [PATCH 023/103] fix(web): enable auto-width for popups without a variant Popups now automatically adjust their width when no variant is provided, preventing them from being unnecessarily wide with short content. It also removes deprecated size properties. --- web/src/components/core/Popup.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index 242defe744..ab2a6e3353 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -42,10 +42,6 @@ export type PopupProps = { title?: ModalHeaderProps["title"]; /** Extra content to be placed in the header after the title */ titleAddon?: React.ReactNode; - /** The block/height size for the dialog. Default is "auto". */ - blockSize?: "auto" | "small" | "medium" | "large"; - /** The inline/width size for the dialog. Default is "medium". */ - inlineSize?: "auto" | "small" | "medium" | "large"; /** Whether it should display a loading indicator instead of the requested content. */ isLoading?: boolean; /** Text displayed when `isLoading` is set to `true` */ @@ -213,10 +209,6 @@ const Popup = ({ isLoading = false, // TRANSLATORS: progress message loadingText = _("Loading data..."), - inlineSize = "medium", - blockSize = "auto", - variant = "medium", - className = "", children, ...props }: PopupProps) => { @@ -230,10 +222,9 @@ const Popup = ({ return ( /** @ts-ignore */ From 34b3f69877ae816996efdfc0f7cbccaf99175576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Mar 2025 14:29:41 +0000 Subject: [PATCH 024/103] fix(web): improve login page layout Adjusted the layout of the form to improve its visual "flow and rhythm". --- web/src/components/core/LoginPage.tsx | 71 +++++++++++++++------------ 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/web/src/components/core/LoginPage.tsx b/web/src/components/core/LoginPage.tsx index ab9a13bf6b..50e98c3290 100644 --- a/web/src/components/core/LoginPage.tsx +++ b/web/src/components/core/LoginPage.tsx @@ -23,16 +23,19 @@ import React, { useState } from "react"; import { Navigate } from "react-router-dom"; import { + ActionGroup, + Alert, Bullseye, Button, Content, - Divider, Flex, Form, FormGroup, - Stack, + FormHelperText, + HelperText, + HelperTextItem, } from "@patternfly/react-core"; -import { FormValidationError, Page, PasswordInput } from "~/components/core"; +import { Page, PasswordInput } from "~/components/core"; import { AuthErrors, useAuth } from "~/context/auth"; import { _ } from "~/i18n"; import shadowUtils from "@patternfly/react-styles/css/utilities/BoxShadow/box-shadow"; @@ -82,13 +85,8 @@ user privileges.", - + hasHeaderDivider + title={ -
- - {rootExplanationStart} {rootUser} {rootExplanationEnd} - - - {_("Please, provide its password to log in to the system.")} - -
- -
- - setPassword(v)} - /> - + } + pfCardProps={{ + isCompact: false, + isFullHeight: false, + className: shadowUtils.boxShadowMd, + }} + > + + {error && } - {error && } + + setPassword(v)} + /> + + + + {rootExplanationStart} {rootUser} {rootExplanationEnd} + + + {_("Please, provide its password to log in to the system.")} + + + + + - -
+ +
From 4149d43b923d01e4e38f01dd5b2f7ebc2c01a0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Mar 2025 15:45:13 +0000 Subject: [PATCH 025/103] fix(web): accept a ReactNode as Page/Section#title The title can now be any valid ReactNode, not just a string. --- web/src/components/core/Page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 98b1739eb0..6929f194b5 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -54,7 +54,7 @@ import { _ } from "~/i18n"; */ type SectionProps = { /** The section title */ - title?: string; + title?: React.ReactNode; /** The value used for accessible label */ "aria-label"?: string; /** Elements to be rendered in the section footer */ From ad1bc9e2e7d80c6fb652156fee40d54c342c01c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 6 Mar 2025 13:17:00 +0000 Subject: [PATCH 026/103] fix(web): prevent PF/FormGroup from filling full width Override PatternFly styles to ensure that a FormGroup aligns to the start of its flex container instead of stretching to fill all available space by default. https://developer.mozilla.org/en-US/docs/Web/CSS/justify-content#normal --- web/src/assets/styles/index.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index c271ff2fb2..893a0ed804 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -361,6 +361,10 @@ label.pf-m-disabled + .pf-v6-c-check__description { --pf-v6-c-list--m-inline--ColumnGap: var(--pf-t--global--spacer--md); } +.pf-v6-c-form__group { + justify-self: flex-start; +} + // Some utilities not found at PF .w-14ch { inline-size: 14ch; From 9192daf2567e0be6b77727acb5d4058a96c8c0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 6 Mar 2025 15:21:22 +0000 Subject: [PATCH 027/103] fix(web): remove obsolete layout form hacks Among others (out of the scope of the feature this commit belongs), storage/EncryptionSettingsPage form no longer requires individual tweaks to prevent fields from stretching to match the widest one. --- .../storage/EncryptionSettingsPage.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx index 09b0c2d864..bb4e938f34 100644 --- a/web/src/components/storage/EncryptionSettingsPage.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -22,12 +22,11 @@ import React, { useState, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import { ActionGroup, Alert, Checkbox, Content, Form, Stack, Switch } from "@patternfly/react-core"; +import { ActionGroup, Alert, Checkbox, Content, Form, Switch } from "@patternfly/react-core"; import { Page, PasswordAndConfirmationInput } from "~/components/core"; import { useEncryptionMethods } from "~/queries/storage"; import { useEncryption } from "~/queries/storage/config-model"; import { EncryptionMethod } from "~/api/storage/types/config-model"; -import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing"; import { isEmpty } from "~/utils"; import { _ } from "~/i18n"; @@ -113,7 +112,7 @@ at the new file systems, including data, programs, and system files.", -
+ {errors.length > 0 && ( {errors.map((e, i) => ( @@ -126,19 +125,16 @@ at the new file systems, including data, programs, and system files.", isChecked={isEnabled} onChange={() => setIsEnabled(!isEnabled)} /> - - - + {isTpmAvailable && ( Date: Thu, 6 Mar 2025 15:29:26 +0000 Subject: [PATCH 028/103] feat(web): override checkboxes size To make them a bit more bigger. --- web/src/assets/styles/index.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 893a0ed804..7529be7e40 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -373,3 +373,8 @@ label.pf-m-disabled + .pf-v6-c-check__description { div[aria-live]:empty { display: none; } + +input[type="checkbox"] { + inline-size: var(--pf-t--global--font--size--md); + block-size: var(--pf-t--global--font--size--md); +} From f38e90ede1ae00f881537ee96adba2ceb69fd2b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 6 Mar 2025 15:30:05 +0000 Subject: [PATCH 029/103] feat(web): overrides some PF/Checkbox styles To adjust some gaps and centering align the checkbox with its label. --- web/src/assets/styles/index.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 7529be7e40..17af1de231 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -365,6 +365,14 @@ label.pf-m-disabled + .pf-v6-c-check__description { justify-self: flex-start; } +.pf-v6-c-check { + row-gap: var(--pf-t--global--spacer--xs); + + input[type="checkbox"] { + align-self: center; + } +} + // Some utilities not found at PF .w-14ch { inline-size: 14ch; From 1b4275b6cf1590f58d0ab1cb545c8d13cd74ad12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 6 Mar 2025 15:39:55 +0000 Subject: [PATCH 030/103] feat(web): add non-functional version of LVM form Apart form the logic to make it work, the extend size is missing too. --- web/src/components/storage/LvmPage.tsx | 130 +++++++++++++++++++++++++ web/src/routes/paths.ts | 3 + web/src/routes/storage.tsx | 5 + 3 files changed, 138 insertions(+) create mode 100644 web/src/components/storage/LvmPage.tsx diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx new file mode 100644 index 0000000000..691ddda9d6 --- /dev/null +++ b/web/src/components/storage/LvmPage.tsx @@ -0,0 +1,130 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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, { useState } from "react"; +import { + ActionGroup, + Checkbox, + Content, + Flex, + Form, + FormGroup, + Gallery, + Label, + TextInput, +} from "@patternfly/react-core"; +import { Page, SubtleContent } from "~/components/core"; +import { useAvailableDevices } from "~/queries/storage"; +import { deviceLabel } from "./utils"; +import { contentDescription, filesystemLabels, typeDescription } from "./utils/device"; +import { _ } from "~/i18n"; + +/** + * Form for creating a LVM volume group + */ +export default function LvmPage() { + const allDevices = useAvailableDevices(); + const [name, setName] = useState("system"); + // FIXME: decide what to store, if the device object or just its sid and type + // the state accordingly + const [selectedDevices, setSelectedDevices] = useState([]); + const [moveMountPoints, setMoveMountPoints] = useState(true); + + const updateName = (_, value) => setName(value); + const updateSelectedDevices = (value) => { + setSelectedDevices( + selectedDevices.includes(value) + ? selectedDevices.filter((d) => d !== value) + : [...selectedDevices, value], + ); + }; + + const onSubmit = () => { + console.log("TODO: implement the logic to be triggered when LVM form is submitted"); + }; + + return ( + + + {_("New Volume Group")} + {_("Create a new LVM volume group")} + + + + + + + + + + {_( + "The needed LVM physical volumes will be created as partitions on the chosen disks, based on the sizes of the logical volumes. If you select more than one disk, the physical volumes may be distributed along several disks.", + )} + + + {allDevices.map((device) => ( + {deviceLabel(device)}} + description={ + + + {typeDescription(device)} {contentDescription(device)} + + {filesystemLabels(device).map((s, i) => ( + + ))} + {device.systems.map((s, i) => ( + + ))} + + } + isChecked={selectedDevices.includes(device)} + onChange={() => updateSelectedDevices(device)} + /> + ))} + + + + setMoveMountPoints(v)} + /> + + + + + + + + + ); +} diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index b50f3f904b..d5d38d6ab0 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -77,6 +77,9 @@ const STORAGE = { editPartition: "/storage/:id/edit-partition/:partitionId", findSpace: "/storage/:id/find-space", iscsi: "/storage/iscsi", + lvm: { + create: "/storage/lvm/new", + }, dasd: "/storage/dasd", zfcp: { root: "/storage/zfcp", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index e49708c7ba..06e6de1e7d 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -30,6 +30,7 @@ import SpacePolicySelection from "~/components/storage/SpacePolicySelection"; import ProposalPage from "~/components/storage/ProposalPage"; import ISCSIPage from "~/components/storage/ISCSIPage"; import PartitionPage from "~/components/storage/PartitionPage"; +import LvmPage from "~/components/storage/LvmPage"; import ZFCPPage from "~/components/storage/zfcp/ZFCPPage"; import ZFCPDiskActivationPage from "~/components/storage/zfcp/ZFCPDiskActivationPage"; import DASDPage from "~/components/storage/dasd/DASDPage"; @@ -49,6 +50,10 @@ const routes = (): Route => ({ path: PATHS.bootDevice, element: , }, + { + path: PATHS.lvm.create, + element: , + }, { path: PATHS.encryption, element: , From 16b73f8d641d1e233bff4bc82d3cb72cdc18b59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 10 Mar 2025 14:34:54 +0000 Subject: [PATCH 031/103] web: recover styles --- web/src/assets/styles/index.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 17af1de231..8cdb560b93 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -373,6 +373,11 @@ label.pf-m-disabled + .pf-v6-c-check__description { } } +.pf-v6-c-list.pf-m-inline { + --pf-v6-c-list--m-inline--RowGap: var(--pf-t--global--spacer--sm); + --pf-v6-c-list--m-inline--ColumnGap: var(--pf-t--global--spacer--md); +} + // Some utilities not found at PF .w-14ch { inline-size: 14ch; From 9e9a64253fb45f840abe63ea9001229535213cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 10 Mar 2025 14:38:17 +0000 Subject: [PATCH 032/103] web: adapt storage paths --- web/src/components/storage/DriveEditor.test.tsx | 2 +- web/src/routes/paths.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index 06c2e6b276..3e2449c9d6 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -216,7 +216,7 @@ describe("PartitionMenuItem", () => { name: "Edit swap", }); await user.click(editSwapButton); - expect(mockNavigateFn).toHaveBeenCalledWith("/storage/sda/edit-partition/swap"); + expect(mockNavigateFn).toHaveBeenCalledWith("/storage/devices/sda/partitions/swap/edit"); }); }); diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index d5d38d6ab0..249b6c1b33 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -73,9 +73,9 @@ const STORAGE = { root: "/storage", bootDevice: "/storage/select-boot-device", encryption: "/storage/encryption", - addPartition: "/storage/:id/add-partition", - editPartition: "/storage/:id/edit-partition/:partitionId", - findSpace: "/storage/:id/find-space", + addPartition: "/storage/devices/:id/partitions/new", + editPartition: "/storage/devices/:id/partitions/:partitionId/edit", + findSpace: "/storage/devices/:id/space/edit", iscsi: "/storage/iscsi", lvm: { create: "/storage/lvm/new", From 1ca34bdd1e73b6d11c4b488b3d9a10cbfa0ff13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 11 Mar 2025 11:14:04 +0000 Subject: [PATCH 033/103] fix(storage): improve boot config solver --- .../lib/agama/storage/config_solvers/boot.rb | 76 ++++-- .../test/agama/storage/config_solver_test.rb | 251 +++++++++++------- 2 files changed, 209 insertions(+), 118 deletions(-) diff --git a/service/lib/agama/storage/config_solvers/boot.rb b/service/lib/agama/storage/config_solvers/boot.rb index 40096a2e72..b3106c0fbb 100644 --- a/service/lib/agama/storage/config_solvers/boot.rb +++ b/service/lib/agama/storage/config_solvers/boot.rb @@ -39,49 +39,53 @@ def solve(config) private # Finds a device for booting and sets its alias, if needed. + # + # A boot device cannot be automatically inferred in the following scenarios: + # * The root partition or logical volume is missing. + # * A disk is directly formated and mounted as root. + # * The volume group allocating the root logical volume uses whole drives as physical + # volumes. def solve_device_alias return unless config.boot.configure? && config.boot.device.default? - drive_config = root_drive_config - return unless drive_config + drive = root_drive + return unless drive - drive_config.ensure_alias - config.boot.device.device_alias = drive_config.alias + drive.ensure_alias + config.boot.device.device_alias = drive.alias end # Config of the drive used for allocating root, directly or inderectly. # # @return [Configs::Drive, nil] nil if the boot device cannot be inferred from the config. - def root_drive_config - drive_config = config.drives.find { |d| root_drive_config?(d) } - - drive_config || root_lvm_device_config + def root_drive + drive = config.drives.find { |d| root_drive?(d) } + drive || root_lvm_device end - # Config of the first drive used to allocate the root volume group config, if any. + # Config of the first drive used for allocating the physical volumes of the root volume + # group. # # @return [Configs::Drive, nil] - def root_lvm_device_config - volume_group_config = root_volume_group_config - return unless volume_group_config + def root_lvm_device + volume_group = root_volume_group + return unless volume_group - config.drives - .select { |d| candidate_for_physical_volumes?(d, volume_group_config) } - .first + first_target_lvm_device(volume_group) || first_physical_volume_device(volume_group) end # Config of the volume group containing the root logical volume, if any. # # @return [Configs::VolumeGroup, nil] - def root_volume_group_config - config.volume_groups.find { |v| root_volume_group_config?(v) } + def root_volume_group + config.volume_groups.find { |v| root_volume_group?(v) } end # Whether the given drive config contains a root partition config. # # @param config [Configs::Drive] # @return [Boolean] - def root_drive_config?(config) + def root_drive?(config) config.partitions.any?(&:root?) end @@ -89,23 +93,41 @@ def root_drive_config?(config) # # @param config [Configs::VolumeGroup] # @return [Boolean] - def root_volume_group_config?(config) + def root_volume_group?(config) config.logical_volumes.any?(&:root?) end - # Whether the given drive config can be used to allocate physcial volumes. + # Config of the first target device for creating physical volumes. # - # @param drive [Configs::Drive] - # @param volume_group [Configs::VolumeGroup] + # @param config [Configs::VolumeGroup] + # @return [Configs::Drive, nil] + def first_target_lvm_device(config) + device_alias = config.physical_volumes_devices.first + return unless device_alias + + self.config.drives.find { |d| d.alias?(device_alias) } + end + + # Config of the device of the first partition used as physical volume. # - # @return [Boolean] - def candidate_for_physical_volumes?(drive, volume_group) - return true if volume_group.physical_volumes_devices.any? { |d| drive.alias?(d) } + # @param config [Configs::VolumeGroup] + # @return [Configs::Drive, nil] + def first_physical_volume_device(config) + device_alias = config.physical_volumes.find { |p| partition_alias?(p) } + return unless device_alias - volume_group.physical_volumes.any? do |pv| - drive.partitions.any? { |p| p.alias?(pv) } + self.config.drives.find do |drive| + drive.partitions.any? { |p| p.alias?(device_alias) } end end + + # Whether there is a partition with the given alias. + # + # @param device_alias [String] + # @return [Boolean] + def partition_alias?(device_alias) + config.partitions.any? { |p| p.alias?(device_alias) } + end end end end diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index 3592f57e61..3c5094898c 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -118,133 +118,202 @@ context "if a config does not specify the boot device alias" do let(:config_json) do { - boot: { configure: true }, - drives: [ - { - alias: device_alias, - partitions: [ - { filesystem: { path: "/" } } - ] - } - ] + boot: { configure: configure_boot }, + drives: drives, + volumeGroups: volume_groups } end - let(:device_alias) { "root" } + let(:drives) { [] } + let(:volume_groups) { [] } - context "and the boot device is set to be the default" do - before do - config.boot.device.default = true - end + context "and boot is not set to be configured" do + let(:configure_boot) { false } - it "sets the alias of the root drive as boot device alias" do + it "does not set a boot device alias" do subject.solve(config) - boot = config.boot - expect(boot.device.device_alias).to eq("root") + expect(config.boot.device.device_alias).to be_nil end + end - context "and the root drive has no alias" do - let(:device_alias) { nil } + context "and boot is set to be configured" do + let(:configure_boot) { true } - it "sets an alias to the root drive" do - subject.solve(config) - drive = config.drives.first - expect(drive.alias).to_not be_nil + context "and the boot device is not set to default" do + before do + config.boot.device.default = false end - it "sets the alias of root drive as boot device alias" do + it "does not set a boot device alias" do subject.solve(config) - boot = config.boot - drive = config.drives.first - expect(boot.device.device_alias).to eq(drive.alias) + expect(config.boot.device.device_alias).to be_nil end + end - context "and root is over a logical volume" do - let(:scenario) { "disks.yaml" } + context "and the boot device is set to default" do + before do + config.boot.device.default = true + end - let(:config_json) do - { - boot: { configure: true }, - drives: [ - { - search: "/dev/vda", - alias: device_alias, - partitions: [ - { search: "/dev/vda2", alias: "pv" } - ] - }, - { search: "/dev/vdb", alias: "disk2" } - ], - volumeGroups: [ - { - physicalVolumes: ["disk2", "pv"], - logicalVolumes: [ - { filesystem: { path: "/" } } - ] - } - ] - } + context "and there is a root partition" do + let(:drives) do + [ + { + alias: device_alias, + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ] end - let(:device_alias) { "disk1" } + let(:device_alias) { "root" } - it "sets the alias of first partitioned pv drive as boot device alias" do + it "sets the alias of the root device as boot device alias" do subject.solve(config) - boot = config.boot - expect(boot.device.device_alias).to eq("disk1") + expect(config.boot.device.device_alias).to eq("root") end - context "and the drive has no alias" do + context "and the root device has no alias" do let(:device_alias) { nil } - it "sets an alias to the drive" do + it "sets an alias to the root device" do subject.solve(config) - drive = config.drives.find { |d| d.search.name == "/dev/vda" } + drive = config.drives.first expect(drive.alias).to_not be_nil end - it "sets the alias of the drive as boot device alias" do + it "sets the alias of the root device as boot device alias" do subject.solve(config) - boot = config.boot - drive = config.drives.find { |d| d.search.name == "/dev/vda" } - expect(boot.device.device_alias).to eq(drive.alias) + drive = config.drives.first + expect(config.boot.device.device_alias).to eq(drive.alias) end end end - end - end - context "and the boot device is not set to be the default" do - before do - config.boot.device.default = false - end + context "and there is a root logical volume" do + let(:volume_groups) do + [ + { + name: "system", + physicalVolumes: physical_volumes, + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + ] + end - it "does not set a boot device alias" do - subject.solve(config) - boot = config.boot - expect(boot.device.device_alias).to be_nil - end - end + context "and there are target devices for physical volumes" do + let(:drives) do + [ + { alias: "disk1" }, + { alias: "disk2" } + ] + end - context "and boot is not set to be configured" do - let(:config_json) do - { - boot: { configure: false }, - drives: [ - { - alias: "disk1", - partitions: [ - { filesystem: { path: "/" } } + let(:physical_volumes) { [{ generate: ["disk2", "disk1"] }] } + + it "sets the alias of the first target device as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk2") + end + end + + context "and there is no target device for physical volumes" do + let(:drives) do + [ + { + alias: "disk1", + partitions: [ + { alias: "p1" } + ] + }, + { + alias: device_alias, + partitions: [ + { alias: "p2" } + ] + }, + { alias: "disk3" } ] - } - ] - } - end + end - it "does not set a boot device alias" do - subject.solve(config) - boot = config.boot - expect(boot.device.device_alias).to be_nil + let(:device_alias) { "disk2" } + + context "and there is any partition as physical volume" do + let(:physical_volumes) { ["disk3", "p2", "p1"] } + + it "sets the alias of the device of the first partition as boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to eq("disk2") + end + + context "and the device of the first partition has no alias" do + let(:device_alias) { nil } + + it "sets an alias to the device" do + subject.solve(config) + drive = config.drives[1] + expect(drive.alias).to_not be_nil + end + + it "sets the alias of the device as boot device alias" do + subject.solve(config) + drive = config.drives[1] + expect(config.boot.device.device_alias).to eq(drive.alias) + end + end + end + + context "and there is no partition as physical volume" do + let(:physical_volumes) { ["disk1", "disk2"] } + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end + end + end + + context "and there is neither a partition nor a logical volume for root" do + let(:drives) do + [ + { + alias: "disk1", + partitions: [ + { + filesystem: { path: "/test1" } + } + ] + } + ] + end + + let(:volume_groups) do + [ + { + name: "system", + physicalVolumes: [{ generate: ["disk1"] }], + logicalVolumes: [ + { + filesystem: { path: "/test2" } + } + ] + } + ] + end + + it "does not set a boot device alias" do + subject.solve(config) + expect(config.boot.device.device_alias).to be_nil + end + end end end end From 590be11c3be91262ce14dd7e06269c5fd37dca0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 11 Mar 2025 11:15:54 +0000 Subject: [PATCH 034/103] fix(storage): adapt message of boot error - There are several reasons because of the boot device cannot be inferred (e.g., no root, root directly over a disk, etc). --- service/lib/agama/storage/config_checkers/boot.rb | 11 ++++++----- service/test/agama/storage/config_checker_test.rb | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/service/lib/agama/storage/config_checkers/boot.rb b/service/lib/agama/storage/config_checkers/boot.rb index 013385d79c..817e0819ad 100644 --- a/service/lib/agama/storage/config_checkers/boot.rb +++ b/service/lib/agama/storage/config_checkers/boot.rb @@ -51,16 +51,17 @@ def device_alias storage_config.boot.device.device_alias end + # Error if a boot device is required and unknown. + # + # This happens when the config solver is not able to infer a boot device, see + # {ConfigSolvers::Boot}. + # # @return [Issue, nil] def missing_alias_issue return unless configure? && device_alias.nil? - # Currently this situation only happens because the config solver was not able to find - # a device config containing a root volume. The message could become inaccurate if the - # solver logic changes. error( - _("The boot device cannot be automatically selected because there is no root (/) " \ - "file system") + _("The boot device cannot be automatically selected") ) end diff --git a/service/test/agama/storage/config_checker_test.rb b/service/test/agama/storage/config_checker_test.rb index 542423431a..656c0d1767 100644 --- a/service/test/agama/storage/config_checker_test.rb +++ b/service/test/agama/storage/config_checker_test.rb @@ -288,7 +288,7 @@ issue = issues.first expect(issue.error?).to eq(true) - expect(issue.description).to match(/there is no root \(\/\) file system/) + expect(issue.description).to match(/The boot device cannot be automatically selected/) end end From 5b1dfe7cd5b1c32f93c9d9f13ec9e31691a6271a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 11 Mar 2025 11:21:46 +0000 Subject: [PATCH 035/103] fix(storage): export device name to model if possible --- service/lib/agama/storage/config.rb | 11 +++------ .../to_model_conversions/boot_device.rb | 4 ++-- .../to_model_conversions/drive.rb | 7 +----- .../to_model_conversions/partition.rb | 4 ++-- .../lib/agama/storage/configs/with_search.rb | 17 +++++++++++++- service/lib/y2storage/agama_proposal.rb | 13 ++++++++--- .../config_conversions/to_model_test.rb | 23 +++++++++++++++++++ 7 files changed, 57 insertions(+), 22 deletions(-) diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index 3df7501307..8a7c75df45 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -60,16 +60,11 @@ def initialize @nfs_mounts = [] end - # Name of the device that will be used to boot the target system, if any. - # - # @note The config has to be solved. - # - # @return [String, nil] + # @return [Configs::Drive, nil] def boot_device return unless boot.configure? && boot.device.device_alias - boot_drive = drives.find { |d| d.alias?(boot.device.device_alias) } - boot_drive&.found_device&.name + drives.find { |d| d.alias?(boot.device.device_alias) } end # return [Array] diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/boot_device.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/boot_device.rb index 71e1283f01..23b68ac257 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/boot_device.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/boot_device.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -39,7 +39,7 @@ def initialize(config) def conversions { default: config.boot.device.default?, - name: config.boot_device + name: config.boot_device&.device_name } end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb index 7589812a49..e91d0c9a4f 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb @@ -45,7 +45,7 @@ def initialize(config) # @see Base#conversions def conversions { - name: convert_name, + name: config.device_name, alias: config.alias, mountPath: config.filesystem&.path, filesystem: convert_filesystem, @@ -54,11 +54,6 @@ def conversions partitions: convert_partitions } end - - # @return [String, nil] - def convert_name - config.found_device&.name || config.search&.name - end end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb index 04e7d4ceda..1f7d52cdf4 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -43,7 +43,7 @@ def initialize(config) # @see Base#conversions def conversions { - name: config.found_device&.name, + name: config.device_name, alias: config.alias, id: config.id&.to_s, mountPath: config.filesystem&.path, diff --git a/service/lib/agama/storage/configs/with_search.rb b/service/lib/agama/storage/configs/with_search.rb index fcf3f3cea1..d1c15da5b5 100644 --- a/service/lib/agama/storage/configs/with_search.rb +++ b/service/lib/agama/storage/configs/with_search.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -41,6 +41,21 @@ module WithSearch def found_device search&.device end + + # Name of the device. + # + # If the config is not solved, then it returns the searched name (if any). + # If the config is solved, then it returns either the name of the found device or the + # searched name. But the searched name is returned only if the device is not going to be + # created. + # + # @return [String, nil] + def device_name + device = found_device + return device.name if device + + search&.name unless search&.create_device? + end end end end diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb index ad367483d5..3b1303f821 100644 --- a/service/lib/y2storage/agama_proposal.rb +++ b/service/lib/y2storage/agama_proposal.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -179,7 +179,7 @@ def boot_partitions(devicegraph) checker = BootRequirementsChecker.new( devicegraph, planned_devices: planned_devices.mountable_devices, - boot_disk_name: config.boot_device + boot_disk_name: boot_device_name ) # NOTE: Should we try with :desired first? checker.needed_partitions(:min) @@ -210,7 +210,7 @@ def drives_with_empty_partition_table(devicegraph) # # @return [Array] names of partitionable devices def disks_for_clean - (drives_names + [config.boot_device]).compact.uniq + (drives_names + [boot_device_name]).compact.uniq end # Creates the planned devices on a given devicegraph @@ -221,6 +221,13 @@ def create_devices(devicegraph) result = devices_creator.populated_devicegraph(planned_devices, drives_names, space_maker) end + # Name of the boot device. + # + # @return [String, nil] + def boot_device_name + config.boot_device&.found_device&.name + end + # Names of all the devices that correspond to a drive from the config # # @return [Array] diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index 76661cb653..cc95c32981 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -214,6 +214,7 @@ expect(model_json[:partitions]).to eq( [ { + name: "/not/found", delete: false, deleteIfNeeded: false, resize: false, @@ -676,6 +677,10 @@ { search: "/dev/vdb", alias: "vdb" + }, + { + search: "/not/found", + alias: "not-found" } ] } @@ -715,6 +720,24 @@ } ) end + + context "and the boot device is not found" do + let(:device_alias) { "not-found" } + + it "generates the expected JSON for 'boot'" do + boot_model = subject.convert[:boot] + + expect(boot_model).to eq( + { + configure: true, + device: { + default: false, + name: "/not/found" + } + } + ) + end + end end end From f3aedabe680cc336dcea3b1451586649a801c9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 11 Mar 2025 13:50:26 +0000 Subject: [PATCH 036/103] refactor(storage): move methods to config --- service/lib/agama/storage/config.rb | 21 ++ .../to_model_conversions/config.rb | 9 +- .../lib/agama/storage/config_solvers/boot.rb | 46 +-- service/test/agama/storage/config_test.rb | 300 ++++++++++++++++++ 4 files changed, 338 insertions(+), 38 deletions(-) create mode 100644 service/test/agama/storage/config_test.rb diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index 8a7c75df45..9c5ab1027d 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -67,6 +67,27 @@ def boot_device drives.find { |d| d.alias?(boot.device.device_alias) } end + # Device config containing root. + # + # @return [Configs::Drive, Configs::VolumeGroup, nil] + def root_device + root_drive || root_volume_group + end + + # Drive config containing root. + # + # @return [Configs::Drive, nil] + def root_drive + drives.find { |d| d.root? || d.partitions.any?(&:root?) } + end + + # Volume group config containing a logical volume for root. + # + # @return [Configs::LogicalVolume, nil] + def root_volume_group + volume_groups.find { |v| v.logical_volumes.any?(&:root?) } + end + # return [Array] def partitions drives.flat_map(&:partitions) diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index 0b04adc599..6ae74abcbd 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -95,7 +95,7 @@ def base_encryption # # @return [Configs::Encryption, nil] nil if there is no encryption for root. def root_encryption - root_partition&.encryption || root_volume_group&.physical_volumes_encryption + root_partition&.encryption || config.root_volume_group&.physical_volumes_encryption end # Partition config for root. @@ -105,13 +105,6 @@ def root_partition config.partitions.find(&:root?) end - # Volume group config containing a logical volume for root. - # - # @return [Configs::LogicalVolume, nil] - def root_volume_group - config.volume_groups.find { |v| v.logical_volumes.any?(&:root?) } - end - # Encryption from the first encrypted partition or from the first volume group with # automatically generated and encrypted physical volumes. # diff --git a/service/lib/agama/storage/config_solvers/boot.rb b/service/lib/agama/storage/config_solvers/boot.rb index b3106c0fbb..9a6d83b81e 100644 --- a/service/lib/agama/storage/config_solvers/boot.rb +++ b/service/lib/agama/storage/config_solvers/boot.rb @@ -48,19 +48,28 @@ def solve(config) def solve_device_alias return unless config.boot.configure? && config.boot.device.default? - drive = root_drive - return unless drive + device = root_device + return unless device - drive.ensure_alias - config.boot.device.device_alias = drive.alias + device.ensure_alias + config.boot.device.device_alias = device.alias end # Config of the drive used for allocating root, directly or inderectly. # # @return [Configs::Drive, nil] nil if the boot device cannot be inferred from the config. + def root_device + root_drive || root_lvm_device + end + + # Config of the drive used for allocating the root partition. + # + # @return [Configs::Drive, nil] def root_drive - drive = config.drives.find { |d| root_drive?(d) } - drive || root_lvm_device + drive = config.root_drive + return unless drive&.partitions&.any? + + drive end # Config of the first drive used for allocating the physical volumes of the root volume @@ -68,35 +77,12 @@ def root_drive # # @return [Configs::Drive, nil] def root_lvm_device - volume_group = root_volume_group + volume_group = config.root_volume_group return unless volume_group first_target_lvm_device(volume_group) || first_physical_volume_device(volume_group) end - # Config of the volume group containing the root logical volume, if any. - # - # @return [Configs::VolumeGroup, nil] - def root_volume_group - config.volume_groups.find { |v| root_volume_group?(v) } - end - - # Whether the given drive config contains a root partition config. - # - # @param config [Configs::Drive] - # @return [Boolean] - def root_drive?(config) - config.partitions.any?(&:root?) - end - - # Whether the given volume group config contains a root logical volume config. - # - # @param config [Configs::VolumeGroup] - # @return [Boolean] - def root_volume_group?(config) - config.logical_volumes.any?(&:root?) - end - # Config of the first target device for creating physical volumes. # # @param config [Configs::VolumeGroup] diff --git a/service/test/agama/storage/config_test.rb b/service/test/agama/storage/config_test.rb new file mode 100644 index 0000000000..d04a9a26e2 --- /dev/null +++ b/service/test/agama/storage/config_test.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "./storage_helpers" +require "agama/config" +require "agama/storage/config_conversions" + +describe Agama::Storage::Config do + include Agama::RSpec::StorageHelpers + + subject do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + end + + describe "#boot_device" do + context "if boot config is not set to be configured" do + let(:config_json) do + { + boot: { + configure: false, + device: "boot" + }, + drives: [ + { alias: "boot" } + ] + } + end + + it "returns nil" do + expect(subject.boot_device).to be_nil + end + end + + context "if boot config is set to be configured" do + let(:config_json) do + { + boot: { + configure: true, + device: device_alias + }, + drives: [ + { + alias: "disk1", + partitions: [ + { alias: "part1" } + ] + } + ] + } + end + + context "and boot config has not a device alias" do + let(:device_alias) { nil } + + it "returns nil" do + expect(subject.boot_device).to be_nil + end + end + + context "and boot config has a device alias" do + context "and there is not a drive config with the boot device alias" do + let(:device_alias) { "part1" } + + it "returns nil" do + expect(subject.boot_device).to be_nil + end + end + + context "and there is a drive config with the boot device alias" do + let(:device_alias) { "disk1" } + + it "returns the drive config" do + expect(subject.boot_device).to be_a(Agama::Storage::Configs::Drive) + expect(subject.boot_device.alias).to eq("disk1") + end + end + end + end + end + + describe "#root_device" do + let(:config_json) do + { + drives: [ + { alias: "disk1" }, + drive + ], + volumeGroups: [ + { name: "vg1" }, + volume_group + ] + } + end + + let(:root_volume_group) do + { + name: "vg2", + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + end + + context "if there is a drive used for root" do + let(:drive) do + { + alias: "disk2", + filesystem: { path: "/" } + } + end + + let(:volume_group) { root_volume_group } + + it "returns the drive" do + expect(subject.root_device).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_device.alias).to eq("disk2") + end + end + + context "if there is a drive containing a partition used for root" do + let(:drive) do + { + alias: "disk2", + partitions: [ + { + alias: "part1", + filesystem: { path: "/" } + } + ] + } + end + + let(:volume_group) { root_volume_group } + + it "returns the drive" do + expect(subject.root_device).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_device.alias).to eq("disk2") + end + end + + context "if there is neither root drive nor root partition" do + let(:drive) { {} } + + context "and there is not a volume group containing a logical volume used for root" do + let(:volume_group) { {} } + + it "returns nil" do + expect(subject.root_device).to be_nil + end + end + + context "and there is a volume group containing a logical volume used for root" do + let(:volume_group) { root_volume_group } + + it "returns the volume group" do + expect(subject.root_device).to be_a(Agama::Storage::Configs::VolumeGroup) + expect(subject.root_device.name).to eq("vg2") + end + end + end + end + + describe "#root_drive" do + let(:config_json) do + { + drives: [ + { alias: "disk1" }, + drive + ], + volumeGroups: [ + { + name: "vg1", + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + ] + } + end + + context "if there is a drive used for root" do + let(:drive) do + { + alias: "disk2", + filesystem: { path: "/" } + } + end + + it "returns the drive" do + expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_drive.alias).to eq("disk2") + end + end + + context "if there is a drive containing a partition used for root" do + let(:drive) do + { + alias: "disk2", + partitions: [ + { + alias: "part1", + filesystem: { path: "/" } + } + ] + } + end + + it "returns the drive" do + expect(subject.root_drive).to be_a(Agama::Storage::Configs::Drive) + expect(subject.root_drive.alias).to eq("disk2") + end + end + + context "if there is neither root drive nor root partition" do + let(:drive) { {} } + + it "returns nil" do + expect(subject.root_drive).to be_nil + end + end + end + + describe "#root_volume_group" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + alias: "part1", + filesystem: { path: "/" } + } + ] + } + ], + volumeGroups: [ + { + name: "vg1", + logicalVolumes: [ + { + filesystem: { path: "/home" } + } + ] + }, + volume_group + ] + } + end + + context "if there is a volume group containing a logical volume used for root" do + let(:volume_group) do + { + name: "vg2", + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + end + + it "returns the volume group" do + expect(subject.root_volume_group).to be_a(Agama::Storage::Configs::VolumeGroup) + expect(subject.root_volume_group.name).to eq("vg2") + end + end + + context "if there is not a volume group containing a logical volume used for root" do + let(:volume_group) { { name: "vg2" } } + + it "returns nil" do + expect(subject.root_volume_group).to be_nil + end + end + end +end From 822b4c4868e4ac08e0826729239cd8b96cf52179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 12 Mar 2025 07:26:16 +0000 Subject: [PATCH 037/103] feat(storage): do not use aliases in the model - Use names for the target devices of a volume group. - Make the model agnostic to alias concept for now. --- .../agama-lib/share/storage.model.schema.json | 8 +- service/lib/agama/storage/config.rb | 7 ++ .../from_model_conversions/drive.rb | 1 - .../from_model_conversions/partition.rb | 1 - .../from_model_conversions/with_partitions.rb | 6 +- .../to_model_conversions/config.rb | 2 +- .../to_model_conversions/drive.rb | 1 - .../to_model_conversions/partition.rb | 1 - .../to_model_conversions/volume_group.rb | 19 +++- .../test/agama/dbus/storage/manager_test.rb | 3 - .../config_conversions/from_model_test.rb | 97 ++++--------------- .../config_conversions/to_model_test.rb | 62 +++--------- service/test/agama/storage/config_test.rb | 42 ++++++++ service/test/agama/storage/proposal_test.rb | 2 - 14 files changed, 103 insertions(+), 149 deletions(-) diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index 8eb6034084..6d7ee4de69 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -56,7 +56,6 @@ "required": ["name"], "properties": { "name": { "type": "string" }, - "alias": { "$ref": "#/$defs/alias" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, "spacePolicy": { "$ref": "#/$defs/spacePolicy" }, @@ -72,7 +71,6 @@ "additionalProperties": false, "properties": { "name": { "type": "string" }, - "alias": { "$ref": "#/$defs/alias" }, "id": { "$ref": "#/$defs/partitionId" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, @@ -92,7 +90,7 @@ "extentSize": { "type": "integer" }, "targetDevices": { "type": "array", - "items": { "$ref": "#/$defs/alias" } + "items": { "type": "string" } }, "logicalVolumes": { "type": "array", @@ -112,10 +110,6 @@ "stripeSize": { "type": "integer" } } }, - "alias": { - "description": "Alias used to reference a device.", - "type": "string" - }, "spacePolicy": { "enum": ["delete", "resize", "keep", "custom"] }, diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb index 9c5ab1027d..1d480bc700 100644 --- a/service/lib/agama/storage/config.rb +++ b/service/lib/agama/storage/config.rb @@ -88,6 +88,13 @@ def root_volume_group volume_groups.find { |v| v.logical_volumes.any?(&:root?) } end + # Drive with the given alias. + # + # @return [Configs::Drive, nil] + def drive(device_alias) + drives.find { |d| d.alias?(device_alias) } + end + # return [Array] def partitions drives.flat_map(&:partitions) diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb index 461ee6d9cb..e229d873b4 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb @@ -67,7 +67,6 @@ def default_config def conversions { search: convert_search, - alias: drive_model[:alias], filesystem: convert_filesystem, ptable_type: convert_ptable_type, partitions: convert_partitions(encryption_model) diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb index 577dc02345..ca53d87971 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb @@ -63,7 +63,6 @@ def default_config def conversions { search: convert_search, - alias: partition_model[:alias], encryption: convert_encryption, filesystem: convert_filesystem, size: convert_size, diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb index ec8e68fc34..175820ef1e 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb @@ -99,14 +99,12 @@ def resize_action_partition?(partition_model) partition_model[:size] && !partition_model.dig(:size, :default) end - # TODO: improve check by ensuring the alias is referenced by other device. + # TODO: improve check by ensuring the partition is referenced by other device. # # @param partition_model [Hash] # @return [Boolean] def any_usage?(partition_model) - partition_model[:mountPath] || - partition_model[:filesystem] || - partition_model[:alias] + partition_model[:mountPath] || partition_model[:filesystem] end # @return [Configs::Partition] diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index 6ae74abcbd..2527480fb2 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -69,7 +69,7 @@ def convert_drives # @return [Array] def convert_volume_groups - config.volume_groups.map { |v| ToModelConversions::VolumeGroup.new(v).convert } + config.volume_groups.map { |v| ToModelConversions::VolumeGroup.new(v, config).convert } end # @return [Array] diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb index e91d0c9a4f..78dbc52887 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb @@ -46,7 +46,6 @@ def initialize(config) def conversions { name: config.device_name, - alias: config.alias, mountPath: config.filesystem&.path, filesystem: convert_filesystem, spacePolicy: convert_space_policy, diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb index 1f7d52cdf4..8911a0a463 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb @@ -44,7 +44,6 @@ def initialize(config) def conversions { name: config.device_name, - alias: config.alias, id: config.id&.to_s, mountPath: config.filesystem&.path, filesystem: convert_filesystem, diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb index ea66c7cb1f..19fbf87728 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb @@ -31,23 +31,38 @@ class VolumeGroup < Base include WithFilesystem # @param config [Configs::VolumeGroup] - def initialize(config) + # @param storage_config [Storage::Config] + def initialize(config, storage_config) super() @config = config + @storage_config = storage_config end private + # @return [Storage::Config] + attr_reader :storage_config + # @see Base#conversions def conversions { name: config.name, extentSize: config.extent_size&.to_i, - targetDevices: config.physical_volumes_devices, + targetDevices: convert_target_devices, logicalVolumes: convert_logical_volumes } end + # Name of the target devices. + # + # @return [Array] + def convert_target_devices + config.physical_volumes_devices + .map { |a| storage_config.drive(a)&.device_name } + .compact + end + + # @return [Array] def convert_logical_volumes config.logical_volumes.map do |logical_volume| ToModelConversions::LogicalVolume.new(logical_volume).convert diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index bd6b664880..4385e8010a 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -744,7 +744,6 @@ def serialize(value) drives: [ { name: "/dev/sda", - alias: "root", spacePolicy: "keep", partitions: [ { @@ -797,7 +796,6 @@ def serialize(value) drives: [ { name: "/dev/sda", - alias: "sda", partitions: [ { mountPath: "/" } ] @@ -821,7 +819,6 @@ def serialize(value) drives: [ { name: "/dev/sda", - alias: "sda", spacePolicy: "keep", partitions: [ { diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index 2810bb72f7..ed04f3faf4 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -32,13 +32,6 @@ using Y2Storage::Refinements::SizeCasts -shared_examples "without alias" do |config_proc| - it "does not set #alias" do - config = config_proc.call(subject.convert) - expect(config.alias).to be_nil - end -end - shared_examples "without filesystem" do |config_proc| it "does not set #filesystem" do config = config_proc.call(subject.convert) @@ -145,15 +138,6 @@ end end -shared_examples "with alias" do |config_proc| - let(:device_alias) { "test" } - - it "sets #alias to the expected value" do - config = config_proc.call(subject.convert) - expect(config.alias).to eq("test") - end -end - shared_examples "with mountPath" do |config_proc| let(:mountPath) { "/test" } @@ -471,11 +455,6 @@ end end - context "if a partition does not spicify 'alias'" do - let(:partition) { {} } - include_examples "without alias", partition_proc - end - context "if a partition does not spicify 'id'" do let(:partition) { {} } @@ -510,11 +489,6 @@ include_examples "with name", partition_proc end - context "if a partition specifies 'alias'" do - let(:partition) { { alias: device_alias } } - include_examples "with alias", partition_proc - end - context "if a partition spicifies 'id'" do let(:partition) { { id: "esp" } } @@ -971,59 +945,36 @@ context "and the boot device specifies a 'name'" do let(:name) { "/dev/vda" } - context "and there is a drive model for the given boot device name" do + context "and there is a drive config for the given boot device name" do let(:drives) do [ - { name: "/dev/vda", alias: device_alias } + { name: "/dev/vda" } ] end - context "and the drive model specifies an alias" do - let(:device_alias) { "boot" } - - it "does not add more drives" do - config = subject.convert - expect(config.drives.size).to eq(1) - - drive = config.drives.first - expect(drive.alias).to eq("boot") - end + it "does not add more drives" do + config = subject.convert + expect(config.drives.size).to eq(1) + expect(config.drives.first.search.name).to eq("/dev/vda") + end - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(false) - expect(boot.device.device_alias).to eq("boot") - end + it "sets an alias to the drive config" do + config = subject.convert + drive = config.drives.first + expect(drive.alias).to_not be_nil end - context "and the drive model does not specify an alias" do - let(:device_alias) { nil } - - it "does not add more drives" do - config = subject.convert - expect(config.drives.size).to eq(1) - end - - it "sets an alias to the boot drive config" do - config = subject.convert - drive = config.drives.first - expect(drive.alias).to_not be_nil - end - - it "sets #boot to the expected value" do - config = subject.convert - boot = config.boot - drive = config.drives.first - expect(boot.configure?).to eq(true) - expect(boot.device.default?).to eq(false) - expect(boot.device.device_alias).to eq(drive.alias) - end + it "sets #boot to the expected value" do + config = subject.convert + boot = config.boot + drive = config.drives.first + expect(boot.configure?).to eq(true) + expect(boot.device.default?).to eq(false) + expect(boot.device.device_alias).to eq(drive.alias) end end - context "and there is no drive model for the given boot device name" do + context "and there is not a drive config for the given boot device name" do let(:drives) do [ { name: "/dev/vdb" } @@ -1150,11 +1101,6 @@ end end - context "if a drive does not spicify 'alias'" do - let(:drive) { {} } - include_examples "without alias", drive_proc - end - context "if a drive does not spicify neither 'mountPath' nor 'filesystem'" do let(:drive) { {} } include_examples "without filesystem", drive_proc @@ -1175,11 +1121,6 @@ include_examples "with name", drive_proc end - context "if a drive specifies 'alias'" do - let(:drive) { { alias: device_alias } } - include_examples "with alias", drive_proc - end - context "if a drive specifies 'mountPath'" do let(:drive) { { mountPath: mountPath } } include_examples "with mountPath", drive_proc diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index cc95c32981..fbe1129a31 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -34,13 +34,6 @@ end end -shared_examples "without alias" do |result_scope| - it "generates the expected JSON" do - model_json = result_scope.call(subject.convert) - expect(model_json.keys).to_not include(:alias) - end -end - shared_examples "without filesystem" do |result_scope| it "generates the expected JSON" do model_json = result_scope.call(subject.convert) @@ -74,15 +67,6 @@ end end -shared_examples "with alias" do |result_scope| - let(:device_alias) { "test" } - - it "generates the expected JSON" do - model_json = result_scope.call(subject.convert) - expect(model_json[:alias]).to eq("test") - end -end - shared_examples "with filesystem" do |result_scope| let(:filesystem) do { @@ -284,11 +268,6 @@ include_examples "without name", partition_result_scope end - context "if #alias is not configured for a partition" do - let(:partition) { {} } - include_examples "without alias", partition_result_scope - end - context "if #id is not configured for a partition" do let(:partition) { {} } @@ -308,11 +287,6 @@ include_examples "with name", partition_result_scope, partition_scope end - context "if #alias is configured for a partition" do - let(:partition) { { alias: device_alias } } - include_examples "with alias", partition_result_scope - end - context "if #id is configured for a partition" do let(:partition) { { id: "esp" } } @@ -1048,11 +1022,6 @@ drive_result_scope = proc { |c| c[:drives].first } drive_scope = proc { |d| d.find_by_name("/dev/vda") } - context "if #alias is not configured for a drive" do - let(:drive) { {} } - include_examples "without alias", drive_result_scope - end - context "if #filesystem is not configured for a drive" do let(:drive) { {} } include_examples "without filesystem", drive_result_scope @@ -1068,11 +1037,6 @@ include_examples "without partitions", drive_result_scope end - context "if #alias is configured for a drive" do - let(:drive) { { alias: device_alias } } - include_examples "with alias", drive_result_scope - end - context "if #filesystem is configured for a drive" do let(:drive) { { filesystem: filesystem } } include_examples "with filesystem", drive_result_scope @@ -1096,7 +1060,19 @@ context "if #volume_groups is configured" do let(:config_json) do - { volumeGroups: volume_groups } + { + drives: [ + { + search: "/dev/vda", + alias: "disk1" + }, + { + search: "/dev/vdb", + alias: "disk2" + } + ], + volumeGroups: volume_groups + } end let(:volume_groups) do @@ -1180,7 +1156,7 @@ it "generates the expected JSON" do model_json = vg_result_scope.call(subject.convert) - expect(model_json[:targetDevices]).to eq(["disk1", "disk2"]) + expect(model_json[:targetDevices]).to eq(["/dev/vda", "/dev/vdb"]) end end @@ -1206,11 +1182,6 @@ include_examples "without name", lv_result_scope end - context "if #alias is not configured for a logical volume" do - let(:logical_volume) { {} } - include_examples "without alias", lv_result_scope - end - context "if #filesystem is not configured for a logical volume" do let(:logical_volume) { {} } include_examples "without filesystem", lv_result_scope @@ -1257,11 +1228,6 @@ end end - context "if #alias is configured for a logical volume" do - let(:logical_volume) { { alias: device_alias } } - include_examples "with alias", lv_result_scope - end - context "if #filesystem is configured for a logical volume" do let(:logical_volume) { { filesystem: filesystem } } include_examples "with filesystem", lv_result_scope diff --git a/service/test/agama/storage/config_test.rb b/service/test/agama/storage/config_test.rb index d04a9a26e2..b83b140c0b 100644 --- a/service/test/agama/storage/config_test.rb +++ b/service/test/agama/storage/config_test.rb @@ -297,4 +297,46 @@ end end end + + describe "#drive" do + let(:config_json) do + { + drives: [ + { + alias: "disk1", + partitions: [ + { alias: "part1" } + ] + }, + { + alias: "disk2", + partitions: [ + { alias: "disk1" } + ] + } + ] + } + end + + context "if there is a drive with the given alias" do + let(:device_alias) { "disk1" } + + it "returns the drive" do + drive = subject.drive(device_alias) + + expect(drive).to be_a(Agama::Storage::Configs::Drive) + expect(drive.alias).to eq(device_alias) + end + end + + context "if there is not a drive with the given alias" do + let(:device_alias) { "part1" } + + it "returns nil" do + drive = subject.drive(device_alias) + + expect(drive).to be_nil + end + end + end end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index db828f00d7..a817ee2b32 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -449,7 +449,6 @@ def drive(partitions) drives: [ { name: "/dev/sda", - alias: "root", spacePolicy: "keep", partitions: [ { @@ -508,7 +507,6 @@ def drive(partitions) drives: [ { name: "/dev/sda", - alias: "sda", spacePolicy: "keep", partitions: [ { From b5b470338d38b67952dd92dbad6180c584996da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 12 Mar 2025 12:42:37 +0000 Subject: [PATCH 038/103] feat(storage): improve code to generate config from model --- .../from_model_conversions/boot.rb | 14 +- .../from_model_conversions/boot_device.rb | 29 +++- .../from_model_conversions/config.rb | 134 ++++++++---------- .../lib/agama/storage/configs/with_alias.rb | 4 +- 4 files changed, 98 insertions(+), 83 deletions(-) diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb index abd656eb73..fa798fe52f 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/boot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -29,8 +29,18 @@ module ConfigConversions module FromModelConversions # Boot conversion from model according to the JSON schema. class Boot < Base + # @param model_json [Hash] Boot model. + # @param drives [Array] + def initialize(model_json, drives) + super(model_json) + @drives = drives + end + private + # @return [Array] + attr_reader :drives + # @see Base # @return [Configs::Boot] def default_config @@ -51,7 +61,7 @@ def convert_device boot_device_model = model_json[:device] return if boot_device_model.nil? - FromModelConversions::BootDevice.new(boot_device_model).convert + FromModelConversions::BootDevice.new(boot_device_model, drives).convert end end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/boot_device.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/boot_device.rb index 27f4f0406e..febd55e067 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/boot_device.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/boot_device.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -28,8 +28,18 @@ module ConfigConversions module FromModelConversions # Boot device conversion from model according to the JSON schema. class BootDevice < Base + # @param model_json [Hash] Boot device model. + # @param drives [Array] + def initialize(model_json, drives) + super(model_json) + @drives = drives + end + private + # @return [Array] + attr_reader :drives + # @see Base # @return [Configs::Boot] def default_config @@ -40,9 +50,24 @@ def default_config # @return [Hash] def conversions { - default: model_json[:default] + default: model_json[:default], + device_alias: convert_device_alias } end + + # @return [String, nil] + def convert_device_alias + # Avoid setting an alias if using the default boot device. + return if model_json[:default] + + name = model_json[:name] + return unless name + + drive = drives.find { |d| d.device_name == name } + return unless drive + + drive.ensure_alias + end end end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb index 336ea261c9..19ca87b932 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb @@ -49,37 +49,30 @@ def default_config end # @see Base#conversions - # The conversion of boot and drives is special because the relationship between them. - # - # A boot model can indicates a boot device name. If so, the following steps are needed: - # * Add a drive model for the boot device if there is no drive model for it, see - # {#convert_drives}. - # * Add an alias to the drive config of the boot device if it has no alias, and set that - # alias to the boot device config, see #{convert_boot_device_alias}. - # # @return [Hash] def conversions - boot_config = convert_boot - drive_configs = convert_drives - drive_config = boot_drive_config(drive_configs) - - convert_boot_device_alias(boot_config, drive_config) + drives = convert_drives { - boot: boot_config, - drives: drive_configs + boot: convert_boot(drives || []), + drives: drives } end + # @param drives [Array] # @return [Configs::Boot, nil] - def convert_boot + def convert_boot(drives) boot_model = model_json[:boot] return unless boot_model - FromModelConversions::Boot.new(boot_model).convert + FromModelConversions::Boot.new(boot_model, drives).convert end + # @return [Array, nil] def convert_drives + add_missing_drives + + drive_models = model_json[:drives] return unless drive_models drive_models.map { |d| convert_drive(d) } @@ -93,87 +86,72 @@ def convert_drive(drive_model) .convert end - # Conversion for the boot device alias. + # Add missing drives to the model. # - # It requieres both boot and drives already converted. + # Adds a drive for the selected boot device and for the LVM target devices if needed. # - # @param boot_config [Configs::Boot, nil] The boot config can be modified. - # @param drive_config [Configs::Drive, nil] The drive config can be modifed. - def convert_boot_device_alias(boot_config, drive_config) - return unless boot_config && drive_config - return unless boot_config.configure? && !boot_config.device.default? - - drive_config.ensure_alias - boot_config.device.device_alias = drive_config.alias - end + # @return [Array, nil] + def add_missing_drives + model_json[:drives] ||= [] + drives = model_json[:drives] - # Drive config for the boot device, if any. - # - # @param drive_configs [Array, nil] - # @return [Configs::Drive, nil] - def boot_drive_config(drive_configs) - return unless drive_configs && boot_device_name + # Add boot device, if needed. + drives << boot_device if missing_boot_device? - drive_configs.find { |d| d.search.name == boot_device_name } + # Add target devices, if needed. + lvm_target_names.each do |name| + drives << lvm_target_device(name) if missing_drive?(name) + end end - # Drive models to convert to drive configs. + # Whether the boot drive is missing in the model. # - # It includes all the drives from the drive section of the model, adding a drive for the - # selected boot device if needed. See {#calculate_drive_models}. - # - # @return [Array, nil] - def drive_models - return @drive_models if @calculated_drive_models - - @drive_models = calculate_drive_models - end - - # @see #drive_models - # @return [Array, nil] - def calculate_drive_models - @calculated_drive_models = true + # @return [Boolean] + def missing_boot_device? + configure_boot = model_json.dig(:boot, :configure) + default_boot = model_json.dig(:boot, :device, :default) + boot_device_name = model_json.dig(:boot, :device, :name) - models = model_json[:drives] - return if models.nil? && !missing_boot_drive? + return false unless configure_boot && !default_boot && !boot_device_name.nil? - models ||= [] - # The main use case for using a specific device for booting is to share the boot - # partition with other installed systems. So let's ensure the partitions are not deleted - # by setting the "keep" space policy. - models << { name: boot_device_name, spacePolicy: "keep" } if missing_boot_drive? - models + missing_drive?(boot_device_name) end - # Whether a drive model for the boot device is missing in the list of drives. See - # {#calculate_missing_boot_device}. + # Whether a drive with the given name is missing in the model. # + # @param name [String] # @return [Boolean] - def missing_boot_drive? - return @missing_boot_drive if @calculated_missing_boot_drive - - @missing_boot_drive ||= calculate_missing_boot_drive + def missing_drive?(name) + drives = model_json[:drives] || [] + drives.none? { |d| d[:name] == name } end - # @see #missing_boot_drive? - # @return [Boolean] - def calculate_missing_boot_drive - @calculated_missing_boot_drive = true - - configure_boot = model_json.dig(:boot, :configure) - default_boot = model_json.dig(:boot, :device, :default) + # All the target devices for creating LVM physical volumes. + # + # @return [Array] + def lvm_target_names + volume_groups = model_json[:volumeGroups] || [] + volume_groups.flat_map { |v| v[:targetDevices] || [] } + end - return false unless configure_boot && !default_boot && !boot_device_name.nil? + # Drive model for the boot device. + # + # @return [Hash] + def boot_device + name = model_json.dig(:boot, :device, :name) - drive_models = model_json[:drives] || [] - drive_models.none? { |d| d[:name] == boot_device_name } + # The main use case for using a specific device for booting is to share the boot + # partition with other installed systems. So let's ensure the partitions are not deleted + # by setting the "keep" space policy. + { name: name, spacePolicy: "keep" } end - # Name of the device selected for booting, if any. + # Drive model for a LVM target device. # - # @return [String, nil] - def boot_device_name - @boot_device_name ||= model_json.dig(:boot, :device, :name) + # @param name [String] + # @return [Hash] + def lvm_target_device(name) + { name: name, spacePolicy: product_config.space_policy } end end end diff --git a/service/lib/agama/storage/configs/with_alias.rb b/service/lib/agama/storage/configs/with_alias.rb index 250d8d93e0..51b81be95f 100644 --- a/service/lib/agama/storage/configs/with_alias.rb +++ b/service/lib/agama/storage/configs/with_alias.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -37,6 +37,8 @@ def alias?(value) end # Ensures the config has a value for alias. + # + # @return [String] def ensure_alias self.alias ||= SecureRandom.alphanumeric(10) end From b3ad22557b7ae766969f2f3c81b8cf7f6d6f6da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 12 Mar 2025 17:20:12 +0000 Subject: [PATCH 039/103] feat(storage): generate lvm config from model --- .../from_model_conversions.rb | 2 + .../from_model_conversions/config.rb | 25 +- .../from_model_conversions/logical_volume.rb | 70 +++++ .../from_model_conversions/volume_group.rb | 113 +++++++ .../from_model_conversions/with_encryption.rb | 2 +- .../config_conversions/from_model_test.rb | 282 +++++++++++++++++- 6 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb create mode 100644 service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions.rb index 5e86901b9d..dc9cefcd2d 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions.rb @@ -26,9 +26,11 @@ require "agama/storage/config_conversions/from_model_conversions/encryption" require "agama/storage/config_conversions/from_model_conversions/filesystem" require "agama/storage/config_conversions/from_model_conversions/filesystem_type" +require "agama/storage/config_conversions/from_model_conversions/logical_volume" require "agama/storage/config_conversions/from_model_conversions/partition" require "agama/storage/config_conversions/from_model_conversions/search" require "agama/storage/config_conversions/from_model_conversions/size" +require "agama/storage/config_conversions/from_model_conversions/volume_group" module Agama module Storage diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb index 19ca87b932..d4f4864650 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb @@ -22,6 +22,7 @@ require "agama/storage/config_conversions/from_model_conversions/base" require "agama/storage/config_conversions/from_model_conversions/boot" require "agama/storage/config_conversions/from_model_conversions/drive" +require "agama/storage/config_conversions/from_model_conversions/volume_group" require "agama/storage/config" module Agama @@ -54,8 +55,9 @@ def conversions drives = convert_drives { - boot: convert_boot(drives || []), - drives: drives + boot: convert_boot(drives || []), + drives: drives, + volume_groups: convert_volume_groups(drives || []) } end @@ -86,6 +88,25 @@ def convert_drive(drive_model) .convert end + # @param drives [Array] + # @return [Hash] + def convert_volume_groups(drives) + volume_group_models = model_json[:volumeGroups] + return unless volume_group_models + + volume_group_models.map { |v| convert_volume_group(v, drives) } + end + + # @param volume_group_model [Hash] + # @param drives [Array] + # + # @return [Configs::VolumeGroup] + def convert_volume_group(volume_group_model, drives) + FromModelConversions::VolumeGroup + .new(volume_group_model, drives, model_json[:encryption]) + .convert + end + # Add missing drives to the model. # # Adds a drive for the selected boot device and for the LVM target devices if needed. diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb new file mode 100644 index 0000000000..87654c9d26 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/with_filesystem" +require "agama/storage/config_conversions/from_model_conversions/with_size" +require "agama/storage/configs/logical_volume" +require "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Logical volume conversion from model according to the JSON schema. + class LogicalVolume < Base + include WithFilesystem + include WithSize + + private + + alias_method :logical_volume_model, :model_json + + # @see Base + # @return [Configs::LogicalVolume] + def default_config + Configs::LogicalVolume.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + name: logical_volume_model[:name], + filesystem: convert_filesystem, + size: convert_size, + stripes: logical_volume_model[:stripes], + stripe_size: convert_stripe_size + } + end + + # @return [Y2Storage::DiskSize, nil] + def convert_stripe_size + value = logical_volume_model[:stripeSize] + return unless value + + Y2Storage::DiskSize.new(value) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb new file mode 100644 index 0000000000..d2241f0c9b --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/logical_volume" +require "agama/storage/config_conversions/from_model_conversions/encryption" +require "agama/storage/configs/volume_group" +require "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Volume group conversion from model according to the JSON schema. + class VolumeGroup < Base + # @param model_json [Hash] + # @param drives [Array] + # @param encryption_model [Hash, nil] + def initialize(model_json, drives, encryption_model = nil) + super(model_json) + @drives = drives + @encryption_model = encryption_model + end + + private + + alias_method :volume_group_model, :model_json + + # @return [Array] + attr_reader :drives + + # @return [Hash, nil] + attr_reader :encryption_model + + # @see Base + # @return [Configs::VolumeGroup] + def default_config + Configs::VolumeGroup.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + name: volume_group_model[:name], + extent_size: convert_extent_size, + physical_volumes_devices: convert_physical_volumes_devices, + physical_volumes_encryption: convert_physical_volumes_encryption, + logical_volumes: convert_logical_volumes + } + end + + # @return [Y2Storage::DiskSize, nil] + def convert_extent_size + value = volume_group_model[:extentSize] + return unless value + + Y2Storage::DiskSize.new(value) + end + + # @return [Array, nil] + def convert_physical_volumes_devices + target_names = volume_group_model[:targetDevices] + return unless target_names + + target_names + .map { |n| drive(n)&.ensure_alias } + .compact + end + + # @return [Configs::Encryption, nil] + def convert_physical_volumes_encryption + return unless encryption_model + + FromModelConversions::Encryption.new(encryption_model).convert + end + + # @return [Array, nil] + def convert_logical_volumes + logical_volumes_model = volume_group_model[:logicalVolumes] + return unless logical_volumes_model + + logical_volumes_model.map { |l| FromModelConversions::LogicalVolume.new(l).convert } + end + + # @param name [String] + # @return [Configs::Drive, nil] + def drive(name) + drives.find { |d| d.device_name == name } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb index 1ff08b3bb7..e1855d4ce5 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb @@ -29,7 +29,7 @@ module FromModelConversions module WithEncryption # @return [Configs::Encryption, nil] def convert_encryption - # Do not encrypt reused partitions. + # Do not encrypt a reused device. return if model_json[:name] return if encryption_model.nil? diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index ed04f3faf4..f084d6ee00 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -891,6 +891,11 @@ config = subject.convert expect(config.drives).to be_empty end + + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups).to be_empty + end end context "with a JSON specifying 'boot'" do @@ -1021,11 +1026,11 @@ context "with a JSON specifying 'encryption'" do let(:model_json) do { - encryption: { + encryption: { method: "luks1", password: "12345" }, - drives: [ + drives: [ { name: "/dev/vda", partitions: [ @@ -1033,6 +1038,12 @@ {} ] } + ], + volumeGroups: [ + { + name: "system", + targetDevices: ["/dev/vda"] + } ] } end @@ -1047,6 +1058,15 @@ expect(new_partition.encryption.password).to eq("12345") expect(reused_partition.encryption).to be_nil end + + it "sets #encryption for the automatically created physical volumes" do + config = subject.convert + volume_group = config.volume_groups.first + target_encryption = volume_group.physical_volumes_encryption + + expect(target_encryption.method.id).to eq(:luks1) + expect(target_encryption.password).to eq("12345") + end end context "with a JSON specifying 'drives'" do @@ -1156,5 +1176,263 @@ include_examples "with spacePolicy and partitions", drive_proc end end + + context "with a JSON specifying 'volumeGroups'" do + let(:model_json) do + { + drives: drives, + volumeGroups: volume_groups + } + end + + let(:drives) { [] } + + let(:volume_groups) do + [ + volume_group, + { name: "vg2" } + ] + end + + let(:volume_group) do + { name: "vg1" } + end + + context "with an empty list" do + let(:volume_groups) { [] } + + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups).to eq([]) + end + end + + context "with a list of volume groups" do + it "sets #volume_groups to the expected value" do + config = subject.convert + expect(config.volume_groups.size).to eq(2) + expect(config.volume_groups).to all(be_a(Agama::Storage::Configs::VolumeGroup)) + + vg1, vg2 = config.volume_groups + expect(vg1.name).to eq("vg1") + expect(vg1.logical_volumes).to eq([]) + expect(vg2.name).to eq("vg2") + expect(vg2.logical_volumes).to eq([]) + end + end + + volume_group_proc = proc { |c| c.volume_groups.first } + + context "if a volume group does not specify 'name'" do + let(:volume_group) { {} } + + it "does not set #name" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.name).to be_nil + end + end + + context "if a volume group does not specify 'extentSize'" do + let(:volume_group) { {} } + + it "does not set #extent_size" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.extent_size).to be_nil + end + end + + context "if a volume group does not specify 'targetDevices'" do + let(:volume_group) { {} } + + it "sets #physical_volumes_devices to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.physical_volumes_devices).to eq([]) + end + end + + context "if a volume group does not specify 'logicalVolumes'" do + let(:volume_group) { {} } + + it "sets #logical_volumes to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.logical_volumes).to eq([]) + end + end + + context "if a volume group specifies 'name'" do + let(:volume_group) { { name: "vg1" } } + + it "sets #name to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.name).to eq("vg1") + end + end + + context "if a volume group specifies 'extentSize'" do + let(:volume_group) { { extentSize: 1.KiB.to_i } } + + it "sets #extent_size to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.extent_size).to eq(1.KiB) + end + end + + context "if a volume group specifies 'targetDevices'" do + let(:volume_group) { { targetDevices: ["/dev/vda", "/dev/vdc"] } } + + let(:drives) do + [ + { name: "/dev/vda" }, + { name: "/dev/vdb" } + ] + end + + it "adds the missing drives" do + config = subject.convert + expect(config.drives.size).to eq(3) + expect(config.drives).to all(be_a(Agama::Storage::Configs::Drive)) + expect(config.drives).to include(an_object_having_attributes({ device_name: "/dev/vdc" })) + end + + it "sets an alias to the target drives" do + config = subject.convert + vda = config.drives.find { |d| d.device_name == "/dev/vda" } + vdb = config.drives.find { |d| d.device_name == "/dev/vdb" } + vdc = config.drives.find { |d| d.device_name == "/dev/vdc" } + expect(vda.alias).to_not be_nil + expect(vdb.alias).to be_nil + expect(vda.alias).to_not be_nil + end + + it "sets #physical_volumes_devices to the expected value" do + config = subject.convert + volume_group = volume_group_proc.call(config) + vda = config.drives.find { |d| d.device_name == "/dev/vda" } + vdc = config.drives.find { |d| d.device_name == "/dev/vdc" } + expect(volume_group.physical_volumes_devices).to eq([vda.alias, vdc.alias]) + end + end + + context "if a volume group specifies 'logicalVolumes'" do + let(:volume_group) { { logicalVolumes: logical_volumes } } + + let(:logical_volumes) do + [ + logical_volume, + { name: "lv2" } + ] + end + + let(:logical_volume) { { name: "lv1" } } + + context "with an empty list" do + let(:logical_volumes) { [] } + + it "sets #logical_volumes to empty" do + config = subject.convert + expect(config.logical_volumes).to eq([]) + end + end + + context "with a list of logical volumes" do + it "sets #logical_volumes to the expected value" do + volume_group = volume_group_proc.call(subject.convert) + expect(volume_group.logical_volumes) + .to all(be_a(Agama::Storage::Configs::LogicalVolume)) + expect(volume_group.logical_volumes.size).to eq(2) + + lv1, lv2 = volume_group.logical_volumes + expect(lv1.name).to eq("lv1") + expect(lv2.name).to eq("lv2") + end + end + + logical_volume_proc = proc { |c| volume_group_proc.call(c).logical_volumes.first } + + context "if a logical volume does not specify 'name'" do + let(:logical_volume) { {} } + + it "does not set #name" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.name).to be_nil + end + end + + context "if a logical volume does not spicify neither 'mountPath' nor 'filesystem'" do + let(:logical_volume) { {} } + include_examples "without filesystem", logical_volume_proc + end + + context "if a logical volume does not spicify 'size'" do + let(:logical_volume) { {} } + include_examples "without size", logical_volume_proc + end + + context "if a logical volume does not spicify 'stripes'" do + let(:logical_volume) { {} } + + it "does not set #stripes" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripes).to be_nil + end + end + + context "if a logical volume does not spicify 'stripeSize'" do + let(:logical_volume) { {} } + + it "does not set #stripe_size" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripe_size).to be_nil + end + end + + context "if a logical volume specifies 'name'" do + let(:logical_volume) { { name: "lv1" } } + + it "sets #name to the expected value" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.name).to eq("lv1") + end + end + + context "if a logical volume specifies 'mountPath'" do + let(:logical_volume) { { mountPath: mountPath } } + include_examples "with mountPath", logical_volume_proc + end + + context "if a logical volume specifies 'filesystem'" do + let(:logical_volume) { { filesystem: filesystem } } + include_examples "with filesystem", logical_volume_proc + end + + context "if a logical volume specifies both 'mountPath' and 'filesystem'" do + let(:logical_volume) { { mountPath: mountPath, filesystem: filesystem } } + include_examples "with mountPath and filesystem", logical_volume_proc + end + + context "if a logical volume spicifies 'size'" do + let(:logical_volume) { { size: size } } + include_examples "with size", logical_volume_proc + end + + context "if a logical volume specifies 'stripes'" do + let(:logical_volume) { { stripes: 4 } } + + it "sets #stripes to the expected value" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripes).to eq(4) + end + end + + context "if a logical volume specifies 'stripeSize'" do + let(:logical_volume) { { stripeSize: 2.KiB.to_i } } + + it "sets #stripeSize to the expected value" do + logical_volume = logical_volume_proc.call(subject.convert) + expect(logical_volume.stripe_size).to eq(2.KiB) + end + end + end + end end end From 9a87a49a8ebf4a26d9bb6bd012789db351989cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 13 Mar 2025 12:13:23 +0000 Subject: [PATCH 040/103] fix(storage): use vgName and lvName in model --- .../share/examples/storage/model.json | 8 +++---- .../agama-lib/share/storage.model.schema.json | 6 ++--- .../from_model_conversions/logical_volume.rb | 2 +- .../from_model_conversions/volume_group.rb | 2 +- .../to_model_conversions/logical_volume.rb | 2 +- .../to_model_conversions/volume_group.rb | 2 +- .../config_conversions/from_model_test.rb | 22 +++++++++---------- .../config_conversions/to_model_test.rb | 17 +++++++++----- 8 files changed, 33 insertions(+), 28 deletions(-) diff --git a/rust/agama-lib/share/examples/storage/model.json b/rust/agama-lib/share/examples/storage/model.json index b7a47507fa..dfb6249729 100644 --- a/rust/agama-lib/share/examples/storage/model.json +++ b/rust/agama-lib/share/examples/storage/model.json @@ -13,7 +13,6 @@ "drives": [ { "name": "/dev/vda", - "alias": "root", "mountPath": "/", "filesystem": { "default": true, @@ -78,18 +77,17 @@ }, { "name": "/dev/vdc", - "alias": "lvm", "spacePolicy": "delete" } ], "volumeGroups": [ { - "name": "vg0", + "vgName": "vg0", "extentSize": 1024, - "targetDevices": ["lvm"], + "targetDevices": ["/dev/vdc"], "logicalVolumes": [ { - "name": "lv0", + "lvName": "lv0", "mountPath": "/data", "filesystem": { "default": false, diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index 6d7ee4de69..03afb9ca51 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -84,9 +84,9 @@ "volumeGroup": { "type": "object", "additionalProperties": false, - "required": ["name"], + "required": ["vgName"], "properties": { - "name": { "type": "string" }, + "vgName": { "type": "string" }, "extentSize": { "type": "integer" }, "targetDevices": { "type": "array", @@ -102,7 +102,7 @@ "type": "object", "additionalProperties": false, "properties": { - "name": { "type": "string" }, + "lvName": { "type": "string" }, "mountPath": { "type": "string" }, "filesystem": { "$ref": "#/$defs/filesystem" }, "size": { "$ref": "#/$defs/size" }, diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb index 87654c9d26..fd040277ef 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/logical_volume.rb @@ -48,7 +48,7 @@ def default_config # @return [Hash] def conversions { - name: logical_volume_model[:name], + name: logical_volume_model[:lvName], filesystem: convert_filesystem, size: convert_size, stripes: logical_volume_model[:stripes], diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb index d2241f0c9b..e178bb2c3f 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/volume_group.rb @@ -60,7 +60,7 @@ def default_config # @return [Hash] def conversions { - name: volume_group_model[:name], + name: volume_group_model[:vgName], extent_size: convert_extent_size, physical_volumes_devices: convert_physical_volumes_devices, physical_volumes_encryption: convert_physical_volumes_encryption, diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb index 2c674a4b52..b99fd93c79 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/logical_volume.rb @@ -43,7 +43,7 @@ def initialize(config) # @see Base#conversions def conversions { - name: config.name, + lvName: config.name, alias: config.alias, mountPath: config.filesystem&.path, filesystem: convert_filesystem, diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb index 19fbf87728..f380d10aaf 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/volume_group.rb @@ -46,7 +46,7 @@ def initialize(config, storage_config) # @see Base#conversions def conversions { - name: config.name, + vgName: config.name, extentSize: config.extent_size&.to_i, targetDevices: convert_target_devices, logicalVolumes: convert_logical_volumes diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index f084d6ee00..ad9ca4deab 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -1041,7 +1041,7 @@ ], volumeGroups: [ { - name: "system", + vgName: "system", targetDevices: ["/dev/vda"] } ] @@ -1190,12 +1190,12 @@ let(:volume_groups) do [ volume_group, - { name: "vg2" } + { vgName: "vg2" } ] end let(:volume_group) do - { name: "vg1" } + { vgName: "vg1" } end context "with an empty list" do @@ -1223,7 +1223,7 @@ volume_group_proc = proc { |c| c.volume_groups.first } - context "if a volume group does not specify 'name'" do + context "if a volume group does not specify 'vgName'" do let(:volume_group) { {} } it "does not set #name" do @@ -1259,8 +1259,8 @@ end end - context "if a volume group specifies 'name'" do - let(:volume_group) { { name: "vg1" } } + context "if a volume group specifies 'vgName'" do + let(:volume_group) { { vgName: "vg1" } } it "sets #name to the expected value" do volume_group = volume_group_proc.call(subject.convert) @@ -1319,11 +1319,11 @@ let(:logical_volumes) do [ logical_volume, - { name: "lv2" } + { lvName: "lv2" } ] end - let(:logical_volume) { { name: "lv1" } } + let(:logical_volume) { { lvName: "lv1" } } context "with an empty list" do let(:logical_volumes) { [] } @@ -1349,7 +1349,7 @@ logical_volume_proc = proc { |c| volume_group_proc.call(c).logical_volumes.first } - context "if a logical volume does not specify 'name'" do + context "if a logical volume does not specify 'lvName'" do let(:logical_volume) { {} } it "does not set #name" do @@ -1386,8 +1386,8 @@ end end - context "if a logical volume specifies 'name'" do - let(:logical_volume) { { name: "lv1" } } + context "if a logical volume specifies 'lvName'" do + let(:logical_volume) { { lvName: "lv1" } } it "sets #name to the expected value" do logical_volume = logical_volume_proc.call(subject.convert) diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index fbe1129a31..93490272e2 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -1099,7 +1099,11 @@ context "if #name is not configured for a volume group" do let(:volume_group) { {} } - include_examples "without name", vg_result_scope + + it "generates the expected JSON" do + model_json = vg_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:vgName) + end end context "if #extent_size is not configured for a volume group" do @@ -1134,7 +1138,7 @@ it "generates the expected JSON" do model_json = vg_result_scope.call(subject.convert) - expect(model_json[:name]).to eq("test") + expect(model_json[:vgName]).to eq("test") end end @@ -1175,11 +1179,14 @@ end lv_result_scope = proc { |c| vg_result_scope.call(c)[:logicalVolumes].first } - # partition_scope = proc { |c| device_scope.call(c).partitions.first } context "if #name is not configured for a logical volume" do let(:logical_volume) { {} } - include_examples "without name", lv_result_scope + + it "generates the expected JSON" do + model_json = lv_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:lvName) + end end context "if #filesystem is not configured for a logical volume" do @@ -1224,7 +1231,7 @@ it "generates the expected JSON" do model_json = lv_result_scope.call(subject.convert) - expect(model_json[:name]).to eq("test") + expect(model_json[:lvName]).to eq("test") end end From 6bc9c6d781634491bb8f49ffb357c73029c521d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 14 Mar 2025 10:56:05 +0000 Subject: [PATCH 041/103] fix(storage): do not generate keep action partition from model --- .../from_model_conversions/with_partitions.rb | 82 +++++++++++++------ .../config_conversions/from_model_test.rb | 75 ++++++++++------- 2 files changed, 104 insertions(+), 53 deletions(-) diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb index 175820ef1e..2098f2102b 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb @@ -43,40 +43,47 @@ def convert_partitions(encryption_model = nil) when "resize" [used_partition_configs(encryption_model), resize_all_partition_config].flatten else - partition_configs(encryption_model) + [used_partition_configs(encryption_model), action_partition_configs].flatten end end - # @param partition_model [Hash] # @param encryption_model [Hash, nil] - # - # @return [Configs::Partition] - def convert_partition(partition_model, encryption_model = nil) - FromModelConversions::Partition.new(partition_model, encryption_model).convert + # @return [Array] + def used_partition_configs(encryption_model = nil) + used_partitions.map { |p| convert_partition(p, encryption_model) } end # @return [Array] - # @param encryption_model [Hash, nil] - def partition_configs(encryption_model = nil) - partitions.map { |p| convert_partition(p, encryption_model) } + def action_partition_configs + action_partitions.map { |p| convert_partition(p) } end # Partitions with any usage (format, mount, etc). - # @param encryption_model [Hash, nil] # - # @return [Array] - def used_partition_configs(encryption_model = nil) - used_partitions.map { |p| convert_partition(p, encryption_model) } + # @return [Array] + def used_partitions + partitions.reject { |p| space_policy_partition?(p) } end + # Partitions representing a spece policy action (delete, resize if needed), excluding + # the keep actions. + # + # Omitting the partitions that only represent a keep action is important. Otherwise, the + # resulting config would contain a partition without any usage (delete, resize, format, + # etc) and without a mount path. Such a partition is not supported by the model yet (see + # {ModelSupportChecker}) and would make impossible to build a model again from the + # resulting config. + # # @return [Array] - def partitions - model_json[:partitions] || [] + def action_partitions + partitions + .select { |p| space_policy_partition?(p) } + .reject { |p| keep_action_partition?(p) } end # @return [Array] - def used_partitions - partitions.reject { |p| space_policy_partition?(p) } + def partitions + model_json[:partitions] || [] end # Whether the partition only represents a space policy action. @@ -84,19 +91,40 @@ def used_partitions # @param partition_model [Hash] # @return [Boolean] def space_policy_partition?(partition_model) - partition_model[:delete] || - partition_model[:deleteIfNeeded] || - resize_action_partition?(partition_model) + delete_action_partition?(partition_model) || + resize_action_partition?(partition_model) || + keep_action_partition?(partition_model) + end + + # @param partition_model [Hash] + # @return [Boolean] + def delete_action_partition?(partition_model) + partition_model[:delete] || partition_model[:deleteIfNeeded] end # @param partition_model [Hash] # @return [Boolean] def resize_action_partition?(partition_model) - return false if partition_model[:name].nil? || any_usage?(partition_model) + return false if delete_action_partition?(partition_model) - return true if partition_model[:resizeIfNeeded] + return false if any_usage?(partition_model) - partition_model[:size] && !partition_model.dig(:size, :default) + partition_model[:name] && ( + partition_model[:resizeIfNeeded] || + (partition_model[:size] && !partition_model.dig(:size, :default)) + ) + end + + # @param partition_model [Hash] + # @return [Boolean] + def keep_action_partition?(partition_model) + return false if delete_action_partition?(partition_model) + + return false if resize_action_partition?(partition_model) + + return false if any_usage?(partition_model) + + !partition_model[:name].nil? end # TODO: improve check by ensuring the partition is referenced by other device. @@ -116,6 +144,14 @@ def delete_all_partition_config def resize_all_partition_config Configs::Partition.new_for_shrink_any_if_needed end + + # @param partition_model [Hash] + # @param encryption_model [Hash, nil] + # + # @return [Configs::Partition] + def convert_partition(partition_model, encryption_model = nil) + FromModelConversions::Partition.new(partition_model, encryption_model).convert + end end end end diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index ad9ca4deab..bfc9857368 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -485,7 +485,9 @@ end context "if a partition specifies 'name'" do - let(:partition) { { name: name } } + # Add mount path in order to use the partition. Otherwise the partition is omitted because it + # is considered a keep action. + let(:partition) { { name: name, mountPath: "/test2" } } include_examples "with name", partition_proc end @@ -579,49 +581,55 @@ shared_examples "with spacePolicy and partitions" do |config_proc| let(:partitions) do [ - # Partition exists and it is used. + # Reused partition with some usage. { name: "/dev/vda1", mountPath: "/test1", size: { default: true, min: 10.GiB.to_i } }, - # Partition exists and it is used. + # Reused partition with some usage. { name: "/dev/vda2", mountPath: "/test2", resizeIfNeeded: true, size: { default: false, min: 10.GiB.to_i } }, - # Partition exists and it is used. + # Reused partition with some usage. { name: "/dev/vda3", mountPath: "/test3", resize: true, size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } }, - # Partition exists and it is not used (space action). + # Reused partition representing a space action (resize). { name: "/dev/vda4", resizeIfNeeded: true, size: { default: false, min: 10.GiB.to_i } }, - # Partition exists and it is not used (space action). + # Reused partition representing a space action (resize). { name: "/dev/vda5", resize: true, size: { default: false, min: 10.GiB.to_i, max: 10.GiB.to_i } }, - # Partition exists and it is not used (space action). + # Reused partition representing a space action (delete). { name: "/dev/vda6", delete: true }, - # Partition exists and it is not used (space action). + # Reused partition representing a space action (delete). { name: "/dev/vda7", deleteIfNeeded: true }, - # Partition does not exist. + # Reused partition representing a space action (keep). + { + name: "/dev/vda8" + }, + # New partition. + {}, + # New partition. { mountPath: "/", resizeIfNeeded: true, @@ -637,11 +645,12 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(4) + expect(partitions.size).to eq(5) expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].filesystem.path).to eq("/") + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") end end @@ -651,14 +660,15 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(5) + expect(partitions.size).to eq(6) expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].filesystem.path).to eq("/") - expect(partitions[4].search.name).to be_nil - expect(partitions[4].search.max).to be_nil - expect(partitions[4].delete).to eq(true) + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") + expect(partitions[5].search.name).to be_nil + expect(partitions[5].search.max).to be_nil + expect(partitions[5].delete).to eq(true) end end @@ -668,16 +678,17 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(5) + expect(partitions.size).to eq(6) expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].filesystem.path).to eq("/") - expect(partitions[4].search.name).to be_nil - expect(partitions[4].search.max).to be_nil - expect(partitions[4].size.default?).to eq(false) - expect(partitions[4].size.min).to eq(Y2Storage::DiskSize.zero) - expect(partitions[4].size.max).to be_nil + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") + expect(partitions[5].search.name).to be_nil + expect(partitions[5].search.max).to be_nil + expect(partitions[5].size.default?).to eq(false) + expect(partitions[5].size.min).to eq(Y2Storage::DiskSize.zero) + expect(partitions[5].size.max).to be_nil end end @@ -687,15 +698,16 @@ it "sets #partitions to the expected value" do config = config_proc.call(subject.convert) partitions = config.partitions - expect(partitions.size).to eq(8) + expect(partitions.size).to eq(9) expect(partitions[0].search.name).to eq("/dev/vda1") expect(partitions[1].search.name).to eq("/dev/vda2") expect(partitions[2].search.name).to eq("/dev/vda3") - expect(partitions[3].search.name).to eq("/dev/vda4") - expect(partitions[4].search.name).to eq("/dev/vda5") - expect(partitions[5].search.name).to eq("/dev/vda6") - expect(partitions[6].search.name).to eq("/dev/vda7") - expect(partitions[7].filesystem.path).to eq("/") + expect(partitions[3].filesystem).to be_nil + expect(partitions[4].filesystem.path).to eq("/") + expect(partitions[5].search.name).to eq("/dev/vda4") + expect(partitions[6].search.name).to eq("/dev/vda5") + expect(partitions[7].search.name).to eq("/dev/vda6") + expect(partitions[8].search.name).to eq("/dev/vda7") end context "if a partition spicifies 'resizeIfNeeded'" do @@ -1034,7 +1046,10 @@ { name: "/dev/vda", partitions: [ - { name: "/dev/vda1" }, + { + name: "/dev/vda1", + mountPath: "/test" + }, {} ] } From b8926ca262ef15b6d90a4e4c551d5f58f1a16902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 13 Mar 2025 12:36:02 +0000 Subject: [PATCH 042/103] web: update config-model types --- web/src/api/storage/types/config-model.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index 7cf4871e7d..89504d14c0 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -5,10 +5,6 @@ */ export type EncryptionMethod = "luks1" | "luks2" | "tpmFde"; -/** - * Alias used to reference a device. - */ -export type Alias = string; export type FilesystemType = | "bcachefs" | "btrfs" @@ -53,7 +49,6 @@ export interface Encryption { } export interface Drive { name: string; - alias?: Alias; mountPath?: string; filesystem?: Filesystem; spacePolicy?: SpacePolicy; @@ -69,7 +64,6 @@ export interface Filesystem { } export interface Partition { name?: string; - alias?: Alias; id?: PartitionId; mountPath?: string; filesystem?: Filesystem; @@ -85,13 +79,13 @@ export interface Size { max?: number; } export interface VolumeGroup { - name: string; + vgName: string; extentSize?: number; - targetDevices?: Alias[]; + targetDevices?: string[]; logicalVolumes?: LogicalVolume[]; } export interface LogicalVolume { - name?: string; + lvName?: string; mountPath?: string; filesystem?: Filesystem; size?: Size; From 4169c3867d39b843fcd9140bd3b7250aa37418eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 13 Mar 2025 12:55:02 +0000 Subject: [PATCH 043/103] web: adapt model hooks --- web/src/hooks/storage/model.ts | 14 +++++++------- web/src/queries/storage/config-model.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index be686feb2c..1e39652005 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -25,8 +25,8 @@ import { configModelQuery } from "~/queries/storage/config-model"; import * as apiModel from "~/api/storage/types/config-model"; import * as model from "~/types/storage/model"; -function findDrive(modelData: apiModel.Config, alias: string): apiModel.Drive | undefined { - return modelData.drives.find((d) => d.alias === alias); +function findDrive(modelData: apiModel.Config, name: string): apiModel.Drive | undefined { + return modelData.drives.find((d) => d.name === name); } function buildDrive(driveData: apiModel.Drive): model.Drive { @@ -42,9 +42,9 @@ function buildVolumeGroup( modelData: apiModel.Config, ): model.VolumeGroup { const buildTargetDevices = (): model.Drive[] => { - const aliases = volumeGroupData.targetDevices || []; - return aliases - .map((a) => findDrive(modelData, a)) + const names = volumeGroupData.targetDevices || []; + return names + .map((n) => findDrive(modelData, n)) .filter((d) => d) .map(buildDrive); }; @@ -77,9 +77,9 @@ function useModel(): model.Model | null { return data ? buildModel(data) : null; } -function useVolumeGroup(name: string): model.VolumeGroup | null { +function useVolumeGroup(vgName: string): model.VolumeGroup | null { const model = useModel(); - const volumeGroup = model?.volumeGroups?.find((v) => v.name === name); + const volumeGroup = model?.volumeGroups?.find((v) => v.vgName === vgName); return volumeGroup || null; } diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index f951e5325b..5a865d6c0b 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -41,11 +41,11 @@ function isSpacePartition(partition: configModel.Partition): boolean { } function isUsedPartition(partition: configModel.Partition): boolean { - return partition.filesystem !== undefined || partition.alias !== undefined; + return partition.filesystem !== undefined; } function isReusedPartition(partition: configModel.Partition): boolean { - return !isNewPartition(partition) && isUsedPartition(partition) && !isSpacePartition(partition); + return !isNewPartition(partition) && isUsedPartition(partition); } function findDrive(model: configModel.Config, driveName: string): configModel.Drive | undefined { @@ -85,10 +85,10 @@ function isExplicitBoot(model: configModel.Config, driveName: string): boolean { return !model.boot?.device?.default && driveName === model.boot?.device?.name; } -function driveHasPv(model: configModel.Config, driveAlias: string): boolean { - if (!driveAlias) return false; +function driveHasPv(model: configModel.Config, name: string): boolean { + if (!name) return false; - return model.volumeGroups.flatMap((g) => g.targetDevices).includes(driveAlias); + return model.volumeGroups.flatMap((g) => g.targetDevices).includes(name); } function allMountPaths(drive: configModel.Drive): string[] { @@ -439,7 +439,7 @@ export function useDrive(name: string): DriveHook | null { return { isBoot: isBoot(model, name), isExplicitBoot: isExplicitBoot(model, name), - hasPv: driveHasPv(model, drive.alias), + hasPv: driveHasPv(model, drive.name), allMountPaths: allMountPaths(drive), configuredExistingPartitions: configuredExistingPartitions(drive), switch: (newName) => mutate(switchDrive(model, name, newName)), From eab7a22616b40346c8c6251655d6bdcd52c58174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 13 Mar 2025 12:55:45 +0000 Subject: [PATCH 044/103] web: adapt components --- .../components/storage/ConfigEditor.test.tsx | 2 +- .../components/storage/SpaceActionsTable.tsx | 2 +- .../components/storage/VolumeGroupEditor.tsx | 4 +-- web/src/components/storage/utils/drive.tsx | 6 +--- .../components/storage/utils/partition.tsx | 34 +++++++++++++------ 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/web/src/components/storage/ConfigEditor.test.tsx b/web/src/components/storage/ConfigEditor.test.tsx index 8c2e3e90fd..2ad8b63599 100644 --- a/web/src/components/storage/ConfigEditor.test.tsx +++ b/web/src/components/storage/ConfigEditor.test.tsx @@ -100,7 +100,7 @@ describe("if no volume group is used for installation", () => { describe("if a volume group is used for installation", () => { beforeEach(() => { const modelData: apiModel.Config = { - volumeGroups: [{ name: "/dev/system" }], + volumeGroups: [{ vgName: "/dev/system" }], }; mockUseConfigModel.mockReturnValue(modelData); }); diff --git a/web/src/components/storage/SpaceActionsTable.tsx b/web/src/components/storage/SpaceActionsTable.tsx index 7dd5221255..3fe7c36200 100644 --- a/web/src/components/storage/SpaceActionsTable.tsx +++ b/web/src/components/storage/SpaceActionsTable.tsx @@ -49,7 +49,7 @@ import { Table, Td, Th, Tr, Thead, Tbody } from "@patternfly/react-table"; import { useConfigModel } from "~/queries/storage/config-model"; const isUsedPartition = (partition: configModel.Partition): boolean => { - return partition.filesystem !== undefined || partition.alias !== undefined; + return partition.filesystem !== undefined; }; // FIXME: there is too much logic here. This is one of those cases that should be considered diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 4cf7c1800c..9d0d72b973 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -63,7 +63,7 @@ const EditVgOption = () => { const VgMenu = ({ vg }: { vg: model.VolumeGroup }) => { return ( - {vg.name}}> + {vg.vgName}}> @@ -102,7 +102,7 @@ const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { export type VolumeGroupEditorProps = { vg: apiModel.VolumeGroup }; export default function VolumeGroupEditor({ vg }: VolumeGroupEditorProps) { - const volumeGroup = useVolumeGroup(vg.name); + const volumeGroup = useVolumeGroup(vg.vgName); return ( diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index 41d39d0e0e..e8c15018ba 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -// @ts-check - import { _, n_, formatList } from "~/i18n"; import { configModel } from "~/api/storage/types"; import { SpacePolicy, SPACE_POLICIES, baseName, formattedPath } from "~/components/storage/utils"; @@ -31,8 +29,6 @@ import { sprintf } from "sprintf-js"; * String to identify the drive. */ const label = (drive: configModel.Drive): string => { - if (drive.alias) return drive.alias; - return baseName(drive.name); }; diff --git a/web/src/components/storage/utils/partition.tsx b/web/src/components/storage/utils/partition.tsx index 98545476a8..d5af39149f 100644 --- a/web/src/components/storage/utils/partition.tsx +++ b/web/src/components/storage/utils/partition.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,17 +20,21 @@ * find current contact information at www.suse.com. */ -// @ts-check +/** + * @fixme This utils file smells. It accepts both partition and logical volume, but the file is + * called partition.tsx. Moreover, some logic (e.g., checking whether a file system is reused) can + * be moved to the model hook. + */ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { filesystemType, formattedPath, sizeDescription } from "~/components/storage/utils"; -import { configModel } from "~/api/storage/types"; +import * as apiModel from "~/api/storage/types/config-model"; /** - * String to identify the drive. + * String to identify the partition. */ -const pathWithSize = (partition: configModel.Partition | configModel.LogicalVolume): string => { +const pathWithSize = (partition: apiModel.Partition): string => { return sprintf( // TRANSLATORS: %1$s is an already formatted mount path (eg. "/"), // %2$s is a size description (eg. at least 10 GiB) @@ -41,12 +45,22 @@ const pathWithSize = (partition: configModel.Partition | configModel.LogicalVolu }; /** - * String to identify the type of partition to be created (or used). + * @fixme Workaround to make possible to distinguish between partition and logical volume. Note that + * a logical volume has not the property 'name' yet, see {@link typeDescription}. + */ +function isPartition( + device: apiModel.Partition | apiModel.LogicalVolume, +): device is apiModel.Partition { + return Object.hasOwn(device, "name"); +} + +/** + * String to identify the type of device to be created (or used). */ -const typeDescription = (partition: configModel.Partition | configModel.LogicalVolume): string => { +const typeDescription = (partition: apiModel.Partition | apiModel.LogicalVolume): string => { const fs = filesystemType(partition.filesystem); - if (partition.name) { + if (isPartition(partition) && partition.name) { if (partition.filesystem.reuse) { // TRANSLATORS: %1$s is a filesystem type (eg. Btrfs), %2$s is a device name (eg. /dev/sda3). if (fs) return sprintf(_("Current %1$s at %2$s"), fs, partition.name); @@ -64,7 +78,7 @@ const typeDescription = (partition: configModel.Partition | configModel.LogicalV /** * Combination of {@link typeDescription} and the size of the target partition. */ -const typeWithSize = (partition: configModel.Partition | configModel.LogicalVolume): string => { +const typeWithSize = (partition: apiModel.Partition | apiModel.LogicalVolume): string => { return sprintf( // TRANSLATORS: %1$s is a filesystem type description (eg. "Btrfs with snapshots"), // %2$s is a description of the size or the size limits (eg. "at least 10 GiB") @@ -74,4 +88,4 @@ const typeWithSize = (partition: configModel.Partition | configModel.LogicalVolu ); }; -export { pathWithSize, typeDescription, typeWithSize }; +export { pathWithSize, typeWithSize }; From f1a034f032f1bf7b240fa4ec5c9bf3661f3d0d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 14 Mar 2025 11:22:01 +0000 Subject: [PATCH 045/103] web: do not remove target pv drive - Avoid removing the previous boot device after changing boot if the device is used as target device for physical volumes. --- web/src/queries/storage/config-model.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 5a865d6c0b..ff80ce0a69 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -111,7 +111,11 @@ function configuredExistingPartitions(drive: configModel.Drive): configModel.Par function setBoot(originalModel: configModel.Config, boot: configModel.Boot) { const model = copyModel(originalModel); const name = model.boot?.device?.name; - const remove = name !== undefined && isExplicitBoot(model, name) && !isUsedDrive(model, name); + const remove = + name !== undefined && + isExplicitBoot(model, name) && + !isUsedDrive(model, name) && + !driveHasPv(model, name); if (remove) removeDrive(model, name); From 26be7cd8a059e64371f0bd4c3523940ccae3c97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 05:54:02 +0000 Subject: [PATCH 046/103] web: add references to the model - A volume group references its target devices. - A drive references the volume groups that use it as target device. --- web/src/hooks/storage/model.ts | 63 ++++++++++++++++++++++------------ web/src/types/storage/model.ts | 15 ++++---- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index 1e39652005..e78ec42ea8 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -25,12 +25,21 @@ import { configModelQuery } from "~/queries/storage/config-model"; import * as apiModel from "~/api/storage/types/config-model"; import * as model from "~/types/storage/model"; -function findDrive(modelData: apiModel.Config, name: string): apiModel.Drive | undefined { - return modelData.drives.find((d) => d.name === name); -} +const findDrive = (model: model.Model, name: string): model.Drive | undefined => { + return model.drives.find((d) => d.name === name); +}; + +function buildDrive(driveData: apiModel.Drive, model: model.Model): model.Drive { + const findVolumeGroups = (targetName: string): model.VolumeGroup[] => { + return model.volumeGroups.filter((v) => + v.getTargetDevices().some((d) => d.name === targetName), + ); + }; -function buildDrive(driveData: apiModel.Drive): model.Drive { - return { ...driveData }; + return { + ...driveData, + getVolumeGroups: () => findVolumeGroups(driveData.name), + }; } function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.LogicalVolume { @@ -39,37 +48,41 @@ function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.Lo function buildVolumeGroup( volumeGroupData: apiModel.VolumeGroup, - modelData: apiModel.Config, + model: model.Model, ): model.VolumeGroup { - const buildTargetDevices = (): model.Drive[] => { - const names = volumeGroupData.targetDevices || []; - return names - .map((n) => findDrive(modelData, n)) - .filter((d) => d) - .map(buildDrive); + const buildLogicalVolumes = (): model.LogicalVolume[] => { + return (volumeGroupData.logicalVolumes || []).map(buildLogicalVolume); }; - const buildLogicalVolumes = (): model.LogicalVolume[] => { - const logicalVolumesData = volumeGroupData.logicalVolumes || []; - return logicalVolumesData.map(buildLogicalVolume); + const findTargetDevices = (): model.Drive[] => { + return (volumeGroupData.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); }; return { ...volumeGroupData, - targetDevices: buildTargetDevices(), logicalVolumes: buildLogicalVolumes(), + getTargetDevices: findTargetDevices, }; } function buildModel(modelData: apiModel.Config): model.Model { - const buildVolumeGroups = (): model.VolumeGroup[] => { - const volumeGroupsData = modelData.volumeGroups || []; - return volumeGroupsData.map((v) => buildVolumeGroup(v, modelData)); + const model: model.Model = { + drives: [], + volumeGroups: [], }; - return { - volumeGroups: buildVolumeGroups(), + const buildDrives = (): model.Drive[] => { + return (modelData.drives || []).map((d) => buildDrive(d, model)); + }; + + const buildVolumeGroups = (): model.VolumeGroup[] => { + return (modelData.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); }; + + // Important! Modify the model object instead of assigning a new one. + model.drives = buildDrives(); + model.volumeGroups = buildVolumeGroups(); + return model; } function useModel(): model.Model | null { @@ -77,6 +90,12 @@ function useModel(): model.Model | null { return data ? buildModel(data) : null; } +function useDrive(name: string): model.Drive | null { + const model = useModel(); + const drive = model?.drives?.find((d) => d.name === name); + return drive || null; +} + function useVolumeGroup(vgName: string): model.VolumeGroup | null { const model = useModel(); const volumeGroup = model?.volumeGroups?.find((v) => v.vgName === vgName); @@ -85,4 +104,4 @@ function useVolumeGroup(vgName: string): model.VolumeGroup | null { export default useModel; -export { useVolumeGroup }; +export { useDrive, useVolumeGroup }; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index 4d3e4efdc5..138fd726a1 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -22,17 +22,20 @@ import * as apiModel from "~/api/storage/types/config-model"; -type Drive = apiModel.Drive; +type Model = { + drives: Drive[]; + volumeGroups: VolumeGroup[]; +}; -type LogicalVolume = apiModel.LogicalVolume; +interface Drive extends apiModel.Drive { + getVolumeGroups: () => VolumeGroup[]; +} interface VolumeGroup extends Omit { - targetDevices: Drive[]; + getTargetDevices: () => Drive[]; logicalVolumes: LogicalVolume[]; } -type Model = { - volumeGroups: VolumeGroup[]; -}; +type LogicalVolume = apiModel.LogicalVolume; export type { Model, Drive, VolumeGroup, LogicalVolume }; From ebb881af5500708225441f7be6041f378980a6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 05:56:10 +0000 Subject: [PATCH 047/103] fix(web): show name of the volume group - The drive editor shows the name of the first volume group that uses it as target device. --- web/src/components/storage/DriveEditor.tsx | 14 ++++++++------ web/src/components/storage/VolumeGroupEditor.tsx | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index a38baf1175..e27aa7cb53 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -30,6 +30,7 @@ import { configModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDrive } from "~/queries/storage/config-model"; +import { useDrive as useDriveModel } from "~/hooks/storage/model"; import * as driveUtils from "~/components/storage/utils/drive"; import { contentDescription } from "~/components/storage/utils/device"; import DeviceMenu from "~/components/storage/DeviceMenu"; @@ -126,12 +127,13 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { }; const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { + /** @todo Replace the useDrive hook from /queries by the hook from /hooks. */ + const volumeGroups = useDriveModel(drive.name)?.getVolumeGroups() || []; const driveModel = useDrive(drive.name); if (!driveModel) return; const { isBoot, isExplicitBoot, hasPv } = driveModel; - // TODO: Get volume groups associated to the drive. - const volumeGroups = []; + const vgName = volumeGroups[0]?.vgName; const mainText = (): string => { if (driveUtils.hasReuse(drive)) { @@ -154,12 +156,12 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { return sprintf( // TRANSLATORS: %s is the name of the LVM _("This device will contain the LVM group '%s' and any partition needed to boot"), - volumeGroups[0], + vgName, ); } // TRANSLATORS: %s is the name of the LVM - return sprintf(_("This device will contain the LVM group '%s'"), volumeGroups[0]); + return sprintf(_("This device will contain the LVM group '%s'"), vgName); } // The current device will be the only option to choose from @@ -213,15 +215,15 @@ const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { // TRANSLATORS: %1$s is the name of the disk (eg. sda) and %2$s the name of the LVM _("%1$s will still contain the LVM group '%2$s' and any partition needed to boot"), name, - volumeGroups[0], + vgName, ); } return sprintf( // TRANSLATORS: %1$s is the name of the LVM and %2$s the name of the disk (eg. sda) _("The LVM group '%1$s' will remain at %2$s"), + vgName, name, - volumeGroups[0], ); } diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 9d0d72b973..2ad734a75d 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -43,8 +43,8 @@ import { import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; const RemoveVgOption = ({ vg }: { vg: model.VolumeGroup }) => { - const device = vg.targetDevices[0]; - const desc = sprintf(_("The logical volumes will become partitions at %s"), device.name); + const device = vg.getTargetDevices()[0]; + const desc = sprintf(_("The logical volumes will become partitions at %s"), device?.name); return ( From 66decfe3c016b056a6675018ba7b16bae390fc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 06:18:51 +0000 Subject: [PATCH 048/103] web: mark config-model hooks as deprecated --- web/src/components/storage/PartitionPage.tsx | 4 ++++ web/src/queries/storage/config-model.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 135bfe2705..53ec77bdd6 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -1136,6 +1136,10 @@ function CustomSize({ value, onChange }: CustomSizeProps) { ); } +/** + * @fixme This component has to be adapted to use the new hooks from ~/hooks/storage/instead of the + * deprecated hooks from ~/queries/storage/config-model. + */ export default function PartitionPage() { const navigate = useNavigate(); const headingId = useId(); diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index ff80ce0a69..008be268c1 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -20,6 +20,8 @@ * find current contact information at www.suse.com. */ +/** @deprecated These hooks will be replaced by new hooks at ~/hooks/storage/ */ + import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { fetchConfigModel, setConfigModel, solveConfigModel } from "~/api/storage"; import { configModel, Volume } from "~/api/storage/types"; From c01f85c09541a1cc3386b3eee25764fa23f5d091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 06:19:13 +0000 Subject: [PATCH 049/103] fix(web): do not offer to create existing mount points from logical volumes --- web/src/queries/storage/config-model.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 008be268c1..f0865b2349 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -325,9 +325,11 @@ function setSpacePolicy( } function usedMountPaths(model: configModel.Config): string[] { - if (!model.drives) return []; + const drives = model.drives || []; + const volumeGroups = model.volumeGroups || []; + const logicalVolumes = volumeGroups.flatMap((v) => v.logicalVolumes || []); - return model.drives.flatMap(allMountPaths); + return [...drives, ...logicalVolumes].flatMap(allMountPaths); } function unusedMountPaths(model: configModel.Config, volumes: Volume[]): string[] { From fd360fe64a6cdfbce145269dfd510e0b96bece8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 09:05:24 +0000 Subject: [PATCH 050/103] chore(web): rename configModel as apiModel --- web/src/api/storage.ts | 8 +- web/src/api/storage/types.ts | 8 +- .../overview/StorageSection.test.tsx | 6 +- .../components/overview/StorageSection.tsx | 10 +- .../storage/AddExistingDeviceMenu.test.tsx | 6 +- .../components/storage/ConfigEditor.test.tsx | 2 +- .../components/storage/DriveEditor.test.tsx | 7 +- web/src/components/storage/DriveEditor.tsx | 10 +- .../components/storage/EncryptionSection.tsx | 4 +- .../storage/EncryptionSettingsPage.tsx | 4 +- .../components/storage/MountPathMenuItem.tsx | 2 +- .../components/storage/PartitionPage.test.tsx | 6 +- web/src/components/storage/PartitionPage.tsx | 22 ++-- .../storage/SpaceActionsTable.test.tsx | 4 +- .../components/storage/SpaceActionsTable.tsx | 6 +- .../storage/SpacePolicySelection.tsx | 4 +- .../components/storage/VolumeGroupEditor.tsx | 2 +- web/src/components/storage/utils.ts | 6 +- web/src/components/storage/utils/drive.tsx | 16 +-- .../components/storage/utils/partition.tsx | 2 +- web/src/hooks/storage/model.ts | 2 +- web/src/queries/storage.ts | 7 +- web/src/queries/storage/config-model.ts | 116 +++++++++--------- web/src/types/storage/model.ts | 2 +- 24 files changed, 128 insertions(+), 134 deletions(-) diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 22d2556576..d681383355 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -22,7 +22,7 @@ import { get, post, put } from "~/api/http"; import { Job } from "~/types/job"; -import { Action, config, configModel, ProductParams, Volume } from "~/api/storage/types"; +import { Action, config, apiModel, ProductParams, Volume } from "~/api/storage/types"; /** * Starts the storage probing process. @@ -36,16 +36,16 @@ const reprobe = (): Promise => post("/api/storage/reprobe"); const fetchConfig = (): Promise => get("/api/storage/config").then((config) => config.storage ?? null); -const fetchConfigModel = (): Promise => +const fetchConfigModel = (): Promise => get("/api/storage/config_model"); const setConfig = (config: config.Config) => put("/api/storage/config", { storage: config }); const resetConfig = () => put("/api/storage/config/reset", {}); -const setConfigModel = (model: configModel.Config) => put("/api/storage/config_model", model); +const setConfigModel = (model: apiModel.Config) => put("/api/storage/config_model", model); -const solveConfigModel = (model: configModel.Config): Promise => { +const solveConfigModel = (model: apiModel.Config): Promise => { const serializedModel = encodeURIComponent(JSON.stringify(model)); return get(`/api/storage/config_model/solve?model=${serializedModel}`); }; diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index 216d0eda10..740a39fdf8 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -import * as config from "./types/config"; -import * as configModel from "./types/config-model"; - export * from "./types/openapi"; -export { config, configModel }; +export * as config from "./types/config"; +export * as apiModel from "./types/config-model"; diff --git a/web/src/components/overview/StorageSection.test.tsx b/web/src/components/overview/StorageSection.test.tsx index 1ee9c1515e..91fc00026a 100644 --- a/web/src/components/overview/StorageSection.test.tsx +++ b/web/src/components/overview/StorageSection.test.tsx @@ -24,16 +24,16 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { StorageSection } from "~/components/overview"; -import * as ConfigModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { Issue } from "~/types/issues"; -const sdaDrive: ConfigModel.Drive = { +const sdaDrive: apiModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [], }; -const sdbDrive: ConfigModel.Drive = { +const sdbDrive: apiModel.Drive = { name: "/dev/sdb", spacePolicy: "delete", partitions: [], diff --git a/web/src/components/overview/StorageSection.tsx b/web/src/components/overview/StorageSection.tsx index b3c8eb5246..aed82411af 100644 --- a/web/src/components/overview/StorageSection.tsx +++ b/web/src/components/overview/StorageSection.tsx @@ -27,15 +27,15 @@ import { useDevices, useAvailableDevices } from "~/queries/storage"; import { useConfigModel } from "~/queries/storage/config-model"; import { useSystemErrors } from "~/queries/issues"; import { StorageDevice } from "~/types/storage"; -import * as ConfigModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { _ } from "~/i18n"; -const findDriveDevice = (drive: ConfigModel.Drive, devices: StorageDevice[]) => +const findDriveDevice = (drive: apiModel.Drive, devices: StorageDevice[]) => devices.find((d) => d.name === drive.name); const NoDeviceSummary = () => _("No device selected yet"); -const SingleDiskSummary = ({ drive }: { drive: ConfigModel.Drive }) => { +const SingleDiskSummary = ({ drive }: { drive: apiModel.Drive }) => { const devices = useDevices("system", { suspense: true }); const device = findDriveDevice(drive, devices); const options = { @@ -64,7 +64,7 @@ const SingleDiskSummary = ({ drive }: { drive: ConfigModel.Drive }) => { ); }; -const MultipleDisksSummary = ({ drives }: { drives: ConfigModel.Drive[] }): string => { +const MultipleDisksSummary = ({ drives }: { drives: apiModel.Drive[] }): string => { const options = { resize: _("Install using several devices shrinking existing partitions as needed."), keep: _("Install using several devices without modifying existing partitions."), @@ -79,7 +79,7 @@ const MultipleDisksSummary = ({ drives }: { drives: ConfigModel.Drive[] }): stri return options[drives[0].spacePolicy]; }; -const ModelSummary = ({ model }: { model: ConfigModel.Config }): React.ReactNode => { +const ModelSummary = ({ model }: { model: apiModel.Config }): React.ReactNode => { const devices = useDevices("system", { suspense: true }); const drives = model?.drives || []; const existDevice = (name: string) => devices.some((d) => d.name === name); diff --git a/web/src/components/storage/AddExistingDeviceMenu.test.tsx b/web/src/components/storage/AddExistingDeviceMenu.test.tsx index b2dcf13f74..e6effb9483 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.test.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.test.tsx @@ -25,7 +25,7 @@ import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import AddExistingDeviceMenu from "~/components/storage/AddExistingDeviceMenu"; import { StorageDevice } from "~/types/storage"; -import * as ConfigModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; const vda: StorageDevice = { sid: 59, @@ -55,13 +55,13 @@ const vdb: StorageDevice = { systems: [], }; -const vdaDrive: ConfigModel.Drive = { +const vdaDrive: apiModel.Drive = { name: "/dev/vda", spacePolicy: "delete", partitions: [], }; -const vdbDrive: ConfigModel.Drive = { +const vdbDrive: apiModel.Drive = { name: "/dev/vdb", spacePolicy: "delete", partitions: [], diff --git a/web/src/components/storage/ConfigEditor.test.tsx b/web/src/components/storage/ConfigEditor.test.tsx index 2ad8b63599..720e9a3033 100644 --- a/web/src/components/storage/ConfigEditor.test.tsx +++ b/web/src/components/storage/ConfigEditor.test.tsx @@ -25,7 +25,7 @@ import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import ConfigEditor from "~/components/storage/ConfigEditor"; import { StorageDevice } from "~/types/storage"; -import * as apiModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; const disk: StorageDevice = { sid: 60, diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index 3e2449c9d6..ea5b55dae0 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -24,9 +24,8 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender, mockNavigateFn } from "~/test-utils"; import DriveEditor from "~/components/storage/DriveEditor"; -import * as ConfigModel from "~/api/storage/types/config-model"; import { StorageDevice } from "~/types/storage"; -import { Volume } from "~/api/storage/types"; +import { apiModel, Volume } from "~/api/storage/types"; const volume1: Volume = { mountPath: "/", @@ -122,7 +121,7 @@ const sdb: StorageDevice = { description: "", }; -const drive1: ConfigModel.Drive = { +const drive1: apiModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [ @@ -145,7 +144,7 @@ const drive1: ConfigModel.Drive = { ], }; -const drive2: ConfigModel.Drive = { +const drive2: apiModel.Drive = { name: "/dev/sdb", spacePolicy: "delete", partitions: [ diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index e27aa7cb53..605b2ef27c 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -26,7 +26,7 @@ import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; import { baseName, deviceLabel, formattedPath, SPACE_POLICIES } from "~/components/storage/utils"; import { useAvailableDevices } from "~/queries/storage"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDrive } from "~/queries/storage/config-model"; @@ -54,7 +54,7 @@ import { import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; -export type DriveEditorProps = { drive: configModel.Drive; driveDevice: StorageDevice }; +export type DriveEditorProps = { drive: apiModel.Drive; driveDevice: StorageDevice }; // FIXME: Presentation is quite poor const SpacePolicySelectorIntro = ({ device }) => { @@ -82,7 +82,7 @@ const SpacePolicySelectorIntro = ({ device }) => { const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { const navigate = useNavigate(); const { setSpacePolicy } = useDrive(drive.name); - const onSpacePolicyChange = (spacePolicy: configModel.SpacePolicy) => { + const onSpacePolicyChange = (spacePolicy: apiModel.SpacePolicy) => { if (spacePolicy === "custom") { return navigate(generatePath(PATHS.findSpace, { id: baseName(drive.name) })); } else { @@ -126,7 +126,7 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { ); }; -const SearchSelectorIntro = ({ drive }: { drive: configModel.Drive }) => { +const SearchSelectorIntro = ({ drive }: { drive: apiModel.Drive }) => { /** @todo Replace the useDrive hook from /queries by the hook from /hooks. */ const volumeGroups = useDriveModel(drive.name)?.getVolumeGroups() || []; const driveModel = useDrive(drive.name); @@ -393,7 +393,7 @@ const DriveSelector = ({ drive, selected, toggleAriaLabel }) => { const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { const { isBoot, hasPv } = useDrive(drive.name); - const text = (drive: configModel.Drive): string => { + const text = (drive: apiModel.Drive): string => { if (driveUtils.hasRoot(drive)) { if (hasPv) { if (isBoot) { diff --git a/web/src/components/storage/EncryptionSection.tsx b/web/src/components/storage/EncryptionSection.tsx index b4aa77f20e..22974b4bc1 100644 --- a/web/src/components/storage/EncryptionSection.tsx +++ b/web/src/components/storage/EncryptionSection.tsx @@ -24,11 +24,11 @@ import React from "react"; import { Card, CardBody, Content } from "@patternfly/react-core"; import { Link, Page } from "~/components/core"; import { useEncryption } from "~/queries/storage/config-model"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; -function encryptionLabel(method?: EncryptionMethod) { +function encryptionLabel(method?: apiModel.EncryptionMethod) { if (!method) return _("Encryption is disabled"); if (method === "tpmFde") return _("Encryption is enabled using TPM unlocking"); diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx index bb4e938f34..634c5e3ff2 100644 --- a/web/src/components/storage/EncryptionSettingsPage.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -26,7 +26,7 @@ import { ActionGroup, Alert, Checkbox, Content, Form, Switch } from "@patternfly import { Page, PasswordAndConfirmationInput } from "~/components/core"; import { useEncryptionMethods } from "~/queries/storage"; import { useEncryption } from "~/queries/storage/config-model"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import { isEmpty } from "~/utils"; import { _ } from "~/i18n"; @@ -41,7 +41,7 @@ export default function EncryptionSettingsPage() { const [errors, setErrors] = useState([]); const [isEnabled, setIsEnabled] = useState(false); const [password, setPassword] = useState(""); - const [method, setMethod] = useState("luks2"); + const [method, setMethod] = useState("luks2"); const passwordRef = useRef(); const formId = "encryptionSettingsForm"; diff --git a/web/src/components/storage/MountPathMenuItem.tsx b/web/src/components/storage/MountPathMenuItem.tsx index 6f8ef3ab29..8e8683eb12 100644 --- a/web/src/components/storage/MountPathMenuItem.tsx +++ b/web/src/components/storage/MountPathMenuItem.tsx @@ -26,7 +26,7 @@ import { useVolume } from "~/queries/storage"; import * as partitionUtils from "~/components/storage/utils/partition"; import { Icon } from "~/components/layout"; import { MenuItem, MenuItemAction } from "@patternfly/react-core"; -import * as apiModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; export type MountPathMenuItemProps = { device: apiModel.Partition | apiModel.LogicalVolume; diff --git a/web/src/components/storage/PartitionPage.test.tsx b/web/src/components/storage/PartitionPage.test.tsx index a9a940f9b4..c9119a4d95 100644 --- a/web/src/components/storage/PartitionPage.test.tsx +++ b/web/src/components/storage/PartitionPage.test.tsx @@ -25,7 +25,7 @@ import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import PartitionPage from "./PartitionPage"; import { StorageDevice } from "~/types/storage"; -import { configModel, Volume } from "~/api/storage/types"; +import { apiModel, Volume } from "~/api/storage/types"; import { gib } from "./utils"; jest.mock("~/queries/issues", () => ({ @@ -76,7 +76,7 @@ const sda: StorageDevice = { description: "", }; -const mockDrive: configModel.Drive = { +const mockDrive: apiModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [ @@ -99,7 +99,7 @@ const mockDrive: configModel.Drive = { ], }; -const mockSolvedConfigModel: configModel.Config = { +const mockSolvedConfigModel: apiModel.Config = { drives: [mockDrive], }; diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 53ec77bdd6..de980139d6 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -65,7 +65,7 @@ import { } from "~/components/storage/utils"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { STORAGE as PATHS } from "~/routes/paths"; import { compact, uniq } from "~/utils"; @@ -101,14 +101,14 @@ type ErrorsHandler = { getVisibleError: (id: string) => Error | undefined; }; -function toPartitionConfig(value: FormValue): configModel.Partition { +function toPartitionConfig(value: FormValue): apiModel.Partition { const name = (): string | undefined => { if (value.target === NO_VALUE || value.target === NEW_PARTITION) return undefined; return value.target; }; - const filesystemType = (): configModel.FilesystemType | undefined => { + const filesystemType = (): apiModel.FilesystemType | undefined => { if (value.filesystem === NO_VALUE) return undefined; if (value.filesystem === BTRFS_SNAPSHOTS) return "btrfs"; @@ -119,10 +119,10 @@ function toPartitionConfig(value: FormValue): configModel.Partition { * This will be fixed in the future by directly exporting the volumes as a JSON, similar to the * config model. The schema for the volumes will define the explicit list of filesystem types. */ - return value.filesystem as configModel.FilesystemType; + return value.filesystem as apiModel.FilesystemType; }; - const filesystem = (): configModel.Filesystem | undefined => { + const filesystem = (): apiModel.Filesystem | undefined => { if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true, default: true }; const type = filesystemType(); @@ -136,7 +136,7 @@ function toPartitionConfig(value: FormValue): configModel.Partition { }; }; - const size = (): configModel.Size | undefined => { + const size = (): apiModel.Size | undefined => { if (value.sizeOption === "auto") return undefined; if (value.minSize === NO_VALUE) return undefined; @@ -155,7 +155,7 @@ function toPartitionConfig(value: FormValue): configModel.Partition { }; } -function toFormValue(partitionConfig: configModel.Partition): FormValue { +function toFormValue(partitionConfig: apiModel.Partition): FormValue { const mountPoint = (): string => partitionConfig.mountPath || NO_VALUE; const target = (): string => partitionConfig.name || NEW_PARTITION; @@ -219,7 +219,7 @@ function useDefaultFilesystem(mountPoint: string): string { return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; } -function useInitialPartitionConfig(): configModel.Partition | null { +function useInitialPartitionConfig(): apiModel.Partition | null { const { partitionId: mountPath } = useParams(); const device = useDevice(); const drive = useDrive(device?.name); @@ -383,7 +383,7 @@ function useErrors(value: FormValue): ErrorsHandler { return { errors, getError, getVisibleError }; } -function useSolvedModel(value: FormValue): configModel.Config | null { +function useSolvedModel(value: FormValue): apiModel.Config | null { const device = useDevice(); const model = useConfigModel(); const { errors } = useErrors(value); @@ -392,7 +392,7 @@ function useSolvedModel(value: FormValue): configModel.Config | null { partitionConfig.size = undefined; if (partitionConfig.filesystem) partitionConfig.filesystem.label = undefined; - let sparseModel: configModel.Config | undefined; + let sparseModel: apiModel.Config | undefined; if (device && !errors.length && value.target === NEW_PARTITION && value.filesystem !== NO_VALUE) { /** @@ -417,7 +417,7 @@ function useSolvedModel(value: FormValue): configModel.Config | null { return solvedModel; } -function useSolvedPartitionConfig(value: FormValue): configModel.Partition | undefined { +function useSolvedPartitionConfig(value: FormValue): apiModel.Partition | undefined { const model = useSolvedModel(value); const device = useDevice(); const drive = model?.drives?.find((d) => d.name === device.name); diff --git a/web/src/components/storage/SpaceActionsTable.test.tsx b/web/src/components/storage/SpaceActionsTable.test.tsx index 1de9f9d53b..71c6823789 100644 --- a/web/src/components/storage/SpaceActionsTable.test.tsx +++ b/web/src/components/storage/SpaceActionsTable.test.tsx @@ -28,7 +28,7 @@ import { deviceChildren, gib } from "~/components/storage/utils"; import { plainRender } from "~/test-utils"; import SpaceActionsTable, { SpaceActionsTableProps } from "~/components/storage/SpaceActionsTable"; import { StorageDevice } from "~/types/storage"; -import * as configModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; const sda: StorageDevice = { sid: 59, @@ -81,7 +81,7 @@ sda.partitionTable = { unusedSlots: [{ start: 3, size: gib(2) }], }; -const mockDrive: configModel.Drive = { +const mockDrive: apiModel.Drive = { name: "/dev/sda", partitions: [ { diff --git a/web/src/components/storage/SpaceActionsTable.tsx b/web/src/components/storage/SpaceActionsTable.tsx index 3fe7c36200..d4b97c3f26 100644 --- a/web/src/components/storage/SpaceActionsTable.tsx +++ b/web/src/components/storage/SpaceActionsTable.tsx @@ -43,18 +43,18 @@ import { } from "~/components/storage/device-utils"; import { Icon } from "~/components/layout"; import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { TreeTableColumn } from "~/components/core/TreeTable"; import { Table, Td, Th, Tr, Thead, Tbody } from "@patternfly/react-table"; import { useConfigModel } from "~/queries/storage/config-model"; -const isUsedPartition = (partition: configModel.Partition): boolean => { +const isUsedPartition = (partition: apiModel.Partition): boolean => { return partition.filesystem !== undefined; }; // FIXME: there is too much logic here. This is one of those cases that should be considered // when restructuring the hooks and queries. -const useReusedPartition = (name: string): configModel.Partition | undefined => { +const useReusedPartition = (name: string): apiModel.Partition | undefined => { const model = useConfigModel(); if (!model || !name) return; diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index d43d607484..b9960b2750 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -28,14 +28,14 @@ import { SpaceActionsTable } from "~/components/storage"; import { baseName, deviceChildren } from "~/components/storage/utils"; import { _ } from "~/i18n"; import { PartitionSlot, SpacePolicyAction, StorageDevice } from "~/types/storage"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { useDevices } from "~/queries/storage"; import { useConfigModel, useDrive } from "~/queries/storage/config-model"; import { toStorageDevice } from "./device-utils"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import { sprintf } from "sprintf-js"; -const partitionAction = (partition: configModel.Partition) => { +const partitionAction = (partition: apiModel.Partition) => { if (partition.delete) return "delete"; if (partition.resizeIfNeeded) return "resizeIfNeeded"; diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 2ad734a75d..c980976b7b 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -23,7 +23,7 @@ import React from "react"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; -import * as apiModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import * as model from "~/types/storage/model"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { useVolumeGroup } from "~/hooks/storage/model"; diff --git a/web/src/components/storage/utils.ts b/web/src/components/storage/utils.ts index 00680ca7fa..ef0ec1e5b9 100644 --- a/web/src/components/storage/utils.ts +++ b/web/src/components/storage/utils.ts @@ -33,7 +33,7 @@ import xbytes from "xbytes"; import { _, N_ } from "~/i18n"; import { PartitionSlot, StorageDevice } from "~/types/storage"; -import { configModel, Volume } from "~/api/storage/types"; +import { apiModel, Volume } from "~/api/storage/types"; import { sprintf } from "sprintf-js"; /** @@ -296,7 +296,7 @@ const filesystemLabel = (fstype: string): string => { * * @returns undefined if there is not enough information */ -const filesystemType = (filesystem: configModel.Filesystem): string | undefined => { +const filesystemType = (filesystem: apiModel.Filesystem): string | undefined => { if (filesystem.type) { if (filesystem.snapshots) return _("Btrfs with snapshots"); @@ -324,7 +324,7 @@ const formattedPath = (path: string): string => { /** * Representation of the given size limits. */ -const sizeDescription = (size: configModel.Size): string => { +const sizeDescription = (size: apiModel.Size): string => { const minSize = deviceSize(size.min); const maxSize = size.max ? deviceSize(size.max) : undefined; diff --git a/web/src/components/storage/utils/drive.tsx b/web/src/components/storage/utils/drive.tsx index e8c15018ba..b4f90e98d1 100644 --- a/web/src/components/storage/utils/drive.tsx +++ b/web/src/components/storage/utils/drive.tsx @@ -21,18 +21,18 @@ */ import { _, n_, formatList } from "~/i18n"; -import { configModel } from "~/api/storage/types"; +import { apiModel } from "~/api/storage/types"; import { SpacePolicy, SPACE_POLICIES, baseName, formattedPath } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; /** * String to identify the drive. */ -const label = (drive: configModel.Drive): string => { +const label = (drive: apiModel.Drive): string => { return baseName(drive.name); }; -const spacePolicyEntry = (drive: configModel.Drive): SpacePolicy => { +const spacePolicyEntry = (drive: apiModel.Drive): SpacePolicy => { return SPACE_POLICIES.find((p) => p.id === drive.spacePolicy); }; @@ -70,7 +70,7 @@ const resizeTextFor = (partitions) => { * FIXME: the case with two sentences looks a bit weird. But trying to summarize everything in one * sentence was too hard. */ -const contentActionsDescription = (drive: configModel.Drive): string => { +const contentActionsDescription = (drive: apiModel.Drive): string => { const policyLabel = spacePolicyEntry(drive).summaryLabel; // eslint-disable-next-line agama-i18n/string-literals @@ -98,7 +98,7 @@ const contentActionsDescription = (drive: configModel.Drive): string => { * FIXME: right now, this considers only the case in which the drive is going to host some formatted * partitions. */ -const contentDescription = (drive: configModel.Drive): string => { +const contentDescription = (drive: apiModel.Drive): string => { const newPartitions = drive.partitions.filter((p) => !p.name); const reusedPartitions = drive.partitions.filter((p) => p.name && p.mountPath); @@ -141,15 +141,15 @@ const contentDescription = (drive: configModel.Drive): string => { return sprintf(_("Partitions will be used and created for %s"), formatList(mountPaths)); }; -const hasFilesystem = (drive: configModel.Drive): boolean => { +const hasFilesystem = (drive: apiModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath); }; -const hasRoot = (drive: configModel.Drive): boolean => { +const hasRoot = (drive: apiModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath && p.mountPath === "/"); }; -const hasReuse = (drive: configModel.Drive): boolean => { +const hasReuse = (drive: apiModel.Drive): boolean => { return drive.partitions && drive.partitions.some((p) => p.mountPath && p.name); }; diff --git a/web/src/components/storage/utils/partition.tsx b/web/src/components/storage/utils/partition.tsx index d5af39149f..fc936d726c 100644 --- a/web/src/components/storage/utils/partition.tsx +++ b/web/src/components/storage/utils/partition.tsx @@ -29,7 +29,7 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { filesystemType, formattedPath, sizeDescription } from "~/components/storage/utils"; -import * as apiModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; /** * String to identify the partition. diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index e78ec42ea8..f8e1e0c9b0 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -22,7 +22,7 @@ import { useQuery } from "@tanstack/react-query"; import { configModelQuery } from "~/queries/storage/config-model"; -import * as apiModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; import * as model from "~/types/storage/model"; const findDrive = (model: model.Model, name: string): model.Drive | undefined => { diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index 59c8ba3094..a1e93a2599 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -34,8 +34,7 @@ import { } from "~/api/storage"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { useInstallerClient } from "~/context/installer"; -import { config, ProductParams, Volume } from "~/api/storage/types"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { config, apiModel, ProductParams, Volume } from "~/api/storage/types"; import { Action, StorageDevice } from "~/types/storage"; import { QueryHookOptions } from "~/types/queries"; @@ -154,10 +153,10 @@ const useProductParams = (options?: QueryHookOptions): ProductParams => { * @note The ids of the encryption methods reported by product params are different to the * EncryptionMethod values. This should be fixed at the bakcend size. */ -const useEncryptionMethods = (options?: QueryHookOptions): EncryptionMethod[] => { +const useEncryptionMethods = (options?: QueryHookOptions): apiModel.EncryptionMethod[] => { const productParams = useProductParams(options); - const encryptionMethods = React.useMemo((): EncryptionMethod[] => { + const encryptionMethods = React.useMemo((): apiModel.EncryptionMethod[] => { const conversions = { luks1: "luks1", luks2: "luks2", diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index f0865b2349..c88875dc34 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -24,43 +24,42 @@ import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { fetchConfigModel, setConfigModel, solveConfigModel } from "~/api/storage"; -import { configModel, Volume } from "~/api/storage/types"; -import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { apiModel, Volume } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; import { SpacePolicyAction } from "~/types/storage"; import { useVolumes } from "~/queries/storage"; -function copyModel(model: configModel.Config): configModel.Config { +function copyModel(model: apiModel.Config): apiModel.Config { return JSON.parse(JSON.stringify(model)); } -function isNewPartition(partition: configModel.Partition): boolean { +function isNewPartition(partition: apiModel.Partition): boolean { return partition.name === undefined; } -function isSpacePartition(partition: configModel.Partition): boolean { +function isSpacePartition(partition: apiModel.Partition): boolean { return partition.resizeIfNeeded || partition.delete || partition.deleteIfNeeded; } -function isUsedPartition(partition: configModel.Partition): boolean { +function isUsedPartition(partition: apiModel.Partition): boolean { return partition.filesystem !== undefined; } -function isReusedPartition(partition: configModel.Partition): boolean { +function isReusedPartition(partition: apiModel.Partition): boolean { return !isNewPartition(partition) && isUsedPartition(partition); } -function findDrive(model: configModel.Config, driveName: string): configModel.Drive | undefined { +function findDrive(model: apiModel.Config, driveName: string): apiModel.Drive | undefined { const drives = model?.drives || []; return drives.find((d) => d.name === driveName); } -function removeDrive(model: configModel.Config, driveName: string): configModel.Config { +function removeDrive(model: apiModel.Config, driveName: string): apiModel.Config { model.drives = model.drives.filter((d) => d.name !== driveName); return model; } -function isUsedDrive(model: configModel.Config, driveName: string) { +function isUsedDrive(model: apiModel.Config, driveName: string) { const drive = findDrive(model, driveName); if (drive === undefined) return false; @@ -68,10 +67,10 @@ function isUsedDrive(model: configModel.Config, driveName: string) { } function findPartition( - model: configModel.Config, + model: apiModel.Config, driveName: string, mountPath: string, -): configModel.Partition | undefined { +): apiModel.Partition | undefined { const drive = findDrive(model, driveName); if (drive === undefined) return undefined; @@ -79,27 +78,27 @@ function findPartition( return partitions.find((p) => p.mountPath === mountPath); } -function isBoot(model: configModel.Config, driveName: string): boolean { +function isBoot(model: apiModel.Config, driveName: string): boolean { return model.boot?.configure && driveName === model.boot?.device?.name; } -function isExplicitBoot(model: configModel.Config, driveName: string): boolean { +function isExplicitBoot(model: apiModel.Config, driveName: string): boolean { return !model.boot?.device?.default && driveName === model.boot?.device?.name; } -function driveHasPv(model: configModel.Config, name: string): boolean { +function driveHasPv(model: apiModel.Config, name: string): boolean { if (!name) return false; return model.volumeGroups.flatMap((g) => g.targetDevices).includes(name); } -function allMountPaths(drive: configModel.Drive): string[] { +function allMountPaths(drive: apiModel.Drive): string[] { if (drive.mountPath) return [drive.mountPath]; return drive.partitions.map((p) => p.mountPath).filter((m) => m); } -function configuredExistingPartitions(drive: configModel.Drive): configModel.Partition[] { +function configuredExistingPartitions(drive: apiModel.Drive): apiModel.Partition[] { const allPartitions = drive.partitions || []; if (drive.spacePolicy === "custom") @@ -110,7 +109,7 @@ function configuredExistingPartitions(drive: configModel.Drive): configModel.Par return allPartitions.filter((p) => isReusedPartition(p)); } -function setBoot(originalModel: configModel.Config, boot: configModel.Boot) { +function setBoot(originalModel: apiModel.Config, boot: apiModel.Boot) { const model = copyModel(originalModel); const name = model.boot?.device?.name; const remove = @@ -125,7 +124,7 @@ function setBoot(originalModel: configModel.Config, boot: configModel.Boot) { return model; } -function setBootDevice(originalModel: configModel.Config, deviceName: string): configModel.Config { +function setBootDevice(originalModel: apiModel.Config, deviceName: string): apiModel.Config { return setBoot(originalModel, { configure: true, device: { @@ -135,7 +134,7 @@ function setBootDevice(originalModel: configModel.Config, deviceName: string): c }); } -function setDefaultBootDevice(originalModel: configModel.Config): configModel.Config { +function setDefaultBootDevice(originalModel: apiModel.Config): apiModel.Config { return setBoot(originalModel, { configure: true, device: { @@ -144,31 +143,31 @@ function setDefaultBootDevice(originalModel: configModel.Config): configModel.Co }); } -function disableBoot(originalModel: configModel.Config): configModel.Config { +function disableBoot(originalModel: apiModel.Config): apiModel.Config { return setBoot(originalModel, { configure: false }); } function setEncryption( - originalModel: configModel.Config, - method: EncryptionMethod, + originalModel: apiModel.Config, + method: apiModel.EncryptionMethod, password: string, -): configModel.Config { +): apiModel.Config { const model = copyModel(originalModel); model.encryption = { method, password }; return model; } -function disableEncryption(originalModel: configModel.Config): configModel.Config { +function disableEncryption(originalModel: apiModel.Config): apiModel.Config { const model = copyModel(originalModel); model.encryption = null; return model; } function deletePartition( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, mountPath: string, -): configModel.Config { +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); if (drive === undefined) return; @@ -185,10 +184,10 @@ function deletePartition( * the partition is replaced. * */ export function addPartition( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, - partition: configModel.Partition, -): configModel.Config { + partition: apiModel.Partition, +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); if (drive === undefined) return; @@ -203,11 +202,11 @@ export function addPartition( } export function editPartition( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, mountPath: string, - partition: configModel.Partition, -): configModel.Config { + partition: apiModel.Partition, +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); const partitions = drive?.partitions || []; @@ -220,10 +219,10 @@ export function editPartition( } function switchDrive( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, newDriveName: string, -): configModel.Config { +): apiModel.Config { if (driveName === newDriveName) return; const model = copyModel(originalModel); @@ -256,7 +255,7 @@ function switchDrive( return model; } -function addDrive(originalModel: configModel.Config, driveName: string): configModel.Config { +function addDrive(originalModel: apiModel.Config, driveName: string): apiModel.Config { if (findDrive(originalModel, driveName)) return; const model = copyModel(originalModel); @@ -266,10 +265,10 @@ function addDrive(originalModel: configModel.Config, driveName: string): configM } function setCustomSpacePolicy( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, actions: SpacePolicyAction[], -): configModel.Config { +): apiModel.Config { const model = copyModel(originalModel); const drive = findDrive(model, driveName); if (drive === undefined) return model; @@ -309,11 +308,11 @@ function setCustomSpacePolicy( } function setSpacePolicy( - originalModel: configModel.Config, + originalModel: apiModel.Config, driveName: string, - spacePolicy: configModel.SpacePolicy, + spacePolicy: apiModel.SpacePolicy, actions?: SpacePolicyAction[], -): configModel.Config { +): apiModel.Config { if (spacePolicy === "custom") return setCustomSpacePolicy(originalModel, driveName, actions || []); @@ -324,7 +323,7 @@ function setSpacePolicy( return model; } -function usedMountPaths(model: configModel.Config): string[] { +function usedMountPaths(model: apiModel.Config): string[] { const drives = model.drives || []; const volumeGroups = model.volumeGroups || []; const logicalVolumes = volumeGroups.flatMap((v) => v.logicalVolumes || []); @@ -332,7 +331,7 @@ function usedMountPaths(model: configModel.Config): string[] { return [...drives, ...logicalVolumes].flatMap(allMountPaths); } -function unusedMountPaths(model: configModel.Config, volumes: Volume[]): string[] { +function unusedMountPaths(model: apiModel.Config, volumes: Volume[]): string[] { const volPaths = volumes.filter((v) => v.mountPath.length).map((v) => v.mountPath); const assigned = usedMountPaths(model); return volPaths.filter((p) => !assigned.includes(p)); @@ -347,7 +346,7 @@ const configModelQuery = { /** * Hook that returns the config model. */ -export function useConfigModel(options?: QueryHookOptions): configModel.Config { +export function useConfigModel(options?: QueryHookOptions): apiModel.Config { const query = configModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); @@ -360,7 +359,7 @@ export function useConfigModel(options?: QueryHookOptions): configModel.Config { export function useConfigModelMutation() { const queryClient = useQueryClient(); const query = { - mutationFn: (model: configModel.Config) => setConfigModel(model), + mutationFn: (model: apiModel.Config) => setConfigModel(model), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), }; @@ -371,7 +370,7 @@ export function useConfigModelMutation() { * @todo Use a hash key from the model object as id for the query. * Hook that returns the config model. */ -export function useSolvedConfigModel(model?: configModel.Config): configModel.Config | null { +export function useSolvedConfigModel(model?: apiModel.Config): apiModel.Config | null { const query = useSuspenseQuery({ queryKey: ["storage", "solvedConfigModel", JSON.stringify(model)], queryFn: () => (model ? solveConfigModel(model) : Promise.resolve(null)), @@ -405,8 +404,8 @@ export function useBoot(): BootHook { } export type EncryptionHook = { - encryption?: configModel.Encryption; - enable: (method: EncryptionMethod, password: string) => void; + encryption?: apiModel.Encryption; + enable: (method: apiModel.EncryptionMethod, password: string) => void; disable: () => void; }; @@ -416,7 +415,7 @@ export function useEncryption(): EncryptionHook { return { encryption: model?.encryption, - enable: (method: EncryptionMethod, password: string) => + enable: (method: apiModel.EncryptionMethod, password: string) => mutate(setEncryption(model, method, password)), disable: () => mutate(disableEncryption(model)), }; @@ -427,13 +426,13 @@ export type DriveHook = { isExplicitBoot: boolean; hasPv: boolean; allMountPaths: string[]; - configuredExistingPartitions: configModel.Partition[]; + configuredExistingPartitions: apiModel.Partition[]; switch: (newName: string) => void; - getPartition: (mountPath: string) => configModel.Partition | undefined; - addPartition: (partition: configModel.Partition) => void; - editPartition: (mountPath: string, partition: configModel.Partition) => void; + getPartition: (mountPath: string) => apiModel.Partition | undefined; + addPartition: (partition: apiModel.Partition) => void; + editPartition: (mountPath: string, partition: apiModel.Partition) => void; deletePartition: (mountPath: string) => void; - setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => void; + setSpacePolicy: (policy: apiModel.SpacePolicy, actions?: SpacePolicyAction[]) => void; delete: () => void; }; @@ -453,18 +452,17 @@ export function useDrive(name: string): DriveHook | null { switch: (newName) => mutate(switchDrive(model, name, newName)), delete: () => mutate(removeDrive(model, name)), getPartition: (mountPath: string) => findPartition(model, name, mountPath), - addPartition: (partition: configModel.Partition) => - mutate(addPartition(model, name, partition)), - editPartition: (mountPath: string, partition: configModel.Partition) => + addPartition: (partition: apiModel.Partition) => mutate(addPartition(model, name, partition)), + editPartition: (mountPath: string, partition: apiModel.Partition) => mutate(editPartition(model, name, mountPath, partition)), deletePartition: (mountPath: string) => mutate(deletePartition(model, name, mountPath)), - setSpacePolicy: (policy: configModel.SpacePolicy, actions?: SpacePolicyAction[]) => + setSpacePolicy: (policy: apiModel.SpacePolicy, actions?: SpacePolicyAction[]) => mutate(setSpacePolicy(model, name, policy, actions)), }; } export type ModelHook = { - model: configModel.Config; + model: apiModel.Config; usedMountPaths: string[]; unusedMountPaths: string[]; addDrive: (driveName: string) => void; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index 138fd726a1..587744c128 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import * as apiModel from "~/api/storage/types/config-model"; +import { apiModel } from "~/api/storage/types"; type Model = { drives: Drive[]; From 08ef220eac802aed147403a4e160052e7e3c22a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 09:46:21 +0000 Subject: [PATCH 051/103] chore(web): rename file --- web/src/api/storage/types.ts | 2 +- web/src/api/storage/types/{config-model.ts => model.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename web/src/api/storage/types/{config-model.ts => model.ts} (100%) diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index 740a39fdf8..09ad88d304 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -22,4 +22,4 @@ export * from "./types/openapi"; export * as config from "./types/config"; -export * as apiModel from "./types/config-model"; +export * as apiModel from "./types/model"; diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/model.ts similarity index 100% rename from web/src/api/storage/types/config-model.ts rename to web/src/api/storage/types/model.ts From 99a6cceb01dd1fc8ae4418b753289be116025ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 09:23:57 +0000 Subject: [PATCH 052/103] web: improve how model is exported and imported --- web/src/components/storage/VolumeGroupEditor.tsx | 2 +- web/src/components/storage/utils/volume-group.tsx | 2 +- web/src/hooks/storage/model.ts | 6 ++---- web/src/types/storage.ts | 2 ++ 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index c980976b7b..5b1402c26e 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -24,7 +24,7 @@ import React from "react"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { apiModel } from "~/api/storage/types"; -import * as model from "~/types/storage/model"; +import { model } from "~/types/storage"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { useVolumeGroup } from "~/hooks/storage/model"; import DeviceMenu from "~/components/storage/DeviceMenu"; diff --git a/web/src/components/storage/utils/volume-group.tsx b/web/src/components/storage/utils/volume-group.tsx index 3c58409d71..91b1dd2766 100644 --- a/web/src/components/storage/utils/volume-group.tsx +++ b/web/src/components/storage/utils/volume-group.tsx @@ -21,7 +21,7 @@ */ import { _, n_, formatList } from "~/i18n"; -import * as model from "~/types/storage/model"; +import { model } from "~/types/storage"; import { formattedPath } from "~/components/storage/utils"; import { sprintf } from "sprintf-js"; diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index f8e1e0c9b0..c2e66c60fa 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -23,7 +23,7 @@ import { useQuery } from "@tanstack/react-query"; import { configModelQuery } from "~/queries/storage/config-model"; import { apiModel } from "~/api/storage/types"; -import * as model from "~/types/storage/model"; +import { model } from "~/types/storage"; const findDrive = (model: model.Model, name: string): model.Drive | undefined => { return model.drives.find((d) => d.name === name); @@ -102,6 +102,4 @@ function useVolumeGroup(vgName: string): model.VolumeGroup | null { return volumeGroup || null; } -export default useModel; - -export { useDrive, useVolumeGroup }; +export { useModel as default, useDrive, useVolumeGroup }; diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index 2f62aef190..e8dc73f9d6 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -126,3 +126,5 @@ export type { SpacePolicyAction, StorageDevice, }; + +export * as model from "~/types/storage/model"; From 768ea04b116708c5770bcb9d4f9bcb9bf893eff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 16:01:08 +0000 Subject: [PATCH 053/103] web: add hooks for api model --- web/src/hooks/storage/api-model.ts | 37 ++++++++++++++++++++ web/src/hooks/storage/model.ts | 39 +++++++++++---------- web/src/hooks/storage/update-api-model.ts | 41 +++++++++++++++++++++++ 3 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 web/src/hooks/storage/api-model.ts create mode 100644 web/src/hooks/storage/update-api-model.ts diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts new file mode 100644 index 0000000000..b40e6279be --- /dev/null +++ b/web/src/hooks/storage/api-model.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { configModelQuery } from "~/queries/storage/config-model"; +import { apiModel } from "~/api/storage/types"; +import { QueryHookOptions } from "~/types/queries"; + +function useApiModel(options?: QueryHookOptions): apiModel.Config | null { + const query = configModelQuery; + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(query); + + // Returns a copy. + return data ? JSON.parse(JSON.stringify(data)) : null; +} + +export { useApiModel as default }; diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index c2e66c60fa..353e4c74c3 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -19,9 +19,8 @@ * To contact SUSE LLC about this file by physical or electronic mail, you may * find current contact information at www.suse.com. */ - -import { useQuery } from "@tanstack/react-query"; -import { configModelQuery } from "~/queries/storage/config-model"; +import useApiModel from "~/hooks/storage/api-model"; +import { QueryHookOptions } from "~/types/queries"; import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; @@ -29,7 +28,7 @@ const findDrive = (model: model.Model, name: string): model.Drive | undefined => return model.drives.find((d) => d.name === name); }; -function buildDrive(driveData: apiModel.Drive, model: model.Model): model.Drive { +function buildDrive(apiDrive: apiModel.Drive, model: model.Model): model.Drive { const findVolumeGroups = (targetName: string): model.VolumeGroup[] => { return model.volumeGroups.filter((v) => v.getTargetDevices().some((d) => d.name === targetName), @@ -37,8 +36,8 @@ function buildDrive(driveData: apiModel.Drive, model: model.Model): model.Drive }; return { - ...driveData, - getVolumeGroups: () => findVolumeGroups(driveData.name), + ...apiDrive, + getVolumeGroups: () => findVolumeGroups(apiDrive.name), }; } @@ -47,36 +46,36 @@ function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.Lo } function buildVolumeGroup( - volumeGroupData: apiModel.VolumeGroup, + apiVolumeGroup: apiModel.VolumeGroup, model: model.Model, ): model.VolumeGroup { const buildLogicalVolumes = (): model.LogicalVolume[] => { - return (volumeGroupData.logicalVolumes || []).map(buildLogicalVolume); + return (apiVolumeGroup.logicalVolumes || []).map(buildLogicalVolume); }; const findTargetDevices = (): model.Drive[] => { - return (volumeGroupData.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); + return (apiVolumeGroup.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); }; return { - ...volumeGroupData, + ...apiVolumeGroup, logicalVolumes: buildLogicalVolumes(), getTargetDevices: findTargetDevices, }; } -function buildModel(modelData: apiModel.Config): model.Model { +function buildModel(apiModel: apiModel.Config): model.Model { const model: model.Model = { drives: [], volumeGroups: [], }; const buildDrives = (): model.Drive[] => { - return (modelData.drives || []).map((d) => buildDrive(d, model)); + return (apiModel.drives || []).map((d) => buildDrive(d, model)); }; const buildVolumeGroups = (): model.VolumeGroup[] => { - return (modelData.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); + return (apiModel.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); }; // Important! Modify the model object instead of assigning a new one. @@ -85,19 +84,19 @@ function buildModel(modelData: apiModel.Config): model.Model { return model; } -function useModel(): model.Model | null { - const { data } = useQuery(configModelQuery); - return data ? buildModel(data) : null; +function useModel(options?: QueryHookOptions): model.Model | null { + const apiModel = useApiModel(options); + return apiModel ? buildModel(apiModel) : null; } -function useDrive(name: string): model.Drive | null { - const model = useModel(); +function useDrive(name: string, options?: QueryHookOptions): model.Drive | null { + const model = useModel(options); const drive = model?.drives?.find((d) => d.name === name); return drive || null; } -function useVolumeGroup(vgName: string): model.VolumeGroup | null { - const model = useModel(); +function useVolumeGroup(vgName: string, options?: QueryHookOptions): model.VolumeGroup | null { + const model = useModel(options); const volumeGroup = model?.volumeGroups?.find((v) => v.vgName === vgName); return volumeGroup || null; } diff --git a/web/src/hooks/storage/update-api-model.ts b/web/src/hooks/storage/update-api-model.ts new file mode 100644 index 0000000000..c88fb6e1bc --- /dev/null +++ b/web/src/hooks/storage/update-api-model.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { useMutation, useQueryClient } from "@tanstack/react-query"; +import { setConfigModel } from "~/api/storage"; +import { apiModel } from "~/api/storage/types"; + +type UpdateApiModelFn = (apiModel: apiModel.Config) => void; + +function useUpdateApiModel(): UpdateApiModelFn { + const queryClient = useQueryClient(); + const query = { + mutationFn: (apiModel: apiModel.Config) => setConfigModel(apiModel), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), + }; + + const { mutate } = useMutation(query); + return mutate; +} + +export { useUpdateApiModel as default }; +export type { UpdateApiModelFn }; From 035290a058546d5e188a891695007225d57e1f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 16:01:43 +0000 Subject: [PATCH 054/103] web: add hook for adding a volume group --- web/src/hooks/storage/add-volume-group.ts | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 web/src/hooks/storage/add-volume-group.ts diff --git a/web/src/hooks/storage/add-volume-group.ts b/web/src/hooks/storage/add-volume-group.ts new file mode 100644 index 0000000000..cdd15902b4 --- /dev/null +++ b/web/src/hooks/storage/add-volume-group.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 useApiModel from "~/hooks/storage/api-model"; +import useUpdateApiModel from "~/hooks/storage/update-api-model"; +import { QueryHookOptions } from "~/types/queries"; +import { apiModel } from "~/api/storage/types"; + +function toLogicalVolume(partition: apiModel.Partition) { + return { ...partition }; +} + +function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup) { + if (!drive.partitions) return; + + const newPartitions = drive.partitions.filter((p) => !p.name); + const reusedPartitions = drive.partitions.filter((p) => p.name); + drive.partitions = [...reusedPartitions]; + const logicalVolumes = volumeGroup.logicalVolumes || []; + volumeGroup.logicalVolumes = [...logicalVolumes, ...newPartitions.map(toLogicalVolume)]; +} + +function addVolumeGroup( + apiModel: apiModel.Config, + vgName: string, + targetDevices: string[], + moveContent: boolean, +): apiModel.Config { + const volumeGroup = { vgName, targetDevices }; + if (moveContent) { + (apiModel.drives || []) + .filter((d) => targetDevices.includes(d.name)) + .forEach((d) => movePartitions(d, volumeGroup)); + } + apiModel.volumeGroups ||= []; + apiModel.volumeGroups.push(volumeGroup); + return apiModel; +} + +type AddVolumeGroupFn = (vgName: string, targetDevices: string[], moveContent: boolean) => void; + +function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, targetDevices: string[], moveContent: boolean) => { + updateApiModel(addVolumeGroup(apiModel, vgName, targetDevices, moveContent)); + }; +} + +export { useAddVolumeGroup as default }; +export type { AddVolumeGroupFn }; From 1f50898e58c9de7f073c18b143b6287889f8f651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 16:03:05 +0000 Subject: [PATCH 055/103] web: add menu option for adding a volume group --- web/src/components/storage/ConfigEditorMenu.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 03e1449b0e..2f3238243c 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -62,6 +62,14 @@ export default function ConfigEditorMenu() { )} > + navigate(PATHS.lvm.create)} + description={_("Extend the installation using LVM")} + > + {_("Add LVM volume group")} + + navigate(PATHS.bootDevice)} From e6b987d890c543954d1ce1ceacd2299d358f0e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 18 Mar 2025 16:04:09 +0000 Subject: [PATCH 056/103] web: adapt lvm page to create a volume group --- web/src/components/storage/LvmPage.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 691ddda9d6..767c6524c0 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -21,6 +21,7 @@ */ import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { ActionGroup, Checkbox, @@ -34,19 +35,22 @@ import { } from "@patternfly/react-core"; import { Page, SubtleContent } from "~/components/core"; import { useAvailableDevices } from "~/queries/storage"; +import { StorageDevice } from "~/types/storage"; +import useAddVolumeGroup from "~/hooks/storage/add-volume-group"; import { deviceLabel } from "./utils"; import { contentDescription, filesystemLabels, typeDescription } from "./utils/device"; +import { STORAGE as PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; /** * Form for creating a LVM volume group */ export default function LvmPage() { + const navigate = useNavigate(); + const addVolumeGroup = useAddVolumeGroup(); const allDevices = useAvailableDevices(); const [name, setName] = useState("system"); - // FIXME: decide what to store, if the device object or just its sid and type - // the state accordingly - const [selectedDevices, setSelectedDevices] = useState([]); + const [selectedDevices, setSelectedDevices] = useState([]); const [moveMountPoints, setMoveMountPoints] = useState(true); const updateName = (_, value) => setName(value); @@ -59,7 +63,12 @@ export default function LvmPage() { }; const onSubmit = () => { - console.log("TODO: implement the logic to be triggered when LVM form is submitted"); + addVolumeGroup( + name, + selectedDevices.map((d) => d.name), + moveMountPoints, + ); + navigate(PATHS.root); }; return ( @@ -120,7 +129,7 @@ export default function LvmPage() { /> - + From c99d36d719d95a02725cc446a110dd684d8decc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 19 Mar 2025 10:43:23 +0000 Subject: [PATCH 057/103] web: add validations to volume group form --- web/src/components/storage/LvmPage.tsx | 40 ++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 767c6524c0..98d6e50ba1 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -24,6 +24,7 @@ import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import { ActionGroup, + Alert, Checkbox, Content, Flex, @@ -35,23 +36,42 @@ import { } from "@patternfly/react-core"; import { Page, SubtleContent } from "~/components/core"; import { useAvailableDevices } from "~/queries/storage"; -import { StorageDevice } from "~/types/storage"; +import { StorageDevice, model } from "~/types/storage"; +import useModel from "~/hooks/storage/model"; import useAddVolumeGroup from "~/hooks/storage/add-volume-group"; import { deviceLabel } from "./utils"; import { contentDescription, filesystemLabels, typeDescription } from "./utils/device"; import { STORAGE as PATHS } from "~/routes/paths"; +import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; +function checkErrors(model: model.Model, vgName: string, targetDevices: StorageDevice[]): string[] { + const vgNameError = (): string | undefined => { + if (!vgName.length) return sprintf(_("Name is empty"), vgName); + + const exist = model.volumeGroups.some((v) => v.vgName === vgName); + if (exist) return sprintf(_("'%s' already exists"), vgName); + }; + + const targetDevicesError = (): string | undefined => { + if (!targetDevices.length) return _("No disk is selected"); + }; + + return [vgNameError(), targetDevicesError()].filter((d) => d); +} + /** * Form for creating a LVM volume group */ export default function LvmPage() { const navigate = useNavigate(); + const model = useModel(); const addVolumeGroup = useAddVolumeGroup(); const allDevices = useAvailableDevices(); - const [name, setName] = useState("system"); + const [name, setName] = useState(model.volumeGroups.length ? "" : "system"); const [selectedDevices, setSelectedDevices] = useState([]); const [moveMountPoints, setMoveMountPoints] = useState(true); + const [errors, setErrors] = useState([]); const updateName = (_, value) => setName(value); const updateSelectedDevices = (value) => { @@ -62,7 +82,14 @@ export default function LvmPage() { ); }; - const onSubmit = () => { + const onSubmit = (e) => { + e.preventDefault(); + + const errors = checkErrors(model, name, selectedDevices); + setErrors(errors); + + if (errors.length) return; + addVolumeGroup( name, selectedDevices.map((d) => d.name), @@ -80,6 +107,13 @@ export default function LvmPage() {
+ {errors.length > 0 && ( + + {errors.map((e, i) => ( +

{e}

+ ))} +
+ )} From 96996b60f4a6ae5650e5382a9856461eb8d48a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 20 Mar 2025 12:11:48 +0000 Subject: [PATCH 058/103] web: fix assignment of default vg name - Memoize hooks to avoid creating a different object with each render. --- web/src/components/storage/LvmPage.tsx | 8 ++++++-- web/src/hooks/storage/api-model.ts | 9 +++++++-- web/src/hooks/storage/model.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 98d6e50ba1..ca1c1d6b5e 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { ActionGroup, @@ -68,11 +68,15 @@ export default function LvmPage() { const model = useModel(); const addVolumeGroup = useAddVolumeGroup(); const allDevices = useAvailableDevices(); - const [name, setName] = useState(model.volumeGroups.length ? "" : "system"); + const [name, setName] = useState(""); const [selectedDevices, setSelectedDevices] = useState([]); const [moveMountPoints, setMoveMountPoints] = useState(true); const [errors, setErrors] = useState([]); + useEffect(() => { + if (model && !model.volumeGroups.length) setName("system"); + }, [model]); + const updateName = (_, value) => setName(value); const updateSelectedDevices = (value) => { setSelectedDevices( diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts index b40e6279be..5f387be88b 100644 --- a/web/src/hooks/storage/api-model.ts +++ b/web/src/hooks/storage/api-model.ts @@ -20,6 +20,7 @@ * find current contact information at www.suse.com. */ +import { useMemo } from "react"; import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { configModelQuery } from "~/queries/storage/config-model"; import { apiModel } from "~/api/storage/types"; @@ -30,8 +31,12 @@ function useApiModel(options?: QueryHookOptions): apiModel.Config | null { const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); - // Returns a copy. - return data ? JSON.parse(JSON.stringify(data)) : null; + const apiModel = useMemo((): apiModel.Config | null => { + // Returns a copy. + return data ? JSON.parse(JSON.stringify(data)) : null; + }, [data]); + + return apiModel; } export { useApiModel as default }; diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index 353e4c74c3..630f82500d 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -19,6 +19,8 @@ * To contact SUSE LLC about this file by physical or electronic mail, you may * find current contact information at www.suse.com. */ + +import { useMemo } from "react"; import useApiModel from "~/hooks/storage/api-model"; import { QueryHookOptions } from "~/types/queries"; import { apiModel } from "~/api/storage/types"; @@ -86,7 +88,12 @@ function buildModel(apiModel: apiModel.Config): model.Model { function useModel(options?: QueryHookOptions): model.Model | null { const apiModel = useApiModel(options); - return apiModel ? buildModel(apiModel) : null; + + const model = useMemo((): model.Model | null => { + return apiModel ? buildModel(apiModel) : null; + }, [apiModel]); + + return model; } function useDrive(name: string, options?: QueryHookOptions): model.Drive | null { From 0971c644399240528e0e5e828c55c58ae7058761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez?= Date: Fri, 21 Mar 2025 06:41:05 +0000 Subject: [PATCH 059/103] Fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David Díaz <1691872+dgdavid@users.noreply.github.com> --- .../from_model_conversions/with_partitions.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb index 2098f2102b..c5ba9d4219 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb @@ -65,7 +65,7 @@ def used_partitions partitions.reject { |p| space_policy_partition?(p) } end - # Partitions representing a spece policy action (delete, resize if needed), excluding + # Partitions representing a space policy action (delete, resize if needed), excluding # the keep actions. # # Omitting the partitions that only represent a keep action is important. Otherwise, the From c61249fe8981d75a8cc724a6a36966b6708270ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 08:58:44 +0000 Subject: [PATCH 060/103] web: small code improvement --- web/src/components/storage/LvmPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index ca1c1d6b5e..67baae5243 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -163,7 +163,7 @@ export default function LvmPage() { "Create logical volumes for the mount points currently configured at the selected disks", )} isChecked={moveMountPoints} - onChange={(_, v) => setMoveMountPoints(v)} + onChange={() => setMoveMountPoints(!moveMountPoints)} /> From fb381fef60df989a7ec07ea7027cc7389767d0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 09:05:54 +0000 Subject: [PATCH 061/103] web: improve wording --- web/src/components/storage/LvmPage.tsx | 10 ++++++---- web/src/components/storage/PartitionPage.test.tsx | 2 +- web/src/components/storage/PartitionPage.tsx | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 67baae5243..18bb9c2ff5 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -105,8 +105,7 @@ export default function LvmPage() { return ( - {_("New Volume Group")} - {_("Create a new LVM volume group")} + {_("Configure LVM Volume Group")} @@ -124,7 +123,9 @@ export default function LvmPage() { {_( - "The needed LVM physical volumes will be created as partitions on the chosen disks, based on the sizes of the logical volumes. If you select more than one disk, the physical volumes may be distributed along several disks.", + "The needed LVM physical volumes will be added as partitions on the chosen disks, \ + based on the sizes of the logical volumes. If you select more than one disk, the \ + physical volumes may be distributed along several disks.", )} @@ -160,7 +161,8 @@ export default function LvmPage() { setMoveMountPoints(!moveMountPoints)} diff --git a/web/src/components/storage/PartitionPage.test.tsx b/web/src/components/storage/PartitionPage.test.tsx index c9119a4d95..a520f89bf5 100644 --- a/web/src/components/storage/PartitionPage.test.tsx +++ b/web/src/components/storage/PartitionPage.test.tsx @@ -151,7 +151,7 @@ beforeEach(() => { describe("PartitionPage", () => { it("renders a form for defining a partition", async () => { const { user } = installerRender(); - screen.getByRole("form", { name: "Define partition at /dev/sda" }); + screen.getByRole("form", { name: "Configure partition at /dev/sda" }); const mountPoint = screen.getByRole("button", { name: "Mount point toggle" }); const mountPointMode = screen.getByRole("button", { name: "Mount point mode" }); const filesystem = screen.getByRole("button", { name: "File system" }); diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index de980139d6..bd17e5b8cf 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -1248,7 +1248,7 @@ export default function PartitionPage() { - {sprintf(_("Define partition at %s"), device.name)} + {sprintf(_("Configure partition at %s"), device.name)} From e5f0ef0fbaafc8ca0381780720876cd018135e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 19 Mar 2025 15:42:50 +0000 Subject: [PATCH 062/103] web: add #isUsed to model drives - The method #buildModel is extracted to a helper because it will be used by other hooks and/or helpers. --- web/src/hooks/storage/helpers/build-model.ts | 110 +++++++++++++++++++ web/src/hooks/storage/model.ts | 62 +---------- web/src/types/storage/model.ts | 1 + 3 files changed, 112 insertions(+), 61 deletions(-) create mode 100644 web/src/hooks/storage/helpers/build-model.ts diff --git a/web/src/hooks/storage/helpers/build-model.ts b/web/src/hooks/storage/helpers/build-model.ts new file mode 100644 index 0000000000..ef43c1c99e --- /dev/null +++ b/web/src/hooks/storage/helpers/build-model.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { apiModel } from "~/api/storage/types"; +import { model } from "~/types/storage"; + +const findDrive = (model: model.Model, name: string): model.Drive | undefined => { + return model.drives.find((d) => d.name === name); +}; + +function buildDrive( + apiDrive: apiModel.Drive, + apiModel: apiModel.Config, + model: model.Model, +): model.Drive { + const getVolumeGroups = (): model.VolumeGroup[] => { + return model.volumeGroups.filter((v) => + v.getTargetDevices().some((d) => d.name === apiDrive.name), + ); + }; + + const isExplicitBoot = (): boolean => { + return ( + apiModel.boot?.configure && + !apiModel.boot.device?.default && + apiModel.boot.device?.name === apiDrive.name + ); + }; + + const isTargetDevice = (): boolean => { + const targetDevices = (apiModel.volumeGroups || []).flatMap((v) => v.targetDevices || []); + return targetDevices.includes(apiDrive.name); + }; + + const isUsed = (): boolean => { + return ( + isExplicitBoot() || + isTargetDevice() || + apiDrive.mountPath !== undefined || + apiDrive.partitions?.some((p) => p.mountPath) + ); + }; + + return { + ...apiDrive, + isUsed: isUsed(), + getVolumeGroups, + }; +} + +function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.LogicalVolume { + return { ...logicalVolumeData }; +} + +function buildVolumeGroup( + apiVolumeGroup: apiModel.VolumeGroup, + model: model.Model, +): model.VolumeGroup { + const buildLogicalVolumes = (): model.LogicalVolume[] => { + return (apiVolumeGroup.logicalVolumes || []).map(buildLogicalVolume); + }; + + const findTargetDevices = (): model.Drive[] => { + return (apiVolumeGroup.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); + }; + + return { + ...apiVolumeGroup, + logicalVolumes: buildLogicalVolumes(), + getTargetDevices: findTargetDevices, + }; +} + +export default function buildModel(apiModel: apiModel.Config): model.Model { + const model: model.Model = { + drives: [], + volumeGroups: [], + }; + + const buildDrives = (): model.Drive[] => { + return (apiModel.drives || []).map((d) => buildDrive(d, apiModel, model)); + }; + + const buildVolumeGroups = (): model.VolumeGroup[] => { + return (apiModel.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); + }; + + // Important! Modify the model object instead of assigning a new one. + model.drives = buildDrives(); + model.volumeGroups = buildVolumeGroups(); + return model; +} diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index 630f82500d..ad0b83b930 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -22,70 +22,10 @@ import { useMemo } from "react"; import useApiModel from "~/hooks/storage/api-model"; +import buildModel from "~/hooks/storage/helpers/build-model"; import { QueryHookOptions } from "~/types/queries"; -import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; -const findDrive = (model: model.Model, name: string): model.Drive | undefined => { - return model.drives.find((d) => d.name === name); -}; - -function buildDrive(apiDrive: apiModel.Drive, model: model.Model): model.Drive { - const findVolumeGroups = (targetName: string): model.VolumeGroup[] => { - return model.volumeGroups.filter((v) => - v.getTargetDevices().some((d) => d.name === targetName), - ); - }; - - return { - ...apiDrive, - getVolumeGroups: () => findVolumeGroups(apiDrive.name), - }; -} - -function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.LogicalVolume { - return { ...logicalVolumeData }; -} - -function buildVolumeGroup( - apiVolumeGroup: apiModel.VolumeGroup, - model: model.Model, -): model.VolumeGroup { - const buildLogicalVolumes = (): model.LogicalVolume[] => { - return (apiVolumeGroup.logicalVolumes || []).map(buildLogicalVolume); - }; - - const findTargetDevices = (): model.Drive[] => { - return (apiVolumeGroup.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); - }; - - return { - ...apiVolumeGroup, - logicalVolumes: buildLogicalVolumes(), - getTargetDevices: findTargetDevices, - }; -} - -function buildModel(apiModel: apiModel.Config): model.Model { - const model: model.Model = { - drives: [], - volumeGroups: [], - }; - - const buildDrives = (): model.Drive[] => { - return (apiModel.drives || []).map((d) => buildDrive(d, model)); - }; - - const buildVolumeGroups = (): model.VolumeGroup[] => { - return (apiModel.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); - }; - - // Important! Modify the model object instead of assigning a new one. - model.drives = buildDrives(); - model.volumeGroups = buildVolumeGroups(); - return model; -} - function useModel(options?: QueryHookOptions): model.Model | null { const apiModel = useApiModel(options); diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index 587744c128..955d09e85b 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -28,6 +28,7 @@ type Model = { }; interface Drive extends apiModel.Drive { + isUsed: boolean; getVolumeGroups: () => VolumeGroup[]; } From ca4e1d0c64c1d034e7d14241173239859c81e0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 19 Mar 2025 15:45:49 +0000 Subject: [PATCH 063/103] web: add hook for editing a volume group --- web/src/hooks/storage/add-volume-group.ts | 38 ++--------- web/src/hooks/storage/edit-volume-group.ts | 59 +++++++++++++++++ web/src/hooks/storage/helpers/drive.ts | 43 ++++++++++++ web/src/hooks/storage/helpers/volume-group.ts | 65 +++++++++++++++++++ 4 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 web/src/hooks/storage/edit-volume-group.ts create mode 100644 web/src/hooks/storage/helpers/drive.ts create mode 100644 web/src/hooks/storage/helpers/volume-group.ts diff --git a/web/src/hooks/storage/add-volume-group.ts b/web/src/hooks/storage/add-volume-group.ts index cdd15902b4..fa970ee515 100644 --- a/web/src/hooks/storage/add-volume-group.ts +++ b/web/src/hooks/storage/add-volume-group.ts @@ -22,49 +22,19 @@ import useApiModel from "~/hooks/storage/api-model"; import useUpdateApiModel from "~/hooks/storage/update-api-model"; +import { addVolumeGroup } from "~/hooks/storage/helpers/volume-group"; import { QueryHookOptions } from "~/types/queries"; -import { apiModel } from "~/api/storage/types"; -function toLogicalVolume(partition: apiModel.Partition) { - return { ...partition }; -} - -function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup) { - if (!drive.partitions) return; - - const newPartitions = drive.partitions.filter((p) => !p.name); - const reusedPartitions = drive.partitions.filter((p) => p.name); - drive.partitions = [...reusedPartitions]; - const logicalVolumes = volumeGroup.logicalVolumes || []; - volumeGroup.logicalVolumes = [...logicalVolumes, ...newPartitions.map(toLogicalVolume)]; -} - -function addVolumeGroup( - apiModel: apiModel.Config, +export type AddVolumeGroupFn = ( vgName: string, targetDevices: string[], moveContent: boolean, -): apiModel.Config { - const volumeGroup = { vgName, targetDevices }; - if (moveContent) { - (apiModel.drives || []) - .filter((d) => targetDevices.includes(d.name)) - .forEach((d) => movePartitions(d, volumeGroup)); - } - apiModel.volumeGroups ||= []; - apiModel.volumeGroups.push(volumeGroup); - return apiModel; -} +) => void; -type AddVolumeGroupFn = (vgName: string, targetDevices: string[], moveContent: boolean) => void; - -function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { +export default function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { const apiModel = useApiModel(options); const updateApiModel = useUpdateApiModel(); return (vgName: string, targetDevices: string[], moveContent: boolean) => { updateApiModel(addVolumeGroup(apiModel, vgName, targetDevices, moveContent)); }; } - -export { useAddVolumeGroup as default }; -export type { AddVolumeGroupFn }; diff --git a/web/src/hooks/storage/edit-volume-group.ts b/web/src/hooks/storage/edit-volume-group.ts new file mode 100644 index 0000000000..d0fa723e43 --- /dev/null +++ b/web/src/hooks/storage/edit-volume-group.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 useApiModel from "~/hooks/storage/api-model"; +import useUpdateApiModel from "~/hooks/storage/update-api-model"; +import { addVolumeGroup } from "~/hooks/storage/helpers/volume-group"; +import { deleteIfUnused } from "~/hooks/storage/helpers/drive"; +import { QueryHookOptions } from "~/types/queries"; +import { apiModel } from "~/api/storage/types"; + +function editVolumeGroup( + apiModel: apiModel.Config, + oldVgName: string, + vgName: string, + targetDevices: string[], +): apiModel.Config { + const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === oldVgName); + if (index === -1) return; + + const oldTargetDevices = apiModel.volumeGroups[index].targetDevices || []; + + addVolumeGroup(apiModel, vgName, targetDevices, false, index); + oldTargetDevices.forEach((d) => deleteIfUnused(apiModel, d)); + + return apiModel; +} + +export type EditVolumeGroupFn = ( + odlVgName: string, + VgName: string, + targetDevices: string[], +) => void; + +export default function useEditVolumeGroup(options?: QueryHookOptions): EditVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (oldVgName: string, vgName: string, targetDevices: string[]) => { + updateApiModel(editVolumeGroup(apiModel, oldVgName, vgName, targetDevices)); + }; +} diff --git a/web/src/hooks/storage/helpers/drive.ts b/web/src/hooks/storage/helpers/drive.ts new file mode 100644 index 0000000000..13e17636a4 --- /dev/null +++ b/web/src/hooks/storage/helpers/drive.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { apiModel } from "~/api/storage/types"; +import { model } from "~/types/storage"; +import buildModel from "~/hooks/storage/helpers/build-model"; + +function buildDrive(apiModel: apiModel.Config, name: string): model.Drive | undefined { + const model = buildModel(apiModel); + return model.drives.find((d) => d.name === name); +} + +function deleteIfUnused(apiModel: apiModel.Config, name: string) { + const index = (apiModel.drives || []).findIndex((d) => d.name === name); + if (index === -1) return; + + const drive = buildDrive(apiModel, name); + if (!drive || drive.isUsed) return; + + apiModel.drives.splice(index, 1); + return apiModel; +} + +export { deleteIfUnused }; diff --git a/web/src/hooks/storage/helpers/volume-group.ts b/web/src/hooks/storage/helpers/volume-group.ts new file mode 100644 index 0000000000..6be4365d62 --- /dev/null +++ b/web/src/hooks/storage/helpers/volume-group.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { apiModel } from "~/api/storage/types"; + +function toLogicalVolume(partition: apiModel.Partition) { + return { ...partition }; +} + +function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup) { + if (!drive.partitions) return; + + const newPartitions = drive.partitions.filter((p) => !p.name); + const reusedPartitions = drive.partitions.filter((p) => p.name); + drive.partitions = [...reusedPartitions]; + const logicalVolumes = volumeGroup.logicalVolumes || []; + volumeGroup.logicalVolumes = [...logicalVolumes, ...newPartitions.map(toLogicalVolume)]; +} + +function addVolumeGroup( + apiModel: apiModel.Config, + vgName: string, + targetDevices: string[], + moveContent: boolean, + index?: number, +): apiModel.Config { + const volumeGroup = { vgName, targetDevices }; + + if (moveContent) { + (apiModel.drives || []) + .filter((d) => targetDevices.includes(d.name)) + .forEach((d) => movePartitions(d, volumeGroup)); + } + + apiModel.volumeGroups ||= []; + + if (index === undefined || index >= apiModel.volumeGroups.length) { + apiModel.volumeGroups.push(volumeGroup); + } else { + apiModel.volumeGroups.splice(index, 1, volumeGroup); + } + + return apiModel; +} + +export { addVolumeGroup }; From e3c2238d4c5075d46a621ef99b99e7e8de7ef95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 20 Mar 2025 11:02:37 +0000 Subject: [PATCH 064/103] web: reorganize storage paths --- .../storage/ConfigEditorMenu.test.tsx | 2 +- .../components/storage/ConfigEditorMenu.tsx | 4 ++-- .../components/storage/DriveEditor.test.tsx | 2 +- web/src/components/storage/DriveEditor.tsx | 12 +++++++---- .../storage/EncryptionSection.test.tsx | 2 +- .../components/storage/EncryptionSection.tsx | 2 +- web/src/routes/paths.ts | 20 +++++++++++-------- web/src/routes/storage.tsx | 18 ++++++++--------- 8 files changed, 35 insertions(+), 27 deletions(-) diff --git a/web/src/components/storage/ConfigEditorMenu.test.tsx b/web/src/components/storage/ConfigEditorMenu.test.tsx index 8fd8124006..d28568b246 100644 --- a/web/src/components/storage/ConfigEditorMenu.test.tsx +++ b/web/src/components/storage/ConfigEditorMenu.test.tsx @@ -68,7 +68,7 @@ it("allows users to change the boot options", async () => { const { user, menu } = await openMenu(); const bootItem = within(menu).getByRole("menuitem", { name: /boot options/ }); await user.click(bootItem); - expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.bootDevice); + expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.editBootDevice); }); it("allows users to reset the config", async () => { diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 2f3238243c..095a01737c 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -64,7 +64,7 @@ export default function ConfigEditorMenu() { navigate(PATHS.lvm.create)} + onClick={() => navigate(PATHS.volumeGroup.add)} description={_("Extend the installation using LVM")} > {_("Add LVM volume group")} @@ -72,7 +72,7 @@ export default function ConfigEditorMenu() { navigate(PATHS.bootDevice)} + onClick={() => navigate(PATHS.editBootDevice)} description={_("Select the disk to configure partitions for booting")} > {_("Change boot options")} diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index ea5b55dae0..48ba96aa98 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -215,7 +215,7 @@ describe("PartitionMenuItem", () => { name: "Edit swap", }); await user.click(editSwapButton); - expect(mockNavigateFn).toHaveBeenCalledWith("/storage/devices/sda/partitions/swap/edit"); + expect(mockNavigateFn).toHaveBeenCalledWith("/storage/drives/sda/partitions/swap/edit"); }); }); diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 605b2ef27c..e212a69967 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -84,7 +84,7 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { const { setSpacePolicy } = useDrive(drive.name); const onSpacePolicyChange = (spacePolicy: apiModel.SpacePolicy) => { if (spacePolicy === "custom") { - return navigate(generatePath(PATHS.findSpace, { id: baseName(drive.name) })); + return navigate(generatePath(PATHS.drive.editSpacePolicy, { id: baseName(drive.name) })); } else { setSpacePolicy(spacePolicy); } @@ -470,7 +470,9 @@ const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { itemId="add-partition" description={_("Add another partition or mount an existing one")} role="menuitem" - onClick={() => navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) }))} + onClick={() => + navigate(generatePath(PATHS.drive.partition.add, { id: baseName(drive.name) })) + } > {_("Add or use partition")} @@ -484,7 +486,7 @@ const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { const PartitionMenuItem = ({ driveName, mountPath }) => { const drive = useDrive(driveName); const partition = drive.getPartition(mountPath); - const editPath = generatePath(PATHS.editPartition, { + const editPath = generatePath(PATHS.drive.partition.edit, { id: baseName(driveName), partitionId: encodeURIComponent(mountPath), }); @@ -519,7 +521,9 @@ const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { key="add-partition" itemId="add-partition" description={_("Add another partition or mount an existing one")} - onClick={() => navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) }))} + onClick={() => + navigate(generatePath(PATHS.drive.partition.add, { id: baseName(drive.name) })) + } > {_("Add or use partition")} diff --git a/web/src/components/storage/EncryptionSection.test.tsx b/web/src/components/storage/EncryptionSection.test.tsx index a56a6c6c03..f2f70243db 100644 --- a/web/src/components/storage/EncryptionSection.test.tsx +++ b/web/src/components/storage/EncryptionSection.test.tsx @@ -79,6 +79,6 @@ describe("EncryptionSection", () => { it("renders a link for navigating to encryption settings", () => { plainRender(); const editLink = screen.getByRole("link", { name: "Edit" }); - expect(editLink).toHaveAttribute("href", STORAGE.encryption); + expect(editLink).toHaveAttribute("href", STORAGE.editEncryption); }); }); diff --git a/web/src/components/storage/EncryptionSection.tsx b/web/src/components/storage/EncryptionSection.tsx index 22974b4bc1..7f4c9dddd7 100644 --- a/web/src/components/storage/EncryptionSection.tsx +++ b/web/src/components/storage/EncryptionSection.tsx @@ -47,7 +47,7 @@ export default function EncryptionSection() { the new file systems, including data, programs, and system files.", )} pfCardBodyProps={{ isFilled: true }} - actions={{_("Edit")}} + actions={{_("Edit")}} > diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 249b6c1b33..b5f86e7a18 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -71,15 +71,19 @@ const SOFTWARE = { const STORAGE = { root: "/storage", - bootDevice: "/storage/select-boot-device", - encryption: "/storage/encryption", - addPartition: "/storage/devices/:id/partitions/new", - editPartition: "/storage/devices/:id/partitions/:partitionId/edit", - findSpace: "/storage/devices/:id/space/edit", - iscsi: "/storage/iscsi", - lvm: { - create: "/storage/lvm/new", + editBootDevice: "/storage/boot-device/edit", + editEncryption: "/storage/encryption/edit", + drive: { + editSpacePolicy: "/storage/drives/:id/space-policy/edit", + partition: { + add: "/storage/drives/:id/partitions/add", + edit: "/storage/drives/:id/partitions/:partitionId/edit", + }, + }, + volumeGroup: { + add: "/storage/volume-groups/add", }, + iscsi: "/storage/iscsi", dasd: "/storage/dasd", zfcp: { root: "/storage/zfcp", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 06e6de1e7d..61bf5b5226 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -47,29 +47,29 @@ const routes = (): Route => ({ element: , }, { - path: PATHS.bootDevice, + path: PATHS.editBootDevice, element: , }, { - path: PATHS.lvm.create, - element: , - }, - { - path: PATHS.encryption, + path: PATHS.editEncryption, element: , }, { - path: PATHS.findSpace, + path: PATHS.drive.editSpacePolicy, element: , }, { - path: PATHS.addPartition, + path: PATHS.drive.partition.add, element: , }, { - path: PATHS.editPartition, + path: PATHS.drive.partition.edit, element: , }, + { + path: PATHS.volumeGroup.add, + element: , + }, { path: PATHS.iscsi, element: , From 1fb8f6df9bb4605358770d9a6f4929f23d95da53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 20 Mar 2025 11:42:31 +0000 Subject: [PATCH 065/103] web: allow editing a volume group --- web/src/components/storage/LvmPage.tsx | 89 ++++++++++++------- .../components/storage/VolumeGroupEditor.tsx | 18 +++- web/src/routes/paths.ts | 1 + web/src/routes/storage.tsx | 4 + 4 files changed, 75 insertions(+), 37 deletions(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 18bb9c2ff5..b04f48700d 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -21,7 +21,7 @@ */ import React, { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { ActionGroup, Alert, @@ -37,47 +37,59 @@ import { import { Page, SubtleContent } from "~/components/core"; import { useAvailableDevices } from "~/queries/storage"; import { StorageDevice, model } from "~/types/storage"; -import useModel from "~/hooks/storage/model"; +import useModel, { useVolumeGroup } from "~/hooks/storage/model"; import useAddVolumeGroup from "~/hooks/storage/add-volume-group"; +import useEditVolumeGroup from "~/hooks/storage/edit-volume-group"; import { deviceLabel } from "./utils"; import { contentDescription, filesystemLabels, typeDescription } from "./utils/device"; import { STORAGE as PATHS } from "~/routes/paths"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -function checkErrors(model: model.Model, vgName: string, targetDevices: StorageDevice[]): string[] { - const vgNameError = (): string | undefined => { - if (!vgName.length) return sprintf(_("Name is empty"), vgName); +function vgNameError( + vgName: string, + model: model.Model, + volumeGroup?: model.VolumeGroup, +): string | undefined { + if (!vgName.length) return sprintf(_("Name is empty"), vgName); - const exist = model.volumeGroups.some((v) => v.vgName === vgName); - if (exist) return sprintf(_("'%s' already exists"), vgName); - }; - - const targetDevicesError = (): string | undefined => { - if (!targetDevices.length) return _("No disk is selected"); - }; + const exist = model.volumeGroups.some((v) => v.vgName === vgName); + if (exist && vgName !== volumeGroup?.vgName) return sprintf(_("'%s' already exists"), vgName); +} - return [vgNameError(), targetDevicesError()].filter((d) => d); +function targetDevicesError(targetDevices: StorageDevice[]): string | undefined { + if (!targetDevices.length) return _("No disk is selected"); } /** - * Form for creating a LVM volume group + * Form for configuring a LVM volume group. */ export default function LvmPage() { const navigate = useNavigate(); const model = useModel(); const addVolumeGroup = useAddVolumeGroup(); + const editVolumeGroup = useEditVolumeGroup(); const allDevices = useAvailableDevices(); const [name, setName] = useState(""); const [selectedDevices, setSelectedDevices] = useState([]); const [moveMountPoints, setMoveMountPoints] = useState(true); const [errors, setErrors] = useState([]); + const { id } = useParams(); + const volumeGroup = useVolumeGroup(id); useEffect(() => { - if (model && !model.volumeGroups.length) setName("system"); - }, [model]); + if (volumeGroup) { + setName(volumeGroup.vgName); + const targetNames = volumeGroup.getTargetDevices().map((d) => d.name); + const targetDevices = allDevices.filter((d) => targetNames.includes(d.name)); + setSelectedDevices(targetDevices); + } else if (model && !model.volumeGroups.length) { + setName("system"); + } + }, [model, volumeGroup, allDevices]); const updateName = (_, value) => setName(value); + const updateSelectedDevices = (value) => { setSelectedDevices( selectedDevices.includes(value) @@ -86,19 +98,28 @@ export default function LvmPage() { ); }; + const checkErrors = (): string[] => { + return [vgNameError(name, model, volumeGroup), targetDevicesError(selectedDevices)].filter( + (e) => e, + ); + }; + const onSubmit = (e) => { e.preventDefault(); - const errors = checkErrors(model, name, selectedDevices); + const errors = checkErrors(); setErrors(errors); if (errors.length) return; - addVolumeGroup( - name, - selectedDevices.map((d) => d.name), - moveMountPoints, - ); + const selectedDeviceNames = selectedDevices.map((d) => d.name); + + if (!volumeGroup) { + addVolumeGroup(name, selectedDeviceNames, moveMountPoints); + } else { + editVolumeGroup(volumeGroup.vgName, name, selectedDeviceNames); + } + navigate(PATHS.root); }; @@ -157,17 +178,19 @@ export default function LvmPage() { ))} - - setMoveMountPoints(!moveMountPoints)} - /> - + {!volumeGroup && ( + + setMoveMountPoints(v)} + /> + + )} diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 5b1402c26e..03a52689ad 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -21,8 +21,10 @@ */ import React from "react"; +import { useNavigate, generatePath } from "react-router-dom"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; +import { STORAGE as PATHS } from "~/routes/paths"; import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; import { contentDescription } from "~/components/storage/utils/volume-group"; @@ -53,10 +55,18 @@ const RemoveVgOption = ({ vg }: { vg: model.VolumeGroup }) => { ); }; -const EditVgOption = () => { +const EditVgOption = ({ vg }: { vg: model.VolumeGroup }) => { + const navigate = useNavigate(); + return ( - - {_("Edit volume group")} + navigate(generatePath(PATHS.volumeGroup.edit, { id: vg.vgName }))} + > + {_("Edit volume group")} ); }; @@ -65,7 +75,7 @@ const VgMenu = ({ vg }: { vg: model.VolumeGroup }) => { return ( {vg.vgName}}> - + diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index b5f86e7a18..298ed442ea 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -82,6 +82,7 @@ const STORAGE = { }, volumeGroup: { add: "/storage/volume-groups/add", + edit: "/storage/volume-groups/:id/edit", }, iscsi: "/storage/iscsi", dasd: "/storage/dasd", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 61bf5b5226..cbaf786eb8 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -70,6 +70,10 @@ const routes = (): Route => ({ path: PATHS.volumeGroup.add, element: , }, + { + path: PATHS.volumeGroup.edit, + element: , + }, { path: PATHS.iscsi, element: , From a47f1a66759c031336d8661fedeacf86d1a5a831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 11:28:34 +0000 Subject: [PATCH 066/103] web: improvements in the storage hooks - Helpers do not modify the given apiModel object. --- web/src/hooks/storage/api-model.ts | 13 ++------- web/src/hooks/storage/edit-volume-group.ts | 21 +-------------- .../hooks/storage/helpers/copy-api-model.ts | 27 +++++++++++++++++++ web/src/hooks/storage/helpers/drive.ts | 9 ++++--- web/src/hooks/storage/helpers/volume-group.ts | 27 ++++++++++++++++++- 5 files changed, 62 insertions(+), 35 deletions(-) create mode 100644 web/src/hooks/storage/helpers/copy-api-model.ts diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts index 5f387be88b..afc4260dc6 100644 --- a/web/src/hooks/storage/api-model.ts +++ b/web/src/hooks/storage/api-model.ts @@ -20,23 +20,14 @@ * find current contact information at www.suse.com. */ -import { useMemo } from "react"; import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { configModelQuery } from "~/queries/storage/config-model"; import { apiModel } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; -function useApiModel(options?: QueryHookOptions): apiModel.Config | null { +export default function useApiModel(options?: QueryHookOptions): apiModel.Config | null { const query = configModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); - - const apiModel = useMemo((): apiModel.Config | null => { - // Returns a copy. - return data ? JSON.parse(JSON.stringify(data)) : null; - }, [data]); - - return apiModel; + return data || null; } - -export { useApiModel as default }; diff --git a/web/src/hooks/storage/edit-volume-group.ts b/web/src/hooks/storage/edit-volume-group.ts index d0fa723e43..bd8402b325 100644 --- a/web/src/hooks/storage/edit-volume-group.ts +++ b/web/src/hooks/storage/edit-volume-group.ts @@ -22,27 +22,8 @@ import useApiModel from "~/hooks/storage/api-model"; import useUpdateApiModel from "~/hooks/storage/update-api-model"; -import { addVolumeGroup } from "~/hooks/storage/helpers/volume-group"; -import { deleteIfUnused } from "~/hooks/storage/helpers/drive"; +import { editVolumeGroup } from "~/hooks/storage/helpers/volume-group"; import { QueryHookOptions } from "~/types/queries"; -import { apiModel } from "~/api/storage/types"; - -function editVolumeGroup( - apiModel: apiModel.Config, - oldVgName: string, - vgName: string, - targetDevices: string[], -): apiModel.Config { - const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === oldVgName); - if (index === -1) return; - - const oldTargetDevices = apiModel.volumeGroups[index].targetDevices || []; - - addVolumeGroup(apiModel, vgName, targetDevices, false, index); - oldTargetDevices.forEach((d) => deleteIfUnused(apiModel, d)); - - return apiModel; -} export type EditVolumeGroupFn = ( odlVgName: string, diff --git a/web/src/hooks/storage/helpers/copy-api-model.ts b/web/src/hooks/storage/helpers/copy-api-model.ts new file mode 100644 index 0000000000..049a48de2f --- /dev/null +++ b/web/src/hooks/storage/helpers/copy-api-model.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { apiModel } from "~/api/storage/types"; + +export default function copyApiModel(apiModel: apiModel.Config): apiModel.Config { + return JSON.parse(JSON.stringify(apiModel)); +} diff --git a/web/src/hooks/storage/helpers/drive.ts b/web/src/hooks/storage/helpers/drive.ts index 13e17636a4..ac6e0bda88 100644 --- a/web/src/hooks/storage/helpers/drive.ts +++ b/web/src/hooks/storage/helpers/drive.ts @@ -22,6 +22,7 @@ import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; +import copyApiModel from "~/hooks/storage/helpers/copy-api-model"; import buildModel from "~/hooks/storage/helpers/build-model"; function buildDrive(apiModel: apiModel.Config, name: string): model.Drive | undefined { @@ -29,12 +30,14 @@ function buildDrive(apiModel: apiModel.Config, name: string): model.Drive | unde return model.drives.find((d) => d.name === name); } -function deleteIfUnused(apiModel: apiModel.Config, name: string) { +function deleteIfUnused(apiModel: apiModel.Config, name: string): apiModel.Config { + apiModel = copyApiModel(apiModel); + const index = (apiModel.drives || []).findIndex((d) => d.name === name); - if (index === -1) return; + if (index === -1) return apiModel; const drive = buildDrive(apiModel, name); - if (!drive || drive.isUsed) return; + if (!drive || drive.isUsed) return apiModel; apiModel.drives.splice(index, 1); return apiModel; diff --git a/web/src/hooks/storage/helpers/volume-group.ts b/web/src/hooks/storage/helpers/volume-group.ts index 6be4365d62..95f7436129 100644 --- a/web/src/hooks/storage/helpers/volume-group.ts +++ b/web/src/hooks/storage/helpers/volume-group.ts @@ -21,6 +21,8 @@ */ import { apiModel } from "~/api/storage/types"; +import copyApiModel from "~/hooks/storage/helpers/copy-api-model"; +import { deleteIfUnused } from "~/hooks/storage/helpers/drive"; function toLogicalVolume(partition: apiModel.Partition) { return { ...partition }; @@ -43,6 +45,8 @@ function addVolumeGroup( moveContent: boolean, index?: number, ): apiModel.Config { + apiModel = copyApiModel(apiModel); + const volumeGroup = { vgName, targetDevices }; if (moveContent) { @@ -62,4 +66,25 @@ function addVolumeGroup( return apiModel; } -export { addVolumeGroup }; +function editVolumeGroup( + apiModel: apiModel.Config, + oldVgName: string, + vgName: string, + targetDevices: string[], +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === oldVgName); + if (index === -1) return apiModel; + + const oldTargetDevices = apiModel.volumeGroups[index].targetDevices || []; + + apiModel = addVolumeGroup(apiModel, vgName, targetDevices, false, index); + oldTargetDevices.forEach((d) => { + apiModel = deleteIfUnused(apiModel, d); + }); + + return apiModel; +} + +export { addVolumeGroup, editVolumeGroup }; From 2c3eef8561b0ba6f11093ccb3c737a3398d39f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 12:41:56 +0000 Subject: [PATCH 067/103] web: fix helper for editing a volume group - The logical volumes are kept. --- web/src/hooks/storage/helpers/volume-group.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/web/src/hooks/storage/helpers/volume-group.ts b/web/src/hooks/storage/helpers/volume-group.ts index 95f7436129..d14713d458 100644 --- a/web/src/hooks/storage/helpers/volume-group.ts +++ b/web/src/hooks/storage/helpers/volume-group.ts @@ -43,7 +43,6 @@ function addVolumeGroup( vgName: string, targetDevices: string[], moveContent: boolean, - index?: number, ): apiModel.Config { apiModel = copyApiModel(apiModel); @@ -56,12 +55,7 @@ function addVolumeGroup( } apiModel.volumeGroups ||= []; - - if (index === undefined || index >= apiModel.volumeGroups.length) { - apiModel.volumeGroups.push(volumeGroup); - } else { - apiModel.volumeGroups.splice(index, 1, volumeGroup); - } + apiModel.volumeGroups.push(volumeGroup); return apiModel; } @@ -77,10 +71,11 @@ function editVolumeGroup( const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === oldVgName); if (index === -1) return apiModel; - const oldTargetDevices = apiModel.volumeGroups[index].targetDevices || []; + const oldVolumeGroup = apiModel.volumeGroups[index]; + const newVolumeGroup = { ...oldVolumeGroup, vgName, targetDevices }; - apiModel = addVolumeGroup(apiModel, vgName, targetDevices, false, index); - oldTargetDevices.forEach((d) => { + apiModel.volumeGroups.splice(index, 1, newVolumeGroup); + (oldVolumeGroup.targetDevices || []).forEach((d) => { apiModel = deleteIfUnused(apiModel, d); }); From 01364168349841186de4fa0066546e44349d4307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 17:40:07 +0000 Subject: [PATCH 068/103] web: add basic unit test for LVM form The test is limited and could be improved, especially in terms of mocking. It also highlights potential issues with form initialization and the use of useEffect. However, it ensures the form works as expected at a basic level. --- web/src/components/storage/LvmPage.test.tsx | 300 ++++++++++++++++++++ web/src/components/storage/LvmPage.tsx | 4 +- 2 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 web/src/components/storage/LvmPage.test.tsx diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx new file mode 100644 index 0000000000..af13d8b4a7 --- /dev/null +++ b/web/src/components/storage/LvmPage.test.tsx @@ -0,0 +1,300 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { installerRender, mockParams } from "~/test-utils"; +import { model, StorageDevice } from "~/types/storage"; +import { apiModel } from "~/api/storage/types"; +import { gib } from "./utils"; +import { Drive } from "~/types/storage/model"; +import LvmPage from "./LvmPage"; + +const sda1: StorageDevice = { + sid: 69, + name: "/dev/sda1", + description: "Swap partition", + isDrive: false, + type: "partition", + size: gib(2), + shrinking: { unsupported: ["Resizing is not supported"] }, + start: 1, +}; + +const sda: StorageDevice = { + 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, + shrinking: { unsupported: ["Resizing is not supported"] }, + systems: [], + partitionTable: { + type: "gpt", + partitions: [sda1], + unpartitionedSize: 0, + unusedSlots: [{ start: 3, size: gib(2) }], + }, + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], + description: "", +}; + +const mockSdaDrive: Drive = { + name: "/dev/sda", + spacePolicy: "delete", + partitions: [ + { + mountPath: "swap", + size: { + min: gib(2), + default: false, // false: user provided, true: calculated + }, + filesystem: { default: false, type: "swap" }, + }, + { + mountPath: "/home", + size: { + min: gib(16), + default: true, + }, + filesystem: { default: false, type: "xfs" }, + }, + ], + isUsed: true, + getVolumeGroups: () => [], +}; + +const mockRootVolumeGroup: model.VolumeGroup = { + vgName: "fakeRootVg", + getTargetDevices: () => [mockSdaDrive], + logicalVolumes: [], +}; + +const mockAddVolumeGroup = jest.fn(); +const mockEditVolumeGroup = jest.fn(); +let mockVolumeGroups: apiModel.VolumeGroup[] = []; + +let mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: mockVolumeGroups, +}; + +const mockUseAllDevices = [sda]; + +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssuesChanges: jest.fn(), + useIssues: () => [], +})); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useAvailableDevices: () => mockUseAllDevices, + useDevices: () => [sda], +})); + +jest.mock("~/hooks/storage/model", () => ({ + ...jest.requireActual("~/hooks/storage/model"), + __esModule: true, + useVolumeGroup: (id: string) => (id ? mockRootVolumeGroup : null), + default: () => mockUseModel, +})); + +jest.mock("~/hooks/storage/add-volume-group", () => ({ + ...jest.requireActual("~/hooks/storage/add-volume-group"), + __esModule: true, + default: () => mockAddVolumeGroup, +})); + +jest.mock("~/hooks/storage/edit-volume-group", () => ({ + ...jest.requireActual("~/hooks/storage/edit-volume-group"), + __esModule: true, + default: () => mockEditVolumeGroup, +})); + +describe("LvmPage", () => { + describe("when creating a new volume group", () => { + beforeEach(() => { + mockVolumeGroups = []; + }); + + it("allows configuring a new LVM volume group (without moving mount points)", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const moveMountPointsCheckbox = screen.getByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Clear default value for name + await user.clear(name); + await user.type(name, "root-vg"); + await user.click(sdaCheckbox); + // By default move move mount points should be checked + expect(moveMountPointsCheckbox).toBeChecked(); + await user.click(moveMountPointsCheckbox); + expect(moveMountPointsCheckbox).not.toBeChecked(); + await user.click(acceptButton); + expect(mockAddVolumeGroup).toHaveBeenCalledWith("root-vg", ["/dev/sda"], false); + }); + + it("allows configuring a new LVM volume group (moving mount points)", async () => { + const { user } = installerRender(); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const moveMountPointsCheckbox = screen.getByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + await user.click(sdaCheckbox); + expect(moveMountPointsCheckbox).toBeChecked(); + await user.click(acceptButton); + expect(mockAddVolumeGroup).toHaveBeenCalledWith("system", ["/dev/sda"], true); + }); + + it("performs basic validations", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Let's clean the default given name + await user.clear(name); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText("Name is empty"); + screen.getByText("No disk is selected"); + + // Type a name + await user.type(name, "root-vg"); + await user.click(acceptButton); + expect(screen.queryByText("Name is empty")).toBeNull(); + screen.getByText("Warning alert:"); + screen.getByText("No disk is selected"); + + // Select a disk + expect(sdaCheckbox).not.toBeChecked(); + await user.click(sdaCheckbox); + expect(sdaCheckbox).toBeChecked(); + await user.click(acceptButton); + expect(screen.queryByText("Name is empty")).toBeNull(); + expect(screen.queryByText("Warning alert:")).toBeNull(); + expect(screen.queryByText("No disk is selected")).toBeNull(); + }); + + describe("when there are LVM volume groups", () => { + beforeEach(() => { + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [mockRootVolumeGroup], + }; + }); + + it("does not pre-fill the name input", () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + expect(name).toHaveValue(""); + }); + }); + + describe("when there are no LVM volume groups yet", () => { + beforeEach(() => { + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [], + }; + }); + + it("pre-fills the name input with 'system'", () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + expect(name).toHaveValue("system"); + }); + }); + }); + + describe("when editing", () => { + beforeEach(() => { + mockParams({ id: "fakeRootVg" }); + mockVolumeGroups = [mockRootVolumeGroup]; + }); + + it("performs basic validations", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Let's clean the default given name + await user.clear(name); + await user.click(sdaCheckbox); + expect(name).toHaveValue(""); + expect(sdaCheckbox).not.toBeChecked(); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText("Name is empty"); + screen.getByText("No disk is selected"); + }); + + it("pre-fills form with the current volume group configuration", async () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const sdaCheckbox = screen.getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + expect(name).toHaveValue("fakeRootVg"); + expect(sdaCheckbox).toBeChecked(); + }); + + it("does not offer option for moving mount points", () => { + installerRender(); + expect( + screen.queryByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }), + ).toBeNull(); + }); + + it("triggers the hook for updating the volume group when user accepts changes", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.clear(name); + await user.type(name, "updatedRootVg"); + await user.click(acceptButton); + expect(mockEditVolumeGroup).toHaveBeenCalledWith("fakeRootVg", "updatedRootVg", ["/dev/sda"]); + }); + }); +}); diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index b04f48700d..1b8e85beed 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -51,7 +51,7 @@ function vgNameError( model: model.Model, volumeGroup?: model.VolumeGroup, ): string | undefined { - if (!vgName.length) return sprintf(_("Name is empty"), vgName); + if (!vgName.length) return _("Name is empty"); const exist = model.volumeGroups.some((v) => v.vgName === vgName); if (exist && vgName !== volumeGroup?.vgName) return sprintf(_("'%s' already exists"), vgName); @@ -65,8 +65,10 @@ function targetDevicesError(targetDevices: StorageDevice[]): string | undefined * Form for configuring a LVM volume group. */ export default function LvmPage() { + const { id } = useParams(); const navigate = useNavigate(); const model = useModel(); + const volumeGroup = useVolumeGroup(id); const addVolumeGroup = useAddVolumeGroup(); const editVolumeGroup = useEditVolumeGroup(); const allDevices = useAvailableDevices(); From 13cc74786a7366396a313e1efe13de757c40997e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 18:06:21 +0000 Subject: [PATCH 069/103] fix(web): drop repeated lines Introduced by mistake by undoing changes. --- web/src/components/storage/LvmPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 1b8e85beed..30f8821408 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -76,8 +76,6 @@ export default function LvmPage() { const [selectedDevices, setSelectedDevices] = useState([]); const [moveMountPoints, setMoveMountPoints] = useState(true); const [errors, setErrors] = useState([]); - const { id } = useParams(); - const volumeGroup = useVolumeGroup(id); useEffect(() => { if (volumeGroup) { From 77a435fbfd66675512ef677fb627a36584a8e39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Sat, 22 Mar 2025 19:21:00 +0000 Subject: [PATCH 070/103] fix(web): improve error messages Refactor error messages to be clearer, more helpful, and concise. While this improves the messaging, it remains inconsistent with the rest of the interface until the tone, style, and error handling can be unified. --- web/src/components/storage/LvmPage.test.tsx | 40 ++++++++++++--------- web/src/components/storage/LvmPage.tsx | 9 ++--- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index af13d8b4a7..d3014b167f 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -24,7 +24,6 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import { model, StorageDevice } from "~/types/storage"; -import { apiModel } from "~/api/storage/types"; import { gib } from "./utils"; import { Drive } from "~/types/storage/model"; import LvmPage from "./LvmPage"; @@ -99,13 +98,18 @@ const mockRootVolumeGroup: model.VolumeGroup = { logicalVolumes: [], }; +const mockHomeVolumeGroup: model.VolumeGroup = { + vgName: "fakeHomeVg", + getTargetDevices: () => [mockSdaDrive], + logicalVolumes: [], +}; + const mockAddVolumeGroup = jest.fn(); const mockEditVolumeGroup = jest.fn(); -let mockVolumeGroups: apiModel.VolumeGroup[] = []; let mockUseModel = { drives: [mockSdaDrive], - volumeGroups: mockVolumeGroups, + volumeGroups: [], }; const mockUseAllDevices = [sda]; @@ -143,10 +147,6 @@ jest.mock("~/hooks/storage/edit-volume-group", () => ({ describe("LvmPage", () => { describe("when creating a new volume group", () => { - beforeEach(() => { - mockVolumeGroups = []; - }); - it("allows configuring a new LVM volume group (without moving mount points)", async () => { const { user } = installerRender(); const name = screen.getByRole("textbox", { name: "Name" }); @@ -195,24 +195,24 @@ describe("LvmPage", () => { await user.clear(name); await user.click(acceptButton); screen.getByText("Warning alert:"); - screen.getByText("Name is empty"); - screen.getByText("No disk is selected"); + screen.getByText(/Enter a name/); + screen.getByText(/Select at least one disk/); // Type a name await user.type(name, "root-vg"); await user.click(acceptButton); - expect(screen.queryByText("Name is empty")).toBeNull(); screen.getByText("Warning alert:"); - screen.getByText("No disk is selected"); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + screen.getByText(/Select at least one disk/); // Select a disk expect(sdaCheckbox).not.toBeChecked(); await user.click(sdaCheckbox); expect(sdaCheckbox).toBeChecked(); await user.click(acceptButton); - expect(screen.queryByText("Name is empty")).toBeNull(); expect(screen.queryByText("Warning alert:")).toBeNull(); - expect(screen.queryByText("No disk is selected")).toBeNull(); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + expect(screen.queryByText(/Select at least one disk/)).toBeNull(); }); describe("when there are LVM volume groups", () => { @@ -249,7 +249,10 @@ describe("LvmPage", () => { describe("when editing", () => { beforeEach(() => { mockParams({ id: "fakeRootVg" }); - mockVolumeGroups = [mockRootVolumeGroup]; + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [mockRootVolumeGroup, mockHomeVolumeGroup], + }; }); it("performs basic validations", async () => { @@ -266,8 +269,13 @@ describe("LvmPage", () => { expect(sdaCheckbox).not.toBeChecked(); await user.click(acceptButton); screen.getByText("Warning alert:"); - screen.getByText("Name is empty"); - screen.getByText("No disk is selected"); + screen.getByText(/Enter a name/); + screen.getByText(/Select at least one disk/); + // Enter a name already in use + await user.type(name, "fakeHomeVg"); + await user.click(acceptButton); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + screen.getByText(/Enter a different name/); }); it("pre-fills form with the current volume group configuration", async () => { diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 30f8821408..dd1420f263 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -51,14 +51,15 @@ function vgNameError( model: model.Model, volumeGroup?: model.VolumeGroup, ): string | undefined { - if (!vgName.length) return _("Name is empty"); + if (!vgName.length) return _("Enter a name for the volume group."); const exist = model.volumeGroups.some((v) => v.vgName === vgName); - if (exist && vgName !== volumeGroup?.vgName) return sprintf(_("'%s' already exists"), vgName); + if (exist && vgName !== volumeGroup?.vgName) + return sprintf(_("Volume group '%s' already exists. Enter a different name."), vgName); } function targetDevicesError(targetDevices: StorageDevice[]): string | undefined { - if (!targetDevices.length) return _("No disk is selected"); + if (!targetDevices.length) return _("Select at least one disk."); } /** @@ -132,7 +133,7 @@ export default function LvmPage() { {errors.length > 0 && ( - + {errors.map((e, i) => (

{e}

))} From 235fc0b5865197c37fe54b08685230e39b3c0092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 11:56:53 +0000 Subject: [PATCH 071/103] web: add hook for deleting a volume group --- web/src/hooks/storage/delete-volume-group.ts | 34 +++++++++++++++++++ web/src/hooks/storage/helpers/volume-group.ts | 18 +++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 web/src/hooks/storage/delete-volume-group.ts diff --git a/web/src/hooks/storage/delete-volume-group.ts b/web/src/hooks/storage/delete-volume-group.ts new file mode 100644 index 0000000000..43f3103958 --- /dev/null +++ b/web/src/hooks/storage/delete-volume-group.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 useApiModel from "~/hooks/storage/api-model"; +import useUpdateApiModel from "~/hooks/storage/update-api-model"; +import { QueryHookOptions } from "~/types/queries"; +import { deleteVolumeGroup } from "~/hooks/storage/helpers/volume-group"; + +export type DeleteVolumeGroupFn = (vgName: string) => void; + +export default function useDeleteVolumeGroup(options?: QueryHookOptions): DeleteVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string) => updateApiModel(deleteVolumeGroup(apiModel, vgName)); +} diff --git a/web/src/hooks/storage/helpers/volume-group.ts b/web/src/hooks/storage/helpers/volume-group.ts index d14713d458..5fac437e74 100644 --- a/web/src/hooks/storage/helpers/volume-group.ts +++ b/web/src/hooks/storage/helpers/volume-group.ts @@ -82,4 +82,20 @@ function editVolumeGroup( return apiModel; } -export { addVolumeGroup, editVolumeGroup }; +function deleteVolumeGroup(apiModel: apiModel.Config, vgName: string): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === vgName); + if (index === -1) return apiModel; + + const targetDevices = apiModel.volumeGroups[index].targetDevices || []; + + apiModel.volumeGroups.splice(index, 1); + targetDevices.forEach((d) => { + apiModel = deleteIfUnused(apiModel, d); + }); + + return apiModel; +} + +export { addVolumeGroup, editVolumeGroup, deleteVolumeGroup }; From 5f79c37d0ae14e37618e1126a2cc89a04b2ca92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 21 Mar 2025 12:15:46 +0000 Subject: [PATCH 072/103] web: allow deleting a volume group --- .../components/storage/VolumeGroupEditor.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 03a52689ad..c9c932937e 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -23,12 +23,12 @@ import React from "react"; import { useNavigate, generatePath } from "react-router-dom"; import { _ } from "~/i18n"; -import { sprintf } from "sprintf-js"; import { STORAGE as PATHS } from "~/routes/paths"; import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { useVolumeGroup } from "~/hooks/storage/model"; +import useDeleteVolumeGroup from "~/hooks/storage/delete-volume-group"; import DeviceMenu from "~/components/storage/DeviceMenu"; import DeviceHeader from "~/components/storage/DeviceHeader"; import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; @@ -44,13 +44,19 @@ import { import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; -const RemoveVgOption = ({ vg }: { vg: model.VolumeGroup }) => { - const device = vg.getTargetDevices()[0]; - const desc = sprintf(_("The logical volumes will become partitions at %s"), device?.name); +const DeleteVgOption = ({ vg }: { vg: model.VolumeGroup }) => { + const deleteVolumeGroup = useDeleteVolumeGroup(); return ( - - {_("Do not create")} + deleteVolumeGroup(vg.vgName)} + > + {_("Delete volume group")} ); }; @@ -76,7 +82,7 @@ const VgMenu = ({ vg }: { vg: model.VolumeGroup }) => { {vg.vgName}}> - + ); From 8a585ad0ecbfc9d75251865f6b3fa652d71d557f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 06:28:33 +0000 Subject: [PATCH 073/103] web: add empty state for config editor --- web/src/components/storage/ConfigEditor.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index cd0335a13c..1d3ef873a2 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -21,16 +21,24 @@ */ import React from "react"; +import { _ } from "~/i18n"; import { useDevices } from "~/queries/storage"; import { useConfigModel } from "~/queries/storage/config-model"; import DriveEditor from "~/components/storage/DriveEditor"; import VolumeGroupEditor from "~/components/storage/VolumeGroupEditor"; -import { List, ListItem } from "@patternfly/react-core"; +import { List, ListItem, EmptyState } from "@patternfly/react-core"; export default function ConfigEditor() { const model = useConfigModel({ suspense: true }); const devices = useDevices("system", { suspense: true }); + const drives = model.drives || []; + const volumeGroups = model.volumeGroups || []; + + if (!drives.length && !volumeGroups.length) { + return ; + } + return ( {model.volumeGroups?.map((vg, i) => { From ff0b31c293ed8806978f70859809b4f47b69812d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 07:52:32 +0000 Subject: [PATCH 074/103] fix(web): suggestion from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `model.Drive` instead of directly importing the `Drive` type. This aims to make clear which Drive type is being used when reading, writing, or maintaining the code. However, StorageDevice is not under the `model` namespace (yet?) because it doesn’t currently conflict with a type from `apiModel`. It would be nice to find a better organization for these types, but there is neither time nor a clear vision for how to structure this complex area at the moment. --- web/src/components/storage/LvmPage.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index d3014b167f..e1e65aaf29 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -25,7 +25,6 @@ import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import { model, StorageDevice } from "~/types/storage"; import { gib } from "./utils"; -import { Drive } from "~/types/storage/model"; import LvmPage from "./LvmPage"; const sda1: StorageDevice = { @@ -67,7 +66,7 @@ const sda: StorageDevice = { description: "", }; -const mockSdaDrive: Drive = { +const mockSdaDrive: model.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [ From 235e38f13e617e631f3a342cd8da9cde4b464c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 08:01:21 +0000 Subject: [PATCH 075/103] doc(web): drop repeated word --- web/src/components/storage/LvmPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index e1e65aaf29..4c437be9a1 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -160,7 +160,7 @@ describe("LvmPage", () => { await user.clear(name); await user.type(name, "root-vg"); await user.click(sdaCheckbox); - // By default move move mount points should be checked + // By default move mount points should be checked expect(moveMountPointsCheckbox).toBeChecked(); await user.click(moveMountPointsCheckbox); expect(moveMountPointsCheckbox).not.toBeChecked(); From 429fde7ffa6c04d5f7452b86f1a5ced781ebe5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 13:47:05 +0000 Subject: [PATCH 076/103] web: fix typo --- web/src/hooks/storage/edit-volume-group.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/hooks/storage/edit-volume-group.ts b/web/src/hooks/storage/edit-volume-group.ts index bd8402b325..25cc9676a6 100644 --- a/web/src/hooks/storage/edit-volume-group.ts +++ b/web/src/hooks/storage/edit-volume-group.ts @@ -27,7 +27,7 @@ import { QueryHookOptions } from "~/types/queries"; export type EditVolumeGroupFn = ( odlVgName: string, - VgName: string, + vgName: string, targetDevices: string[], ) => void; From 0633d14f96de6cbd818437155673b90586c0eaf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 13:17:32 +0000 Subject: [PATCH 077/103] fix(web): use Alert instead of EmptyState Use Alert to warn users that no configuration has been set yet. If possible, we would like to reserve EmptyState for situations where it occupies the whole page. --- web/src/components/storage/ConfigEditor.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index 1d3ef873a2..dc84f4f8ab 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -26,17 +26,20 @@ import { useDevices } from "~/queries/storage"; import { useConfigModel } from "~/queries/storage/config-model"; import DriveEditor from "~/components/storage/DriveEditor"; import VolumeGroupEditor from "~/components/storage/VolumeGroupEditor"; -import { List, ListItem, EmptyState } from "@patternfly/react-core"; +import { Alert, List, ListItem } from "@patternfly/react-core"; export default function ConfigEditor() { const model = useConfigModel({ suspense: true }); const devices = useDevices("system", { suspense: true }); - const drives = model.drives || []; const volumeGroups = model.volumeGroups || []; if (!drives.length && !volumeGroups.length) { - return ; + return ( + + {_("Use the actions below to get started.")} + + ); } return ( From 3676217feafeef5c1e9cab8625e26a64b7fd3878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 15:17:46 +0000 Subject: [PATCH 078/103] refactor(web): update behavior of "use disk" menu No longer renders nothing when all available disks have been configured. Instead, it now renders as disabled with an informative label. Although previously discussed, this change is necessary to swap the order of the actions menu in the config editor section. It feels more natural to place it before the "More options" menu. The approach of inverting the order to avoid altering the placement when the "use disk" menu disappears seems to not scale well, especially in the context of an empty config editor. To address this, this commit adopts a similar approach to the partitions form page: disabling the control and rendering an informative label. While this still leaves room for improvements and fixes, particularly regarding a11y, it feels like a step toward a more predictable and consistent interface. --- .../storage/AddExistingDeviceMenu.test.tsx | 58 ++++++++++++++----- .../storage/AddExistingDeviceMenu.tsx | 49 +++++++++------- 2 files changed, 70 insertions(+), 37 deletions(-) diff --git a/web/src/components/storage/AddExistingDeviceMenu.test.tsx b/web/src/components/storage/AddExistingDeviceMenu.test.tsx index e6effb9483..6864ce7618 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.test.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.test.tsx @@ -81,24 +81,49 @@ jest.mock("~/queries/storage/config-model", () => ({ })); describe("when there are unused disks", () => { - beforeEach(() => { - mockUseConfigModelFn.mockReturnValue({ drives: [] }); - }); + describe("and no disks have been configured yet", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [] }); + }); - it("renders the menu", async () => { - plainRender(); - expect(screen.queryByText(/Use additional disk/)).toBeInTheDocument(); + it("renders the menu with correct label", async () => { + plainRender(); + expect(screen.queryByText(/Use a disk/)).toBeInTheDocument(); + }); + + it("allows users to add a new drive", async () => { + const { user } = plainRender(); + + const button = screen.getByRole("button", { name: /Use a disk/ }); + await user.click(button); + const devicesMenu = screen.getByRole("menu"); + const vdaItem = within(devicesMenu).getByRole("menuitem", { name: /vda/ }); + await user.click(vdaItem); + expect(mockAddDriveFn).toHaveBeenCalled(); + }); }); - it("allows users to add a new drive", async () => { - const { user } = plainRender(); + describe("but some disks are already configured", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive] }); + }); + + it("renders the menu with correct label", async () => { + plainRender(); + expect(screen.queryByText(/Use additional disk/)).toBeInTheDocument(); + }); + + it("allows users to add a new drive to an unused disk", async () => { + const { user } = plainRender(); - const button = screen.getByRole("button", { name: /Use additional/ }); - await user.click(button); - const devicesMenu = screen.getByRole("menu"); - const vdaItem = within(devicesMenu).getByRole("menuitem", { name: /vda/ }); - await user.click(vdaItem); - expect(mockAddDriveFn).toHaveBeenCalled(); + const button = screen.getByRole("button", { name: /Use additional disk/ }); + await user.click(button); + const devicesMenu = screen.getByRole("menu"); + expect(within(devicesMenu).queryByRole("menuitem", { name: /vda/ })).toBeNull(); + const vdbItem = within(devicesMenu).getByRole("menuitem", { name: /vdb/ }); + await user.click(vdbItem); + expect(mockAddDriveFn).toHaveBeenCalled(); + }); }); }); @@ -107,8 +132,9 @@ describe("when there are no more unused disks", () => { mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive, vdbDrive] }); }); - it("renders nothing", async () => { + it("renders the menu as disabled with an informative label", async () => { plainRender(); - expect(screen.queryByText(/Use additional disk/)).toBeNull(); + const button = screen.getByRole("button", { name: /All disks configured/ }); + expect(button).toBeDisabled(); }); }); diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/AddExistingDeviceMenu.tsx index 0729890966..2ac3050135 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.tsx @@ -41,6 +41,28 @@ import { useAvailableDevices } from "~/queries/storage"; import { useConfigModel, useModel } from "~/queries/storage/config-model"; import { deviceLabel } from "~/components/storage/utils"; +const Header = ({ drivesCount }) => { + const desc = sprintf( + n_( + "Extends the installation beyond the currently selected disk", + "Extends the installation beyond the current %d disks", + drivesCount, + ), + drivesCount, + ); + + return ( + + ); +}; + export default function AddExistingDeviceMenu() { const [isOpen, setIsOpen] = useState(false); const toggle = () => setIsOpen(!isOpen); @@ -49,22 +71,12 @@ export default function AddExistingDeviceMenu() { const modelHook = useModel(); const drivesNames = model.drives.map((d) => d.name); + const drivesCount = drivesNames.length; const devices = allDevices.filter((d) => !drivesNames.includes(d.name)); - const Header = ({ drives }) => { - const desc = sprintf( - n_( - "Extends the installation beyond the currently selected disk", - "Extends the installation beyond the current %d disks", - drives.length, - ), - drives.length, - ); - - return ; - }; + const isDisabled = !devices.length; - if (!devices.length) return null; + const enabledToggleText = drivesCount ? _("Use additional disk") : _("Use a disk"); return ( ) => ( - - {_("Use additional disk")} + + {isDisabled ? _("All disks configured") : enabledToggleText} )} > {/* @ts-expect-error See https://github.com/patternfly/patternfly/issues/7327 */} - }> + }> {devices.map((device) => ( Date: Mon, 24 Mar 2025 15:34:40 +0000 Subject: [PATCH 079/103] refactor(web): swap order of config editor actions Reorder the actions so "Use a disk" appears before "More options" as it feels more natural. As mentioned in a previous commit, the order was originally inverted to keep the menus in the same place, because the "Use a disk" menu wasn't rendered when all disks were used. However, this is no longer the case. --- web/src/components/storage/ProposalPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 9849307c67..9104ef675e 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -198,10 +198,10 @@ function ProposalSections(): React.ReactNode { actions={ <> - + - + } From 738ea2f5eb06bc530b66227ef2f375cc97bc34f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Tue, 25 Mar 2025 15:42:40 +0000 Subject: [PATCH 080/103] fix(web): adjust the no configuration alert Improved its wording and make a "resets to default" action immediately available in the alert body. Added a unit test for it and another one for ensuring the order of the so called "editors", which follows a specific intended sequence. To achieve this, the `compareDocumentPosition` method was used. See https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition --- .../components/storage/ConfigEditor.test.tsx | 70 ++++++++++++++----- web/src/components/storage/ConfigEditor.tsx | 35 ++++++++-- 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/web/src/components/storage/ConfigEditor.test.tsx b/web/src/components/storage/ConfigEditor.test.tsx index 720e9a3033..7537c017c2 100644 --- a/web/src/components/storage/ConfigEditor.test.tsx +++ b/web/src/components/storage/ConfigEditor.test.tsx @@ -55,14 +55,28 @@ jest.mock("~/queries/storage/config-model", () => ({ jest.mock("./DriveEditor", () => () =>
drive editor
); jest.mock("./VolumeGroupEditor", () => () =>
volume group editor
); +const hasDrives: apiModel.Config = { + drives: [{ name: "/dev/vda" }], +}; + +const hasVolumeGroups: apiModel.Config = { + volumeGroups: [{ vgName: "/dev/system" }], +}; + +const hasBoth: apiModel.Config = { + drives: [{ name: "/dev/vda" }], + volumeGroups: [{ vgName: "/dev/system" }], +}; + +const hasNothing: apiModel.Config = {}; + beforeEach(() => { mockUseDevices.mockReturnValue([disk]); }); -describe("if no drive is used for installation", () => { +describe("when no drive is used for installation", () => { beforeEach(() => { - const modelData: apiModel.Config = {}; - mockUseConfigModel.mockReturnValue(modelData); + mockUseConfigModel.mockReturnValue(hasVolumeGroups); }); it("does not render the drive editor", () => { @@ -71,12 +85,9 @@ describe("if no drive is used for installation", () => { }); }); -describe("if a drive is used for installation", () => { +describe("when a drive is used for installation", () => { beforeEach(() => { - const modelData: apiModel.Config = { - drives: [{ name: "/dev/vda" }], - }; - mockUseConfigModel.mockReturnValue(modelData); + mockUseConfigModel.mockReturnValue(hasDrives); }); it("renders the drive editor", () => { @@ -85,10 +96,9 @@ describe("if a drive is used for installation", () => { }); }); -describe("if no volume group is used for installation", () => { +describe("when no volume group is used for installation", () => { beforeEach(() => { - const modelData: apiModel.Config = {}; - mockUseConfigModel.mockReturnValue(modelData); + mockUseConfigModel.mockReturnValue(hasDrives); }); it("does not render the volume group editor", () => { @@ -97,16 +107,42 @@ describe("if no volume group is used for installation", () => { }); }); -describe("if a volume group is used for installation", () => { +describe("when a volume group is used for installation", () => { beforeEach(() => { - const modelData: apiModel.Config = { - volumeGroups: [{ vgName: "/dev/system" }], - }; - mockUseConfigModel.mockReturnValue(modelData); + mockUseConfigModel.mockReturnValue(hasVolumeGroups); }); - it("renders the drive editor", () => { + it("renders the volume group editor", () => { plainRender(); expect(screen.queryByText("volume group editor")).toBeInTheDocument(); }); }); + +describe("when both a drive and volume group are used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasBoth); + }); + + it("renders a volume group editor followed by drive editor", () => { + plainRender(); + const volumeGroupEditor = screen.getByText("volume group editor"); + const driveEditor = screen.getByText("drive editor"); + + expect(volumeGroupEditor.compareDocumentPosition(driveEditor)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); +}); + +describe("when neither a drive nor volume group are used for installation", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(hasNothing); + }); + + it("renders a no configuration alert with a button for resetting to default", () => { + plainRender(); + screen.getByText("Custom alert:"); + screen.getByText("No devices configured yet"); + screen.getByRole("button", { name: "resets to default" }); + }); +}); diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index dc84f4f8ab..33304e4183 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -22,11 +22,36 @@ import React from "react"; import { _ } from "~/i18n"; -import { useDevices } from "~/queries/storage"; +import { useDevices, useResetConfigMutation } from "~/queries/storage"; import { useConfigModel } from "~/queries/storage/config-model"; import DriveEditor from "~/components/storage/DriveEditor"; import VolumeGroupEditor from "~/components/storage/VolumeGroupEditor"; -import { Alert, List, ListItem } from "@patternfly/react-core"; +import { Alert, Button, List, ListItem } from "@patternfly/react-core"; + +const NoDevicesConfiguredAlert = () => { + const { mutate: reset } = useResetConfigMutation(); + const title = _("No devices configured yet"); + // TRANSLATORS: %s will be replaced by a "resets to default" button + const body = _( + "Use actions below to set up your devices or click %s to start from scratch with the default configuration.", + ); + const [bodyStart, bodyEnd] = body.split("%s"); + + return ( + + {bodyStart}{" "} + {" "} + {bodyEnd} + + ); +}; export default function ConfigEditor() { const model = useConfigModel({ suspense: true }); @@ -35,11 +60,7 @@ export default function ConfigEditor() { const volumeGroups = model.volumeGroups || []; if (!drives.length && !volumeGroups.length) { - return ( - - {_("Use the actions below to get started.")} - - ); + return ; } return ( From 2837138b5ed4ffd7e4e1afbb06b4433be6844d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 07:18:59 +0000 Subject: [PATCH 081/103] fix(web): use imperative mode in button wording --- web/src/components/storage/ConfigEditor.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index 33304e4183..708f6d2186 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -31,7 +31,7 @@ import { Alert, Button, List, ListItem } from "@patternfly/react-core"; const NoDevicesConfiguredAlert = () => { const { mutate: reset } = useResetConfigMutation(); const title = _("No devices configured yet"); - // TRANSLATORS: %s will be replaced by a "resets to default" button + // TRANSLATORS: %s will be replaced by a "reset to default" button const body = _( "Use actions below to set up your devices or click %s to start from scratch with the default configuration.", ); @@ -43,8 +43,8 @@ const NoDevicesConfiguredAlert = () => { {" "} From 293b4fc79261c3d4d84d3a9f90ac1e1594704cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 14:39:11 +0000 Subject: [PATCH 082/103] refactor(web): improve the menu for adding storage devices This update includes several changes to improve the menu, including: * Renaming the label to "Configure a device." * Moving the "Add LVM volume group" option from "More options" to this menu. * Implementing a drill-down menu for selecting a disk to enhance the user experience by reducing overwhelming choices. * Refactoring the code to use PF/Menu components instead of the PF/Dropdown equivalents. Further improvements to wording and internal logic are needed. The latter will be made when extracting the shared logic into a more generic component for easier reuse. Slightly related with changes made at eee368d9fe31120759afe858174c213419d316de --- .../storage/AddExistingDeviceMenu.test.tsx | 106 ++++---- .../storage/AddExistingDeviceMenu.tsx | 246 +++++++++++++----- .../components/storage/ConfigEditorMenu.tsx | 8 - 3 files changed, 243 insertions(+), 117 deletions(-) diff --git a/web/src/components/storage/AddExistingDeviceMenu.test.tsx b/web/src/components/storage/AddExistingDeviceMenu.test.tsx index 6864ce7618..7111e8603b 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.test.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.test.tsx @@ -21,8 +21,8 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; +import { screen } from "@testing-library/react"; +import { mockNavigateFn, plainRender } from "~/test-utils"; import AddExistingDeviceMenu from "~/components/storage/AddExistingDeviceMenu"; import { StorageDevice } from "~/types/storage"; import { apiModel } from "~/api/storage/types"; @@ -80,61 +80,73 @@ jest.mock("~/queries/storage/config-model", () => ({ useConfigModel: () => mockUseConfigModelFn(), })); -describe("when there are unused disks", () => { - describe("and no disks have been configured yet", () => { - beforeEach(() => { - mockUseConfigModelFn.mockReturnValue({ drives: [] }); - }); +describe("AddExistingDeviceMenu", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [] }); + }); - it("renders the menu with correct label", async () => { - plainRender(); - expect(screen.queryByText(/Use a disk/)).toBeInTheDocument(); - }); + it("renders an initially closed menu ", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: "Configure a device", expanded: false }); + expect(screen.queryAllByRole("menu").length).toBe(0); + await user.click(toggler); + expect(toggler).toHaveAttribute("aria-expanded", "true"); + expect(screen.queryAllByRole("menu").length).not.toBe(0); + }); - it("allows users to add a new drive", async () => { - const { user } = plainRender(); + it("allows users to add a new LVM volume group", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: "Configure a device", expanded: false }); + await user.click(toggler); + const lvmMenuItem = screen.getByRole("menuitem", { name: /LVM/ }); + await user.click(lvmMenuItem); + expect(mockNavigateFn).toHaveBeenCalledWith("/storage/volume-groups/add"); + }); - const button = screen.getByRole("button", { name: /Use a disk/ }); - await user.click(button); - const devicesMenu = screen.getByRole("menu"); - const vdaItem = within(devicesMenu).getByRole("menuitem", { name: /vda/ }); - await user.click(vdaItem); - expect(mockAddDriveFn).toHaveBeenCalled(); + describe("when there are unused disks", () => { + describe("and no disks have been configured yet", () => { + it("allows users to add a new drive", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: /Configure a device/ }); + await user.click(toggler); + const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); + await user.click(disksMenuItem); + const vdaItem = screen.getByRole("menuitem", { name: /vda/ }); + await user.click(vdaItem); + expect(mockAddDriveFn).toHaveBeenCalled(); + }); }); - }); - describe("but some disks are already configured", () => { - beforeEach(() => { - mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive] }); + describe("but some disks are already configured", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive] }); + }); + + it("allows users to add a new drive to an unused disk", async () => { + const { user } = plainRender(); + const toggler = screen.getByRole("button", { name: /Configure a device/ }); + await user.click(toggler); + const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); + await user.click(disksMenuItem); + expect(screen.queryByRole("menuitem", { name: /vda/ })).toBeNull(); + const vdbItem = screen.getByRole("menuitem", { name: /vdb/ }); + await user.click(vdbItem); + expect(mockAddDriveFn).toHaveBeenCalled(); + }); }); + }); - it("renders the menu with correct label", async () => { - plainRender(); - expect(screen.queryByText(/Use additional disk/)).toBeInTheDocument(); + describe("when there are no more unused disks", () => { + beforeEach(() => { + mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive, vdbDrive] }); }); - it("allows users to add a new drive to an unused disk", async () => { + it("renders the disks menu as disabled with an informative label", async () => { const { user } = plainRender(); - - const button = screen.getByRole("button", { name: /Use additional disk/ }); - await user.click(button); - const devicesMenu = screen.getByRole("menu"); - expect(within(devicesMenu).queryByRole("menuitem", { name: /vda/ })).toBeNull(); - const vdbItem = within(devicesMenu).getByRole("menuitem", { name: /vdb/ }); - await user.click(vdbItem); - expect(mockAddDriveFn).toHaveBeenCalled(); + const toggler = screen.getByRole("button", { name: /Configure a device/ }); + await user.click(toggler); + const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); + expect(disksMenuItem).toBeDisabled(); }); }); }); - -describe("when there are no more unused disks", () => { - beforeEach(() => { - mockUseConfigModelFn.mockReturnValue({ drives: [vdaDrive, vdbDrive] }); - }); - - it("renders the menu as disabled with an informative label", async () => { - plainRender(); - const button = screen.getByRole("button", { name: /All disks configured/ }); - expect(button).toBeDisabled(); - }); -}); diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/AddExistingDeviceMenu.tsx index 2ac3050135..50724b5a2c 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.tsx +++ b/web/src/components/storage/AddExistingDeviceMenu.tsx @@ -20,85 +20,79 @@ * find current contact information at www.suse.com. */ -import React, { useState } from "react"; -import { _, n_ } from "~/i18n"; -import { sprintf } from "sprintf-js"; +import React, { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { - Dropdown, - DropdownList, - DropdownItem, - DropdownGroup, - MenuToggleElement, MenuToggle, - Divider, Split, Flex, Label, + DrilldownMenu, + MenuContent, + Divider, + MenuContainer, + Menu, + MenuList, + MenuItem, } from "@patternfly/react-core"; -import { MenuHeader } from "~/components/core"; import MenuDeviceDescription from "./MenuDeviceDescription"; import { useAvailableDevices } from "~/queries/storage"; import { useConfigModel, useModel } from "~/queries/storage/config-model"; import { deviceLabel } from "~/components/storage/utils"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { sprintf } from "sprintf-js"; +import { _, n_ } from "~/i18n"; +import { StorageDevice } from "~/types/storage"; -const Header = ({ drivesCount }) => { - const desc = sprintf( - n_( - "Extends the installation beyond the currently selected disk", - "Extends the installation beyond the current %d disks", - drivesCount, - ), - drivesCount, - ); - - return ( - - ); +type DisksDrillDownMenuItemProps = { + /** Available devices to be chosen */ + devices: StorageDevice[]; + /** The amount of drives already configured */ + drivesCount: number; + /** Callback function to be triggered when a device is selected */ + onDeviceClick: (deviceName: StorageDevice["name"]) => void; }; -export default function AddExistingDeviceMenu() { - const [isOpen, setIsOpen] = useState(false); - const toggle = () => setIsOpen(!isOpen); - const allDevices = useAvailableDevices(); - const model = useConfigModel({ suspense: true }); - const modelHook = useModel(); - - const drivesNames = model.drives.map((d) => d.name); - const drivesCount = drivesNames.length; - const devices = allDevices.filter((d) => !drivesNames.includes(d.name)); - +/** + * Internal component holding the logic for rendering the disks drilldown menu + */ +const DisksDrillDownMenuItem = ({ + drivesCount, + devices, + onDeviceClick, +}: DisksDrillDownMenuItemProps) => { const isDisabled = !devices.length; - const enabledToggleText = drivesCount ? _("Use additional disk") : _("Use a disk"); + const disabledDescription = _("Already using all availalbe disks."); + const enabledDescription = drivesCount + ? sprintf( + n_( + "Extends the installation beyond the currently selected disk", + "Extends the installation beyond the current %d disks", + drivesCount, + ), + drivesCount, + ) + : _("Extends the installation using a disk"); return ( - ) => ( - - {isDisabled ? _("All disks configured") : enabledToggleText} - - )} - > - - {/* @ts-expect-error See https://github.com/patternfly/patternfly/issues/7327 */} - }> + + + {_("Back")} + {devices.map((device) => ( - } - onClick={() => modelHook.addDrive(device.name)} + onClick={() => onDeviceClick(device.name)} > {deviceLabel(device)} @@ -110,10 +104,138 @@ export default function AddExistingDeviceMenu() { ))} - + ))} - - - + + } + > + {n_( + "Select another disk to define partitions", + "Select a disk to define partitions", + drivesCount, + )} +
+ ); +}; + +/** + * Menu that provides options for users to configure storage drives + * + * It uses a drilled-down menu approach for disks, making the available options less + * overwhelming by presenting them in a more organized manner. + * + * TODO: Refactor and test the component after extracting a basic DrillDown menu to + * share the internal logic with other potential menus that could benefit from a similar + * approach. + */ +export default function AddExistingDeviceMenu() { + const navigate = useNavigate(); + const model = useConfigModel({ suspense: true }); + const { addDrive } = useModel(); + const allDevices = useAvailableDevices(); + const menuRef = useRef(); + const toggleRef = useRef(); + const [isOpen, setIsOpen] = useState(false); + const [menuDrilledIn, setMenuDrilledIn] = React.useState([]); + const [drilldownPath, setDrilldownPath] = React.useState([]); + const [activeMenu, setActiveMenu] = React.useState("root"); + const [menuHeights, setMenuHeights] = React.useState({}); + + const resetState = () => { + setMenuDrilledIn([]); + setDrilldownPath([]); + setActiveMenu("root"); + }; + + const toggle = () => { + setIsOpen(!isOpen); + resetState(); + }; + + const addDriveAndClose = (driveName) => { + addDrive(driveName); + setIsOpen(false); + resetState(); + }; + + const drivesNames = model.drives.map((d) => d.name); + const drivesCount = drivesNames.length; + const devices = allDevices.filter((d) => !drivesNames.includes(d.name)); + + const drillIn = ( + _: React.KeyboardEvent | React.MouseEvent, + fromMenuId: string, + toMenuId: string, + pathId: string, + ) => { + setMenuDrilledIn([...menuDrilledIn, fromMenuId]); + setDrilldownPath([...drilldownPath, pathId]); + setActiveMenu(toMenuId); + }; + + const drillOut = (_: React.KeyboardEvent | React.MouseEvent, toMenuId: string) => { + const menuDrilledInSansLast = menuDrilledIn.slice(0, menuDrilledIn.length - 1); + const pathSansLast = drilldownPath.slice(0, drilldownPath.length - 1); + setMenuDrilledIn(menuDrilledInSansLast); + setDrilldownPath(pathSansLast); + setActiveMenu(toMenuId); + }; + + const setHeight = (menuId: string, height: number) => { + // FIXME: look for a better way to avoid test crashing because of this + // method + if (process.env.NODE_ENV === "test") return; + + if ( + menuHeights[menuId] === undefined || + (menuId !== "root" && menuHeights[menuId] !== height) + ) { + setMenuHeights({ ...menuHeights, [menuId]: height }); + } + }; + + return ( + + {_("Configure a device")} + + } + menuRef={menuRef} + menu={ + + + + + + navigate(PATHS.volumeGroup.add)} + description={_("Extend the installation using LVM")} + > + {_("Add LVM volume group")} + + + + + } + /> ); } diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 095a01737c..a03b263cdc 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -62,14 +62,6 @@ export default function ConfigEditorMenu() { )} > - navigate(PATHS.volumeGroup.add)} - description={_("Extend the installation using LVM")} - > - {_("Add LVM volume group")} - - navigate(PATHS.editBootDevice)} From a37893a9ed61a31a6b7d3a6cb6c1d48a2f742190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 14:46:32 +0000 Subject: [PATCH 083/103] fix(web): add missing "s" to a button label And update its unit test. Related with 2837138b5ed4ffd7e4e1afbb06b4433be6844d17 --- web/src/components/storage/ConfigEditor.test.tsx | 2 +- web/src/components/storage/ConfigEditor.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/ConfigEditor.test.tsx b/web/src/components/storage/ConfigEditor.test.tsx index 7537c017c2..66c472bd29 100644 --- a/web/src/components/storage/ConfigEditor.test.tsx +++ b/web/src/components/storage/ConfigEditor.test.tsx @@ -143,6 +143,6 @@ describe("when neither a drive nor volume group are used for installation", () = plainRender(); screen.getByText("Custom alert:"); screen.getByText("No devices configured yet"); - screen.getByRole("button", { name: "resets to default" }); + screen.getByRole("button", { name: "reset to defaults" }); }); }); diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx index 708f6d2186..a2ed75ac07 100644 --- a/web/src/components/storage/ConfigEditor.tsx +++ b/web/src/components/storage/ConfigEditor.tsx @@ -44,7 +44,7 @@ const NoDevicesConfiguredAlert = () => { { // TRANSLATORS: label for a button - _("reset to default") + _("reset to defaults") } {" "} From c7994083acb1e556a7ea282c2d8cb23f61a4f756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 15:22:20 +0000 Subject: [PATCH 084/103] fix(web): rename component rendering configure devices menu Because it is not longer a menu for adding only existing disks, but for configuring a device. A better naming will be always welcome, though. --- ...eMenu.test.tsx => ConfigureDeviceMenu.test.tsx} | 14 +++++++------- ...stingDeviceMenu.tsx => ConfigureDeviceMenu.tsx} | 2 +- web/src/components/storage/ProposalPage.test.tsx | 2 +- web/src/components/storage/ProposalPage.tsx | 14 +++++++------- 4 files changed, 16 insertions(+), 16 deletions(-) rename web/src/components/storage/{AddExistingDeviceMenu.test.tsx => ConfigureDeviceMenu.test.tsx} (91%) rename web/src/components/storage/{AddExistingDeviceMenu.tsx => ConfigureDeviceMenu.tsx} (99%) diff --git a/web/src/components/storage/AddExistingDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx similarity index 91% rename from web/src/components/storage/AddExistingDeviceMenu.test.tsx rename to web/src/components/storage/ConfigureDeviceMenu.test.tsx index 7111e8603b..5d532e2c70 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.test.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx @@ -23,7 +23,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { mockNavigateFn, plainRender } from "~/test-utils"; -import AddExistingDeviceMenu from "~/components/storage/AddExistingDeviceMenu"; +import ConfigureDeviceMenu from "./ConfigureDeviceMenu"; import { StorageDevice } from "~/types/storage"; import { apiModel } from "~/api/storage/types"; @@ -80,13 +80,13 @@ jest.mock("~/queries/storage/config-model", () => ({ useConfigModel: () => mockUseConfigModelFn(), })); -describe("AddExistingDeviceMenu", () => { +describe("ConfigureDeviceMenu", () => { beforeEach(() => { mockUseConfigModelFn.mockReturnValue({ drives: [] }); }); it("renders an initially closed menu ", async () => { - const { user } = plainRender(); + const { user } = plainRender(); const toggler = screen.getByRole("button", { name: "Configure a device", expanded: false }); expect(screen.queryAllByRole("menu").length).toBe(0); await user.click(toggler); @@ -95,7 +95,7 @@ describe("AddExistingDeviceMenu", () => { }); it("allows users to add a new LVM volume group", async () => { - const { user } = plainRender(); + const { user } = plainRender(); const toggler = screen.getByRole("button", { name: "Configure a device", expanded: false }); await user.click(toggler); const lvmMenuItem = screen.getByRole("menuitem", { name: /LVM/ }); @@ -106,7 +106,7 @@ describe("AddExistingDeviceMenu", () => { describe("when there are unused disks", () => { describe("and no disks have been configured yet", () => { it("allows users to add a new drive", async () => { - const { user } = plainRender(); + const { user } = plainRender(); const toggler = screen.getByRole("button", { name: /Configure a device/ }); await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); @@ -123,7 +123,7 @@ describe("AddExistingDeviceMenu", () => { }); it("allows users to add a new drive to an unused disk", async () => { - const { user } = plainRender(); + const { user } = plainRender(); const toggler = screen.getByRole("button", { name: /Configure a device/ }); await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); @@ -142,7 +142,7 @@ describe("AddExistingDeviceMenu", () => { }); it("renders the disks menu as disabled with an informative label", async () => { - const { user } = plainRender(); + const { user } = plainRender(); const toggler = screen.getByRole("button", { name: /Configure a device/ }); await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); diff --git a/web/src/components/storage/AddExistingDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx similarity index 99% rename from web/src/components/storage/AddExistingDeviceMenu.tsx rename to web/src/components/storage/ConfigureDeviceMenu.tsx index 50724b5a2c..dafb5586cd 100644 --- a/web/src/components/storage/AddExistingDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -128,7 +128,7 @@ const DisksDrillDownMenuItem = ({ * share the internal logic with other potential menus that could benefit from a similar * approach. */ -export default function AddExistingDeviceMenu() { +export default function ConfigureDeviceMenu() { const navigate = useNavigate(); const model = useConfigModel({ suspense: true }); const { addDrive } = useModel(); diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index ce0b3dc426..a089cf5416 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -106,7 +106,7 @@ jest.mock("./ProposalFailedInfo", () => () =>
failed info
); jest.mock("./UnsupportedModelInfo", () => () =>
unsupported info
); jest.mock("./ProposalResultSection", () => () =>
result
); jest.mock("./ConfigEditor", () => () =>
installation devices
); -jest.mock("./AddExistingDeviceMenu", () => () =>
add device menu
); +jest.mock("./ConfigureDeviceMenu", () => () =>
add device menu
); jest.mock("./ConfigEditorMenu", () => () =>
config editor menu
); jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
registration alert
diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 9104ef675e..f1eae52f59 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -36,14 +36,15 @@ import { } from "@patternfly/react-core"; import { Page, Link } from "~/components/core/"; import { Icon, Loading } from "~/components/layout"; +import ConfigEditor from "./ConfigEditor"; +import ConfigEditorMenu from "./ConfigEditorMenu"; +import ConfigureDeviceMenu from "./ConfigureDeviceMenu"; +import EncryptionSection from "./EncryptionSection"; +import FixableConfigInfo from "./FixableConfigInfo"; +import ProposalFailedInfo from "./ProposalFailedInfo"; import ProposalResultSection from "./ProposalResultSection"; import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; -import ProposalFailedInfo from "./ProposalFailedInfo"; -import FixableConfigInfo from "./FixableConfigInfo"; import UnsupportedModelInfo from "./UnsupportedModelInfo"; -import ConfigEditor from "./ConfigEditor"; -import ConfigEditorMenu from "./ConfigEditorMenu"; -import AddExistingDeviceMenu from "./AddExistingDeviceMenu"; import { useAvailableDevices, useResetConfigMutation, @@ -57,7 +58,6 @@ import { useDASDSupported } from "~/queries/storage/dasd"; import { useSystemErrors, useConfigErrors } from "~/queries/issues"; import { STORAGE as PATHS } from "~/routes/paths"; import { _, n_ } from "~/i18n"; -import EncryptionSection from "./EncryptionSection"; function InvalidConfigEmptyState(): React.ReactNode { const errors = useConfigErrors("storage"); @@ -198,7 +198,7 @@ function ProposalSections(): React.ReactNode { actions={ <> - + From edc4b6d3635f4710221e2f2a86797230be5b9992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 13:31:48 +0000 Subject: [PATCH 085/103] web: reorganize hooks and helpers --- web/src/components/storage/DriveEditor.tsx | 2 +- web/src/components/storage/LvmPage.test.tsx | 17 ++--- web/src/components/storage/LvmPage.tsx | 9 ++- .../components/storage/VolumeGroupEditor.tsx | 3 +- .../copy-api-model.ts => helpers/storage.ts} | 4 +- .../storage}/build-model.ts | 4 +- .../helpers => helpers/storage}/drive.ts | 4 +- .../storage}/volume-group.ts | 4 +- web/src/hooks/storage/add-volume-group.ts | 40 ------------ web/src/hooks/storage/api-model.ts | 23 ++++++- .../{delete-volume-group.ts => drive.ts} | 17 +++-- web/src/hooks/storage/edit-volume-group.ts | 40 ------------ web/src/hooks/storage/model.ts | 18 +----- web/src/hooks/storage/update-api-model.ts | 41 ------------ web/src/hooks/storage/volume-group.ts | 64 +++++++++++++++++++ 15 files changed, 119 insertions(+), 171 deletions(-) rename web/src/{hooks/storage/helpers/copy-api-model.ts => helpers/storage.ts} (90%) rename web/src/{hooks/storage/helpers => helpers/storage}/build-model.ts (97%) rename web/src/{hooks/storage/helpers => helpers/storage}/drive.ts (92%) rename web/src/{hooks/storage/helpers => helpers/storage}/volume-group.ts (95%) delete mode 100644 web/src/hooks/storage/add-volume-group.ts rename web/src/hooks/storage/{delete-volume-group.ts => drive.ts} (63%) delete mode 100644 web/src/hooks/storage/edit-volume-group.ts delete mode 100644 web/src/hooks/storage/update-api-model.ts create mode 100644 web/src/hooks/storage/volume-group.ts diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index a239338f85..8bd0aff096 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -30,7 +30,7 @@ import { apiModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDrive, useModel } from "~/queries/storage/config-model"; -import { useDrive as useDriveModel } from "~/hooks/storage/model"; +import { useDrive as useDriveModel } from "~/hooks/storage/drive"; import * as driveUtils from "~/components/storage/utils/drive"; import { contentDescription } from "~/components/storage/utils/device"; import DeviceMenu from "~/components/storage/DeviceMenu"; diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index 4c437be9a1..b27787b0d9 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -128,20 +128,15 @@ jest.mock("~/queries/storage", () => ({ jest.mock("~/hooks/storage/model", () => ({ ...jest.requireActual("~/hooks/storage/model"), __esModule: true, - useVolumeGroup: (id: string) => (id ? mockRootVolumeGroup : null), - default: () => mockUseModel, + useModel: () => mockUseModel, })); -jest.mock("~/hooks/storage/add-volume-group", () => ({ - ...jest.requireActual("~/hooks/storage/add-volume-group"), +jest.mock("~/hooks/storage/volume-group", () => ({ + ...jest.requireActual("~/hooks/storage/volume-group"), __esModule: true, - default: () => mockAddVolumeGroup, -})); - -jest.mock("~/hooks/storage/edit-volume-group", () => ({ - ...jest.requireActual("~/hooks/storage/edit-volume-group"), - __esModule: true, - default: () => mockEditVolumeGroup, + useVolumeGroup: (id: string) => (id ? mockRootVolumeGroup : null), + useAddVolumeGroup: () => mockAddVolumeGroup, + useEditVolumeGroup: () => mockEditVolumeGroup, })); describe("LvmPage", () => { diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index dd1420f263..cd4f371db4 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -37,9 +37,12 @@ import { import { Page, SubtleContent } from "~/components/core"; import { useAvailableDevices } from "~/queries/storage"; import { StorageDevice, model } from "~/types/storage"; -import useModel, { useVolumeGroup } from "~/hooks/storage/model"; -import useAddVolumeGroup from "~/hooks/storage/add-volume-group"; -import useEditVolumeGroup from "~/hooks/storage/edit-volume-group"; +import { useModel } from "~/hooks/storage/model"; +import { + useVolumeGroup, + useAddVolumeGroup, + useEditVolumeGroup, +} from "~/hooks/storage/volume-group"; import { deviceLabel } from "./utils"; import { contentDescription, filesystemLabels, typeDescription } from "./utils/device"; import { STORAGE as PATHS } from "~/routes/paths"; diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index c9c932937e..190cf1ac61 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -27,8 +27,7 @@ import { STORAGE as PATHS } from "~/routes/paths"; import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; import { contentDescription } from "~/components/storage/utils/volume-group"; -import { useVolumeGroup } from "~/hooks/storage/model"; -import useDeleteVolumeGroup from "~/hooks/storage/delete-volume-group"; +import { useVolumeGroup, useDeleteVolumeGroup } from "~/hooks/storage/volume-group"; import DeviceMenu from "~/components/storage/DeviceMenu"; import DeviceHeader from "~/components/storage/DeviceHeader"; import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; diff --git a/web/src/hooks/storage/helpers/copy-api-model.ts b/web/src/helpers/storage.ts similarity index 90% rename from web/src/hooks/storage/helpers/copy-api-model.ts rename to web/src/helpers/storage.ts index 049a48de2f..1350535c89 100644 --- a/web/src/hooks/storage/helpers/copy-api-model.ts +++ b/web/src/helpers/storage.ts @@ -22,6 +22,8 @@ import { apiModel } from "~/api/storage/types"; -export default function copyApiModel(apiModel: apiModel.Config): apiModel.Config { +function copyApiModel(apiModel: apiModel.Config): apiModel.Config { return JSON.parse(JSON.stringify(apiModel)); } + +export { copyApiModel }; diff --git a/web/src/hooks/storage/helpers/build-model.ts b/web/src/helpers/storage/build-model.ts similarity index 97% rename from web/src/hooks/storage/helpers/build-model.ts rename to web/src/helpers/storage/build-model.ts index ef43c1c99e..b4ec39c6ea 100644 --- a/web/src/hooks/storage/helpers/build-model.ts +++ b/web/src/helpers/storage/build-model.ts @@ -89,7 +89,7 @@ function buildVolumeGroup( }; } -export default function buildModel(apiModel: apiModel.Config): model.Model { +function buildModel(apiModel: apiModel.Config): model.Model { const model: model.Model = { drives: [], volumeGroups: [], @@ -108,3 +108,5 @@ export default function buildModel(apiModel: apiModel.Config): model.Model { model.volumeGroups = buildVolumeGroups(); return model; } + +export { buildModel }; diff --git a/web/src/hooks/storage/helpers/drive.ts b/web/src/helpers/storage/drive.ts similarity index 92% rename from web/src/hooks/storage/helpers/drive.ts rename to web/src/helpers/storage/drive.ts index ac6e0bda88..d4bbaab13c 100644 --- a/web/src/hooks/storage/helpers/drive.ts +++ b/web/src/helpers/storage/drive.ts @@ -22,8 +22,8 @@ import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; -import copyApiModel from "~/hooks/storage/helpers/copy-api-model"; -import buildModel from "~/hooks/storage/helpers/build-model"; +import { copyApiModel } from "~/helpers/storage"; +import { buildModel } from "~/helpers/storage/build-model"; function buildDrive(apiModel: apiModel.Config, name: string): model.Drive | undefined { const model = buildModel(apiModel); diff --git a/web/src/hooks/storage/helpers/volume-group.ts b/web/src/helpers/storage/volume-group.ts similarity index 95% rename from web/src/hooks/storage/helpers/volume-group.ts rename to web/src/helpers/storage/volume-group.ts index 5fac437e74..33b54f8d26 100644 --- a/web/src/hooks/storage/helpers/volume-group.ts +++ b/web/src/helpers/storage/volume-group.ts @@ -21,8 +21,8 @@ */ import { apiModel } from "~/api/storage/types"; -import copyApiModel from "~/hooks/storage/helpers/copy-api-model"; -import { deleteIfUnused } from "~/hooks/storage/helpers/drive"; +import { copyApiModel } from "~/helpers/storage"; +import { deleteIfUnused } from "~/helpers/storage/drive"; function toLogicalVolume(partition: apiModel.Partition) { return { ...partition }; diff --git a/web/src/hooks/storage/add-volume-group.ts b/web/src/hooks/storage/add-volume-group.ts deleted file mode 100644 index fa970ee515..0000000000 --- a/web/src/hooks/storage/add-volume-group.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 useApiModel from "~/hooks/storage/api-model"; -import useUpdateApiModel from "~/hooks/storage/update-api-model"; -import { addVolumeGroup } from "~/hooks/storage/helpers/volume-group"; -import { QueryHookOptions } from "~/types/queries"; - -export type AddVolumeGroupFn = ( - vgName: string, - targetDevices: string[], - moveContent: boolean, -) => void; - -export default function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); - return (vgName: string, targetDevices: string[], moveContent: boolean) => { - updateApiModel(addVolumeGroup(apiModel, vgName, targetDevices, moveContent)); - }; -} diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts index afc4260dc6..a37fb02dec 100644 --- a/web/src/hooks/storage/api-model.ts +++ b/web/src/hooks/storage/api-model.ts @@ -20,14 +20,31 @@ * find current contact information at www.suse.com. */ -import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; -import { configModelQuery } from "~/queries/storage/config-model"; +import { useQuery, useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiModel } from "~/api/storage/types"; +import { configModelQuery } from "~/queries/storage/config-model"; import { QueryHookOptions } from "~/types/queries"; +import { setConfigModel } from "~/api/storage"; -export default function useApiModel(options?: QueryHookOptions): apiModel.Config | null { +function useApiModel(options?: QueryHookOptions): apiModel.Config | null { const query = configModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); return data || null; } + +type UpdateApiModelFn = (apiModel: apiModel.Config) => void; + +function useUpdateApiModel(): UpdateApiModelFn { + const queryClient = useQueryClient(); + const query = { + mutationFn: (apiModel: apiModel.Config) => setConfigModel(apiModel), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), + }; + + const { mutate } = useMutation(query); + return mutate; +} + +export { useApiModel, useUpdateApiModel }; +export type { UpdateApiModelFn }; diff --git a/web/src/hooks/storage/delete-volume-group.ts b/web/src/hooks/storage/drive.ts similarity index 63% rename from web/src/hooks/storage/delete-volume-group.ts rename to web/src/hooks/storage/drive.ts index 43f3103958..f571174c80 100644 --- a/web/src/hooks/storage/delete-volume-group.ts +++ b/web/src/hooks/storage/drive.ts @@ -20,15 +20,14 @@ * find current contact information at www.suse.com. */ -import useApiModel from "~/hooks/storage/api-model"; -import useUpdateApiModel from "~/hooks/storage/update-api-model"; import { QueryHookOptions } from "~/types/queries"; -import { deleteVolumeGroup } from "~/hooks/storage/helpers/volume-group"; +import { model } from "~/types/storage"; +import { useModel } from "~/hooks/storage/model"; -export type DeleteVolumeGroupFn = (vgName: string) => void; - -export default function useDeleteVolumeGroup(options?: QueryHookOptions): DeleteVolumeGroupFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); - return (vgName: string) => updateApiModel(deleteVolumeGroup(apiModel, vgName)); +function useDrive(name: string, options?: QueryHookOptions): model.Drive | null { + const model = useModel(options); + const drive = model?.drives?.find((d) => d.name === name); + return drive || null; } + +export { useDrive }; diff --git a/web/src/hooks/storage/edit-volume-group.ts b/web/src/hooks/storage/edit-volume-group.ts deleted file mode 100644 index 25cc9676a6..0000000000 --- a/web/src/hooks/storage/edit-volume-group.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 useApiModel from "~/hooks/storage/api-model"; -import useUpdateApiModel from "~/hooks/storage/update-api-model"; -import { editVolumeGroup } from "~/hooks/storage/helpers/volume-group"; -import { QueryHookOptions } from "~/types/queries"; - -export type EditVolumeGroupFn = ( - odlVgName: string, - vgName: string, - targetDevices: string[], -) => void; - -export default function useEditVolumeGroup(options?: QueryHookOptions): EditVolumeGroupFn { - const apiModel = useApiModel(options); - const updateApiModel = useUpdateApiModel(); - return (oldVgName: string, vgName: string, targetDevices: string[]) => { - updateApiModel(editVolumeGroup(apiModel, oldVgName, vgName, targetDevices)); - }; -} diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index ad0b83b930..5968ca2b7d 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -21,8 +21,8 @@ */ import { useMemo } from "react"; -import useApiModel from "~/hooks/storage/api-model"; -import buildModel from "~/hooks/storage/helpers/build-model"; +import { useApiModel } from "~/hooks/storage/api-model"; +import { buildModel } from "~/helpers/storage/build-model"; import { QueryHookOptions } from "~/types/queries"; import { model } from "~/types/storage"; @@ -36,16 +36,4 @@ function useModel(options?: QueryHookOptions): model.Model | null { return model; } -function useDrive(name: string, options?: QueryHookOptions): model.Drive | null { - const model = useModel(options); - const drive = model?.drives?.find((d) => d.name === name); - return drive || null; -} - -function useVolumeGroup(vgName: string, options?: QueryHookOptions): model.VolumeGroup | null { - const model = useModel(options); - const volumeGroup = model?.volumeGroups?.find((v) => v.vgName === vgName); - return volumeGroup || null; -} - -export { useModel as default, useDrive, useVolumeGroup }; +export { useModel }; diff --git a/web/src/hooks/storage/update-api-model.ts b/web/src/hooks/storage/update-api-model.ts deleted file mode 100644 index c88fb6e1bc..0000000000 --- a/web/src/hooks/storage/update-api-model.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { useMutation, useQueryClient } from "@tanstack/react-query"; -import { setConfigModel } from "~/api/storage"; -import { apiModel } from "~/api/storage/types"; - -type UpdateApiModelFn = (apiModel: apiModel.Config) => void; - -function useUpdateApiModel(): UpdateApiModelFn { - const queryClient = useQueryClient(); - const query = { - mutationFn: (apiModel: apiModel.Config) => setConfigModel(apiModel), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), - }; - - const { mutate } = useMutation(query); - return mutate; -} - -export { useUpdateApiModel as default }; -export type { UpdateApiModelFn }; diff --git a/web/src/hooks/storage/volume-group.ts b/web/src/hooks/storage/volume-group.ts new file mode 100644 index 0000000000..75073ca97f --- /dev/null +++ b/web/src/hooks/storage/volume-group.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { addVolumeGroup, editVolumeGroup, deleteVolumeGroup } from "~/helpers/storage/volume-group"; +import { QueryHookOptions } from "~/types/queries"; +import { model } from "~/types/storage"; +import { useModel } from "~/hooks/storage/model"; + +function useVolumeGroup(vgName: string, options?: QueryHookOptions): model.VolumeGroup | null { + const model = useModel(options); + const volumeGroup = model?.volumeGroups?.find((v) => v.vgName === vgName); + return volumeGroup || null; +} + +type AddVolumeGroupFn = (vgName: string, targetDevices: string[], moveContent: boolean) => void; + +function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, targetDevices: string[], moveContent: boolean) => { + updateApiModel(addVolumeGroup(apiModel, vgName, targetDevices, moveContent)); + }; +} + +type EditVolumeGroupFn = (odlVgName: string, VgName: string, targetDevices: string[]) => void; + +function useEditVolumeGroup(options?: QueryHookOptions): EditVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (oldVgName: string, vgName: string, targetDevices: string[]) => { + updateApiModel(editVolumeGroup(apiModel, oldVgName, vgName, targetDevices)); + }; +} + +type DeleteVolumeGroupFn = (vgName: string) => void; + +function useDeleteVolumeGroup(options?: QueryHookOptions): DeleteVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string) => updateApiModel(deleteVolumeGroup(apiModel, vgName)); +} + +export { useVolumeGroup, useAddVolumeGroup, useEditVolumeGroup, useDeleteVolumeGroup }; +export type { AddVolumeGroupFn, EditVolumeGroupFn, DeleteVolumeGroupFn }; From d54548efee048d6a95d6f2c9491329635a357453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 24 Mar 2025 16:10:36 +0000 Subject: [PATCH 086/103] web: add data types - Adapt hooks and helpers to use data types. --- web/src/helpers/storage/build-api-model.ts | 31 ++++++++++++++++++++ web/src/helpers/storage/build-model.ts | 1 + web/src/helpers/storage/volume-group.ts | 18 ++++++------ web/src/hooks/storage/volume-group.ts | 14 ++++----- web/src/types/storage.ts | 1 + web/src/types/storage/data.ts | 34 ++++++++++++++++++++++ web/src/types/storage/model.ts | 6 ++++ 7 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 web/src/helpers/storage/build-api-model.ts create mode 100644 web/src/types/storage/data.ts diff --git a/web/src/helpers/storage/build-api-model.ts b/web/src/helpers/storage/build-api-model.ts new file mode 100644 index 0000000000..b1dc4f82cd --- /dev/null +++ b/web/src/helpers/storage/build-api-model.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { apiModel } from "~/api/storage/types"; +import { data } from "~/types/storage"; + +function buildVolumeGroup(data: data.VolumeGroup): apiModel.VolumeGroup { + const defaultVolumeGroup = { vgName: "system", targetDevices: [] }; + return { ...defaultVolumeGroup, ...data }; +} + +export { buildVolumeGroup }; diff --git a/web/src/helpers/storage/build-model.ts b/web/src/helpers/storage/build-model.ts index b4ec39c6ea..25981b8ef0 100644 --- a/web/src/helpers/storage/build-model.ts +++ b/web/src/helpers/storage/build-model.ts @@ -19,6 +19,7 @@ * To contact SUSE LLC about this file by physical or electronic mail, you may * find current contact information at www.suse.com. */ + import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; diff --git a/web/src/helpers/storage/volume-group.ts b/web/src/helpers/storage/volume-group.ts index 33b54f8d26..c29389d8e2 100644 --- a/web/src/helpers/storage/volume-group.ts +++ b/web/src/helpers/storage/volume-group.ts @@ -23,8 +23,10 @@ import { apiModel } from "~/api/storage/types"; import { copyApiModel } from "~/helpers/storage"; import { deleteIfUnused } from "~/helpers/storage/drive"; +import { buildVolumeGroup } from "~/helpers/storage/build-api-model"; +import { data } from "~/types/storage"; -function toLogicalVolume(partition: apiModel.Partition) { +function toLogicalVolume(partition: apiModel.Partition): apiModel.LogicalVolume { return { ...partition }; } @@ -40,17 +42,16 @@ function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup function addVolumeGroup( apiModel: apiModel.Config, - vgName: string, - targetDevices: string[], + data: data.VolumeGroup, moveContent: boolean, ): apiModel.Config { apiModel = copyApiModel(apiModel); - const volumeGroup = { vgName, targetDevices }; + const volumeGroup = buildVolumeGroup(data); if (moveContent) { (apiModel.drives || []) - .filter((d) => targetDevices.includes(d.name)) + .filter((d) => data.targetDevices.includes(d.name)) .forEach((d) => movePartitions(d, volumeGroup)); } @@ -62,17 +63,16 @@ function addVolumeGroup( function editVolumeGroup( apiModel: apiModel.Config, - oldVgName: string, vgName: string, - targetDevices: string[], + data: data.VolumeGroup, ): apiModel.Config { apiModel = copyApiModel(apiModel); - const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === oldVgName); + const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === vgName); if (index === -1) return apiModel; const oldVolumeGroup = apiModel.volumeGroups[index]; - const newVolumeGroup = { ...oldVolumeGroup, vgName, targetDevices }; + const newVolumeGroup = { ...oldVolumeGroup, ...buildVolumeGroup(data) }; apiModel.volumeGroups.splice(index, 1, newVolumeGroup); (oldVolumeGroup.targetDevices || []).forEach((d) => { diff --git a/web/src/hooks/storage/volume-group.ts b/web/src/hooks/storage/volume-group.ts index 75073ca97f..892e269041 100644 --- a/web/src/hooks/storage/volume-group.ts +++ b/web/src/hooks/storage/volume-group.ts @@ -23,7 +23,7 @@ import { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; import { addVolumeGroup, editVolumeGroup, deleteVolumeGroup } from "~/helpers/storage/volume-group"; import { QueryHookOptions } from "~/types/queries"; -import { model } from "~/types/storage"; +import { model, data } from "~/types/storage"; import { useModel } from "~/hooks/storage/model"; function useVolumeGroup(vgName: string, options?: QueryHookOptions): model.VolumeGroup | null { @@ -32,23 +32,23 @@ function useVolumeGroup(vgName: string, options?: QueryHookOptions): model.Volum return volumeGroup || null; } -type AddVolumeGroupFn = (vgName: string, targetDevices: string[], moveContent: boolean) => void; +type AddVolumeGroupFn = (data: data.VolumeGroup, moveContent: boolean) => void; function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { const apiModel = useApiModel(options); const updateApiModel = useUpdateApiModel(); - return (vgName: string, targetDevices: string[], moveContent: boolean) => { - updateApiModel(addVolumeGroup(apiModel, vgName, targetDevices, moveContent)); + return (data: data.VolumeGroup, moveContent: boolean) => { + updateApiModel(addVolumeGroup(apiModel, data, moveContent)); }; } -type EditVolumeGroupFn = (odlVgName: string, VgName: string, targetDevices: string[]) => void; +type EditVolumeGroupFn = (vgName: string, data: data.VolumeGroup) => void; function useEditVolumeGroup(options?: QueryHookOptions): EditVolumeGroupFn { const apiModel = useApiModel(options); const updateApiModel = useUpdateApiModel(); - return (oldVgName: string, vgName: string, targetDevices: string[]) => { - updateApiModel(editVolumeGroup(apiModel, oldVgName, vgName, targetDevices)); + return (vgName: string, data: data.VolumeGroup) => { + updateApiModel(editVolumeGroup(apiModel, vgName, data)); }; } diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index e8dc73f9d6..b0e7a400a9 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -128,3 +128,4 @@ export type { }; export * as model from "~/types/storage/model"; +export * as data from "~/types/storage/data"; diff --git a/web/src/types/storage/data.ts b/web/src/types/storage/data.ts new file mode 100644 index 0000000000..5ac41ec781 --- /dev/null +++ b/web/src/types/storage/data.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +/** + * Data types. + * + * Types that represent the data used for managing (add, edit) config devices. These types are + * typically used by forms and mutation hooks. + */ + +import { apiModel } from "~/api/storage/types"; + +type VolumeGroup = Partial>; + +export type { VolumeGroup }; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index 955d09e85b..5bcd1d4002 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -20,6 +20,12 @@ * find current contact information at www.suse.com. */ +/** + * Model types. + * + * Types that extend the apiModel by adding calculated properties and methods. + */ + import { apiModel } from "~/api/storage/types"; type Model = { From 80e0b2858c2606b4f3bd34d2412ae618ed824563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 25 Mar 2025 06:05:38 +0000 Subject: [PATCH 087/103] web: adapt lvm page to use data types - In order to keep this change minimal, the form is still using separate states instead of a single stage holding a data type. --- web/src/components/storage/LvmPage.test.tsx | 16 ++++++++++++---- web/src/components/storage/LvmPage.tsx | 14 ++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index b27787b0d9..3a9b901a9f 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -134,7 +134,6 @@ jest.mock("~/hooks/storage/model", () => ({ jest.mock("~/hooks/storage/volume-group", () => ({ ...jest.requireActual("~/hooks/storage/volume-group"), __esModule: true, - useVolumeGroup: (id: string) => (id ? mockRootVolumeGroup : null), useAddVolumeGroup: () => mockAddVolumeGroup, useEditVolumeGroup: () => mockEditVolumeGroup, })); @@ -160,7 +159,10 @@ describe("LvmPage", () => { await user.click(moveMountPointsCheckbox); expect(moveMountPointsCheckbox).not.toBeChecked(); await user.click(acceptButton); - expect(mockAddVolumeGroup).toHaveBeenCalledWith("root-vg", ["/dev/sda"], false); + expect(mockAddVolumeGroup).toHaveBeenCalledWith( + { vgName: "root-vg", targetDevices: ["/dev/sda"] }, + false, + ); }); it("allows configuring a new LVM volume group (moving mount points)", async () => { @@ -175,7 +177,10 @@ describe("LvmPage", () => { await user.click(sdaCheckbox); expect(moveMountPointsCheckbox).toBeChecked(); await user.click(acceptButton); - expect(mockAddVolumeGroup).toHaveBeenCalledWith("system", ["/dev/sda"], true); + expect(mockAddVolumeGroup).toHaveBeenCalledWith( + { vgName: "system", targetDevices: ["/dev/sda"] }, + true, + ); }); it("performs basic validations", async () => { @@ -296,7 +301,10 @@ describe("LvmPage", () => { await user.clear(name); await user.type(name, "updatedRootVg"); await user.click(acceptButton); - expect(mockEditVolumeGroup).toHaveBeenCalledWith("fakeRootVg", "updatedRootVg", ["/dev/sda"]); + expect(mockEditVolumeGroup).toHaveBeenCalledWith("fakeRootVg", { + vgName: "updatedRootVg", + targetDevices: ["/dev/sda"], + }); }); }); }); diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index cd4f371db4..ece9b9d9bb 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -36,7 +36,7 @@ import { } from "@patternfly/react-core"; import { Page, SubtleContent } from "~/components/core"; import { useAvailableDevices } from "~/queries/storage"; -import { StorageDevice, model } from "~/types/storage"; +import { StorageDevice, model, data } from "~/types/storage"; import { useModel } from "~/hooks/storage/model"; import { useVolumeGroup, @@ -67,6 +67,9 @@ function targetDevicesError(targetDevices: StorageDevice[]): string | undefined /** * Form for configuring a LVM volume group. + * + * @todo Adapt states to use a data.VolumeGroup type and initializes its value from a + * model.VolumeGroup (build data.VolumeGroup from model.VolumeGroup). */ export default function LvmPage() { const { id } = useParams(); @@ -116,12 +119,15 @@ export default function LvmPage() { if (errors.length) return; - const selectedDeviceNames = selectedDevices.map((d) => d.name); + const data: data.VolumeGroup = { + vgName: name, + targetDevices: selectedDevices.map((d) => d.name), + }; if (!volumeGroup) { - addVolumeGroup(name, selectedDeviceNames, moveMountPoints); + addVolumeGroup(data, moveMountPoints); } else { - editVolumeGroup(volumeGroup.vgName, name, selectedDeviceNames); + editVolumeGroup(volumeGroup.vgName, data); } navigate(PATHS.root); From 9e59996010f76e7cd13080cc2846194de1110c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 25 Mar 2025 06:49:53 +0000 Subject: [PATCH 088/103] web: helpers reoganization --- web/src/helpers/storage.ts | 29 ------------------- .../{build-api-model.ts => api-model.ts} | 6 +++- web/src/helpers/storage/drive.ts | 4 +-- .../storage/{build-model.ts => model.ts} | 0 web/src/helpers/storage/volume-group.ts | 3 +- web/src/hooks/storage/model.ts | 2 +- 6 files changed, 9 insertions(+), 35 deletions(-) delete mode 100644 web/src/helpers/storage.ts rename web/src/helpers/storage/{build-api-model.ts => api-model.ts} (87%) rename web/src/helpers/storage/{build-model.ts => model.ts} (100%) diff --git a/web/src/helpers/storage.ts b/web/src/helpers/storage.ts deleted file mode 100644 index 1350535c89..0000000000 --- a/web/src/helpers/storage.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) [2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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 { apiModel } from "~/api/storage/types"; - -function copyApiModel(apiModel: apiModel.Config): apiModel.Config { - return JSON.parse(JSON.stringify(apiModel)); -} - -export { copyApiModel }; diff --git a/web/src/helpers/storage/build-api-model.ts b/web/src/helpers/storage/api-model.ts similarity index 87% rename from web/src/helpers/storage/build-api-model.ts rename to web/src/helpers/storage/api-model.ts index b1dc4f82cd..d44e8bb95b 100644 --- a/web/src/helpers/storage/build-api-model.ts +++ b/web/src/helpers/storage/api-model.ts @@ -23,9 +23,13 @@ import { apiModel } from "~/api/storage/types"; import { data } from "~/types/storage"; +function copyApiModel(apiModel: apiModel.Config): apiModel.Config { + return JSON.parse(JSON.stringify(apiModel)); +} + function buildVolumeGroup(data: data.VolumeGroup): apiModel.VolumeGroup { const defaultVolumeGroup = { vgName: "system", targetDevices: [] }; return { ...defaultVolumeGroup, ...data }; } -export { buildVolumeGroup }; +export { copyApiModel, buildVolumeGroup }; diff --git a/web/src/helpers/storage/drive.ts b/web/src/helpers/storage/drive.ts index d4bbaab13c..e17e546d17 100644 --- a/web/src/helpers/storage/drive.ts +++ b/web/src/helpers/storage/drive.ts @@ -22,8 +22,8 @@ import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; -import { copyApiModel } from "~/helpers/storage"; -import { buildModel } from "~/helpers/storage/build-model"; +import { copyApiModel } from "~/helpers/storage/api-model"; +import { buildModel } from "~/helpers/storage/model"; function buildDrive(apiModel: apiModel.Config, name: string): model.Drive | undefined { const model = buildModel(apiModel); diff --git a/web/src/helpers/storage/build-model.ts b/web/src/helpers/storage/model.ts similarity index 100% rename from web/src/helpers/storage/build-model.ts rename to web/src/helpers/storage/model.ts diff --git a/web/src/helpers/storage/volume-group.ts b/web/src/helpers/storage/volume-group.ts index c29389d8e2..0b7a2149cc 100644 --- a/web/src/helpers/storage/volume-group.ts +++ b/web/src/helpers/storage/volume-group.ts @@ -21,9 +21,8 @@ */ import { apiModel } from "~/api/storage/types"; -import { copyApiModel } from "~/helpers/storage"; import { deleteIfUnused } from "~/helpers/storage/drive"; -import { buildVolumeGroup } from "~/helpers/storage/build-api-model"; +import { copyApiModel, buildVolumeGroup } from "~/helpers/storage/api-model"; import { data } from "~/types/storage"; function toLogicalVolume(partition: apiModel.Partition): apiModel.LogicalVolume { diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index 5968ca2b7d..fcf27af15b 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -22,7 +22,7 @@ import { useMemo } from "react"; import { useApiModel } from "~/hooks/storage/api-model"; -import { buildModel } from "~/helpers/storage/build-model"; +import { buildModel } from "~/helpers/storage/model"; import { QueryHookOptions } from "~/types/queries"; import { model } from "~/types/storage"; From 5257973fbdb8de371dc9cbb815c3ff2621939e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 15:10:25 +0000 Subject: [PATCH 089/103] web: add hooks for managing logical volumes --- web/src/api/storage.ts | 1 + web/src/helpers/storage/api-model.ts | 29 ++++++- web/src/helpers/storage/logical-volume.ts | 98 +++++++++++++++++++++++ web/src/helpers/storage/model.ts | 21 ++++- web/src/hooks/storage/api-model.ts | 17 +++- web/src/hooks/storage/logical-volume.ts | 62 ++++++++++++++ web/src/hooks/storage/product.ts | 54 +++++++++++++ web/src/queries/storage.ts | 83 ++++++++++++------- web/src/queries/storage/config-model.ts | 24 ++---- web/src/types/storage/data.ts | 11 ++- web/src/types/storage/model.ts | 5 +- 11 files changed, 351 insertions(+), 54 deletions(-) create mode 100644 web/src/helpers/storage/logical-volume.ts create mode 100644 web/src/hooks/storage/logical-volume.ts create mode 100644 web/src/hooks/storage/product.ts diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index d681383355..b41ac621df 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -86,6 +86,7 @@ export { solveConfigModel, fetchUsableDevices, fetchProductParams, + fetchVolume, fetchVolumes, fetchActions, fetchStorageJobs, diff --git a/web/src/helpers/storage/api-model.ts b/web/src/helpers/storage/api-model.ts index d44e8bb95b..730be5fc42 100644 --- a/web/src/helpers/storage/api-model.ts +++ b/web/src/helpers/storage/api-model.ts @@ -27,9 +27,36 @@ function copyApiModel(apiModel: apiModel.Config): apiModel.Config { return JSON.parse(JSON.stringify(apiModel)); } +function buildFilesystem(data?: data.Filesystem): apiModel.Filesystem | undefined { + if (!data) return; + + return { + ...data, + default: false, + }; +} + +function buildSize(data?: data.Size): apiModel.Size | undefined { + if (!data) return; + + return { + ...data, + default: false, + min: data.min || 0, + }; +} + function buildVolumeGroup(data: data.VolumeGroup): apiModel.VolumeGroup { const defaultVolumeGroup = { vgName: "system", targetDevices: [] }; return { ...defaultVolumeGroup, ...data }; } -export { copyApiModel, buildVolumeGroup }; +function buildLogicalVolume(data: data.LogicalVolume): apiModel.LogicalVolume { + return { + ...data, + filesystem: buildFilesystem(data.filesystem), + size: buildSize(data.size), + }; +} + +export { copyApiModel, buildVolumeGroup, buildLogicalVolume }; diff --git a/web/src/helpers/storage/logical-volume.ts b/web/src/helpers/storage/logical-volume.ts new file mode 100644 index 0000000000..2fbd711925 --- /dev/null +++ b/web/src/helpers/storage/logical-volume.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { apiModel } from "~/api/storage/types"; +import { copyApiModel, buildLogicalVolume } from "~/helpers/storage/api-model"; +import { data } from "~/types/storage"; + +function findVolumeGroupIndex(apiModel: apiModel.Config, vgName: string): number { + return (apiModel.volumeGroups || []).findIndex((v) => v.vgName === vgName); +} + +function findLogicalVolumeIndex(volumeGroup: apiModel.VolumeGroup, mountPath: string): number { + return (volumeGroup.logicalVolumes || []).findIndex((l) => l.mountPath === mountPath); +} + +function addLogicalVolume( + apiModel: apiModel.Config, + vgName: string, + data: data.LogicalVolume, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const vgIndex = findVolumeGroupIndex(apiModel, vgName); + if (vgIndex === -1) return apiModel; + + const volumeGroup = apiModel.volumeGroups[vgIndex]; + const logicalVolume = buildLogicalVolume(data); + + volumeGroup.logicalVolumes ||= []; + volumeGroup.logicalVolumes.push(logicalVolume); + + return apiModel; +} + +function editLogicalVolume( + apiModel: apiModel.Config, + vgName: string, + mountPath: string, + data: data.LogicalVolume, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const vgIndex = findVolumeGroupIndex(apiModel, vgName); + if (vgIndex === -1) return apiModel; + + const volumeGroup = apiModel.volumeGroups[vgIndex]; + + const lvIndex = findLogicalVolumeIndex(volumeGroup, mountPath); + if (lvIndex === -1) return apiModel; + + const oldLogicalVolume = volumeGroup.logicalVolumes[lvIndex]; + const newLogicalVolume = { ...oldLogicalVolume, ...buildLogicalVolume(data) }; + + volumeGroup.logicalVolumes.splice(lvIndex, 1, newLogicalVolume); + + return apiModel; +} + +function deleteLogicalVolume( + apiModel: apiModel.Config, + vgName: string, + mountPath: string, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const vgIndex = findVolumeGroupIndex(apiModel, vgName); + if (vgIndex === -1) return apiModel; + + const volumeGroup = apiModel.volumeGroups[vgIndex]; + + const lvIndex = findLogicalVolumeIndex(volumeGroup, mountPath); + if (lvIndex === -1) return apiModel; + + volumeGroup.logicalVolumes.splice(lvIndex, 1); + + return apiModel; +} + +export { addLogicalVolume, editLogicalVolume, deleteLogicalVolume }; diff --git a/web/src/helpers/storage/model.ts b/web/src/helpers/storage/model.ts index 25981b8ef0..a341feacb7 100644 --- a/web/src/helpers/storage/model.ts +++ b/web/src/helpers/storage/model.ts @@ -32,6 +32,11 @@ function buildDrive( apiModel: apiModel.Config, model: model.Model, ): model.Drive { + const getMountPaths = (): string[] => { + const mountPaths = (apiDrive.partitions || []).map((p) => p.mountPath); + return [apiDrive.mountPath, ...mountPaths].filter((p) => p); + }; + const getVolumeGroups = (): model.VolumeGroup[] => { return model.volumeGroups.filter((v) => v.getTargetDevices().some((d) => d.name === apiDrive.name), @@ -63,6 +68,7 @@ function buildDrive( return { ...apiDrive, isUsed: isUsed(), + getMountPaths, getVolumeGroups, }; } @@ -75,18 +81,23 @@ function buildVolumeGroup( apiVolumeGroup: apiModel.VolumeGroup, model: model.Model, ): model.VolumeGroup { + const getMountPaths = (): string[] => { + return (apiVolumeGroup.logicalVolumes || []).map((l) => l.mountPath).filter((p) => p); + }; + const buildLogicalVolumes = (): model.LogicalVolume[] => { return (apiVolumeGroup.logicalVolumes || []).map(buildLogicalVolume); }; - const findTargetDevices = (): model.Drive[] => { + const getTargetDevices = (): model.Drive[] => { return (apiVolumeGroup.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); }; return { ...apiVolumeGroup, logicalVolumes: buildLogicalVolumes(), - getTargetDevices: findTargetDevices, + getMountPaths, + getTargetDevices, }; } @@ -94,6 +105,7 @@ function buildModel(apiModel: apiModel.Config): model.Model { const model: model.Model = { drives: [], volumeGroups: [], + getMountPaths: () => [], }; const buildDrives = (): model.Drive[] => { @@ -104,9 +116,14 @@ function buildModel(apiModel: apiModel.Config): model.Model { return (apiModel.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); }; + const getMountPaths = (): string[] => { + return [...model.drives, ...model.volumeGroups].flatMap((d) => d.getMountPaths()); + }; + // Important! Modify the model object instead of assigning a new one. model.drives = buildDrives(); model.volumeGroups = buildVolumeGroups(); + model.getMountPaths = getMountPaths; return model; } diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts index a37fb02dec..0610caef21 100644 --- a/web/src/hooks/storage/api-model.ts +++ b/web/src/hooks/storage/api-model.ts @@ -22,17 +22,28 @@ import { useQuery, useSuspenseQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiModel } from "~/api/storage/types"; -import { configModelQuery } from "~/queries/storage/config-model"; +import { apiModelQuery, solveApiModelQuery } from "~/queries/storage"; import { QueryHookOptions } from "~/types/queries"; import { setConfigModel } from "~/api/storage"; function useApiModel(options?: QueryHookOptions): apiModel.Config | null { - const query = configModelQuery; + const query = apiModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); return data || null; } +/** @todo Use a hash key from the model object as id for the query. */ +function useSolvedApiModel( + model?: apiModel.Config, + options?: QueryHookOptions, +): apiModel.Config | null { + const query = solveApiModelQuery(model); + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(query); + return data; +} + type UpdateApiModelFn = (apiModel: apiModel.Config) => void; function useUpdateApiModel(): UpdateApiModelFn { @@ -46,5 +57,5 @@ function useUpdateApiModel(): UpdateApiModelFn { return mutate; } -export { useApiModel, useUpdateApiModel }; +export { useApiModel, useSolvedApiModel, useUpdateApiModel }; export type { UpdateApiModelFn }; diff --git a/web/src/hooks/storage/logical-volume.ts b/web/src/hooks/storage/logical-volume.ts new file mode 100644 index 0000000000..f5ce5c9715 --- /dev/null +++ b/web/src/hooks/storage/logical-volume.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { useApiModel, useUpdateApiModel } from "~/hooks/storage/api-model"; +import { QueryHookOptions } from "~/types/queries"; +import { data } from "~/types/storage"; +import { + addLogicalVolume, + editLogicalVolume, + deleteLogicalVolume, +} from "~/helpers/storage/logical-volume"; + +type AddLogicalVolumeFn = (vgName: string, data: data.LogicalVolume) => void; + +function useAddLogicalVolume(options?: QueryHookOptions): AddLogicalVolumeFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, data: data.LogicalVolume) => { + updateApiModel(addLogicalVolume(apiModel, vgName, data)); + }; +} + +type EditLogicalVolumeFn = (vgName: string, mountPath: string, data: data.LogicalVolume) => void; + +function useEditLogicalVolume(options?: QueryHookOptions): EditLogicalVolumeFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, mountPath: string, data: data.LogicalVolume) => { + updateApiModel(editLogicalVolume(apiModel, vgName, mountPath, data)); + }; +} + +type DeleteLogicalVolumeFn = (vgName: string, mountPath: string) => void; + +function useDeleteLogicalVolume(options?: QueryHookOptions): DeleteLogicalVolumeFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (vgName: string, mountPath: string) => + updateApiModel(deleteLogicalVolume(apiModel, vgName, mountPath)); +} + +export { useAddLogicalVolume, useEditLogicalVolume, useDeleteLogicalVolume }; +export type { DeleteLogicalVolumeFn }; diff --git a/web/src/hooks/storage/product.ts b/web/src/hooks/storage/product.ts new file mode 100644 index 0000000000..e7261848e3 --- /dev/null +++ b/web/src/hooks/storage/product.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { useMemo } from "react"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { QueryHookOptions } from "~/types/queries"; +import { ProductParams, Volume } from "~/api/storage/types"; +import { productParamsQuery, volumeQuery } from "~/queries/storage"; +import { useModel } from "~/hooks/storage/model"; + +function useProductParams(options?: QueryHookOptions): ProductParams { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(productParamsQuery); + return data; +} + +function useMissingMountPaths(options?: QueryHookOptions): string[] { + const productParams = useProductParams(options); + const model = useModel(); + + const missingMountPaths = useMemo(() => { + const currentMountPaths = model?.getMountPaths() || []; + return (productParams?.mountPoints || []).filter((p) => !currentMountPaths.includes(p)); + }, [productParams, model]); + + return missingMountPaths; +} + +function useVolume(mountPath: string, options?: QueryHookOptions): Volume { + const func = options?.suspense ? useSuspenseQuery : useQuery; + const { data } = func(volumeQuery(mountPath)); + return data; +} + +export { useProductParams, useMissingMountPaths, useVolume }; diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index a1e93a2599..2c54764a43 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -26,7 +26,10 @@ import { fetchConfig, setConfig, resetConfig, + fetchConfigModel, + solveConfigModel, fetchActions, + fetchVolume, fetchVolumes, fetchProductParams, fetchUsableDevices, @@ -44,6 +47,52 @@ const configQuery = { staleTime: Infinity, }; +const apiModelQuery = { + queryKey: ["storage", "apiModel"], + queryFn: fetchConfigModel, + staleTime: Infinity, +}; + +const solveApiModelQuery = (apiModel?: apiModel.Config) => ({ + queryKey: ["storage", "solveApiModel", JSON.stringify(apiModel)], + queryFn: () => (apiModel ? solveConfigModel(apiModel) : Promise.resolve(null)), + staleTime: Infinity, +}); + +const devicesQuery = (scope: "result" | "system") => ({ + queryKey: ["storage", "devices", scope], + queryFn: () => fetchDevices(scope), + staleTime: Infinity, +}); + +const productParamsQuery = { + queryKey: ["storage", "productParams"], + queryFn: fetchProductParams, + staleTime: Infinity, +}; + +const volumeQuery = (mountPath: string) => ({ + queryKey: ["storage", "volume", mountPath], + queryFn: () => fetchVolume(mountPath), + staleTime: Infinity, +}); + +const volumesQuery = (mountPaths: string[]) => ({ + queryKey: ["storage", "volumes"], + queryFn: () => fetchVolumes(mountPaths), + staleTime: Infinity, +}); + +const actionsQuery = { + queryKey: ["storage", "devices", "actions"], + queryFn: fetchActions, +}; + +const deprecatedQuery = { + queryKey: ["storage", "dirty"], + queryFn: fetchDevicesDirty, +}; + /** * Hook that returns the unsolved config. */ @@ -80,12 +129,6 @@ const useResetConfigMutation = () => { return useMutation(query); }; -const devicesQuery = (scope: "result" | "system") => ({ - queryKey: ["storage", "devices", scope], - queryFn: () => fetchDevices(scope), - staleTime: Infinity, -}); - /** * Hook that returns the list of storage devices for the given scope. * @@ -132,13 +175,8 @@ const useAvailableDevices = (): StorageDevice[] => { return data; }; -const productParamsQuery = { - queryKey: ["storage", "productParams"], - queryFn: fetchProductParams, - staleTime: Infinity, -}; - /** + * @deprecated Use useProductParams from ~/hooks/storage/product. * Hook that returns the product parameters (e.g., mount points). */ const useProductParams = (options?: QueryHookOptions): ProductParams => { @@ -174,12 +212,6 @@ const useEncryptionMethods = (options?: QueryHookOptions): apiModel.EncryptionMe return encryptionMethods; }; -const volumesQuery = (mountPaths: string[]) => ({ - queryKey: ["storage", "volumes"], - queryFn: () => fetchVolumes(mountPaths), - staleTime: Infinity, -}); - /** * Hook that returns the volumes for the current product. */ @@ -190,6 +222,7 @@ const useVolumes = (): Volume[] => { return data; }; +/** @deprecated Use useVolume from ~/hooks/storage/product. */ function useVolume(mountPoint: string): Volume { const volumes = useVolumes(); const volume = volumes.find((v) => v.mountPath === mountPoint); @@ -226,11 +259,6 @@ const useVolumeDevices = (): StorageDevice[] => { return [...availableDevices, ...mds, ...vgs]; }; -const actionsQuery = { - queryKey: ["storage", "devices", "actions"], - queryFn: fetchActions, -}; - /** * Hook that returns the actions to perform in the storage devices. */ @@ -239,11 +267,6 @@ const useActions = (): Action[] => { return data; }; -const deprecatedQuery = { - queryKey: ["storage", "dirty"], - queryFn: fetchDevicesDirty, -}; - /** * Hook that returns whether the storage devices are "dirty". */ @@ -286,6 +309,10 @@ const useReprobeMutation = () => { }; export { + productParamsQuery, + apiModelQuery, + solveApiModelQuery, + volumeQuery, useConfig, useConfigMutation, useResetConfigMutation, diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 87e9c10cf0..19d80faba2 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -23,11 +23,11 @@ /** @deprecated These hooks will be replaced by new hooks at ~/hooks/storage/ */ import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { fetchConfigModel, setConfigModel, solveConfigModel } from "~/api/storage"; +import { setConfigModel, solveConfigModel } from "~/api/storage"; import { apiModel, Volume } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; import { SpacePolicyAction } from "~/types/storage"; -import { useVolumes } from "~/queries/storage"; +import { apiModelQuery, useVolumes } from "~/queries/storage"; function copyModel(model: apiModel.Config): apiModel.Config { return JSON.parse(JSON.stringify(model)); @@ -331,6 +331,7 @@ function usedMountPaths(model: apiModel.Config): string[] { return [...drives, ...logicalVolumes].flatMap(allMountPaths); } +/** @depreacted Use useMissingMountPaths from ~/hooks/storage/product. */ function unusedMountPaths(model: apiModel.Config, volumes: Volume[]): string[] { const volPaths = volumes.filter((v) => v.mountPath.length).map((v) => v.mountPath); const assigned = usedMountPaths(model); @@ -358,17 +359,9 @@ function hasAdditionalDrives(model: apiModel.Config): boolean { return !onlyToBoot; } -const configModelQuery = { - queryKey: ["storage", "configModel"], - queryFn: fetchConfigModel, - staleTime: Infinity, -}; - -/** - * Hook that returns the config model. - */ +/** @deprecated Use useApiModel from ~/hooks/storage/api-model. */ export function useConfigModel(options?: QueryHookOptions): apiModel.Config { - const query = configModelQuery; + const query = apiModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); return data; @@ -387,10 +380,7 @@ export function useConfigModelMutation() { return useMutation(query); } -/** - * @todo Use a hash key from the model object as id for the query. - * Hook that returns the config model. - */ +/** @deprecated Use useSolvedApiModel from ~/hooks/storage/api-model. */ export function useSolvedConfigModel(model?: apiModel.Config): apiModel.Config | null { const query = useSuspenseQuery({ queryKey: ["storage", "solvedConfigModel", JSON.stringify(model)], @@ -507,5 +497,3 @@ export function useModel(): ModelHook { hasAdditionalDrives: hasAdditionalDrives(model), }; } - -export { configModelQuery }; diff --git a/web/src/types/storage/data.ts b/web/src/types/storage/data.ts index 5ac41ec781..dd74383e59 100644 --- a/web/src/types/storage/data.ts +++ b/web/src/types/storage/data.ts @@ -31,4 +31,13 @@ import { apiModel } from "~/api/storage/types"; type VolumeGroup = Partial>; -export type { VolumeGroup }; +interface LogicalVolume extends Partial> { + filesystem?: Filesystem; + size?: Size; +} + +type Filesystem = Partial>; + +type Size = Partial>; + +export type { VolumeGroup, LogicalVolume, Filesystem, Size }; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index 5bcd1d4002..f0a988bd9d 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -31,16 +31,19 @@ import { apiModel } from "~/api/storage/types"; type Model = { drives: Drive[]; volumeGroups: VolumeGroup[]; + getMountPaths: () => string[]; }; interface Drive extends apiModel.Drive { isUsed: boolean; + getMountPaths: () => string[]; getVolumeGroups: () => VolumeGroup[]; } interface VolumeGroup extends Omit { - getTargetDevices: () => Drive[]; logicalVolumes: LogicalVolume[]; + getTargetDevices: () => Drive[]; + getMountPaths: () => string[]; } type LogicalVolume = apiModel.LogicalVolume; From 619651a7654e0a09a0f95434668832cf7507bc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 15:11:45 +0000 Subject: [PATCH 090/103] web: add logical volume management - Add, edit and delete logical volumes. --- web/src/components/storage/AutoSizeText.tsx | 334 ++++++ web/src/components/storage/DriveEditor.tsx | 5 +- .../components/storage/LogicalVolumePage.tsx | 965 ++++++++++++++++++ web/src/components/storage/LvmPage.test.tsx | 7 +- .../components/storage/MountPathMenuItem.tsx | 4 +- web/src/components/storage/PartitionPage.tsx | 2 +- .../components/storage/VolumeGroupEditor.tsx | 32 +- web/src/routes/paths.ts | 4 + web/src/routes/storage.tsx | 9 + 9 files changed, 1353 insertions(+), 9 deletions(-) create mode 100644 web/src/components/storage/AutoSizeText.tsx create mode 100644 web/src/components/storage/LogicalVolumePage.tsx diff --git a/web/src/components/storage/AutoSizeText.tsx b/web/src/components/storage/AutoSizeText.tsx new file mode 100644 index 0000000000..6676790706 --- /dev/null +++ b/web/src/components/storage/AutoSizeText.tsx @@ -0,0 +1,334 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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 { SubtleContent } from "~/components/core/"; +import { deviceSize } from "~/components/storage/utils"; +import { _, formatList } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { apiModel, Volume } from "~/api/storage/types"; + +type DeviceType = "partition" | "logicalVolume"; + +function deviceTypeLabel(deviceType: DeviceType): string { + return deviceType === "partition" ? _("partition") : _("logical volume"); +} + +type AutoSizeTextFallbackProps = { + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextFallback({ size, deviceType }: AutoSizeTextFallbackProps): React.ReactNode { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1s$ is a size with units (eg. 3 GiB) and %1$s is a device type (eg. partition) + _("A generic size of %s will be used for the new %2$s"), + deviceSize(size.min), + deviceTypeLabel(deviceType), + ); + } + + return sprintf( + // TRANSLATORS: %1s$ and %2s$ are sizes with units (eg. 3 GiB) and %3$s is a device type (eg. partition) + _("A generic size range between %1$s and %2$s will be used for the new %3$s"), + deviceSize(size.min), + deviceSize(size.max), + deviceTypeLabel(deviceType), + ); + } + + return sprintf( + // TRANSLATORS: %1s$ is a size with units (eg. 3 GiB) and %1$s is a device type (eg. partition) + _("A generic minimum size of %1$s will be used for the new %2$s"), + deviceSize(size.min), + deviceTypeLabel(deviceType), + ); +} + +type AutoSizeTextFixedProps = { + path: string; + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextFixed({ path, size, deviceType }: AutoSizeTextFixedProps): React.ReactNode { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("A %1$s of %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s and %3$s are sizes with units (10 GiB), and %4$s is a mount path (/home) + _("A %1$s with a size between %2$s and %3$s will be created for %4$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + deviceSize(size.max), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("A %1$s of at least %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); +} + +type AutoSizeTextRamProps = { + path: string; + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextRam({ path, size, deviceType }: AutoSizeTextRamProps): React.ReactNode { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("Based on the amount of RAM in the system, a %1s$ of %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s and %3$s are sizes with units (10 GiB), and %4$s is a mount path (/home) + _( + "Based on the amount of RAM in the system, a %1s$ with a size between %2$s and %3$s will be created for %4$s", + ), + deviceTypeLabel(deviceType), + deviceSize(size.min), + deviceSize(size.max), + path, + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition), %2$s is a size with units (10 GiB) and %3$s is a mount path (/home) + _("Based on the amount of RAM in the system, a %1s$ of at least %2$s will be created for %3$s"), + deviceTypeLabel(deviceType), + deviceSize(size.min), + path, + ); +} + +type AutoSizeTextDynamicProps = { + volume: Volume; + size: apiModel.Size; + deviceType: DeviceType; +}; + +function AutoSizeTextDynamic({ + volume, + size, + deviceType, +}: AutoSizeTextDynamicProps): React.ReactNode { + const introText = (volume) => { + const path = volume.mountPath; + const otherPaths = volume.outline.sizeRelevantVolumes || []; + const snapshots = !!volume.outline.snapshotsAffectSizes; + const ram = !!volume.outline.adjustByRam; + + if (ram && snapshots) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + if (otherPaths.length > 1) { + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + return sprintf( + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + return sprintf( + // TRANSLATORS: %s is a mount point (eg. /) + _( + "The size range for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.", + ), + path, + ); + } + + if (ram) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + _( + "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + if (snapshots) { + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + if (otherPaths.length > 1) { + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + return sprintf( + _( + "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + } + + return sprintf( + // TRANSLATORS: %s is a mount point (eg. /) + _( + "The size range for %s will be dynamically adjusted based on the usage of Btrfs snapshots.", + ), + path, + ); + } + + if (otherPaths.length === 1) { + return sprintf( + // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) + _( + "The size range for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.", + ), + path, + otherPaths[0], + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths + _( + "The size range for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.", + ), + path, + formatList(otherPaths), + ); + }; + + const limitsText = (size) => { + if (size.max) { + if (size.max === size.min) { + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition) and %2s is a size with units (eg. 10 GiB) + _("The current configuration will result in a %1$s of %2$s."), + deviceTypeLabel(deviceType), + deviceSize(size.min), + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition) %2$s is a min size, %3$s is the max size + _("The current configuration will result in a %1$s with a size between %2$s and %3$s."), + deviceTypeLabel(deviceType), + deviceSize(size.min), + deviceSize(size.max), + ); + } + + return sprintf( + // TRANSLATORS: %1$s is a device type (eg. partition) and %2$s is a size with units (eg. 10 GiB) + _("The current configuration will result in a %1$s of at least %2$s."), + deviceTypeLabel(deviceType), + deviceSize(size.min), + ); + }; + + return ( + <> + {introText(volume)} + {limitsText(size)} + + ); +} + +export type AutoSizeTextProps = { + volume: Volume; + size: apiModel.Size; + deviceType: DeviceType; +}; + +export default function AutoSizeText({ volume, size, deviceType }: AutoSizeTextProps) { + const path = volume.mountPath; + + if (path) { + if (volume.autoSize) { + const otherPaths = volume.outline.sizeRelevantVolumes || []; + + if (otherPaths.length || volume.outline.snapshotsAffectSizes) { + return ; + } + + // This assumes volume.autoSize is correctly set. Ie. if it is set to true then at least one + // of the relevant outline fields (snapshots, RAM and sizeRelevantVolumes) is used. + return ; + } + + return ; + } + + // Fallback volume + // This assumes the fallback volume never uses automatic sizes (ie. re-calculated based on + // other aspects of the configuration). It would be VERY surprising if that's the case. + return ; +} diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index 8bd0aff096..f6ee7af4fa 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -496,10 +496,9 @@ const PartitionMenuItem = ({ driveName, mountPath }) => { id: baseName(driveName), partitionId: encodeURIComponent(mountPath), }); + const deletePartition = () => drive.deletePartition(mountPath); - return ( - - ); + return ; }; const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx new file mode 100644 index 0000000000..38bfb61832 --- /dev/null +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -0,0 +1,965 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +/** + * @fixme This code was done in a hurry for including LVM managent in SLE16 beta3. It must be + * completely refactored. There are a lot of duplications with PartitionPage. Both PartitionPage + * and LogicalVolumePage should be adapted to share as much functionality as possible. + */ + +import React, { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + ActionGroup, + Content, + Flex, + FlexItem, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + SelectGroup, + SelectList, + SelectOption, + SelectOptionProps, + Split, + Stack, + StackItem, + TextInput, +} from "@patternfly/react-core"; +import { NestedContent, Page, SelectWrapper as Select, SubtleContent } from "~/components/core/"; +import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; +import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; +import AutoSizeText from "~/components/storage/AutoSizeText"; +import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils"; +import { compact, uniq } from "~/utils"; +import { _ } from "~/i18n"; +import { sprintf } from "sprintf-js"; +import { useApiModel, useSolvedApiModel } from "~/hooks/storage/api-model"; +import { useModel } from "~/hooks/storage/model"; +import { useMissingMountPaths, useVolume } from "~/hooks/storage/product"; +import { useVolumeGroup } from "~/hooks/storage/volume-group"; +import { useAddLogicalVolume, useEditLogicalVolume } from "~/hooks/storage/logical-volume"; +import { addLogicalVolume, editLogicalVolume } from "~/helpers/storage/logical-volume"; +import { apiModel } from "~/api/storage/types"; +import { data } from "~/types/storage"; +import { STORAGE as PATHS } from "~/routes/paths"; + +const NO_VALUE = ""; +const BTRFS_SNAPSHOTS = "btrfsSnapshots"; + +type SizeOptionValue = "" | "auto" | "custom"; +type CustomSizeValue = "fixed" | "unlimited" | "range"; +type FormValue = { + mountPoint: string; + name: string; + filesystem: string; + filesystemLabel: string; + sizeOption: SizeOptionValue; + minSize: string; + maxSize: string; +}; +type SizeRange = { + min: string; + max: string; +}; +type Error = { + id: string; + message?: string; + isVisible: boolean; +}; + +type ErrorsHandler = { + errors: Error[]; + getError: (id: string) => Error | undefined; + getVisibleError: (id: string) => Error | undefined; +}; + +function toData(value: FormValue): data.LogicalVolume { + const filesystemType = (): apiModel.FilesystemType | undefined => { + if (value.filesystem === NO_VALUE) return undefined; + if (value.filesystem === BTRFS_SNAPSHOTS) return "btrfs"; + + /** + * @note This type cast is needed because the list of filesystems coming from a volume is not + * enumerated (the volume simply contains a list of strings). This implies we have to rely on + * whatever value coming from such a list as a filesystem type accepted by the config model. + * This will be fixed in the future by directly exporting the volumes as a JSON, similar to the + * config model. The schema for the volumes will define the explicit list of filesystem types. + */ + return value.filesystem as apiModel.FilesystemType; + }; + + const filesystem = (): data.Filesystem | undefined => { + const type = filesystemType(); + if (type === undefined) return undefined; + + return { + type, + snapshots: value.filesystem === BTRFS_SNAPSHOTS, + label: value.filesystemLabel, + }; + }; + + const size = (): apiModel.Size | undefined => { + if (value.sizeOption === "auto") return undefined; + if (value.minSize === NO_VALUE) return undefined; + + return { + default: false, + min: parseToBytes(value.minSize), + max: value.maxSize === NO_VALUE ? undefined : parseToBytes(value.maxSize), + }; + }; + + return { + mountPath: value.mountPoint, + lvName: value.name, + filesystem: filesystem(), + size: size(), + }; +} + +function toFormValue(logicalVolume: apiModel.LogicalVolume): FormValue { + const mountPoint = (): string => logicalVolume.mountPath || NO_VALUE; + + const filesystem = (): string => { + const fs = logicalVolume.filesystem; + if (!fs.type) return NO_VALUE; + if (fs.type === "btrfs" && fs.snapshots) return BTRFS_SNAPSHOTS; + + return fs.type; + }; + + const filesystemLabel = (): string => logicalVolume.filesystem?.label || NO_VALUE; + + const sizeOption = (): SizeOptionValue => { + const size = logicalVolume.size; + if (!size || size.default) return "auto"; + + return "custom"; + }; + + const size = (value: number | undefined): string => (value ? deviceSize(value) : NO_VALUE); + + return { + mountPoint: mountPoint(), + name: logicalVolume.lvName, + filesystem: filesystem(), + filesystemLabel: filesystemLabel(), + sizeOption: sizeOption(), + minSize: size(logicalVolume.size?.min), + maxSize: size(logicalVolume.size?.max), + }; +} + +const logicalVolumeName = (mountPath: string): string => { + return mountPath === "/" ? "root" : mountPath.split("/").pop(); +}; + +function useDefaultFilesystem(mountPoint: string): string { + const volume = useVolume(mountPoint, { suspense: true }); + return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; +} + +function useInitialLogicalVolume(): apiModel.LogicalVolume | null { + const { id: vgName, logicalVolumeId: mountPath } = useParams(); + const volumeGroup = useVolumeGroup(vgName); + + if (!volumeGroup || !mountPath) return null; + + const logicalVolume = volumeGroup.logicalVolumes.find((l) => l.mountPath === mountPath); + return logicalVolume || null; +} + +function useInitialFormValue(): FormValue | null { + const logicalVolume = useInitialLogicalVolume(); + const value = useMemo(() => (logicalVolume ? toFormValue(logicalVolume) : null), [logicalVolume]); + return value; +} + +/** Unused predefined mount points. Includes the currently used mount point when editing. */ +function useUnusedMountPoints(): string[] { + const missingMountPaths = useMissingMountPaths(); + const initialLogicalVolume = useInitialLogicalVolume(); + return compact([initialLogicalVolume?.mountPath, ...missingMountPaths]); +} + +function useUsableFilesystems(mountPoint: string): string[] { + const volume = useVolume(mountPoint); + const defaultFilesystem = useDefaultFilesystem(mountPoint); + + const usableFilesystems = useMemo(() => { + const volumeFilesystems = (): string[] => { + const allValues = volume.outline.fsTypes; + + if (volume.mountPath !== "/") return allValues; + + // Btrfs without snapshots is not an option. + if (!volume.outline.snapshotsConfigurable && volume.snapshots) { + return [BTRFS_SNAPSHOTS, ...allValues].filter((v) => v !== "btrfs"); + } + + // Btrfs with snapshots is not an option + if (!volume.outline.snapshotsConfigurable && !volume.snapshots) { + return allValues; + } + + return [BTRFS_SNAPSHOTS, ...allValues]; + }; + + return uniq([defaultFilesystem, ...volumeFilesystems()]); + }, [volume, defaultFilesystem]); + + return usableFilesystems; +} + +function useMountPointError(value: FormValue): Error | undefined { + const model = useModel(); + const mountPoints = model?.getMountPaths() || []; + const initialLogicalVolume = useInitialLogicalVolume(); + const mountPoint = value.mountPoint; + + if (mountPoint === NO_VALUE) { + return { + id: "mountPoint", + isVisible: false, + }; + } + + const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; + if (!regex.test(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a valid mount point"), + isVisible: true, + }; + } + + // Exclude itself when editing + const initialMountPoint = initialLogicalVolume?.mountPath; + if (mountPoint !== initialMountPoint && mountPoints.includes(mountPoint)) { + return { + id: "mountPoint", + message: _("Select or enter a mount point that is not already assigned to another device"), + isVisible: true, + }; + } +} + +function checkLogicalVolumeName(value: FormValue): Error | undefined { + if (value.name?.length) return; + + return { + id: "logicalVolumeName", + message: _("Enter a name"), + isVisible: true, + }; +} + +function checkSize(value: FormValue): Error | undefined { + if (value.sizeOption !== "custom") return; + + const min = value.minSize; + const max = value.maxSize; + + if (!min) { + return { + id: "customSize", + isVisible: false, + }; + } + + const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$/; + const validMin = regexp.test(min); + const validMax = max ? regexp.test(max) : true; + + if (validMin && validMax) { + if (!max || parseToBytes(min) <= parseToBytes(max)) return; + + return { + id: "customSize", + message: _("The minimum cannot be greater than the maximum"), + isVisible: true, + }; + } + + if (validMin) { + return { + id: "customSize", + message: _("The maximum must be a number optionally followed by a unit like GiB or GB"), + isVisible: true, + }; + } + + if (validMax) { + return { + id: "customSize", + message: _("The minimum must be a number optionally followed by a unit like GiB or GB"), + isVisible: true, + }; + } + + return { + id: "customSize", + message: _("Size limits must be numbers optionally followed by a unit like GiB or GB"), + isVisible: true, + }; +} + +function useErrors(value: FormValue): ErrorsHandler { + const mountPointError = useMountPointError(value); + const nameError = checkLogicalVolumeName(value); + const sizeError = checkSize(value); + const errors = compact([mountPointError, nameError, sizeError]); + + const getError = (id: string): Error | undefined => errors.find((e) => e.id === id); + + const getVisibleError = (id: string): Error | undefined => { + const error = getError(id); + return error?.isVisible ? error : undefined; + }; + + return { errors, getError, getVisibleError }; +} + +function useSolvedModel(value: FormValue): apiModel.Config | null { + const { id: vgName, logicalVolumeId: mountPath } = useParams(); + const apiModel = useApiModel(); + const { getError } = useErrors(value); + const mountPointError = getError("mountPoint"); + const data = toData(value); + // Avoid recalculating the solved model because changes in label. + if (data.filesystem) data.filesystem.label = undefined; + // Avoid recalculating the solved model because changes in name. + data.lvName = undefined; + + let sparseModel: apiModel.Config | undefined; + + if (data.filesystem && !mountPointError) { + if (mountPath) { + sparseModel = editLogicalVolume(apiModel, vgName, mountPath, data); + } else { + sparseModel = addLogicalVolume(apiModel, vgName, data); + } + } + + const solvedModel = useSolvedApiModel(sparseModel); + return solvedModel; +} + +function useSolvedLogicalVolume(value: FormValue): apiModel.LogicalVolume | undefined { + const { id: vgName } = useParams(); + const apiModel = useSolvedModel(value); + const volumeGroup = apiModel?.volumeGroups?.find((v) => v.vgName === vgName); + return volumeGroup?.logicalVolumes?.find((l) => l.mountPath === value.mountPoint); +} + +function useSolvedSizes(value: FormValue): SizeRange { + // Remove size values in order to get a solved size. + const valueWithoutSizes: FormValue = { + ...value, + sizeOption: NO_VALUE, + minSize: NO_VALUE, + maxSize: NO_VALUE, + }; + + const logicalVolume = useSolvedLogicalVolume(valueWithoutSizes); + + const solvedSizes = useMemo(() => { + const min = logicalVolume?.size?.min; + const max = logicalVolume?.size?.max; + + return { + min: min ? deviceSize(min) : NO_VALUE, + max: max ? deviceSize(max) : NO_VALUE, + }; + }, [logicalVolume]); + + return solvedSizes; +} + +function useAutoRefreshFilesystem(handler, value: FormValue) { + const { mountPoint } = value; + const defaultFilesystem = useDefaultFilesystem(mountPoint); + + useEffect(() => { + // Reset filesystem if there is no mount point yet. + if (mountPoint === NO_VALUE) handler(NO_VALUE); + // Select default filesystem for the mount point. + if (mountPoint !== NO_VALUE) handler(defaultFilesystem); + }, [handler, mountPoint, defaultFilesystem]); +} + +function useAutoRefreshSize(handler, value: FormValue) { + const solvedSizes = useSolvedSizes(value); + + useEffect(() => { + handler("auto", solvedSizes.min, solvedSizes.max); + }, [handler, solvedSizes]); +} + +function mountPointSelectOptions(mountPoints: string[]): SelectOptionProps[] { + return mountPoints.map((p) => ({ value: p, children: p })); +} + +type LogicalVolumeNameProps = { + id?: string; + value: FormValue; + mountPoint: string; + onChange: (v: string) => void; +}; + +function LogicalVolumeName({ + id, + value, + mountPoint, + onChange, +}: LogicalVolumeNameProps): React.ReactNode { + const { getVisibleError } = useErrors(value); + const error = getVisibleError("logicalVolumeName"); + const isDisabled = mountPoint === NO_VALUE; + + return ( + + onChange(v)} + /> + {error && !isDisabled && ( + + + {error && {error.message}} + + + )} + + ); +} + +type FilesystemOptionLabelProps = { + value: string; +}; + +function FilesystemOptionLabel({ value }: FilesystemOptionLabelProps): React.ReactNode { + if (value === NO_VALUE) return _("Waiting for a mount point"); + if (value === BTRFS_SNAPSHOTS) return _("Btrfs with snapshots"); + + return filesystemLabel(value); +} + +type FilesystemOptionsProps = { + mountPoint: string; +}; + +function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode { + const defaultFilesystem = useDefaultFilesystem(mountPoint); + const usableFilesystems = useUsableFilesystems(mountPoint); + + const defaultOptText = + mountPoint !== NO_VALUE + ? sprintf(_("Default file system for %s"), mountPoint) + : _("Default file system for generic logical volumes"); + + const formatText = _("Format logical volume as"); + + return ( + + {mountPoint === NO_VALUE && ( + + + + )} + {mountPoint !== NO_VALUE && ( + + {usableFilesystems.map((fsType, index) => ( + + + + ))} + + )} + + ); +} + +type FilesystemSelectProps = { + id?: string; + value: string; + mountPoint: string; + onChange: SelectProps["onChange"]; +}; + +function FilesystemSelect({ + id, + value, + mountPoint, + onChange, +}: FilesystemSelectProps): React.ReactNode { + const usedValue = mountPoint === NO_VALUE ? NO_VALUE : value; + + return ( + + ); +} + +type FilesystemLabelProps = { + id?: string; + value: string; + onChange: (v: string) => void; +}; + +function FilesystemLabel({ id, value, onChange }: FilesystemLabelProps): React.ReactNode { + const isValid = (v: string) => /^[\w-_.]*$/.test(v); + + return ( + isValid(v) && onChange(v)} + /> + ); +} + +type SizeOptionLabelProps = { + value: SizeOptionValue; + mountPoint: string; +}; + +function SizeOptionLabel({ value, mountPoint }: SizeOptionLabelProps): React.ReactNode { + if (mountPoint === NO_VALUE) return _("Waiting for a mount point"); + if (value === "auto") return _("Calculated automatically"); + if (value === "custom") return _("Custom"); + + return value; +} + +type SizeOptionsProps = { + mountPoint: string; +}; + +function SizeOptions({ mountPoint }: SizeOptionsProps): React.ReactNode { + return ( + + {mountPoint === NO_VALUE && ( + + + + )} + {mountPoint !== NO_VALUE && ( + <> + + + + + + + + )} + + ); +} + +type AutoSizeInfoProps = { + value: FormValue; +}; + +function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { + const volume = useVolume(value.mountPoint); + const logicalVolume = useSolvedLogicalVolume(value); + const size = logicalVolume?.size; + + if (!size) return; + + return ( + + + + ); +} + +type CustomSizeOptionLabelProps = { + value: CustomSizeValue; +}; + +function CustomSizeOptionLabel({ value }: CustomSizeOptionLabelProps): React.ReactNode { + const labels = { + fixed: _("Same as minimum"), + unlimited: _("None"), + range: _("Limited"), + }; + + return labels[value]; +} + +function CustomSizeOptions(): React.ReactNode { + return ( + + + + + + + + + + + + ); +} + +type CustomSizeProps = { + value: FormValue; + onChange: (size: SizeRange) => void; +}; + +function CustomSize({ value, onChange }: CustomSizeProps) { + const initialOption = (): CustomSizeValue => { + if (value.minSize === NO_VALUE) return "fixed"; + if (value.minSize === value.maxSize) return "fixed"; + if (value.maxSize === NO_VALUE) return "unlimited"; + return "range"; + }; + + const [option, setOption] = React.useState(initialOption()); + const { max: solvedMaxSize } = useSolvedSizes(value); + const { getVisibleError } = useErrors(value); + + const error = getVisibleError("customSize"); + + const changeMinSize = (min: string) => { + const max = option === "fixed" ? min : value.maxSize; + onChange({ min, max }); + }; + + const changeMaxSize = (max: string) => { + onChange({ min: value.minSize, max }); + }; + + const changeOption = (v: CustomSizeValue) => { + setOption(v); + + const min = value.minSize; + if (v === "fixed") onChange({ min, max: min }); + if (v === "unlimited") onChange({ min, max: NO_VALUE }); + if (v === "range") { + const max = solvedMaxSize || NO_VALUE; + onChange({ min, max }); + } + }; + + return ( + + + + {_("Sizes must be entered as a numbers optionally followed by a unit.")} + + + {_( + "If the unit is omitted, bytes (B) will be used. Greater units can be of \ + the form GiB (power of 2) or GB (power of 10).", + )} + + + + + + + changeMinSize(v)} + /> + + + + + + + {option === "range" && ( + changeMaxSize(v)} + /> + )} + + + + + + + {error && {error.message}} + + + + + ); +} + +export default function LogicalVolumePage() { + const navigate = useNavigate(); + const headingId = useId(); + const { id: vgName } = useParams(); + const addLogicalVolume = useAddLogicalVolume(); + const editLogicalVolume = useEditLogicalVolume(); + const [mountPoint, setMountPoint] = useState(NO_VALUE); + const [name, setName] = useState(NO_VALUE); + const [filesystem, setFilesystem] = useState(NO_VALUE); + const [filesystemLabel, setFilesystemLabel] = useState(NO_VALUE); + const [sizeOption, setSizeOption] = useState(NO_VALUE); + const [minSize, setMinSize] = useState(NO_VALUE); + const [maxSize, setMaxSize] = useState(NO_VALUE); + // Filesystem and size selectors should not be auto refreshed before the user interacts with the + // mount point selector. + const [autoRefreshFilesystem, setAutoRefreshFilesystem] = useState(false); + const [autoRefreshSize, setAutoRefreshSize] = useState(false); + + const initialValue = useInitialFormValue(); + const value = { mountPoint, name, filesystem, filesystemLabel, sizeOption, minSize, maxSize }; + const { errors, getVisibleError } = useErrors(value); + const unusedMountPoints = useUnusedMountPoints(); + + // Initializes the form values if there is an initial value (i.e., when editing a logical volume). + React.useEffect(() => { + if (initialValue) { + setMountPoint(initialValue.mountPoint); + setName(initialValue.name); + setFilesystem(initialValue.filesystem); + setFilesystemLabel(initialValue.filesystemLabel); + setSizeOption(initialValue.sizeOption); + setMinSize(initialValue.minSize); + setMaxSize(initialValue.maxSize); + } + }, [ + initialValue, + setMountPoint, + setFilesystem, + setFilesystemLabel, + setSizeOption, + setMinSize, + setMaxSize, + ]); + + const refreshFilesystemHandler = useCallback( + (filesystem: string) => autoRefreshFilesystem && setFilesystem(filesystem), + [autoRefreshFilesystem, setFilesystem], + ); + + useAutoRefreshFilesystem(refreshFilesystemHandler, value); + + const refreshSizeHandler = useCallback( + (sizeOption: SizeOptionValue, minSize: string, maxSize: string) => { + if (autoRefreshSize) { + setSizeOption(sizeOption); + setMinSize(minSize); + setMaxSize(maxSize); + } + }, + [autoRefreshSize, setSizeOption, setMinSize, setMaxSize], + ); + + useAutoRefreshSize(refreshSizeHandler, value); + + const changeMountPoint = (value: string) => { + if (value !== mountPoint) { + setAutoRefreshFilesystem(true); + setAutoRefreshSize(true); + setMountPoint(value); + setName(logicalVolumeName(value)); + } + }; + + const changeFilesystem = (value: string) => { + setAutoRefreshFilesystem(false); + setAutoRefreshSize(false); + setFilesystem(value); + }; + + const changeSize = ({ min, max }) => { + if (min !== undefined) setMinSize(min); + if (max !== undefined) setMaxSize(max); + }; + + const onSubmit = () => { + const data = toData(value); + + if (initialValue) editLogicalVolume(vgName, initialValue.mountPoint, data); + else addLogicalVolume(vgName, data); + + navigate(PATHS.root); + }; + + const isFormValid = errors.length === 0; + const mountPointError = getVisibleError("mountPoint"); + const usedMountPt = mountPointError ? NO_VALUE : mountPoint; + const showLabel = filesystem !== NO_VALUE && usedMountPt !== NO_VALUE; + + return ( + + + + {sprintf(_("Configure LVM logical volume at %s volume group"), vgName)} + + + + + + + + + + + + + + + {!mountPointError && _("Select or enter a mount point")} + {mountPointError?.message} + + + + + + + + + + + + + + + + + + + + + + + {showLabel && ( + + + + + + )} + + + + + + + + {sizeOption === "auto" && } + {sizeOption === "custom" && } + + + + + + + + + + + + ); +} diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index 3a9b901a9f..5a3ea00255 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -89,18 +89,21 @@ const mockSdaDrive: model.Drive = { ], isUsed: true, getVolumeGroups: () => [], + getMountPaths: () => ["/home", "swap"], }; const mockRootVolumeGroup: model.VolumeGroup = { vgName: "fakeRootVg", - getTargetDevices: () => [mockSdaDrive], logicalVolumes: [], + getTargetDevices: () => [mockSdaDrive], + getMountPaths: () => [], }; const mockHomeVolumeGroup: model.VolumeGroup = { vgName: "fakeHomeVg", - getTargetDevices: () => [mockSdaDrive], logicalVolumes: [], + getTargetDevices: () => [mockSdaDrive], + getMountPaths: () => [], }; const mockAddVolumeGroup = jest.fn(); diff --git a/web/src/components/storage/MountPathMenuItem.tsx b/web/src/components/storage/MountPathMenuItem.tsx index 4bea380610..4a667652d2 100644 --- a/web/src/components/storage/MountPathMenuItem.tsx +++ b/web/src/components/storage/MountPathMenuItem.tsx @@ -30,7 +30,7 @@ import { apiModel } from "~/api/storage/types"; export type MountPathMenuItemProps = { device: apiModel.Partition | apiModel.LogicalVolume; editPath?: string; - deleteFn?: (mountPath: string) => void; + deleteFn?: () => void; }; export default function MountPathMenuItem({ @@ -61,7 +61,7 @@ export default function MountPathMenuItem({ icon={} actionId={`delete-${mountPath}`} aria-label={`Delete ${mountPath}`} - onClick={() => deleteFn && deleteFn(mountPath)} + onClick={deleteFn} /> } diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index bd17e5b8cf..35156ee136 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -1137,7 +1137,7 @@ function CustomSize({ value, onChange }: CustomSizeProps) { } /** - * @fixme This component has to be adapted to use the new hooks from ~/hooks/storage/instead of the + * @fixme This component has to be adapted to use the new hooks from ~/hooks/storage/ instead of the * deprecated hooks from ~/queries/storage/config-model. */ export default function PartitionPage() { diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 190cf1ac61..f13487f829 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -28,6 +28,7 @@ import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { useVolumeGroup, useDeleteVolumeGroup } from "~/hooks/storage/volume-group"; +import { useDeleteLogicalVolume } from "~/hooks/storage/logical-volume"; import DeviceMenu from "~/components/storage/DeviceMenu"; import DeviceHeader from "~/components/storage/DeviceHeader"; import MountPathMenuItem from "~/components/storage/MountPathMenuItem"; @@ -36,6 +37,7 @@ import { CardBody, CardHeader, CardTitle, + Divider, Flex, MenuItem, MenuList, @@ -100,6 +102,17 @@ const VgHeader = ({ vg }: { vg: model.VolumeGroup }) => { }; const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { + const navigate = useNavigate(); + const deleteLogicalVolume = useDeleteLogicalVolume(); + + const editPath = (lv: model.LogicalVolume): string => { + return generatePath(PATHS.volumeGroup.logicalVolume.edit, { + id: vg.vgName, + logicalVolumeId: encodeURIComponent(lv.mountPath), + }); + }; + const deleteLv = (lv: model.LogicalVolume) => deleteLogicalVolume(vg.vgName, lv.mountPath); + return ( {contentDescription(vg)}} @@ -107,8 +120,25 @@ const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { > {vg.logicalVolumes.map((lv) => { - return ; + return ( + deleteLv(lv)} + /> + ); })} + {vg.logicalVolumes.length > 0 && } + + navigate(generatePath(PATHS.volumeGroup.logicalVolume.add, { id: vg.vgName })) + } + > + {_("Add logical volume")} + ); diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index ed37ed75ee..33074f28e0 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -83,6 +83,10 @@ const STORAGE = { volumeGroup: { add: "/storage/volume-groups/add", edit: "/storage/volume-groups/:id/edit", + logicalVolume: { + add: "/storage/volume-groups/:id/logical-volumes/add", + edit: "/storage/volume-groups/:id/logical-volumes/:logicalVolumeId/edit", + }, }, iscsi: "/storage/iscsi", dasd: "/storage/dasd", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index cbaf786eb8..56214ac957 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -31,6 +31,7 @@ import ProposalPage from "~/components/storage/ProposalPage"; import ISCSIPage from "~/components/storage/ISCSIPage"; import PartitionPage from "~/components/storage/PartitionPage"; import LvmPage from "~/components/storage/LvmPage"; +import LogicalVolumePage from "~/components/storage/LogicalVolumePage"; import ZFCPPage from "~/components/storage/zfcp/ZFCPPage"; import ZFCPDiskActivationPage from "~/components/storage/zfcp/ZFCPDiskActivationPage"; import DASDPage from "~/components/storage/dasd/DASDPage"; @@ -74,6 +75,14 @@ const routes = (): Route => ({ path: PATHS.volumeGroup.edit, element: , }, + { + path: PATHS.volumeGroup.logicalVolume.add, + element: , + }, + { + path: PATHS.volumeGroup.logicalVolume.edit, + element: , + }, { path: PATHS.iscsi, element: , From 8b265861628b45b5df45fb4fbb796cb64fc377c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 15:41:00 +0000 Subject: [PATCH 091/103] web: use AutoSizeText in PartitionPage --- web/src/components/storage/PartitionPage.tsx | 264 +------------------ 1 file changed, 3 insertions(+), 261 deletions(-) diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 35156ee136..9ebcb4155b 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -46,6 +46,7 @@ import { import { NestedContent, Page, SelectWrapper as Select, SubtleContent } from "~/components/core/"; import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper"; import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable"; +import AutoSizeText from "~/components/storage/AutoSizeText"; import { useDevices, useVolume } from "~/queries/storage"; import { useModel, @@ -63,7 +64,7 @@ import { filesystemLabel, parseToBytes, } from "~/components/storage/utils"; -import { _, formatList } from "~/i18n"; +import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { apiModel } from "~/api/storage/types"; import { STORAGE as PATHS } from "~/routes/paths"; @@ -719,265 +720,6 @@ function SizeOptions({ mountPoint, target }: SizeOptionsProps): React.ReactNode ); } -function AutoSizeTextFallback({ size }) { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %s is a size with units, like "3 GiB" - _("A generic size of %s will be used for the new partition"), - deviceSize(size.min), - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size and %2$s is the max, both with units like "3 GiB" - _("A generic size range between %1$s and %2$s will be used for the new partition"), - deviceSize(size.min), - deviceSize(size.max), - ); - } - - return sprintf( - // TRANSLATORS: %s is a size with units, like "3 GiB" - _("A generic minimum size of %s will be used for the new partition"), - deviceSize(size.min), - ); -} - -function AutoSizeTextFixed({ path, size }) { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _("A partition of %1$s will be created for %2$s"), - deviceSize(size.min), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size, %2$s is the max size and %3$s is a mount path - _("A partition with a size between %1$s and %2$s will be created for %3$s"), - deviceSize(size.min), - deviceSize(size.max), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _("A partition of at least %1$s will be created for %2$s"), - deviceSize(size.min), - path, - ); -} - -function AutoSizeTextRam({ path, size }) { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _("Based on the amount of RAM in the system, a partition of %1$s will be created for %2$s"), - deviceSize(size.min), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size, %2$s is the max size and %3$s is a mount path - _( - "Based on the amount of RAM in the system, a partition with a size between %1$s and %2$s will be created for %3$s", - ), - deviceSize(size.min), - deviceSize(size.max), - path, - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a size with units (10 GiB) and %2$s is a mount path (/home) - _( - "Based on the amount of RAM in the system a partition of at least %1$s will be created for %2$s", - ), - deviceSize(size.min), - path, - ); -} - -function AutoSizeTextDynamic({ volume, size }) { - const introText = (volume) => { - const path = volume.mountPath; - const otherPaths = volume.outline.sizeRelevantVolumes || []; - const snapshots = !!volume.outline.snapshotsAffectSizes; - const ram = !!volume.outline.adjustByRam; - - if (ram && snapshots) { - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - if (otherPaths.length > 1) { - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - return sprintf( - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - } - - return sprintf( - // TRANSLATORS: %s is a mount point (eg. /) - _( - "The size range for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.", - ), - path, - ); - } - - if (ram) { - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - _( - "The size range for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - } - - if (snapshots) { - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - if (otherPaths.length > 1) { - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - return sprintf( - _( - "The size range for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - } - - return sprintf( - // TRANSLATORS: %s is a mount point (eg. /) - _( - "The size range for %s will be dynamically adjusted based on the usage of Btrfs snapshots.", - ), - path, - ); - } - - if (otherPaths.length === 1) { - return sprintf( - // TRANSLATORS: %1$s is a mount point (eg. /) and %2$s is another one (eg. /home) - _( - "The size range for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.", - ), - path, - otherPaths[0], - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a mount point and %2$s is a list of other paths - _( - "The size range for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.", - ), - path, - formatList(otherPaths), - ); - }; - - const limitsText = (size) => { - if (size.max) { - if (size.max === size.min) { - return sprintf( - // TRANSLATORS: %s is a size with units (eg. 10 GiB) - _("The current configuration will result in a partition of %s."), - deviceSize(size.min), - ); - } - - return sprintf( - // TRANSLATORS: %1$s is a min size, %2$s is the max size - _( - "The current configuration will result in a partition with a size between %1$s and %2$s.", - ), - deviceSize(size.min), - deviceSize(size.max), - ); - } - - return sprintf( - // TRANSLATORS: %s is a size with units (eg. 10 GiB) - _("The current configuration will result in a partition of at least %s."), - deviceSize(size.min), - ); - }; - - return ( - <> - {introText(volume)} - {limitsText(size)} - - ); -} - -function AutoSizeText({ volume, size }) { - const path = volume.mountPath; - - if (path) { - if (volume.autoSize) { - const otherPaths = volume.outline.sizeRelevantVolumes || []; - - if (otherPaths.length || volume.outline.snapshotsAffectSizes) { - return ; - } - - // This assumes volume.autoSize is correctly set. Ie. if it is set to true then at least one - // of the relevant outline fields (snapshots, RAM and sizeRelevantVolumes) is used. - return ; - } - - return ; - } - - // Fallback volume - // This assumes the fallback volume never uses automatic sizes (ie. re-calculated based on - // other aspects of the configuration). It would be VERY surprising if that's the case. - return ; -} - type AutoSizeInfoProps = { value: FormValue; }; @@ -991,7 +733,7 @@ function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode { return ( - + ); } From 647347197d55f6d01d233716af6d1eea1be73927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Wed, 26 Mar 2025 21:23:08 +0000 Subject: [PATCH 092/103] refactor(web): change the alerts look and feel Refactor the alert design to stop rendering them as "boxes", which were cluttering the interface. Instead, they now use a left border and a gradient background to maintain visual hints about the scope of their content without the boxy appearance. --- web/src/assets/styles/index.scss | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 8cdb560b93..a6bd864462 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -296,10 +296,27 @@ --pf-v6-c-alert--PaddingBlockEnd: var(--pf-t--global--spacer--sm); --pf-v6-c-alert--PaddingInlineStart: var(--pf-t--global--spacer--md); --pf-v6-c-alert--PaddingInlineEnd: var(--pf-t--global--spacer--md); + border-width: 0; + border-radius: 0; + border-inline-start-width: var(--pf-t--global--border--width--box--status--default); &:has(.pf-v6-c-alert__description) { row-gap: var(--pf-t--global--spacer--xs); } + + $pf-alert-modifiers: "custom", "info", "success", "warning", "danger"; + + @each $modifier in $pf-alert-modifiers { + &.pf-m-#{$modifier} { + background: linear-gradient( + to right in srgb, + color-mix(in srgb, var(--pf-v6-c-alert--m-#{$modifier}--BorderColor), transparent 94%), + color-mix(in srgb, var(--pf-v6-c-alert--m-#{$modifier}--BorderColor), transparent 96%), + color-mix(in srgb, var(--pf-v6-c-alert--m-#{$modifier}--BorderColor), transparent 98%), + transparent + ); + } + } } .pf-v6-c-alert__title { From 8616c05906f865292f51e560ed2a4bd7a91cb6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 27 Mar 2025 06:21:12 +0000 Subject: [PATCH 093/103] web: add lv name when creating from partition --- .../components/storage/LogicalVolumePage.tsx | 7 ++----- web/src/helpers/storage/api-model.ts | 21 ++++++++++++++++++- web/src/helpers/storage/volume-group.ts | 15 +++++++------ 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index 38bfb61832..fb7f2c00ec 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -61,6 +61,7 @@ import { useMissingMountPaths, useVolume } from "~/hooks/storage/product"; import { useVolumeGroup } from "~/hooks/storage/volume-group"; import { useAddLogicalVolume, useEditLogicalVolume } from "~/hooks/storage/logical-volume"; import { addLogicalVolume, editLogicalVolume } from "~/helpers/storage/logical-volume"; +import { buildLogicalVolumeName } from "~/helpers/storage/api-model"; import { apiModel } from "~/api/storage/types"; import { data } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; @@ -173,10 +174,6 @@ function toFormValue(logicalVolume: apiModel.LogicalVolume): FormValue { }; } -const logicalVolumeName = (mountPath: string): string => { - return mountPath === "/" ? "root" : mountPath.split("/").pop(); -}; - function useDefaultFilesystem(mountPoint: string): string { const volume = useVolume(mountPoint, { suspense: true }); return volume.mountPath === "/" && volume.snapshots ? BTRFS_SNAPSHOTS : volume.fsType; @@ -826,7 +823,7 @@ export default function LogicalVolumePage() { setAutoRefreshFilesystem(true); setAutoRefreshSize(true); setMountPoint(value); - setName(logicalVolumeName(value)); + setName(buildLogicalVolumeName(value)); } }; diff --git a/web/src/helpers/storage/api-model.ts b/web/src/helpers/storage/api-model.ts index 730be5fc42..491d550772 100644 --- a/web/src/helpers/storage/api-model.ts +++ b/web/src/helpers/storage/api-model.ts @@ -59,4 +59,23 @@ function buildLogicalVolume(data: data.LogicalVolume): apiModel.LogicalVolume { }; } -export { copyApiModel, buildVolumeGroup, buildLogicalVolume }; +function buildLogicalVolumeName(mountPath?: string): string | undefined { + if (!mountPath) return; + + return mountPath === "/" ? "root" : mountPath.split("/").pop(); +} + +function buildLogicalVolumeFromPartition(partition: apiModel.Partition): apiModel.LogicalVolume { + return { + ...partition, + lvName: buildLogicalVolumeName(partition.mountPath), + }; +} + +export { + copyApiModel, + buildVolumeGroup, + buildLogicalVolume, + buildLogicalVolumeName, + buildLogicalVolumeFromPartition, +}; diff --git a/web/src/helpers/storage/volume-group.ts b/web/src/helpers/storage/volume-group.ts index 0b7a2149cc..1841f2aaf4 100644 --- a/web/src/helpers/storage/volume-group.ts +++ b/web/src/helpers/storage/volume-group.ts @@ -22,13 +22,13 @@ import { apiModel } from "~/api/storage/types"; import { deleteIfUnused } from "~/helpers/storage/drive"; -import { copyApiModel, buildVolumeGroup } from "~/helpers/storage/api-model"; +import { + copyApiModel, + buildVolumeGroup, + buildLogicalVolumeFromPartition, +} from "~/helpers/storage/api-model"; import { data } from "~/types/storage"; -function toLogicalVolume(partition: apiModel.Partition): apiModel.LogicalVolume { - return { ...partition }; -} - function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup) { if (!drive.partitions) return; @@ -36,7 +36,10 @@ function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup const reusedPartitions = drive.partitions.filter((p) => p.name); drive.partitions = [...reusedPartitions]; const logicalVolumes = volumeGroup.logicalVolumes || []; - volumeGroup.logicalVolumes = [...logicalVolumes, ...newPartitions.map(toLogicalVolume)]; + volumeGroup.logicalVolumes = [ + ...logicalVolumes, + ...newPartitions.map(buildLogicalVolumeFromPartition), + ]; } function addVolumeGroup( From 4b6b62342e7b67350ad7c6bfd37eb61b9178b11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 27 Mar 2025 06:52:53 +0000 Subject: [PATCH 094/103] web: preselect disks for system volume group --- web/src/components/storage/LvmPage.test.tsx | 1 + web/src/components/storage/LvmPage.tsx | 3 +++ web/src/helpers/storage/model.ts | 5 +++++ web/src/types/storage/model.ts | 1 + 4 files changed, 10 insertions(+) diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index 5a3ea00255..60aa05d5de 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -88,6 +88,7 @@ const mockSdaDrive: model.Drive = { }, ], isUsed: true, + isAddingPartitions: true, getVolumeGroups: () => [], getMountPaths: () => ["/home", "swap"], }; diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index ece9b9d9bb..2fb746e362 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -92,6 +92,9 @@ export default function LvmPage() { setSelectedDevices(targetDevices); } else if (model && !model.volumeGroups.length) { setName("system"); + const targetNames = model.drives.filter((d) => d.isAddingPartitions).map((d) => d.name); + const targetDevices = allDevices.filter((d) => targetNames.includes(d.name)); + setSelectedDevices(targetDevices); } }, [model, volumeGroup, allDevices]); diff --git a/web/src/helpers/storage/model.ts b/web/src/helpers/storage/model.ts index a341feacb7..d5072a60ff 100644 --- a/web/src/helpers/storage/model.ts +++ b/web/src/helpers/storage/model.ts @@ -65,9 +65,14 @@ function buildDrive( ); }; + const isAddingPartitions = (): boolean => { + return (apiDrive.partitions || []).some((p) => p.mountPath && !p.name); + }; + return { ...apiDrive, isUsed: isUsed(), + isAddingPartitions: isAddingPartitions(), getMountPaths, getVolumeGroups, }; diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index f0a988bd9d..c12eedf980 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -36,6 +36,7 @@ type Model = { interface Drive extends apiModel.Drive { isUsed: boolean; + isAddingPartitions: boolean; getMountPaths: () => string[]; getVolumeGroups: () => VolumeGroup[]; } From beef8b3d74352713d9d0b7f57ea4b085390a6a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 27 Mar 2025 07:39:17 +0000 Subject: [PATCH 095/103] web: fix test --- web/src/components/storage/LvmPage.test.tsx | 33 ++++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx index 60aa05d5de..90b2728adb 100644 --- a/web/src/components/storage/LvmPage.test.tsx +++ b/web/src/components/storage/LvmPage.test.tsx @@ -66,6 +66,16 @@ const sda: StorageDevice = { description: "", }; +const sdb: StorageDevice = { + sid: 60, + isDrive: true, + type: "disk", + name: "/dev/sdb", + size: 1024, + systems: [], + description: "", +}; + const mockSdaDrive: model.Drive = { name: "/dev/sda", spacePolicy: "delete", @@ -115,7 +125,7 @@ let mockUseModel = { volumeGroups: [], }; -const mockUseAllDevices = [sda]; +const mockUseAllDevices = [sda, sdb]; jest.mock("~/queries/issues", () => ({ ...jest.requireActual("~/queries/issues"), @@ -126,7 +136,7 @@ jest.mock("~/queries/issues", () => ({ jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), useAvailableDevices: () => mockUseAllDevices, - useDevices: () => [sda], + useDevices: () => [sda, sdb], })); jest.mock("~/hooks/storage/model", () => ({ @@ -149,6 +159,7 @@ describe("LvmPage", () => { const name = screen.getByRole("textbox", { name: "Name" }); const disks = screen.getByRole("group", { name: "Disks" }); const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const sdbCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sdb, 1 KiB" }); const moveMountPointsCheckbox = screen.getByRole("checkbox", { name: /Move the mount points currently configured at the selected disks to logical volumes/, }); @@ -157,14 +168,17 @@ describe("LvmPage", () => { // Clear default value for name await user.clear(name); await user.type(name, "root-vg"); - await user.click(sdaCheckbox); + await user.click(sdbCheckbox); + + // sda is selected by default because it is adding partitions. + expect(sdaCheckbox).toBeChecked(); // By default move mount points should be checked expect(moveMountPointsCheckbox).toBeChecked(); await user.click(moveMountPointsCheckbox); expect(moveMountPointsCheckbox).not.toBeChecked(); await user.click(acceptButton); expect(mockAddVolumeGroup).toHaveBeenCalledWith( - { vgName: "root-vg", targetDevices: ["/dev/sda"] }, + { vgName: "root-vg", targetDevices: ["/dev/sda", "/dev/sdb"] }, false, ); }); @@ -172,17 +186,17 @@ describe("LvmPage", () => { it("allows configuring a new LVM volume group (moving mount points)", async () => { const { user } = installerRender(); const disks = screen.getByRole("group", { name: "Disks" }); - const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const sdbCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sdb, 1 KiB" }); const moveMountPointsCheckbox = screen.getByRole("checkbox", { name: /Move the mount points currently configured at the selected disks to logical volumes/, }); const acceptButton = screen.getByRole("button", { name: "Accept" }); - await user.click(sdaCheckbox); + await user.click(sdbCheckbox); expect(moveMountPointsCheckbox).toBeChecked(); await user.click(acceptButton); expect(mockAddVolumeGroup).toHaveBeenCalledWith( - { vgName: "system", targetDevices: ["/dev/sda"] }, + { vgName: "system", targetDevices: ["/dev/sda", "/dev/sdb"] }, true, ); }); @@ -194,6 +208,9 @@ describe("LvmPage", () => { const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); + // Unselect sda + await user.click(sdaCheckbox); + // Let's clean the default given name await user.clear(name); await user.click(acceptButton); @@ -208,7 +225,7 @@ describe("LvmPage", () => { expect(screen.queryByText(/Enter a name/)).toBeNull(); screen.getByText(/Select at least one disk/); - // Select a disk + // Select sda again expect(sdaCheckbox).not.toBeChecked(); await user.click(sdaCheckbox); expect(sdaCheckbox).toBeChecked(); From f145f01fe430e733a23b0500fdd55f3c9754556e Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 26 Mar 2025 21:51:49 +0000 Subject: [PATCH 096/103] web: Align grids of multi-line checkboxes --- web/src/assets/styles/index.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 8cdb560b93..cc12cc87e8 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -367,6 +367,7 @@ label.pf-m-disabled + .pf-v6-c-check__description { .pf-v6-c-check { row-gap: var(--pf-t--global--spacer--xs); + align-content: baseline; input[type="checkbox"] { align-self: center; From 53e05e8065ff936bd635395c55d843fd11f3d810 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 26 Mar 2025 21:52:50 +0000 Subject: [PATCH 097/103] web: Handle more recoverable errors --- web/src/components/storage/ProposalPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index f1eae52f59..353abc0a2f 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -237,7 +237,7 @@ export default function ProposalPage(): React.ReactNode { if (isDeprecated) reprobe().catch(console.log); }, [isDeprecated, reprobe]); - const fixable = ["no_root", "required_filesystems"]; + const fixable = ["no_root", "required_filesystems", "vg_target_devices"]; const unfixableErrors = configErrors.filter((e) => !fixable.includes(e.kind)); const isModelEditable = model && !unfixableErrors.length; const hasDevices = !!availableDevices.length; From 86888427597aad1ca1fc17d388a1ad7db1ce90ec Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Wed, 26 Mar 2025 23:16:20 +0000 Subject: [PATCH 098/103] web: Reword some ConfigEditor buttons --- .../storage/ConfigEditorMenu.test.tsx | 4 +-- .../components/storage/ConfigEditorMenu.tsx | 4 +-- .../storage/ConfigureDeviceMenu.test.tsx | 10 ++++---- .../storage/ConfigureDeviceMenu.tsx | 25 +++++++++++-------- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/web/src/components/storage/ConfigEditorMenu.test.tsx b/web/src/components/storage/ConfigEditorMenu.test.tsx index d28568b246..c5df5080c3 100644 --- a/web/src/components/storage/ConfigEditorMenu.test.tsx +++ b/web/src/components/storage/ConfigEditorMenu.test.tsx @@ -53,7 +53,7 @@ beforeEach(() => { async function openMenu() { const { user } = plainRender(); - const button = screen.getByRole("button", { name: "More options toggle" }); + const button = screen.getByRole("button", { name: "Other options toggle" }); await user.click(button); const menu = screen.getByRole("menu"); return { user, menu }; @@ -61,7 +61,7 @@ async function openMenu() { it("renders the menu", () => { plainRender(); - expect(screen.queryByText("More options")).toBeInTheDocument(); + expect(screen.queryByText("Other options")).toBeInTheDocument(); }); it("allows users to change the boot options", async () => { diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index a03b263cdc..91c3e8c4b3 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -54,10 +54,10 @@ export default function ConfigEditorMenu() { - {_("More options")} + {_("Other options")} )} > diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx index 5d532e2c70..ec9d3e4a64 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx @@ -87,7 +87,7 @@ describe("ConfigureDeviceMenu", () => { it("renders an initially closed menu ", async () => { const { user } = plainRender(); - const toggler = screen.getByRole("button", { name: "Configure a device", expanded: false }); + const toggler = screen.getByRole("button", { name: "More devices", expanded: false }); expect(screen.queryAllByRole("menu").length).toBe(0); await user.click(toggler); expect(toggler).toHaveAttribute("aria-expanded", "true"); @@ -96,7 +96,7 @@ describe("ConfigureDeviceMenu", () => { it("allows users to add a new LVM volume group", async () => { const { user } = plainRender(); - const toggler = screen.getByRole("button", { name: "Configure a device", expanded: false }); + const toggler = screen.getByRole("button", { name: "More devices", expanded: false }); await user.click(toggler); const lvmMenuItem = screen.getByRole("menuitem", { name: /LVM/ }); await user.click(lvmMenuItem); @@ -107,7 +107,7 @@ describe("ConfigureDeviceMenu", () => { describe("and no disks have been configured yet", () => { it("allows users to add a new drive", async () => { const { user } = plainRender(); - const toggler = screen.getByRole("button", { name: /Configure a device/ }); + const toggler = screen.getByRole("button", { name: /More devices/ }); await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); await user.click(disksMenuItem); @@ -124,7 +124,7 @@ describe("ConfigureDeviceMenu", () => { it("allows users to add a new drive to an unused disk", async () => { const { user } = plainRender(); - const toggler = screen.getByRole("button", { name: /Configure a device/ }); + const toggler = screen.getByRole("button", { name: /More devices/ }); await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); await user.click(disksMenuItem); @@ -143,7 +143,7 @@ describe("ConfigureDeviceMenu", () => { it("renders the disks menu as disabled with an informative label", async () => { const { user } = plainRender(); - const toggler = screen.getByRole("button", { name: /Configure a device/ }); + const toggler = screen.getByRole("button", { name: /More devices/ }); await user.click(toggler); const disksMenuItem = screen.getByRole("menuitem", { name: /disk to define/ }); expect(disksMenuItem).toBeDisabled(); diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index dafb5586cd..17b43e77d9 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -63,17 +63,20 @@ const DisksDrillDownMenuItem = ({ }: DisksDrillDownMenuItemProps) => { const isDisabled = !devices.length; - const disabledDescription = _("Already using all availalbe disks."); + const disabledDescription = _("Already using all available disks"); const enabledDescription = drivesCount ? sprintf( n_( - "Extends the installation beyond the currently selected disk", - "Extends the installation beyond the current %d disks", + "Extend the installation beyond the currently selected disk", + "Extend the installation beyond the current %d disks", drivesCount, ), drivesCount, ) - : _("Extends the installation using a disk"); + : _("Start configuring a basic installation"); + const title = drivesCount + ? _("Select another disk to define partitions") + : _("Select a disk to define partitions"); return ( } > - {n_( - "Select another disk to define partitions", - "Select a disk to define partitions", - drivesCount, - )} + {title} ); }; @@ -194,6 +193,10 @@ export default function ConfigureDeviceMenu() { } }; + const lvmDescription = allDevices.length + ? _("Define a new LVM on top of one or several disks") + : _("Define a new LVM on the disk"); + return ( - {_("Configure a device")} + {_("More devices")}
} menuRef={menuRef} @@ -228,7 +231,7 @@ export default function ConfigureDeviceMenu() { navigate(PATHS.volumeGroup.add)} - description={_("Extend the installation using LVM")} + description={lvmDescription} > {_("Add LVM volume group")} From 7cc76e233ea6099b5b69be27d2fb5e57326f7b76 Mon Sep 17 00:00:00 2001 From: Ancor Gonzalez Sosa Date: Thu, 27 Mar 2025 07:36:48 +0000 Subject: [PATCH 099/103] web: Clarify wording about boot partitions --- web/src/components/storage/DriveEditor.tsx | 2 +- .../components/storage/ProposalFailedInfo.tsx | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index f6ee7af4fa..20d526fbe1 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -447,7 +447,7 @@ const DriveHeader = ({ drive, driveDevice }: DriveEditorProps) => { if (isBoot) { // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" - return _("Use %s to boot"); + return _("Use %s to configure boot partitions"); } // TRANSLATORS: %s will be replaced by the device name and its size - "/dev/sda, 20 GiB" return _("Use %s"); diff --git a/web/src/components/storage/ProposalFailedInfo.tsx b/web/src/components/storage/ProposalFailedInfo.tsx index b5af24c369..41b8c1e37e 100644 --- a/web/src/components/storage/ProposalFailedInfo.tsx +++ b/web/src/components/storage/ProposalFailedInfo.tsx @@ -29,7 +29,7 @@ import { IssueSeverity } from "~/types/issues"; import * as partitionUtils from "~/components/storage/utils/partition"; import { sprintf } from "sprintf-js"; -function Description({ partitions }) { +function Description({ partitions, booting }) { const newPartitions = partitions.filter((p) => !p.name); if (!newPartitions.length) { @@ -43,17 +43,29 @@ function Description({ partitions }) { } const mountPaths = newPartitions.map((p) => partitionUtils.pathWithSize(p)); - const msg1 = sprintf( - // TRANSLATORS: %s is a list of formatted mount points with a partition size like - // '"/" (at least 10 GiB), "/var" (20 GiB) and "swap" (2 GiB)' - // (or a single mount point in the singular case). - n_( - "It is not possible to allocate the requested partition for %s.", - "It is not possible to allocate the requested partitions for %s.", - mountPaths.length, - ), - formatList(mountPaths), - ); + const msg1 = booting + ? sprintf( + // TRANSLATORS: %s is a list of formatted mount points with a partition size like + // '"/" (at least 10 GiB), "/var" (20 GiB) and "swap" (2 GiB)' + // (or a single mount point in the singular case). + n_( + "It is not possible to allocate the requested partitions for booting and for %s.", + "It is not possible to allocate the requested partitions for booting, %s.", + mountPaths.length, + ), + formatList(mountPaths), + ) + : sprintf( + // TRANSLATORS: %s is a list of formatted mount points with a partition size like + // '"/" (at least 10 GiB), "/var" (20 GiB) and "swap" (2 GiB)' + // (or a single mount point in the singular case). + n_( + "It is not possible to allocate the requested partition for %s.", + "It is not possible to allocate the requested partitions for %s.", + mountPaths.length, + ), + formatList(mountPaths), + ); return ( <> @@ -78,10 +90,11 @@ export default function ProposalFailedInfo() { if (!errors.length) return; const modelPartitions = model.drives.flatMap((d) => d.partitions || []); + const booting = !!model.boot?.configure; return ( - + ); } From 15eda6bb2c27c90241b780a55368bfcd22e89eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 27 Mar 2025 11:45:19 +0000 Subject: [PATCH 100/103] service: changelog --- service/package/rubygem-agama-yast.changes | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 694a03786a..f96a6b02f2 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Mar 27 11:40:23 UTC 2025 - José Iván López González + +- Extend storage model for basic LVM support + (gh#agama-project/agama#2216). + ------------------------------------------------------------------- Tue Mar 25 15:47:13 UTC 2025 - Ladislav Slezák @@ -35,7 +41,7 @@ Wed Mar 12 00:44:33 UTC 2025 - Imobach Gonzalez Sosa - Copy Agama logs to the installed system (gh#agama/agama-project#2148). - Set /var/log/agama-installation permissions to 0700 - (gh#agama/agama-project#2140). + (gh#agama-project/agama#2140). ------------------------------------------------------------------- Wed Mar 5 14:50:04 UTC 2025 - Ladislav Slezák From 36911f724b3a87730a5bb24e2523ff39616b983f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 27 Mar 2025 11:45:30 +0000 Subject: [PATCH 101/103] web: changelog --- web/package/agama-web-ui.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index e3cd032e56..34ec9812c0 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Mar 27 11:42:51 UTC 2025 - José Iván López González + +- Add basic support for creating LVM volume groups and logical + volumes (gh#agama-project/agama#2216). + ------------------------------------------------------------------- Wed Mar 26 08:25:34 UTC 2025 - David Diaz From 138cb18ca1dd345f014d40a5e7ddb1b166105e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Thu, 27 Mar 2025 11:54:25 +0000 Subject: [PATCH 102/103] fix(web): do not add styles to plain alerts I.e., no border and no background --- web/src/assets/styles/index.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 15364fab70..f85fcf5f05 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -317,6 +317,11 @@ ); } } + + &.pf-m-plain { + border: 0; + background: none; + } } .pf-v6-c-alert__title { From 157d4364bb75ebd99ab0ec5da4e2bc6940759262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 27 Mar 2025 12:14:49 +0000 Subject: [PATCH 103/103] web: fix mount path regex --- web/src/components/storage/LogicalVolumePage.tsx | 2 +- web/src/components/storage/PartitionPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index fb7f2c00ec..a540238be9 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -244,7 +244,7 @@ function useMountPointError(value: FormValue): Error | undefined { }; } - const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; + const regex = /^swap$|^\/$|^(\/[^/\s]+)+$/; if (!regex.test(mountPoint)) { return { id: "mountPoint", diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 9ebcb4155b..fe569848d2 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -299,7 +299,7 @@ function useMountPointError(value: FormValue): Error | undefined { }; } - const regex = /^swap$|^\/$|^(\/[^/\s]+([^/]*[^/\s])*)+$/; + const regex = /^swap$|^\/$|^(\/[^/\s]+)+$/; if (!regex.test(mountPoint)) { return { id: "mountPoint",