diff --git a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml index 93dd879063..6a6b2cb38b 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml @@ -61,6 +61,10 @@ + + + + diff --git a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml index 3a8beb2e6c..b199b5925f 100644 --- a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml @@ -116,6 +116,53 @@ --> + + + + + + + diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index 967bae4bae..6f6bed82aa 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -80,6 +80,7 @@ "additionalProperties": false, "required": ["default"], "properties": { + "reuse": { "type": "boolean" }, "default": { "type": "boolean" }, "type": { "$ref": "#/$defs/filesystemType" }, "snapshots": { "type": "boolean" } diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index b99626b13d..381c747209 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -159,7 +159,7 @@ impl<'a> StorageClient<'a> { Ok(settings) } - /// Set the storage config according to the JSON schema + /// Set the storage config model according to the JSON schema pub async fn set_config_model(&self, model: Box) -> Result { Ok(self .storage_proxy @@ -174,6 +174,13 @@ impl<'a> StorageClient<'a> { Ok(config_model) } + /// Solves the storage config model + pub async fn solve_config_model(&self, model: &str) -> Result, ServiceError> { + let serialized_solved_model = self.storage_proxy.solve_config_model(model).await?; + let solved_model = serde_json::from_str(serialized_solved_model.as_str()).unwrap(); + Ok(solved_model) + } + pub async fn calculate(&self, settings: ProposalSettingsPatch) -> Result { let map: HashMap<&str, zbus::zvariant::Value> = settings.into(); let options: HashMap<&str, &zbus::zvariant::Value> = diff --git a/rust/agama-lib/src/storage/proxies/storage1.rs b/rust/agama-lib/src/storage/proxies/storage1.rs index 563bbfd356..2dc37e05bb 100644 --- a/rust/agama-lib/src/storage/proxies/storage1.rs +++ b/rust/agama-lib/src/storage/proxies/storage1.rs @@ -71,6 +71,9 @@ pub trait Storage1 { /// Get the storage config model according to the JSON schema fn get_config_model(&self) -> zbus::Result; + /// Solve a storage config model + fn solve_config_model(&self, model: &str) -> zbus::Result; + /// DeprecatedSystem property #[zbus(property)] fn deprecated_system(&self) -> zbus::Result; diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 99c7e95d22..3bbd61b2d5 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -57,7 +57,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use tokio_stream::{Stream, StreamExt}; -use super::license::{License, LicenseContent}; +use super::license::License; #[derive(Clone)] struct SoftwareState<'a> { diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 260f2a9280..60e69beb98 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -115,6 +115,7 @@ pub async fn storage_service(dbus: zbus::Connection) -> Result>, + query: Query, +) -> Result>, Error> { + let solved_model = state + .client + .solve_config_model(query.model.as_str()) + .await + .map_err(Error::Service)?; + Ok(Json(solved_model)) +} + +#[derive(Deserialize, utoipa::IntoParams)] +struct SolveModelQuery { + /// Serialized config model. + model: String, +} + /// Probes the storage devices. #[utoipa::path( post, diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 918f43de6d..938e8fb244 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -147,6 +147,18 @@ def recover_model JSON.pretty_generate(json) end + # Solves the given serialized config model. + # + # @param serialized_model [String] Serialized storage config model. + # @return [String] Serialized solved model. + def solve_model(serialized_model) + logger.info("Solving storage config model from D-Bus: #{serialized_model}") + + model_json = JSON.parse(serialized_model, symbolize_names: true) + solved_model_json = proposal.solve_model(model_json) + JSON.pretty_generate(solved_model_json) + end + def install busy_while { backend.install } end @@ -173,6 +185,9 @@ def deprecated_system busy_while { apply_config_model(serialized_model) } end dbus_method(:GetConfigModel, "out serialized_model:s") { recover_model } + dbus_method(:SolveConfigModel, "in sparse_model:s, out solved_model:s") do |sparse_model| + solve_model(sparse_model) + end dbus_method(:Install) { install } dbus_method(:Finish) { finish } dbus_reader(:deprecated_system, "b") diff --git a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb b/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb index 786aa1e6fa..bdfa28cc89 100644 --- a/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb +++ b/service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb @@ -51,7 +51,7 @@ def convert "Target" => volume.location.target.to_s, "TargetDevice" => volume.location.device.to_s, "FsType" => volume.fs_type&.to_human_string || "", - "MinSize" => volume.min_size&.to_i, + "MinSize" => min_size_conversion, "AutoSize" => volume.auto_size?, "Snapshots" => volume.btrfs.snapshots?, "Transactional" => volume.btrfs.read_only?, @@ -67,11 +67,20 @@ def convert # @return [Agama::Storage::Volume] attr_reader :volume + # @return [Integer] + def min_size_conversion + min_size = volume.min_size + min_size = volume.outline.base_min_size if volume.auto_size? + min_size.to_i + end + # @param target [Hash] def max_size_conversion(target) - return if volume.max_size.nil? || volume.max_size.unlimited? + max_size = volume.max_size + max_size = volume.outline.base_max_size if volume.auto_size? + return if max_size.unlimited? - target["MaxSize"] = volume.max_size.to_i + target["MaxSize"] = max_size.to_i end # Converts volume outline to D-Bus. diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb index d30091a985..1f7f44636a 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/filesystem.rb @@ -41,8 +41,9 @@ def default_config # @return [Hash] def conversions { - path: model_json[:mountPath], - type: convert_type + reuse: model_json.dig(:filesystem, :reuse), + path: model_json[:mountPath], + type: convert_type } end 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 bbf1325d40..39314623a3 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 @@ -55,8 +55,8 @@ def conversions filesystem: convert_filesystem, size: convert_size, id: convert_id, - delete: partition_model[:delete], - delete_if_needed: partition_model[:deleteIfNeeded] + delete: convert_delete, + delete_if_needed: convert_delete_if_needed } end @@ -67,6 +67,24 @@ def convert_id Y2Storage::PartitionId.find(value) end + + # TODO: do not delete if the partition is used by other device (VG, RAID, etc). + # @return [Boolean] + def convert_delete + # Do not mark to delete if the partition is used. + return false if partition_model[:mountPath] + + partition_model[:delete] + end + + # TODO: do not delete if the partition is used by other device (VG, RAID, etc). + # @return [Boolean] + def convert_delete_if_needed + # Do not mark to delete if the partition is used. + return false if partition_model[:mountPath] + + partition_model[:deleteIfNeeded] + end end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb index eb3e02b448..f0d0c6cab4 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb @@ -38,6 +38,7 @@ def initialize(config) # @see Base#conversions def conversions { + reuse: config.reuse?, default: convert_default, type: convert_type, snapshots: convert_snapshots diff --git a/service/lib/agama/storage/config_solvers/search.rb b/service/lib/agama/storage/config_solvers/search.rb index c16e5a0582..744cf7e740 100644 --- a/service/lib/agama/storage/config_solvers/search.rb +++ b/service/lib/agama/storage/config_solvers/search.rb @@ -144,7 +144,9 @@ def filter_by_disk_analyzer(devices) # Finds the partitions matching the given search config, if any # # @param search_config [Agama::Storage::Configs::Search] - # @return [Y2Storage::Device, nil] + # @param device [#partitions] + # + # @return [Array] def find_partitions(search_config, device) candidates = candidate_devices(search_config, default: device.partitions) candidates.select! { |d| d.is?(:partition) } @@ -175,7 +177,7 @@ def find_device(search_config) # # @param devices [Array] # @param search [Config::Search] - # @return [Y2Storage::Device, nil] + # @return [Array] def next_unassigned_devices(devices, search) devices .reject { |d| sids.include?(d.sid) } diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 825194f085..394cfd68ef 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -22,6 +22,7 @@ require "agama/issue" require "agama/storage/actions_generator" require "agama/storage/config_conversions" +require "agama/storage/config_solver" require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "json" @@ -100,6 +101,24 @@ def model_json ConfigConversions::ToModel.new(config).convert end + # Solves a given model. + # + # @param model_json [Hash] Config model according to the JSON schema. + # @return [Hash, nil] Solved config model or nil if the model cannot be solved yet. + def solve_model(model_json) + return unless storage_manager.probed? + + config = ConfigConversions::FromModel + .new(model_json, product_config: product_config) + .convert + + ConfigSolver + .new(product_config, storage_manager.probed, disk_analyzer: disk_analyzer) + .solve(config) + + ConfigConversions::ToModel.new(config).convert + end + # Calculates a new proposal using the given JSON. # # @raise If the JSON is not valid. diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index ef777ac41b..23496d79e3 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -36,6 +36,10 @@ require "y2storage" require "dbus" +def serialize(value) + JSON.pretty_generate(value) +end + describe Agama::DBus::Storage::Manager do include Agama::RSpec::StorageHelpers @@ -589,10 +593,6 @@ end describe "#recover_config" do - def serialize(value) - JSON.pretty_generate(value) - end - context "if a proposal has not been calculated" do it "returns 'null'" do expect(subject.recover_config).to eq("null") @@ -682,10 +682,6 @@ def serialize(value) end describe "#recover_model" do - def serialize(value) - JSON.pretty_generate(value) - end - context "if a proposal has not been calculated" do it "returns 'null'" do expect(subject.recover_model).to eq("null") @@ -753,6 +749,7 @@ def serialize(value) { mountPath: "/", filesystem: { + reuse: false, default: true, type: "ext4" }, @@ -792,6 +789,74 @@ def serialize(value) end end + describe "#solve_model" do + let(:model) do + { + drives: [ + { + name: "/dev/sda", + alias: "sda", + partitions: [ + { mountPath: "/" } + ] + } + ] + } + end + + it "returns the serialized solved model" do + result = subject.solve_model(model.to_json) + + expect(result).to eq( + serialize({ + boot: { + configure: true, + device: { + default: true, + name: "/dev/sda" + } + }, + drives: [ + { + name: "/dev/sda", + alias: "sda", + spacePolicy: "keep", + partitions: [ + { + mountPath: "/", + filesystem: { + reuse: false, + default: true, + type: "ext4" + }, + size: { + default: true, + min: 0 + }, + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false + } + ] + } + ] + }) + ) + end + + context "if the system has not been probed yet" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) + end + + it "returns 'null'" do + result = subject.solve_model(model.to_json) + expect(result).to eq("null") + end + end + end + describe "#iscsi_discover" do it "performs an iSCSI discovery" do expect(iscsi).to receive(:discover_send_targets) do |address, port, auth| diff --git a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb b/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb index 9fc2db2749..eedc320987 100644 --- a/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb +++ b/service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb @@ -26,9 +26,19 @@ require "y2storage/disk_size" describe Agama::DBus::Storage::VolumeConversion::ToDBus do - let(:default_volume) { Agama::Storage::Volume.new("/test") } + let(:volume1) { Agama::Storage::Volume.new("/test1") } - let(:custom_volume) do + let(:volume2) do + Agama::Storage::Volume.new("/test2").tap do |volume| + volume.min_size = nil + volume.max_size = nil + volume.auto_size = true + volume.outline.base_min_size = Y2Storage::DiskSize.new(1024) + volume.outline.base_max_size = Y2Storage::DiskSize.new(4096) + end + end + + let(:volume3) do volume_outline = Agama::Storage::VolumeOutline.new.tap do |outline| outline.required = true outline.filesystems = [Y2Storage::Filesystems::Type::EXT3, Y2Storage::Filesystems::Type::EXT4] @@ -39,9 +49,11 @@ outline.snapshots_size = Y2Storage::DiskSize.new(1000) outline.snapshots_percentage = 10 outline.adjust_by_ram = true + outline.base_min_size = Y2Storage::DiskSize.new(2048) + outline.base_max_size = Y2Storage::DiskSize.new(4096) end - Agama::Storage::Volume.new("/test").tap do |volume| + Agama::Storage::Volume.new("/test3").tap do |volume| volume.outline = volume_outline volume.fs_type = Y2Storage::Filesystems::Type::EXT4 volume.btrfs.snapshots = true @@ -57,8 +69,8 @@ describe "#convert" do it "converts the volume to a D-Bus hash" do - expect(described_class.new(default_volume).convert).to eq( - "MountPath" => "/test", + expect(described_class.new(volume1).convert).to eq( + "MountPath" => "/test1", "MountOptions" => [], "TargetDevice" => "", "Target" => "default", @@ -78,14 +90,36 @@ } ) - expect(described_class.new(custom_volume).convert).to eq( - "MountPath" => "/test", + expect(described_class.new(volume2).convert).to eq( + "MountPath" => "/test2", + "MountOptions" => [], + "TargetDevice" => "", + "Target" => "default", + "FsType" => "", + "MinSize" => 1024, + "MaxSize" => 4096, + "AutoSize" => true, + "Snapshots" => false, + "Transactional" => false, + "Outline" => { + "Required" => false, + "FsTypes" => [], + "SupportAutoSize" => false, + "AdjustByRam" => false, + "SnapshotsConfigurable" => false, + "SnapshotsAffectSizes" => false, + "SizeRelevantVolumes" => [] + } + ) + + expect(described_class.new(volume3).convert).to eq( + "MountPath" => "/test3", "MountOptions" => ["rw", "default"], "TargetDevice" => "/dev/sda", "Target" => "new_partition", "FsType" => "Ext4", - "MinSize" => 1024, - "MaxSize" => 2048, + "MinSize" => 2048, + "MaxSize" => 4096, "AutoSize" => true, "Snapshots" => true, "Transactional" => true, diff --git a/service/test/agama/storage/autoyast_proposal_test.rb b/service/test/agama/storage/autoyast_proposal_test.rb index eef85a3920..78ebeb75b5 100644 --- a/service/test/agama/storage/autoyast_proposal_test.rb +++ b/service/test/agama/storage/autoyast_proposal_test.rb @@ -42,7 +42,7 @@ let(:scenario) { "windows-linux-pc.yml" } let(:arch) do - instance_double(Y2Storage::Arch, efiboot?: true, ram_size: 4.GiB.to_i) + instance_double(Y2Storage::Arch, x86?: true, efiboot?: true, ram_size: 4.GiB.to_i) end let(:config_path) do @@ -121,7 +121,7 @@ def root_filesystem(disk) expect(efi).to have_attributes( filesystem_type: Y2Storage::Filesystems::Type::VFAT, filesystem_mountpoint: "/boot/efi", - size: 512.MiB + size: 1.GiB ) expect(root).to have_attributes( @@ -292,7 +292,7 @@ def root_filesystem(disk) filesystem_type: Y2Storage::Filesystems::Type::VFAT, filesystem_mountpoint: "/boot/efi", id: Y2Storage::PartitionId::ESP, - size: 512.MiB + size: 1.GiB ) 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 c0908f0056..19acc047ac 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -288,6 +288,25 @@ expect(filesystem.mount_options).to eq([]) end end + + context "if the filesystem specifies 'reuse'" do + let(:filesystem) { { reuse: true } } + + it "sets #filesystem to the expected value" do + config = config_proc.call(subject.convert) + filesystem = config.filesystem + expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) + expect(filesystem.reuse?).to eq(true) + expect(filesystem.type.default?).to eq(true) + expect(filesystem.type.fs_type).to be_nil + expect(filesystem.type.btrfs).to be_nil + expect(filesystem.label).to be_nil + expect(filesystem.path).to be_nil + expect(filesystem.mount_by).to be_nil + expect(filesystem.mkfs_options).to be_empty + expect(filesystem.mount_options).to be_empty + end + end end shared_examples "with mountPath and filesystem" do |config_proc| @@ -789,23 +808,47 @@ end context "if a partition specifies 'delete'" do - let(:partitions) { [{ delete: true }] } + let(:partitions) { [{ delete: true, mountPath: mount_path }] } + + let(:mount_path) { nil } it "sets #delete to true" do config = config_proc.call(subject.convert) partition = config.partitions.first expect(partition.delete?).to eq(true) end + + context "and the partition has a mount path" do + let(:mount_path) { "/test" } + + it "sets #delete to false" do + config = config_proc.call(subject.convert) + partition = config.partitions.first + expect(partition.delete?).to eq(false) + end + end end context "if a partition specifies 'deleteIfNeeded'" do - let(:partitions) { [{ deleteIfNeeded: true }] } + let(:partitions) { [{ deleteIfNeeded: true, mountPath: mount_path }] } + + let(:mount_path) { nil } it "sets #delete_if_needed to true" do config = config_proc.call(subject.convert) partition = config.partitions.first expect(partition.delete_if_needed?).to eq(true) end + + context "and the partition has a mount path" do + let(:mount_path) { "/test" } + + it "sets #delete_if_needed to false" do + config = config_proc.call(subject.convert) + partition = config.partitions.first + expect(partition.delete_if_needed?).to eq(false) + 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 f6b23fefed..5cd8af9e33 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -101,6 +101,7 @@ expect(model_json[:mountPath]).to eq("/test") expect(model_json[:filesystem]).to eq( { + reuse: true, default: false, type: "xfs" } @@ -115,6 +116,7 @@ expect(model_json[:mountPath]).to eq("/") expect(model_json[:filesystem]).to eq( { + reuse: false, default: true, type: "btrfs", snapshots: true diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index eaf9eca62e..b3df1a5052 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -376,6 +376,7 @@ def drive(partitions) { mountPath: "/", filesystem: { + reuse: false, default: true, type: "ext4" }, @@ -415,6 +416,72 @@ def drive(partitions) end end + describe "#solve_model" do + let(:model) do + { + drives: [ + { + name: "/dev/sda", + alias: "sda", + partitions: [ + { mountPath: "/" } + ] + } + ] + } + end + + it "returns the solved model" do + result = subject.solve_model(model) + + expect(result).to eq({ + boot: { + configure: true, + device: { + default: true, + name: "/dev/sda" + } + }, + drives: [ + { + name: "/dev/sda", + alias: "sda", + spacePolicy: "keep", + partitions: [ + { + mountPath: "/", + filesystem: { + reuse: false, + default: true, + type: "ext4" + }, + size: { + default: true, + min: 0 + }, + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false + } + ] + } + ] + }) + end + + context "if the system has not been probed yet" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) + end + + it "returns nil" do + result = subject.solve_model(model) + expect(result).to be_nil + end + end + end + shared_examples "check proposal callbacks" do |action, settings| it "runs all the callbacks" do callback1 = proc {} diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 8ce26ef5da..91907b490b 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -23,6 +23,8 @@ import { get, post, put } from "~/api/http"; import { Job } from "~/types/job"; import { Action, config, configModel, ProductParams, Volume } from "~/api/storage/types"; +import { fetchDevices } from "~/api/storage/devices"; +import { StorageDevice, Volume as StorageVolume, VolumeTarget } from "~/types/storage"; /** * Starts the storage probing process. @@ -43,6 +45,11 @@ const setConfig = (config: config.Config) => put("/api/storage/config", { storag const setConfigModel = (model: configModel.Config) => put("/api/storage/config_model", model); +const solveConfigModel = (model: configModel.Config): Promise => { + const serializedModel = encodeURIComponent(JSON.stringify(model)); + return get(`/api/storage/config_model/solve?model=${serializedModel}`); +}; + const fetchUsableDevices = (): Promise => get(`/api/storage/proposal/usable_devices`); const fetchProductParams = (): Promise => get("/api/storage/product/params"); @@ -52,6 +59,33 @@ const fetchDefaultVolume = (mountPath: string): Promise => { return get(`/api/storage/product/volume_for?mount_path=${path}`); }; +const fetchVolumeTemplates = async (): Promise => { + const buildVolume = ( + rawVolume: Volume, + devices: StorageDevice[], + productMountPoints: string[], + ): StorageVolume => ({ + ...rawVolume, + outline: { + ...rawVolume.outline, + // Indicate whether a volume is defined by the product. + productDefined: productMountPoints.includes(rawVolume.mountPath), + }, + minSize: rawVolume.minSize || 0, + transactional: rawVolume.transactional || false, + target: rawVolume.target as VolumeTarget, + targetDevice: devices.find((d) => d.name === rawVolume.targetDevice), + }); + + const systemDevices = await fetchDevices("system"); + const product = await fetchProductParams(); + const mountPoints = ["", ...product.mountPoints]; + const rawVolumes = await Promise.all(mountPoints.map(fetchDefaultVolume)); + return rawVolumes + .filter((v) => v !== undefined) + .map((v) => buildVolume(v, systemDevices, product.mountPoints)); +}; + const fetchActions = (): Promise => get("/api/storage/devices/actions"); /** @@ -72,9 +106,11 @@ export { fetchConfigModel, setConfig, setConfigModel, + solveConfigModel, fetchUsableDevices, fetchProductParams, fetchDefaultVolume, + fetchVolumeTemplates, fetchActions, fetchStorageJobs, findStorageJob, diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index 2095531c99..61a143416a 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -54,6 +54,7 @@ export interface Drive { partitions?: Partition[]; } export interface Filesystem { + reuse?: boolean; default: boolean; type?: FilesystemType; snapshots?: boolean; diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index ab9bd8ded5..ba331df9b4 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -142,6 +142,36 @@ } } +#productSelectionForm { + input[type="radio"] { + align-self: center; + flex-shrink: 0; + inline-size: 20px; + block-size: 20px; + } + + .pf-v6-c-card { + img { + max-inline-size: 80px; + } + + label { + cursor: pointer; + } + + label::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + } +} + // PatternFly overrides .pf-v6-c-masthead__main { @@ -181,7 +211,8 @@ .pf-v6-c-alert { --pf-v6-c-alert--m-info__title--Color: var(--agm-t--color--waterhole); - --pf-v6-c-alert__icon--FontSize: var(--pf-t--global--font--size--lg); + --pf-v6-c-alert__icon--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--MarginBlockEnd: var(--pf-t--global--spacer--xs); // --pf-v6-c-alert--BoxShadow: var(--pf-t--global--box-shadow--sm); --pf-v6-c-alert--BoxShadow: none; --pf-v6-c-alert--PaddingBlockStart: var(--pf-t--global--spacer--sm); @@ -190,12 +221,12 @@ --pf-v6-c-alert--PaddingInlineEnd: var(--pf-t--global--spacer--md); &:has(.pf-v6-c-alert__description) { - row-gap: var(--pf-t--global--spacer--sm); + row-gap: var(--pf-t--global--spacer--xs); } } .pf-v6-c-alert__title { - font-size: var(--pf-t--global--font--size--lg); + font-size: var(--pf-t--global--font--size--md); } .pf-v6-c-menu { @@ -222,3 +253,26 @@ .pf-v6-c-button { --pf-v6-c-button--BorderRadius: var(--pf-t--global--border--radius--small); } + +// Do not change the default cursor for labels forms because it is confusing +// +// See: +// * https://github.com/openSUSE/Agama/issues/115#issuecomment-1090205696 +// * https://github.com/patternfly/patternfly/issues/4777#issuecomment-1092090484 +.pf-v6-c-form__label { + --pf-v6-c-form__label--hover--Cursor: default; + --pf-v6-c-form__label--m-disabled--hover--Cursor: default; +} + +.pf-v6-c-form { + --pf-v6-c-form__group--m-action--MarginBlockStart: var(--pf-t--global--spacer--md); +} + +// Some utilities not found at PF +.w-14ch { + inline-size: 14ch; +} + +div[aria-live]:empty { + display: none; +} diff --git a/web/src/components/core/NestedContent.test.tsx b/web/src/components/core/NestedContent.test.tsx new file mode 100644 index 0000000000..bedf60167d --- /dev/null +++ b/web/src/components/core/NestedContent.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 NestedContent from "./NestedContent"; + +describe("NestedContent", () => { + it("wraps content in a PF/Content", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-c-content")).toBe(true); + }); + + it("uses inline medium margin when margin prop is not given", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-u-mx-md")).toBe(true); + }); + + it("uses given margin", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-v6-u-m-0")).toBe(true); + }); + + it("allows PF/Content props", () => { + plainRender(Something); + const content = screen.getByText("Something"); + expect(content.classList.contains("pf-m-editorial")).toBe(true); + }); +}); diff --git a/web/src/components/core/NestedContent.tsx b/web/src/components/core/NestedContent.tsx new file mode 100644 index 0000000000..2ee583e22e --- /dev/null +++ b/web/src/components/core/NestedContent.tsx @@ -0,0 +1,48 @@ +/* + * 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, ContentProps } from "@patternfly/react-core"; +import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +type NestedContentProps = { + margin?: keyof typeof spacingStyles; +} & ContentProps; + +/** + * Wrapper on top of PF/Content to allow visually nesting its content by adding + * given margin + * + */ +export default function NestedContent({ + margin = "mxMd", + className, + children, + ...props +}: NestedContentProps) { + const classNames = [className, spacingStyles[margin]].join(" "); + return ( + + {children} + + ); +} diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 7141ff5c29..f28685afa1 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -37,7 +37,6 @@ import { PageSection, PageSectionProps, Split, - Stack, Title, TitleProps, } from "@patternfly/react-core"; @@ -93,10 +92,10 @@ const STICK_TO_TOP = Object.freeze({ default: "top" }); const STICK_TO_BOTTOM = Object.freeze({ default: "bottom" }); // TODO: check if it should have the banner role -const Header = ({ hasGutter = true, children, ...props }) => { +const Header = ({ children, ...props }) => { return ( - {children} + {children} ); }; diff --git a/web/src/components/core/SelectTypeaheadCreatable.tsx b/web/src/components/core/SelectTypeaheadCreatable.tsx new file mode 100644 index 0000000000..949c8c4add --- /dev/null +++ b/web/src/components/core/SelectTypeaheadCreatable.tsx @@ -0,0 +1,323 @@ +/* + * Copyright (c) [2022-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 { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + MenuToggleStatus, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, +} from "@patternfly/react-core"; +import TimesIcon from "@patternfly/react-icons/dist/esm/icons/times-icon"; +import { _ } from "~/i18n"; + +export type SelectTypeaheadCreatableProps = { + id?: string; + value: string; + options: SelectOptionProps[]; + placeholder?: string; + // Text to show for creating a new option. + createText?: string; + onChange?: (value: string) => void; + status?: MenuToggleStatus; + // Accessible name for the toggle + toggleName: string; + // Accessible name for the options list + listName: string; + // Accessible name for input text + inputName: string; + // Accessible name for clear button + clearButtonName: string; +}; + +/** + * Allows selecting or creating a value. + * + * Part of this code was taken from the patternfly example, see + * https://www.patternfly.org/components/menus/select#typeahead-with-create-option. + */ +export default function SelectTypeaheadCreatable({ + id, + value, + options, + placeholder, + createText = _("Add"), + onChange, + status, + toggleName, + listName, + inputName, + clearButtonName, +}: SelectTypeaheadCreatableProps): React.ReactElement { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + const [filterValue, setFilterValue] = React.useState(""); + const [selectOptions, setSelectOptions] = React.useState([]); + const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + + const CREATE_NEW = "create"; + + React.useEffect(() => { + setInputValue(value); + }, [value]); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = options; + + // Filter menu items based on the text input value when one exists. + if (filterValue) { + newSelectOptions = options.filter((menuItem) => + String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()), + ); + + // If no option matches the filter exactly, display creation option. + if (!options.some((option) => option.value === filterValue)) { + newSelectOptions = [ + ...newSelectOptions, + { children: `${createText} "${filterValue}"`, value: CREATE_NEW }, + ]; + } + + // Open the menu when the input value changes and the new value is not empty. + if (!isOpen) { + setIsOpen(true); + } + } + + setSelectOptions(newSelectOptions); + }, [filterValue, isOpen, options, createText]); + + const createItemId = (value) => `select-typeahead-${value.replace(" ", "-")}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const reset = () => { + setInputValue(value); + setFilterValue(""); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const selectOption = (value: string | number, content?: string | number) => { + setInputValue(String(content)); + setFilterValue(""); + onChange && onChange(String(value)); + closeMenu(); + }; + + const onSelect = (event: React.UIEvent | undefined, value: string | number | undefined) => { + event.preventDefault(); + if (value) { + if (value === CREATE_NEW) { + selectOption(filterValue, filterValue); + } else { + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); + } + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + setFilterValue(value); + resetActiveAndFocusedItem(); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + if (key === "ArrowUp") { + // When no index is set or at the first index, focus to the last, otherwise decrement focus + // index. + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = selectOptions.length - 1; + } + } + } + + if (key === "ArrowDown") { + // When no index is set or at the last index, focus to the first, otherwise increment focus + // index. + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + + // Skip disabled options. + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + + switch (event.key) { + case "Enter": + if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { + onSelect(event, focusedItem.value as string); + } + + if (!isOpen) { + setIsOpen(true); + } + + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + selectOption("", ""); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + +