diff --git a/rust/agama-lib/share/storage.schema.json b/rust/agama-lib/share/storage.schema.json index 00461d4e87..0ddae32c14 100644 --- a/rust/agama-lib/share/storage.schema.json +++ b/rust/agama-lib/share/storage.schema.json @@ -594,254 +594,6 @@ } } } - }, - "guided": { - "title": "Guided settings", - "$comment": "This guided section will be extracted to a separate schema. Only storage and legacyAutoyastStorage will be offered as valid schemas for the storage config.", - "type": "object", - "additionalProperties": false, - "properties": { - "target": { - "anyOf": [ - { - "title": "Target for installing", - "description": "Indicates whether to install in a disk or a new LVM.", - "enum": ["disk", "newLvmVg"] - }, - { - "title": "Target disk", - "description": "Indicates to install in a specific disk device.", - "type": "object", - "additionalProperties": false, - "required": ["disk"], - "properties": { - "disk": { - "title": "Device name", - "type": "string", - "examples": ["/dev/vda"] - } - } - }, - { - "title": "New LVM", - "description": "Indicates to install in a new LVM created over some specific devices.", - "type": "object", - "additionalProperties": false, - "required": ["newLvmVg"], - "properties": { - "newLvmVg": { - "description": "List of devices in which to create the physical volumes.", - "type": "array", - "items": { - "title": "Device name", - "type": "string", - "examples": ["/dev/vda"] - } - } - } - } - ] - }, - "boot": { - "$ref": "#/$defs/boot" - }, - "encryption": { - "title": "Encryption", - "description": "Indicates the options for encrypting the new partitions.", - "type": "object", - "additionalProperties": false, - "required": ["password"], - "properties": { - "password": { - "$ref": "#/$defs/encryptionPassword" - }, - "method": { - "title": "Encryption method", - "description": "Method used to encrypt the devices.", - "enum": ["luks2", "tpm_fde"] - }, - "pbkdFunction": { - "$ref": "#/$defs/encryptionPbkdFunction" - } - } - }, - "space": { - "title": "Space policy", - "description": "Indicates how to find space for the new partitions.", - "type": "object", - "additionalProperties": false, - "properties": { - "policy": { - "enum": ["delete", "resize", "keep", "custom"] - }, - "actions": { - "type": "array" - } - }, - "if": { - "properties": { - "policy": { "const": "custom" } - } - }, - "then": { - "required": ["policy", "actions"], - "properties": { - "actions": { - "title": "Custom actions", - "description": "Indicates what to do with specific devices.", - "type": "array", - "items": { - "anyOf": [ - { - "title": "Force delete", - "description": "Indicates to delete a specific device.", - "type": "object", - "required": ["forceDelete"], - "additionalProperties": false, - "properties": { - "forceDelete": { - "description": "Name of the device to delete.", - "type": "string", - "examples": ["/dev/vda"] - } - } - }, - { - "title": "Allow shinking", - "description": "Indicates whether a specific device can be shrunk if needed.", - "type": "object", - "required": ["resize"], - "additionalProperties": false, - "properties": { - "resize": { - "description": "Name of the shrinkable device.", - "type": "string", - "examples": ["/dev/vda"] - } - } - } - ] - } - } - } - }, - "else": { - "required": ["policy"], - "properties": { - "actions": { - "type": "array", - "maxItems": 0 - } - } - } - }, - "volumes": { - "title": "System volumes", - "description": "List of volumes (file systems) to create.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["mount"], - "properties": { - "mount": { - "title": "Mount properties", - "type": "object", - "additionalProperties": false, - "required": ["path"], - "properties": { - "path": { - "title": "Mount path", - "type": "string", - "examples": ["/dev/vda"] - }, - "options": { - "title": "Mount options", - "description": "Options to add to the fourth field of fstab.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "filesystem": { - "$ref": "#/$defs/filesystemType" - }, - "size": { - "$ref": "#/$defs/size" - }, - "target": { - "title": "Volume target", - "description": "Options to indicate the location of a volume.", - "anyOf": [ - { - "title": "Default target", - "description": "The volume is created in the target device for installing.", - "const": "default" - }, - { - "title": "New partition", - "description": "The volume is created over a new partition in a specific disk.", - "type": "object", - "required": ["newPartition"], - "additionalProperties": false, - "properties": { - "newPartition": { - "description": "Name of a disk device.", - "type": "string", - "examples": ["/dev/vda"] - } - } - }, - { - "title": "Dedicated LVM volume group", - "description": "The volume is created over a new dedicated LVM.", - "type": "object", - "additionalProperties": false, - "required": ["newVg"], - "properties": { - "newVg": { - "description": "Name of a disk device.", - "type": "string", - "examples": ["/dev/vda"] - } - } - }, - { - "title": "Re-used existing device", - "description": "The volume is created over an existing device.", - "type": "object", - "additionalProperties": false, - "required": ["device"], - "properties": { - "device": { - "description": "Name of a device.", - "type": "string", - "examples": ["/dev/vda1"] - } - } - }, - { - "title": "Re-used existing file system", - "description": "An existing file system is reused (without formatting).", - "type": "object", - "additionalProperties": false, - "required": ["filesystem"], - "properties": { - "filesystem": { - "description": "Name of a device containing the file system.", - "type": "string", - "examples": ["/dev/vda1"] - } - } - } - ] - } - } - } - } - } } } } diff --git a/rust/agama-lib/src/product/client.rs b/rust/agama-lib/src/product/client.rs index 4eac5e5e33..df58db028a 100644 --- a/rust/agama-lib/src/product/client.rs +++ b/rust/agama-lib/src/product/client.rs @@ -19,11 +19,9 @@ // find current contact information at www.suse.com. use std::collections::HashMap; -use std::str::FromStr; use crate::dbus::{get_optional_property, get_property}; use crate::error::ServiceError; -use crate::software::model::RegistrationRequirement; use crate::software::proxies::SoftwareProductProxy; use serde::Serialize; use zbus::Connection; diff --git a/service/lib/agama/dbus/interfaces/issues.rb b/service/lib/agama/dbus/interfaces/issues.rb index 007a402b63..9a8cae97f9 100644 --- a/service/lib/agama/dbus/interfaces/issues.rb +++ b/service/lib/agama/dbus/interfaces/issues.rb @@ -36,7 +36,7 @@ module Issues # # @return [Array] The description, details, source # and severity of each issue. - # Source: 1 for system, 2 for config and 3 for unknown. + # Source: 1 for system, 2 for config and 0 for unknown. # Severity: 0 for warn and 1 for error. def dbus_issues issues.map do |issue| diff --git a/service/lib/agama/storage/config_checkers/boot.rb b/service/lib/agama/storage/config_checkers/boot.rb index 4bc0cebd3a..013385d79c 100644 --- a/service/lib/agama/storage/config_checkers/boot.rb +++ b/service/lib/agama/storage/config_checkers/boot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -55,7 +55,13 @@ def device_alias def missing_alias_issue return unless configure? && device_alias.nil? - error(_("There is no boot device alias")) + # 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") + ) end # @return [Issue, nil] diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 394cfd68ef..44c07d9438 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2024] SUSE LLC +# Copyright (c) [2022-2025] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/issue" require "agama/storage/actions_generator" +require "agama/storage/config_checker" require "agama/storage/config_conversions" require "agama/storage/config_solver" require "agama/storage/proposal_settings" @@ -93,10 +94,12 @@ def storage_json # Config model according to the JSON schema. # - # @return [Hash, nil] nil if there is no config. + # The config model is generated only if all the config features are supported by the model. + # + # @return [Hash, nil] nil if the config model cannot be generated. def model_json config = config(solved: true) - return unless config + return unless config && model_supported?(config) ConfigConversions::ToModel.new(config).convert end @@ -282,6 +285,26 @@ def config(solved: false) solved ? strategy.config : source_config end + # 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, + config.md_raids, + config.btrfs_raids, + config.nfs_mounts + ].flatten + + encryptable_configs = [ + config.drives, + config.partitions + ].flatten + + unsupported_configs.empty? && encryptable_configs.none?(&:encryption) + end + # Calculates a proposal from guided JSON settings. # # @param guided_json [Hash] e.g., { "target": { "disk": "/dev/vda" } }. @@ -347,7 +370,7 @@ def storage_manager def failed_issue Issue.new( _("Cannot accommodate the required file systems for installation"), - source: Issue::Source::CONFIG, + source: Issue::Source::SYSTEM, severity: Issue::Severity::ERROR ) end @@ -359,7 +382,7 @@ def exception_issue(error) Issue.new( _("A problem ocurred while calculating the storage setup"), details: error.message, - source: Issue::Source::CONFIG, + source: Issue::Source::SYSTEM, severity: Issue::Severity::ERROR ) end diff --git a/service/test/agama/storage/config_checker_test.rb b/service/test/agama/storage/config_checker_test.rb index 3f4185df19..b675e68b28 100644 --- a/service/test/agama/storage/config_checker_test.rb +++ b/service/test/agama/storage/config_checker_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2025] SUSE LLC # # All Rights Reserved. # @@ -288,7 +288,7 @@ issue = issues.first expect(issue.error?).to eq(true) - expect(issue.description).to eq("There is no boot device alias") + expect(issue.description).to match(/there is no root \(\/\) file system/) end end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index b3df1a5052..99f0878bf8 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022-2024] SUSE LLC +# Copyright (c) [2022-2025] SUSE LLC # # All Rights Reserved. # @@ -335,83 +335,175 @@ def drive(partitions) end end + context "if an AutoYaST proposal has been calculated" do + before do + subject.calculate_from_json(autoyast_json) + end + + let(:autoyast_json) do + { + legacyAutoyastStorage: [ + { device: "/dev/vda" } + ] + } + end + + it "returns nil" do + expect(subject.model_json).to be_nil + end + end + context "if an agama proposal has been calculated" do before do subject.calculate_from_json(config_json) end - let(:config_json) do - { - storage: { - drives: [ - { - alias: "root", - partitions: [ - { - filesystem: { path: "/" } + context "and the config contains an encrypted drive" do + let(:config_json) do + { + storage: { + drives: [ + { + encryption: { + luks1: { password: "12345" } } - ] - } - ] + } + ] + } } - } + end + + it "returns nil" do + expect(subject.model_json).to be_nil + end end - it "returns the config model" do - expect(subject.model_json).to eq( + context "and the config contains an encrypted partition" do + let(:config_json) do { - boot: { - configure: true, - device: { - default: true, - name: "/dev/sda" - } - }, - drives: [ - { - name: "/dev/sda", - alias: "root", - spacePolicy: "keep", - partitions: [ - { - mountPath: "/", - filesystem: { - reuse: false, - default: true, - type: "ext4" - }, - size: { - default: true, - min: 0 - }, - delete: false, - deleteIfNeeded: false, - resize: false, - resizeIfNeeded: false - } - ] - } - ] + storage: { + drives: [ + { + partitions: [ + { + encryption: { + luks1: { password: "12345" } + } + } + ] + } + ] + } } - ) + end + + it "returns nil" do + expect(subject.model_json).to be_nil + end end - end - context "if an AutoYaST proposal has been calculated" do - before do - subject.calculate_from_json(autoyast_json) + 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 - let(:autoyast_json) do - { - legacyAutoyastStorage: [ - { device: "/dev/vda" } - ] - } + context "and the config has errors" do + let(:config_json) do + { + storage: { + drives: [ + { + search: "unknown" + } + ] + } + } + end + + it "returns the config model" do + expect(subject.model_json).to eq( + { + boot: { + configure: true, + device: { + default: true + } + }, + drives: [] + } + ) + end end - it "returns nil" do - expect(subject.model_json).to be_nil + context "and the config has not errors" do + let(:config_json) do + { + storage: { + drives: [ + { + alias: "root", + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ] + } + } + end + + it "returns the config model" do + expect(subject.model_json).to eq( + { + boot: { + configure: true, + device: { + default: true, + name: "/dev/sda" + } + }, + drives: [ + { + name: "/dev/sda", + alias: "root", + 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 end end end diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 91907b490b..b2355c2ff2 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -23,8 +23,6 @@ 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. @@ -54,37 +52,13 @@ const fetchUsableDevices = (): Promise => get(`/api/storage/proposal/u const fetchProductParams = (): Promise => get("/api/storage/product/params"); -const fetchDefaultVolume = (mountPath: string): Promise => { +const fetchVolume = (mountPath: string): Promise => { const path = encodeURIComponent(mountPath); 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 fetchVolumes = (mountPaths: string[]): Promise => + Promise.all(mountPaths.map(fetchVolume)); const fetchActions = (): Promise => get("/api/storage/devices/actions"); @@ -109,8 +83,7 @@ export { solveConfigModel, fetchUsableDevices, fetchProductParams, - fetchDefaultVolume, - fetchVolumeTemplates, + fetchVolumes, fetchActions, fetchStorageJobs, findStorageJob, diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index 8ff5222a4f..2954f7a179 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -23,9 +23,73 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import DriveEditor, { DriveEditorProps } from "~/components/storage/DriveEditor"; +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"; + +const volume1: Volume = { + mountPath: "/", + mountOptions: [], + target: "default", + fsType: "Btrfs", + minSize: 1024, + maxSize: 1024, + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: true, + fsTypes: ["Btrfs"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [], + adjustByRam: false, + }, +}; + +const volume2: Volume = { + mountPath: "swap", + mountOptions: [], + target: "default", + fsType: "Swap", + minSize: 1024, + maxSize: 1024, + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: ["Swap"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [], + adjustByRam: false, + }, +}; + +const volume3: Volume = { + mountPath: "/home", + mountOptions: [], + target: "default", + fsType: "XFS", + minSize: 1024, + maxSize: 1024, + autoSize: false, + snapshots: false, + transactional: false, + outline: { + required: false, + fsTypes: ["XFS"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [], + adjustByRam: false, + }, +}; const sda: StorageDevice = { sid: 59, @@ -49,10 +113,27 @@ const sda: StorageDevice = { description: "", }; -const mockDrive: ConfigModel.Drive = { +const sdb: StorageDevice = { + sid: 60, + isDrive: true, + type: "disk", + name: "/dev/sdb", + size: 1024, + description: "", +}; + +const drive1: ConfigModel.Drive = { name: "/dev/sda", spacePolicy: "delete", partitions: [ + { + mountPath: "/", + size: { + min: 1_000_000_000, + default: true, + }, + filesystem: { default: true, type: "btrfs" }, + }, { mountPath: "swap", size: { @@ -64,29 +145,41 @@ const mockDrive: ConfigModel.Drive = { ], }; +const drive2: ConfigModel.Drive = { + name: "/dev/sdb", + spacePolicy: "delete", + partitions: [ + { + mountPath: "/home", + size: { + min: 1_000_000_000, + default: true, + }, + filesystem: { default: true, type: "xfs" }, + }, + ], +}; + const mockDeleteDrive = jest.fn(); const mockDeletePartition = jest.fn(); jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), useAvailableDevices: () => [sda], + useVolume: (mountPath: string): Volume => + [volume1, volume2, volume3].find((v) => v.mountPath === mountPath), })); jest.mock("~/queries/storage/config-model", () => ({ ...jest.requireActual("~/queries/storage/config-model"), - useConfigModel: () => ({ drives: [mockDrive] }), + useConfigModel: () => ({ drives: [drive1, drive2] }), useDrive: () => ({ delete: mockDeleteDrive }), usePartition: () => ({ delete: mockDeletePartition }), })); -const props: DriveEditorProps = { - drive: mockDrive, - driveDevice: sda, -}; - describe("PartitionMenuItem", () => { - it("allows users to delete the partition", async () => { - const { user } = plainRender(); + it("allows users to delete a not required partition", async () => { + const { user } = plainRender(); const partitionsButton = screen.getByRole("button", { name: "Partitions" }); await user.click(partitionsButton); @@ -97,11 +190,23 @@ describe("PartitionMenuItem", () => { await user.click(deleteSwapButton); expect(mockDeletePartition).toHaveBeenCalled(); }); + + it("does not allow users to delete a required partition", async () => { + const { user } = plainRender(); + + const partitionsButton = screen.getByRole("button", { name: "Partitions" }); + await user.click(partitionsButton); + const partitionsMenu = screen.getByRole("menu"); + const deleteRootButton = within(partitionsMenu).queryByRole("menuitem", { + name: "Delete /", + }); + expect(deleteRootButton).not.toBeInTheDocument(); + }); }); describe("RemoveDriveOption", () => { - it("allows users to delete the drive", async () => { - const { user } = plainRender(); + it("allows users to delete a drive which does not contain root", async () => { + const { user } = plainRender(); const driveButton = screen.getByRole("button", { name: "Drive" }); await user.click(driveButton); @@ -112,4 +217,16 @@ describe("RemoveDriveOption", () => { await user.click(deleteDriveButton); expect(mockDeleteDrive).toHaveBeenCalled(); }); + + it("does not allow users to delete a drive which contains root", async () => { + const { user } = plainRender(); + + const driveButton = screen.getByRole("button", { name: "Drive" }); + await user.click(driveButton); + const drivesMenu = screen.getByRole("menu"); + const deleteDriveButton = within(drivesMenu).queryByRole("menuitem", { + name: /Do not use/, + }); + expect(deleteDriveButton).not.toBeInTheDocument(); + }); }); diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index d77b275206..e66052ca03 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -25,7 +25,7 @@ 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 } from "~/queries/storage"; +import { useAvailableDevices, useVolume } from "~/queries/storage"; import { configModel } from "~/api/storage/types"; import { StorageDevice } from "~/types/storage"; import { STORAGE as PATHS } from "~/routes/paths"; @@ -552,6 +552,8 @@ const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { const PartitionMenuItem = ({ driveName, mountPath, description }) => { const { delete: deletePartition } = usePartition(driveName, mountPath); + const volume = useVolume(mountPath); + const isRequired = volume.outline?.required || false; return ( { actionId={`edit-${mountPath}`} aria-label={`Edit ${mountPath}`} /> - } - actionId={`delete-${mountPath}`} - aria-label={`Delete ${mountPath}`} - onClick={deletePartition} - /> + {!isRequired && ( + } + actionId={`delete-${mountPath}`} + aria-label={`Delete ${mountPath}`} + onClick={deletePartition} + /> + )} } > diff --git a/web/src/components/storage/PartitionPage.test.tsx b/web/src/components/storage/PartitionPage.test.tsx index cf81997cf0..ca747ac661 100644 --- a/web/src/components/storage/PartitionPage.test.tsx +++ b/web/src/components/storage/PartitionPage.test.tsx @@ -24,8 +24,8 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender, mockParams } from "~/test-utils"; import PartitionPage from "./PartitionPage"; -import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; -import { configModel } from "~/api/storage/types"; +import { StorageDevice } from "~/types/storage"; +import { configModel, Volume } from "~/api/storage/types"; import { gib } from "./utils"; jest.mock("~/queries/issues", () => ({ @@ -105,7 +105,8 @@ const mockSolvedConfigModel: configModel.Config = { const homeVolumeMock: Volume = { mountPath: "/home", - target: VolumeTarget.DEFAULT, + mountOptions: [], + target: "default", fsType: "Btrfs", minSize: 1024, maxSize: 1024, @@ -120,7 +121,6 @@ const homeVolumeMock: Volume = { snapshotsAffectSizes: false, sizeRelevantVolumes: [], adjustByRam: false, - productDefined: false, }, }; diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 815fed3c41..179a906245 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -52,11 +52,11 @@ import { useSolvedConfigModel, addPartition, } from "~/queries/storage/config-model"; -import { StorageDevice, Volume } from "~/types/storage"; +import { StorageDevice } from "~/types/storage"; import { baseName, deviceSize, parseToBytes } from "~/components/storage/utils"; import { _, formatList } from "~/i18n"; import { sprintf } from "sprintf-js"; -import { configModel } from "~/api/storage/types"; +import { configModel, Volume } from "~/api/storage/types"; import { STORAGE as PATHS } from "~/routes/paths"; import { compact } from "~/utils"; diff --git a/web/src/components/storage/ProposalPage.test.tsx b/web/src/components/storage/ProposalPage.test.tsx index 9b6901952a..ce0b3dc426 100644 --- a/web/src/components/storage/ProposalPage.test.tsx +++ b/web/src/components/storage/ProposalPage.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2024] SUSE LLC + * Copyright (c) [2022-2025] SUSE LLC * * All Rights Reserved. * @@ -29,38 +29,10 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import ProposalPage from "~/components/storage/ProposalPage"; -import { Action, StorageDevice, Volume, VolumeTarget } from "~/types/storage"; +import { StorageDevice } from "~/types/storage"; +import { Issue } from "~/types/issues"; -jest.mock("~/queries/issues", () => ({ - ...jest.requireActual("~/queries/issues"), - useIssuesChanges: jest.fn(), - useIssues: () => [], -})); - -jest.mock("./ProposalResultSection", () => () =>
result section
); -jest.mock("./ProposalTransactionalInfo", () => () =>
trasactional info
); - -const vda: StorageDevice = { - sid: 59, - type: "disk", - isDrive: true, - description: "", - vendor: "Micron", - model: "Micron 1100 SATA", - driver: ["ahci", "mmcblk"], - bus: "IDE", - transport: "usb", - dellBOSS: false, - sdCard: true, - active: true, - name: "/dev/vda", - size: 1e12, - systems: ["Windows 11", "openSUSE Leap 15.2"], - udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], - udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], -}; - -const vdb: StorageDevice = { +const disk: StorageDevice = { sid: 60, type: "disk", isDrive: true, @@ -69,55 +41,267 @@ const vdb: StorageDevice = { model: "Unknown", driver: ["ahci", "mmcblk"], bus: "IDE", - name: "/dev/vdb", + name: "/dev/vda", size: 1e6, }; -/** - * Returns a volume specification with the given path. - */ -const volume = (mountPath: string): Volume => { - return { - mountPath, - target: VolumeTarget.DEFAULT, - fsType: "Btrfs", - minSize: 1024, - maxSize: 1024, - autoSize: false, - snapshots: false, - transactional: false, - outline: { - required: false, - fsTypes: ["Btrfs"], - supportAutoSize: false, - snapshotsConfigurable: false, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [], - adjustByRam: false, - productDefined: false, - }, - }; +const systemError: Issue = { + description: "System error", + kind: "storage", + details: "", + source: 1, + severity: 1, }; -const mockActions: Action[] = []; +const configError: Issue = { + description: "Config error", + kind: "storage", + details: "", + source: 2, + severity: 1, +}; +const mockUseAvailableDevices = jest.fn(); +const mockUseResetConfigMutation = jest.fn(); +const mockUseDeprecated = jest.fn(); +const mockUseDeprecatedChanges = jest.fn(); +const mockUseReprobeMutation = jest.fn(); jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), - useDevices: () => [vda, vdb], - useAvailableDevices: () => [vda, vdb], - useVolumeDevices: () => [vda, vdb], - useVolumeTemplates: () => [volume("/")], - useProductParams: () => ({ - encryptionMethods: [], - mountPoints: ["/", "swap"], - }), - useActions: () => mockActions, - useDeprecated: () => false, - useDeprecatedChanges: jest.fn(), - useProposalMutation: jest.fn(), + useAvailableDevices: () => mockUseAvailableDevices(), + useResetConfigMutation: () => mockUseResetConfigMutation(), + useDeprecated: () => mockUseDeprecated(), + useDeprecatedChanges: () => mockUseDeprecatedChanges(), + useReprobeMutation: () => mockUseReprobeMutation(), })); -it("renders the device, settings and result sections", () => { - installerRender(); - screen.findByText("Device"); +const mockUseConfigModel = jest.fn(); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => mockUseConfigModel(), +})); + +const mockUseZFCPSupported = jest.fn(); +jest.mock("~/queries/storage/zfcp", () => ({ + ...jest.requireActual("~/queries/storage/zfcp"), + useZFCPSupported: () => mockUseZFCPSupported(), +})); + +const mockUseDASDSupported = jest.fn(); +jest.mock("~/queries/storage/dasd", () => ({ + ...jest.requireActual("~/queries/storage/dasd"), + useDASDSupported: () => mockUseDASDSupported(), +})); + +const mockUseSystemErrors = jest.fn(); +const mockUseConfigErrors = jest.fn(); +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useSystemErrors: () => mockUseSystemErrors(), + useConfigErrors: () => mockUseConfigErrors(), +})); + +jest.mock("./ProposalTransactionalInfo", () => () =>
trasactional info
); +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("./ConfigEditorMenu", () => () =>
config editor menu
); +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
registration alert
+)); + +beforeEach(() => { + mockUseResetConfigMutation.mockReturnValue({ mutate: jest.fn() }); + mockUseReprobeMutation.mockReturnValue({ mutateAsync: jest.fn() }); + mockUseDeprecated.mockReturnValue(false); + mockUseSystemErrors.mockReturnValue([]); + mockUseConfigErrors.mockReturnValue([]); +}); + +describe("if there are not devices", () => { + beforeEach(() => { + mockUseAvailableDevices.mockReturnValue([]); + }); + + it("renders an option for activating iSCSI", () => { + installerRender(); + expect(screen.queryByRole("link", { name: /iSCSI/ })).toBeInTheDocument(); + }); + + it("does not render the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).not.toBeInTheDocument(); + }); + + it("does not render the result", () => { + installerRender(); + expect(screen.queryByText("result")).not.toBeInTheDocument(); + }); + + describe("if zFCP is not supported", () => { + beforeEach(() => { + mockUseZFCPSupported.mockReturnValue(false); + }); + + it("does not render an option for activating zFCP", () => { + installerRender(); + expect(screen.queryByRole("link", { name: /zFCP/ })).not.toBeInTheDocument(); + }); + }); + + describe("if DASD is not supported", () => { + beforeEach(() => { + mockUseDASDSupported.mockReturnValue(false); + }); + + it("does not render an option for activating DASD", () => { + installerRender(); + expect(screen.queryByRole("link", { name: /DASD/ })).not.toBeInTheDocument(); + }); + }); + + describe("if zFCP is supported", () => { + beforeEach(() => { + mockUseZFCPSupported.mockReturnValue(true); + }); + + it("renders an option for activating zFCP", () => { + installerRender(); + expect(screen.queryByRole("link", { name: /zFCP/ })).toBeInTheDocument(); + }); + }); + + describe("if DASD is supported", () => { + beforeEach(() => { + mockUseDASDSupported.mockReturnValue(true); + }); + + it("renders an option for activating DASD", () => { + installerRender(); + expect(screen.queryByRole("link", { name: /DASD/ })).toBeInTheDocument(); + }); + }); +}); + +describe("if there is not a model", () => { + beforeEach(() => { + mockUseAvailableDevices.mockReturnValue([disk]); + mockUseConfigModel.mockReturnValue(null); + }); + + describe("and there are system errors", () => { + beforeEach(() => { + mockUseSystemErrors.mockReturnValue([systemError]); + }); + + it("renders an option for resetting the config", () => { + installerRender(); + expect(screen.queryByRole("button", { name: /Reset/ })).toBeInTheDocument(); + }); + + it("does not render the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).not.toBeInTheDocument(); + }); + + it("does not render the result", () => { + installerRender(); + expect(screen.queryByText("result")).not.toBeInTheDocument(); + }); + }); + + describe("and there are not system errors", () => { + beforeEach(() => { + mockUseSystemErrors.mockReturnValue([]); + }); + + it("renders an unsupported model alert", async () => { + installerRender(); + expect(screen.queryByText("unsupported info")).toBeInTheDocument(); + }); + + it("does not render the installation devices", async () => { + installerRender(); + expect(screen.queryByText("installation devices")).not.toBeInTheDocument(); + }); + + it("renders the result", () => { + installerRender(); + expect(screen.queryByText("result")).toBeInTheDocument(); + }); + }); +}); + +describe("if there is a model", () => { + beforeEach(() => { + mockUseAvailableDevices.mockReturnValue([disk]); + mockUseConfigModel.mockReturnValue({ drives: [] }); + }); + + describe("and there are config errors and system errors", () => { + beforeEach(() => { + mockUseConfigErrors.mockReturnValue([configError]); + mockUseSystemErrors.mockReturnValue([systemError]); + }); + + it("renders the config errors", () => { + installerRender(); + expect(screen.queryByText("Config error")).toBeInTheDocument(); + }); + + it("renders an option for resetting the config", () => { + installerRender(); + expect(screen.queryByRole("button", { name: /Reset/ })).toBeInTheDocument(); + }); + + it("does not render the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).not.toBeInTheDocument(); + }); + + it("does not render the result", () => { + installerRender(); + expect(screen.queryByText("result")).not.toBeInTheDocument(); + }); + }); + + describe("and there are not config errors but there are system errors", () => { + beforeEach(() => { + mockUseSystemErrors.mockReturnValue([systemError]); + }); + + it("renders a failed proposal failed", () => { + installerRender(); + expect(screen.queryByText("failed info")).toBeInTheDocument(); + }); + + it("renders the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).toBeInTheDocument(); + }); + + it("does not render the result", () => { + installerRender(); + expect(screen.queryByText("result")).not.toBeInTheDocument(); + }); + }); + + describe("and there are neither config errors nor system errors", () => { + beforeEach(() => { + mockUseSystemErrors.mockReturnValue([]); + mockUseConfigErrors.mockReturnValue([]); + }); + + it("renders the installation devices", () => { + installerRender(); + expect(screen.queryByText("installation devices")).toBeInTheDocument(); + }); + + it("renders the result", () => { + installerRender(); + expect(screen.queryByText("result")).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index 9c3e058332..525fcc8392 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -21,24 +21,207 @@ */ import React from "react"; -import { Content, Grid, GridItem, SplitItem } from "@patternfly/react-core"; -import { Page } from "~/components/core/"; -import { Loading } from "~/components/layout"; -import EncryptionField from "~/components/storage/EncryptionField"; +import { + Button, + Content, + Grid, + GridItem, + Split, + SplitItem, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + List, + ListItem, +} from "@patternfly/react-core"; +import { Page, Link } from "~/components/core/"; +import { Icon, Loading } from "~/components/layout"; import ProposalResultSection from "./ProposalResultSection"; import ProposalTransactionalInfo from "./ProposalTransactionalInfo"; import ProposalFailedInfo from "./ProposalFailedInfo"; +import UnsupportedModelInfo from "./UnsupportedModelInfo"; import ConfigEditor from "./ConfigEditor"; import ConfigEditorMenu from "./ConfigEditorMenu"; import AddExistingDeviceMenu from "./AddExistingDeviceMenu"; -import { toValidationError } from "~/utils"; -import { useIssues } from "~/queries/issues"; -import { IssueSeverity } from "~/types/issues"; -import { useDeprecated, useDeprecatedChanges, useReprobeMutation } from "~/queries/storage"; -import { _ } from "~/i18n"; +import { + useAvailableDevices, + useResetConfigMutation, + useDeprecated, + useDeprecatedChanges, + useReprobeMutation, +} from "~/queries/storage"; +import { useConfigModel } from "~/queries/storage/config-model"; +import { useZFCPSupported } from "~/queries/storage/zfcp"; +import { useDASDSupported } from "~/queries/storage/dasd"; +import { useSystemErrors, useConfigErrors } from "~/queries/issues"; +import { STORAGE as PATHS } from "~/routes/paths"; +import { _, n_ } from "~/i18n"; -export default function ProposalPage() { +function InvalidConfigEmptyState(): React.ReactNode { + const errors = useConfigErrors("storage"); + const { mutate: reset } = useResetConfigMutation(); + + return ( + } + status="warning" + > + + + {n_( + "The current storage configuration has the following issue:", + "The current storage configuration has the following issues:", + errors.length, + )} + + + {errors.map((e, i) => ( + {e.description} + ))} + + + + + {_( + "You may want to discard those settings and start from scratch with a simple configuration.", + )} + + + + + ); +} + +function UnknowConfigEmptyState(): React.ReactNode { + const { mutate: reset } = useResetConfigMutation(); + + return ( + } + status="warning" + > + + + {_("The storage configuration uses elements not supported by this interface.")} + + + + + {_( + "You may want to discard the current settings and start from scratch with a simple configuration.", + )} + + + + + ); +} + +function UnavailableDevicesEmptyState(): React.ReactNode { + const isZFCPSupported = useZFCPSupported(); + const isDASDSupported = useDASDSupported(); + + const description = _( + "There are not disks available for the installation. You may need to configure some device.", + ); + + return ( + } + status="warning" + > + {description} + + + + + {_("Connect to iSCSI targets")} + + + {isZFCPSupported && ( + + + {_("Activate zFCP disks")} + + + )} + {isDASDSupported && ( + + + {_("Manage DASD devices")} + + + )} + + + + ); +} + +function ProposalEmptyState(): React.ReactNode { + const model = useConfigModel({ suspense: true }); + const availableDevices = useAvailableDevices(); + + if (!availableDevices.length) return ; + + return model ? : ; +} + +function ProposalSections(): React.ReactNode { + const model = useConfigModel({ suspense: true }); + const systemErrors = useSystemErrors("storage"); + const hasResult = !systemErrors.length; + + return ( + + + + + {model && ( + + + + + + + + + + + } + > + + + + )} + {hasResult && } + + ); +} + +/** + * @fixme Extract components like ProposalSections, UnknownConfigEmptyState, etc, to separate files + * and test them individually. The proposal page should simply mount all those components. + */ +export default function ProposalPage(): React.ReactNode { const isDeprecated = useDeprecated(); + const model = useConfigModel({ suspense: true }); + const availableDevices = useAvailableDevices(); + const systemErrors = useSystemErrors("storage"); + const configErrors = useConfigErrors("storage"); const { mutateAsync: reprobe } = useReprobeMutation(); useDeprecatedChanges(); @@ -47,63 +230,26 @@ export default function ProposalPage() { if (isDeprecated) reprobe().catch(console.log); }, [isDeprecated, reprobe]); - const errors = useIssues("storage") - .filter((s) => s.severity === IssueSeverity.Error) - .map(toValidationError); - - const validProposal = errors.length === 0; - - if (isDeprecated) { - return ( - - - {_("Storage")} - - - - - - - ); - } + /** + * @fixme For now, a config model is only considered as editable if there is no config error. The + * UI for handling a model is not prepared yet to properly work with a model generated from a + * config with errors. Components like ConfigEditor should be adapted in order to properly manage + * those scenarios. + */ + const isModelEditable = model && !configErrors.length; + const hasDevices = !!availableDevices.length; + const hasResult = !systemErrors.length; + const showSections = hasDevices && (isModelEditable || hasResult); return ( {_("Storage")} - - - - - - - - - - - - - - - - } - > - - - - - - - {validProposal && } - + {isDeprecated && } + {!isDeprecated && !showSections && } + {!isDeprecated && showSections && } ); diff --git a/web/src/components/storage/ProposalResultTable.tsx b/web/src/components/storage/ProposalResultTable.tsx index 9352122d71..66e3cfc085 100644 --- a/web/src/components/storage/ProposalResultTable.tsx +++ b/web/src/components/storage/ProposalResultTable.tsx @@ -146,7 +146,7 @@ type ProposalResultTableProps = { */ export default function ProposalResultTable({ devicesManager }: ProposalResultTableProps) { const model = useConfigModel({ suspense: true }); - const devices = devicesManager.usedDevices(model.drives.map((d) => d.name)); + const devices = devicesManager.usedDevices(model?.drives.map((d) => d.name) || []); return ( ({ @@ -37,12 +37,13 @@ jest.mock("~/queries/software", () => ({ jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), - useVolumeTemplates: () => mockVolumes, + useVolumes: () => mockVolumes, })); const rootVolume: Volume = { mountPath: "/", - target: VolumeTarget.DEFAULT, + mountOptions: [], + target: "default", fsType: "Btrfs", minSize: 1024, maxSize: 2048, @@ -57,7 +58,6 @@ const rootVolume: Volume = { snapshotsAffectSizes: true, sizeRelevantVolumes: [], adjustByRam: false, - productDefined: true, }, }; diff --git a/web/src/components/storage/ProposalTransactionalInfo.tsx b/web/src/components/storage/ProposalTransactionalInfo.tsx index 2a5d3e9321..a0fcf86e7e 100644 --- a/web/src/components/storage/ProposalTransactionalInfo.tsx +++ b/web/src/components/storage/ProposalTransactionalInfo.tsx @@ -25,7 +25,7 @@ import { Alert } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { useProduct } from "~/queries/software"; -import { useVolumeTemplates } from "~/queries/storage"; +import { useVolumes } from "~/queries/storage"; import { isTransactionalSystem } from "~/components/storage/utils"; /** @@ -36,7 +36,7 @@ import { isTransactionalSystem } from "~/components/storage/utils"; */ export default function ProposalTransactionalInfo() { const { selectedProduct } = useProduct({ suspense: true }); - const volumes = useVolumeTemplates(); + const volumes = useVolumes(); if (!isTransactionalSystem(volumes)) return; diff --git a/web/src/components/storage/SnapshotsField.test.tsx b/web/src/components/storage/SnapshotsField.test.tsx deleted file mode 100644 index 372e81227b..0000000000 --- a/web/src/components/storage/SnapshotsField.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) [2024-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 from "react"; -import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import SnapshotsField, { SnapshotsFieldProps } from "~/components/storage/SnapshotsField"; -import { Volume, VolumeTarget } from "~/types/storage"; - -const rootVolume: Volume = { - mountPath: "/", - target: VolumeTarget.DEFAULT, - fsType: "Btrfs", - minSize: 1024, - autoSize: true, - snapshots: true, - transactional: false, - outline: { - required: true, - fsTypes: ["ext4", "btrfs"], - supportAutoSize: true, - snapshotsConfigurable: false, - snapshotsAffectSizes: true, - adjustByRam: false, - sizeRelevantVolumes: ["/home"], - productDefined: true, - }, -}; - -const onChangeFn = jest.fn(); - -let props: SnapshotsFieldProps; - -describe("SnapshotsField", () => { - it("reflects snapshots status", () => { - props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; - plainRender(); - const checkbox: HTMLInputElement = screen.getByRole("switch"); - expect(checkbox.value).toEqual("on"); - }); - - it("allows toggling snapshots status", async () => { - props = { rootVolume: { ...rootVolume, snapshots: true }, onChange: onChangeFn }; - const { user } = plainRender(); - const checkbox: HTMLInputElement = screen.getByRole("switch"); - await user.click(checkbox); - expect(onChangeFn).toHaveBeenCalledWith({ active: false }); - }); -}); diff --git a/web/src/components/storage/SnapshotsField.tsx b/web/src/components/storage/SnapshotsField.tsx deleted file mode 100644 index ed220e1bf4..0000000000 --- a/web/src/components/storage/SnapshotsField.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) [2024-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 from "react"; -import { Split, Switch } from "@patternfly/react-core"; -import { _ } from "~/i18n"; -import { hasFS } from "~/components/storage/utils"; -import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; -import { Volume } from "~/types/storage"; - -export type SnapshotsFieldProps = { - rootVolume: Volume; - onChange?: (config: SnapshotsConfig) => void; -}; - -export type SnapshotsConfig = { - active: boolean; -}; - -/** - * Allows to define snapshots enablement - * @component - */ -export default function SnapshotsField({ rootVolume, onChange }: SnapshotsFieldProps) { - const isChecked = hasFS(rootVolume, "Btrfs") && rootVolume.snapshots; - - const label = _("Use Btrfs snapshots for the root file system"); - - const switchState = () => { - if (onChange) onChange({ active: !isChecked }); - }; - - return ( - - -
-
{label}
-
- {_( - "Allows to boot to a previous version of the \ -system after configuration changes or software upgrades.", - )} -
-
-
- ); -} diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index a32e2891cb..d43d607484 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { Card, CardBody, Content, Form, Grid, GridItem } from "@patternfly/react-core"; +import { ActionGroup, Content, Form } from "@patternfly/react-core"; import { useNavigate, useParams } from "react-router-dom"; import { Page } from "~/components/core"; import { SpaceActionsTable } from "~/components/storage"; @@ -92,7 +92,6 @@ export default function SpacePolicySelection() { navigate(".."); }; - const xl2Columns = 6; const description = _( "Select what to do with each partition in order to find space for allocating the new system.", ); @@ -106,27 +105,17 @@ export default function SpacePolicySelection() {
- - {children.length > 0 && ( - - - - - - - - )} - + + + + +
- - - - ); } diff --git a/web/src/components/storage/UnsupportedModelInfo.test.tsx b/web/src/components/storage/UnsupportedModelInfo.test.tsx new file mode 100644 index 0000000000..434a0645e0 --- /dev/null +++ b/web/src/components/storage/UnsupportedModelInfo.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 UnsupportedModelInfo from "./UnsupportedModelInfo"; + +const mockUseResetConfigMutation = jest.fn(); +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useResetConfigMutation: () => mockUseResetConfigMutation(), +})); + +const mockUseConfigModel = jest.fn(); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useConfigModel: () => mockUseConfigModel(), +})); + +beforeEach(() => { + mockUseResetConfigMutation.mockReturnValue({ mutate: jest.fn() }); +}); + +describe("if there is not a model", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue(null); + }); + + it("renders an alert", () => { + plainRender(); + expect(screen.queryByText(/Unable to modify the settings/)).toBeInTheDocument(); + }); + + it("renders a button for resetting the config", () => { + plainRender(); + expect(screen.queryByRole("button", { name: /Reset/ })).toBeInTheDocument(); + }); +}); + +describe("if there is a model", () => { + beforeEach(() => { + mockUseConfigModel.mockReturnValue({ drives: [] }); + }); + + it("does not renders an alert", () => { + plainRender(); + expect(screen.queryByText(/settings cannot be edited/)).not.toBeInTheDocument(); + }); + + it("does not render a button for resetting the config", () => { + plainRender(); + expect(screen.queryByRole("button", { name: "Reset" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/storage/UnsupportedModelInfo.tsx b/web/src/components/storage/UnsupportedModelInfo.tsx new file mode 100644 index 0000000000..3fa20a2c27 --- /dev/null +++ b/web/src/components/storage/UnsupportedModelInfo.tsx @@ -0,0 +1,61 @@ +/* + * 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 { Alert, Button, Content, Stack, StackItem } from "@patternfly/react-core"; +import { _ } from "~/i18n"; +import { useConfigModel } from "~/queries/storage/config-model"; +import { useResetConfigMutation } from "~/queries/storage"; + +/** + * Info about unsupported model. + */ +export default function UnsupportedModelInfo(): React.ReactNode { + const model = useConfigModel({ suspense: true }); + const { mutate: reset } = useResetConfigMutation(); + + if (model) return null; + + return ( + + + + + {_( + "The storage configuration is valid (see result below) but uses elements not supported by this interface.", + )} + + + {_( + "You can proceed to install with the current settings or you may want to discard the configuration and start from scratch with a simple one.", + )} + + + + + + + + ); +} diff --git a/web/src/components/storage/utils.test.ts b/web/src/components/storage/utils.test.ts index cc1e091ab6..814571a1e1 100644 --- a/web/src/components/storage/utils.test.ts +++ b/web/src/components/storage/utils.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2024] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -20,7 +20,8 @@ * find current contact information at www.suse.com. */ -import { StorageDevice, Volume, VolumeTarget } from "~/types/storage"; +import { StorageDevice } from "~/types/storage"; +import { Volume } from "~/api/storage/types"; import { deviceSize, deviceBaseName, @@ -40,7 +41,8 @@ import { const volume = (properties: object = {}): Volume => { const testVolume: Volume = { mountPath: "/test", - target: VolumeTarget.DEFAULT, + mountOptions: [], + target: "default", fsType: "Btrfs", minSize: 1024, maxSize: 2048, @@ -55,7 +57,6 @@ const volume = (properties: object = {}): Volume => { snapshotsAffectSizes: false, sizeRelevantVolumes: [], adjustByRam: false, - productDefined: false, }, }; diff --git a/web/src/components/storage/utils.ts b/web/src/components/storage/utils.ts index 559f0cfd14..fc598ca03c 100644 --- a/web/src/components/storage/utils.ts +++ b/web/src/components/storage/utils.ts @@ -32,8 +32,8 @@ import xbytes from "xbytes"; import { _, N_ } from "~/i18n"; -import { PartitionSlot, StorageDevice, Volume } from "~/types/storage"; -import { configModel } from "~/api/storage/types"; +import { PartitionSlot, StorageDevice } from "~/types/storage"; +import { configModel, Volume } from "~/api/storage/types"; import { sprintf } from "sprintf-js"; /** diff --git a/web/src/queries/issues.ts b/web/src/queries/issues.ts index 1e87878319..41b5888f63 100644 --- a/web/src/queries/issues.ts +++ b/web/src/queries/issues.ts @@ -23,7 +23,7 @@ import React from "react"; import { useQueryClient, useSuspenseQueries, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { IssuesList, IssuesScope } from "~/types/issues"; +import { IssuesList, IssuesScope, IssueSeverity, IssueSource } from "~/types/issues"; import { fetchIssues } from "~/api/issues"; const scopesFromPath = { @@ -86,4 +86,26 @@ const useIssuesChanges = () => { }, [client, queryClient]); }; -export { useIssues, useAllIssues, useIssuesChanges }; +/** + * Returns the system errors for the given scope. + */ +const useSystemErrors = (scope: IssuesScope) => { + const issues = useIssues(scope); + + return issues + .filter((i) => i.severity === IssueSeverity.Error) + .filter((i) => i.source === IssueSource.System); +}; + +/** + * Returns the config errors for the given scope. + */ +const useConfigErrors = (scope: IssuesScope) => { + const issues = useIssues(scope); + + return issues + .filter((i) => i.severity === IssueSeverity.Error) + .filter((i) => i.source === IssueSource.Config); +}; + +export { useIssues, useAllIssues, useIssuesChanges, useSystemErrors, useConfigErrors }; diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index 9068153cd6..29e0277e16 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -26,16 +26,15 @@ import { fetchConfig, setConfig, fetchActions, - fetchVolumeTemplates, + fetchVolumes, fetchProductParams, fetchUsableDevices, reprobe, } from "~/api/storage"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { useInstallerClient } from "~/context/installer"; -import { config, ProductParams } from "~/api/storage/types"; -import { Action, StorageDevice, Volume } from "~/types/storage"; - +import { config, ProductParams, Volume } from "~/api/storage/types"; +import { Action, StorageDevice } from "~/types/storage"; import { QueryHookOptions } from "~/types/queries"; const configQuery = { @@ -44,30 +43,6 @@ const configQuery = { staleTime: Infinity, }; -const devicesQuery = (scope: "result" | "system") => ({ - queryKey: ["storage", "devices", scope], - queryFn: () => fetchDevices(scope), - staleTime: Infinity, -}); - -const usableDevicesQuery = { - queryKey: ["storage", "usableDevices"], - queryFn: fetchUsableDevices, - staleTime: Infinity, -}; - -const productParamsQuery = { - queryKey: ["storage", "encryptionMethods"], - queryFn: fetchProductParams, - staleTime: Infinity, -}; - -const volumeTemplatesQuery = { - queryKey: ["storage", "volumeTemplates"], - queryFn: fetchVolumeTemplates, - staleTime: Infinity, -}; - /** * Hook that returns the unsolved config. */ @@ -84,13 +59,35 @@ const useConfig = (options?: QueryHookOptions): config.Config => { const useConfigMutation = () => { const queryClient = useQueryClient(); const query = { - mutationFn: (config: config.Config) => setConfig(config), + mutationFn: async (config: config.Config) => await setConfig(config), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["storage"] }), }; return useMutation(query); }; +/** @todo Call to an API method to reset the default config instead of setting a config. */ +const useResetConfigMutation = () => { + const { mutate } = useConfigMutation(); + + return { + mutate: () => + mutate({ + drives: [ + { + partitions: [{ search: "*", delete: true }, { generate: "default" }], + }, + ], + }), + }; +}; + +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. * @@ -107,22 +104,40 @@ const useDevices = ( return data; }; -/** - * Hook that returns the list of available devices for installation. - */ -const useAvailableDevices = (): StorageDevice[] => { - const findDevice = (devices: StorageDevice[], sid: number) => { +const availableDevices = async (devices: StorageDevice[]): Promise => { + const findDevice = (devices: StorageDevice[], sid: number): StorageDevice | undefined => { const device = devices.find((d) => d.sid === sid); - if (device === undefined) console.warn("Device not found:", sid); return device; }; + const availableDevices = await fetchUsableDevices(); + + return availableDevices + .map((sid: number) => findDevice(devices, sid)) + .filter((d: StorageDevice | undefined) => d); +}; + +const availableDevicesQuery = (devices: StorageDevice[]) => ({ + queryKey: ["storage", "availableDevices"], + queryFn: () => availableDevices(devices), + staleTime: Infinity, +}); + +/** + * Hook that returns the list of available devices for installation. + */ +const useAvailableDevices = (): StorageDevice[] => { const devices = useDevices("system", { suspense: true }); - const { data } = useSuspenseQuery(usableDevicesQuery); + const { data } = useSuspenseQuery(availableDevicesQuery(devices)); + return data; +}; - return data.map((sid) => findDevice(devices, sid)).filter((d) => d); +const productParamsQuery = { + queryKey: ["storage", "encryptionMethods"], + queryFn: fetchProductParams, + staleTime: Infinity, }; /** @@ -134,19 +149,26 @@ const useProductParams = (options?: QueryHookOptions): ProductParams => { return data; }; +const volumesQuery = (mountPaths: string[]) => ({ + queryKey: ["storage", "volumes"], + queryFn: () => fetchVolumes(mountPaths), + staleTime: Infinity, +}); + /** - * Hook that returns the volume templates for the current product. + * Hook that returns the volumes for the current product. */ -const useVolumeTemplates = (): Volume[] => { - const { data } = useSuspenseQuery(volumeTemplatesQuery); +const useVolumes = (): Volume[] => { + const product = useProductParams({ suspense: true }); + const mountPoints = ["", ...product.mountPoints]; + const { data } = useSuspenseQuery(volumesQuery(mountPoints)); return data; }; function useVolume(mountPoint: string): Volume { - const volumes = useVolumeTemplates(); + const volumes = useVolumes(); const volume = volumes.find((v) => v.mountPath === mountPoint); const defaultVolume = volumes.find((v) => v.mountPath === ""); - return volume || defaultVolume; } @@ -179,7 +201,7 @@ const useVolumeDevices = (): StorageDevice[] => { return [...availableDevices, ...mds, ...vgs]; }; -const proposalActionsQuery = { +const actionsQuery = { queryKey: ["storage", "devices", "actions"], queryFn: fetchActions, }; @@ -187,8 +209,8 @@ const proposalActionsQuery = { /** * Hook that returns the actions to perform in the storage devices. */ -const useActions = (): Action[] | undefined => { - const { data } = useSuspenseQuery(proposalActionsQuery); +const useActions = (): Action[] => { + const { data } = useSuspenseQuery(actionsQuery); return data; }; @@ -241,10 +263,11 @@ const useReprobeMutation = () => { export { useConfig, useConfigMutation, + useResetConfigMutation, useDevices, useAvailableDevices, useProductParams, - useVolumeTemplates, + useVolumes, useVolume, useVolumeDevices, useActions, diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index 9644afc2b0..ab267f292d 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -22,10 +22,10 @@ import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { fetchConfigModel, setConfigModel, solveConfigModel } from "~/api/storage"; -import { configModel } from "~/api/storage/types"; +import { configModel, Volume } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; -import { SpacePolicyAction, Volume } from "~/types/storage"; -import { useVolumeTemplates } from "~/queries/storage"; +import { SpacePolicyAction } from "~/types/storage"; +import { useVolumes } from "~/queries/storage"; function copyModel(model: configModel.Config): configModel.Config { return JSON.parse(JSON.stringify(model)); @@ -415,7 +415,7 @@ export type ModelHook = { export function useModel(): ModelHook { const model = useConfigModel(); const { mutate } = useConfigModelMutation(); - const volumes = useVolumeTemplates(); + const volumes = useVolumes(); return { model, diff --git a/web/src/queries/storage/dasd.ts b/web/src/queries/storage/dasd.ts index e044ba0ad6..85eab88388 100644 --- a/web/src/queries/storage/dasd.ts +++ b/web/src/queries/storage/dasd.ts @@ -28,6 +28,7 @@ import { enableDiag, fetchDASDDevices, formatDASD, + supportedDASD, } from "~/api/storage/dasd"; import { useInstallerClient } from "~/context/installer"; import React from "react"; @@ -51,6 +52,19 @@ const useDASDDevices = () => { return devices.map((d) => ({ ...d, hexId: hex(d.id) })); }; +const dasdSupportedQuery = { + queryKey: ["dasd", "supported"], + queryFn: supportedDASD, +}; + +/** + * Hook that returns whether DASD is supported. + */ +const useDASDSupported = (): boolean => { + const { data: supported } = useSuspenseQuery(dasdSupportedQuery); + return supported; +}; + /** * Returns a query for retrieving the running dasd format jobs */ @@ -239,6 +253,7 @@ const useFormatDASDMutation = () => { export { useDASDDevices, + useDASDSupported, useDASDDevicesChanges, useDASDFormatJobChanges, useDASDRunningFormatJobs, diff --git a/web/src/queries/storage/zfcp.ts b/web/src/queries/storage/zfcp.ts index b8c180c0e6..294fde95b8 100644 --- a/web/src/queries/storage/zfcp.ts +++ b/web/src/queries/storage/zfcp.ts @@ -70,12 +70,13 @@ const useZFCPDisks = (): ZFCPDisk[] => { }; /** - * Hook that returns zFCP config. + * Hook that returns whether zFCP is supported. */ const useZFCPSupported = (): boolean => { const { data: supported } = useSuspenseQuery(zfcpSupportedQuery); return supported; }; + /** * Hook that returns zFCP config. */ diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index ebba8e7724..6167e4591a 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -97,43 +97,6 @@ type SpacePolicyAction = { value: "delete" | "resizeIfNeeded"; }; -type Volume = { - mountPath: string; - target: VolumeTarget; - targetDevice?: StorageDevice; - fsType: string; - minSize: number; - maxSize?: number; - autoSize: boolean; - snapshots: boolean; - transactional: boolean; - outline: VolumeOutline; -}; - -type VolumeOutline = { - required: boolean; - productDefined: boolean; - fsTypes: string[]; - adjustByRam: boolean; - supportAutoSize: boolean; - snapshotsConfigurable: boolean; - snapshotsAffectSizes: boolean; - sizeRelevantVolumes: string[]; -}; - -/** - * Enum for the possible volume targets. - * - * @readonly - */ -enum VolumeTarget { - DEFAULT = "default", - NEW_PARTITION = "new_partition", - NEW_VG = "new_vg", - DEVICE = "device", - FILESYSTEM = "filesystem", -} - /** * Enum for the encryption method values * @@ -173,8 +136,6 @@ export type { ShrinkingInfo, SpacePolicyAction, StorageDevice, - Volume, - VolumeOutline, }; -export { EncryptionMethods, VolumeTarget }; +export { EncryptionMethods };