From 5fc7c3a16f4a6387444ebedc3a9d9e64b8727314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Fri, 27 Mar 2026 07:17:30 +0000 Subject: [PATCH 01/28] Update openapi types --- web/src/openapi/config/storage.ts | 66 ++++++++++++++++++++++++- web/src/openapi/storage/config-model.ts | 9 +++- web/src/openapi/system/storage.ts | 4 ++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/web/src/openapi/config/storage.ts b/web/src/openapi/config/storage.ts index e20ca9b81e..3f3402c22c 100644 --- a/web/src/openapi/config/storage.ts +++ b/web/src/openapi/config/storage.ts @@ -131,6 +131,15 @@ export type DeletePartitionSearch = SearchAll | SearchName | DeletePartitionAdva * Device base name. */ export type BaseName = string; +export type VolumeGroupSearch = SearchAll | SearchName | VolumeGroupAdvancedSearch; +export type VolumeGroupSearchCondition = SearchConditionName | SearchConditionSize; +export type VolumeGroupSearchSort = + | VolumeGroupSearchSortCriterion + | VolumeGroupSearchSortCriterion[]; +export type VolumeGroupSearchSortCriterion = + | VolumeGroupSearchSortCriterionShort + | VolumeGroupSearchSortCriterionFull; +export type VolumeGroupSearchSortCriterionShort = "name" | "size"; export type PhysicalVolumeElement = | Alias | SimplePhysicalVolumesGenerator @@ -140,11 +149,23 @@ export type LogicalVolumeElement = | AdvancedLogicalVolumesGenerator | LogicalVolume | ThinPoolLogicalVolume - | ThinLogicalVolume; + | ThinLogicalVolume + | LogicalVolumeToDelete + | LogicalVolumeToDeleteIfNeeded; /** * Number of stripes. */ export type LogicalVolumeStripes = number; +export type LogicalVolumeSearch = SearchAll | SearchName | LogicalVolumeAdvancedSearch; +export type LogicalVolumeSearchCondition = SearchConditionName | SearchConditionSize; +export type LogicalVolumeSearchSort = + | LogicalVolumeSearchSortCriterion + | LogicalVolumeSearchSortCriterion[]; +export type LogicalVolumeSearchSortCriterion = + | LogicalVolumeSearchSortCriterionShort + | LogicalVolumeSearchSortCriterionFull; +export type LogicalVolumeSearchSortCriterionShort = "name" | "size" | "number"; +export type DeleteLogicalVolumeSearch = SearchAll | SearchName | DeleteLogicalVolumeAdvancedSearch; export type MdRaidElement = NonPartitionedMdRaid | PartitionedMdRaid; export type MdRaidSearch = SearchAll | SearchName | MdRaidAdvancedSearch; export type MdRaidSearchCondition = SearchConditionName | SearchConditionSize; @@ -392,6 +413,7 @@ export interface PartitionToDeleteIfNeeded { */ export interface VolumeGroup { name: BaseName; + search?: VolumeGroupSearch; extentSize?: SizeValue; /** * Devices to use as physical volumes. @@ -399,6 +421,16 @@ export interface VolumeGroup { physicalVolumes?: PhysicalVolumeElement[]; logicalVolumes?: LogicalVolumeElement[]; } +export interface VolumeGroupAdvancedSearch { + condition?: VolumeGroupSearchCondition; + sort?: VolumeGroupSearchSort; + max?: SearchMax; + ifNotFound?: SearchCreatableActions; +} +export interface VolumeGroupSearchSortCriterionFull { + name?: SearchSortCriterionOrder; + size?: SearchSortCriterionOrder; +} /** * Automatically creates the needed physical volumes in the indicated devices. */ @@ -427,12 +459,23 @@ export interface AdvancedLogicalVolumesGenerator { } export interface LogicalVolume { name?: BaseName; + search?: LogicalVolumeSearch; size?: Size; stripes?: LogicalVolumeStripes; stripeSize?: SizeValue; encryption?: Encryption; filesystem?: Filesystem; } +export interface LogicalVolumeAdvancedSearch { + condition?: LogicalVolumeSearchCondition; + sort?: LogicalVolumeSearchSort; + max?: SearchMax; + ifNotFound?: SearchCreatableActions; +} +export interface LogicalVolumeSearchSortCriterionFull { + name?: SearchSortCriterionOrder; + size?: SearchSortCriterionOrder; +} export interface ThinPoolLogicalVolume { /** * LVM thin pool. @@ -452,6 +495,27 @@ export interface ThinLogicalVolume { encryption?: Encryption; filesystem?: Filesystem; } +export interface LogicalVolumeToDelete { + search: DeleteLogicalVolumeSearch; + /** + * Delete the logical volume. + */ + delete: true; +} +export interface DeleteLogicalVolumeAdvancedSearch { + condition?: LogicalVolumeSearchCondition; + sort?: LogicalVolumeSearchSort; + max?: SearchMax; + ifNotFound?: SearchActions; +} +export interface LogicalVolumeToDeleteIfNeeded { + search: DeleteLogicalVolumeSearch; + /** + * Delete the logical volume if needed to make space. + */ + deleteIfNeeded: true; + size?: Size; +} /** * MD RAID without a partition table (e.g., directly formatted). */ diff --git a/web/src/openapi/storage/config-model.ts b/web/src/openapi/storage/config-model.ts index 2fa306bdb1..bbdb61e9af 100644 --- a/web/src/openapi/storage/config-model.ts +++ b/web/src/openapi/storage/config-model.ts @@ -89,16 +89,23 @@ export interface MdRaid { partitions?: Partition[]; } export interface VolumeGroup { + name?: string; vgName: string; extentSize?: number; targetDevices?: string[]; + spacePolicy?: SpacePolicy; logicalVolumes?: LogicalVolume[]; } export interface LogicalVolume { + name?: string; lvName?: string; mountPath?: string; filesystem?: Filesystem; - size?: Size; stripes?: number; stripeSize?: number; + size?: Size; + delete?: boolean; + deleteIfNeeded?: boolean; + resize?: boolean; + resizeIfNeeded?: boolean; } diff --git a/web/src/openapi/system/storage.ts b/web/src/openapi/system/storage.ts index 9058335e00..b8f7aa02ab 100644 --- a/web/src/openapi/system/storage.ts +++ b/web/src/openapi/system/storage.ts @@ -45,6 +45,10 @@ export interface System { * SIDs of the available MD RAIDs */ availableMdRaids?: number[]; + /** + * SIDs of the available LVM volume groups + */ + availableVolumeGroups?: number[]; /** * SIDs of the drives that are candidate for installation */ From 1639acec0430aba103deed635cf470e9b1d19c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Mon, 30 Mar 2026 08:18:05 +0100 Subject: [PATCH 02/28] Rename conversion methods --- .../storage/DeviceSelectorModal.tsx | 5 +- .../components/storage/NewVgMenuOption.tsx | 4 +- .../components/storage/SearchedDeviceMenu.tsx | 8 +-- web/src/components/storage/utils/device.tsx | 26 ++++++++- web/src/hooks/model/storage/config-model.ts | 32 +++++------ web/src/hooks/model/system/storage.ts | 9 ++- web/src/model/storage/config-model/drive.ts | 12 +--- .../storage/config-model/logical-volume.ts | 19 ++++--- web/src/model/storage/config-model/md-raid.ts | 12 +--- .../model/storage/config-model/partition.ts | 21 +++---- .../storage/config-model/partitionable.ts | 56 ++++++++++++++++++- .../storage/config-model/volume-group.ts | 41 ++------------ web/src/model/storage/device.ts | 4 ++ 13 files changed, 144 insertions(+), 105 deletions(-) diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index 9639ad8fc7..aa4a3a3c44 100644 --- a/web/src/components/storage/DeviceSelectorModal.tsx +++ b/web/src/components/storage/DeviceSelectorModal.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -46,7 +46,8 @@ type DeviceSelectorProps = { }; const size = (device: Storage.Device) => { - return deviceSize(device.block.size); + const bytes = device.volumeGroup?.size || device.block?.size || 0; + return deviceSize(bytes); }; const description = (device: Storage.Device) => { diff --git a/web/src/components/storage/NewVgMenuOption.tsx b/web/src/components/storage/NewVgMenuOption.tsx index c0486a430b..a3edd56dde 100644 --- a/web/src/components/storage/NewVgMenuOption.tsx +++ b/web/src/components/storage/NewVgMenuOption.tsx @@ -28,7 +28,7 @@ import { sprintf } from "sprintf-js"; import { _, n_, formatList } from "~/i18n"; import { useConfigModel, - useAddVolumeGroupFromPartitionable, + useConvertPartitionableToVolumeGroup, } from "~/hooks/model/storage/config-model"; import configModel from "~/model/storage/config-model"; import type { ConfigModel } from "~/model/storage/config-model"; @@ -37,7 +37,7 @@ export type NewVgMenuOptionProps = { device: ConfigModel.Drive | ConfigModel.MdR export default function NewVgMenuOption({ device }: NewVgMenuOptionProps): React.ReactNode { const config = useConfigModel(); - const convertToVg = useAddVolumeGroupFromPartitionable(); + const convertToVg = useConvertPartitionableToVolumeGroup(); if (device.filesystem) return; diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx index 97660e4910..71f118c020 100644 --- a/web/src/components/storage/SearchedDeviceMenu.tsx +++ b/web/src/components/storage/SearchedDeviceMenu.tsx @@ -26,8 +26,8 @@ import NewVgMenuOption from "./NewVgMenuOption"; import { useAvailableDevices } from "~/hooks/model/system/storage"; import { useConfigModel, - useAddDriveFromMdRaid, - useAddMdRaidFromDrive, + useConvertMdRaidToDrive, + useConvertDriveToMdRaid, } from "~/hooks/model/storage/config-model"; import { deviceBaseName, formattedPath } from "~/components/storage/utils"; import configModel from "~/model/storage/config-model"; @@ -324,8 +324,8 @@ export default function SearchedDeviceMenu({ deleteFn, }: SearchedDeviceMenuProps): React.ReactNode { const [isSelectorOpen, setIsSelectorOpen] = useState(false); - const switchToDrive = useAddDriveFromMdRaid(); - const switchToMdRaid = useAddMdRaidFromDrive(); + const switchToDrive = useConvertMdRaidToDrive(); + const switchToMdRaid = useConvertDriveToMdRaid(); const changeTargetFn = (device: Storage.Device) => { const hook = isDrive(device) ? switchToDrive : switchToMdRaid; hook(modelDevice.name, { name: device.name }); diff --git a/web/src/components/storage/utils/device.tsx b/web/src/components/storage/utils/device.tsx index b524b885a2..8456cf7424 100644 --- a/web/src/components/storage/utils/device.tsx +++ b/web/src/components/storage/utils/device.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2026] SUSE LLC * * All Rights Reserved. * @@ -24,6 +24,8 @@ import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; import { compact } from "~/utils"; import type { Storage as System } from "~/model/system"; +import { isEmpty } from "radashi"; +import { baseName } from "~/components/storage/utils"; const driveTypeDescription = (device: System.Device): string => { if (device.drive.type === "multipath") { @@ -61,12 +63,28 @@ const typeDescription = (device: System.Device): string | undefined => { } case "drive": { type = driveTypeDescription(device); + break; + } + case "volumeGroup": { + type = device.description; } } return type; }; +/** + * Description of the content of a LVM volume group. + */ +const volumeGroupContentDescription = (device: System.Device): string => { + if (isEmpty(device.logicalVolumes)) return _("No content found"); + + const lv_names = device.logicalVolumes.map((l) => baseName(l.name)); + if (lv_names.length === 1) return sprintf(_("Volume %s"), lv_names[0]); + + return sprintf(_("Volumes %s"), lv_names.join(", ")); +}; + /* * Description of the device. * @@ -74,6 +92,8 @@ const typeDescription = (device: System.Device): string | undefined => { * device.description (comes from YaST) to be way more granular */ const contentDescription = (device: System.Device): string => { + if (device.class === "volumeGroup") return volumeGroupContentDescription(device); + if (device.partitionTable) { const type = device.partitionTable.type.toUpperCase(); const numPartitions = device.partitions.length; @@ -103,6 +123,10 @@ const filesystemLabels = (device: System.Device): string[] => { return compact(device.partitions.map((p) => p.filesystem?.label)); } + if (device.class === "volumeGroup") { + return compact(device.logicalVolumes.map((l) => l.filesystem?.label)); + } + const label = device.filesystem?.label; return label ? [label] : []; }; diff --git a/web/src/hooks/model/storage/config-model.ts b/web/src/hooks/model/storage/config-model.ts index 70e26ff921..ae91c1520a 100644 --- a/web/src/hooks/model/storage/config-model.ts +++ b/web/src/hooks/model/storage/config-model.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -129,12 +129,12 @@ function useDeleteDrive(): DeleteDriveFn { }; } -type AddDriveFromMdRaidFn = (oldName: string, drive: Data.Drive) => void; +type ConvertDriveToMdRaidFn = (name: string, mdRaidData: Data.MdRaid) => void; -function useAddDriveFromMdRaid(): AddDriveFromMdRaidFn { +function useConvertDriveToMdRaid(): ConvertDriveToMdRaidFn { const config = useConfigModel(); - return (oldName: string, drive: Data.Drive) => { - putStorageModel(configModel.drive.addFromMdRaid(config, oldName, drive)); + return (driveName: string, mdRaidData: Data.MdRaid) => { + putStorageModel(configModel.partitionable.convertToMdRaid(config, driveName, mdRaidData)); }; } @@ -168,12 +168,12 @@ function useDeleteMdRaid(): DeleteMdRaidFn { }; } -type AddMdRaidFromDriveFn = (oldName: string, raid: Data.MdRaid) => void; +type ConvertMdRaidToDriveFn = (name: string, driveData: Data.Drive) => void; -function useAddMdRaidFromDrive(): AddMdRaidFromDriveFn { +function useConvertMdRaidToDrive(): ConvertMdRaidToDriveFn { const config = useConfigModel(); - return (oldName: string, raid: Data.MdRaid) => { - putStorageModel(configModel.mdRaid.addFromDrive(config, oldName, raid)); + return (name: string, driveData: Data.Drive) => { + putStorageModel(configModel.partitionable.convertToDrive(config, name, driveData)); }; } @@ -214,12 +214,12 @@ function useDeleteVolumeGroup(): DeleteVolumeGroupFn { }; } -type AddVolumeGroupFromPartitionableFn = (driveName: string) => void; +type ConvertPartitionableToVolumeGroupFn = (name: string) => void; -function useAddVolumeGroupFromPartitionable(): AddVolumeGroupFromPartitionableFn { +function useConvertPartitionableToVolumeGroup(): ConvertPartitionableToVolumeGroupFn { const config = useConfigModel(); - return (driveName: string) => { - putStorageModel(configModel.volumeGroup.addFromPartitionable(config, driveName)); + return (name: string) => { + putStorageModel(configModel.partitionable.convertToVolumeGroup(config, name)); }; } @@ -339,16 +339,16 @@ export { useDrive, useAddDrive, useDeleteDrive, - useAddDriveFromMdRaid, + useConvertMdRaidToDrive, useMdRaid, useAddMdRaid, useDeleteMdRaid, - useAddMdRaidFromDrive, + useConvertDriveToMdRaid, useVolumeGroup, useAddVolumeGroup, useEditVolumeGroup, useDeleteVolumeGroup, - useAddVolumeGroupFromPartitionable, + useConvertPartitionableToVolumeGroup, useAddLogicalVolume, useEditLogicalVolume, useDeleteLogicalVolume, diff --git a/web/src/hooks/model/system/storage.ts b/web/src/hooks/model/system/storage.ts index 294585ef0c..3582ce86c2 100644 --- a/web/src/hooks/model/system/storage.ts +++ b/web/src/hooks/model/system/storage.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -52,6 +52,7 @@ const enum DeviceGroup { CandidateDrives = "candidateDrives", AvailableMdRaids = "availableMdRaids", CandidateMdRaids = "candidateMdRaids", + AvailableVolumeGroups = "availableVolumeGroups", } function selectDeviceGroups(data: System | null, groups: DeviceGroup[]): Storage.Device[] { @@ -117,7 +118,11 @@ function useCandidateMdRaids(): Storage.Device[] { } const selectAvailableDevices = (data: System | null): Storage.Device[] => - selectDeviceGroups(data, [DeviceGroup.AvailableDrives, DeviceGroup.AvailableMdRaids]); + selectDeviceGroups(data, [ + DeviceGroup.AvailableDrives, + DeviceGroup.AvailableMdRaids, + DeviceGroup.AvailableVolumeGroups, + ]); /** * Hook that returns the list of available devices for installation. diff --git a/web/src/model/storage/config-model/drive.ts b/web/src/model/storage/config-model/drive.ts index 68d3b22dd7..2b3b3f4e2a 100644 --- a/web/src/model/storage/config-model/drive.ts +++ b/web/src/model/storage/config-model/drive.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -35,16 +35,8 @@ function add(config: ConfigModel.Config, data: Data.Drive): ConfigModel.Config { return config; } -function addFromMdRaid( - config: ConfigModel.Config, - oldName: string, - drive: Data.Drive, -): ConfigModel.Config { - return configModel.partitionable.convert(config, oldName, drive.name, "drives"); -} - function remove(config: ConfigModel.Config, index: number): ConfigModel.Config { return configModel.partitionable.remove(config, "drives", index); } -export default { find, add, remove, addFromMdRaid }; +export default { find, add, remove }; diff --git a/web/src/model/storage/config-model/logical-volume.ts b/web/src/model/storage/config-model/logical-volume.ts index 573f8a254e..589b01a8f8 100644 --- a/web/src/model/storage/config-model/logical-volume.ts +++ b/web/src/model/storage/config-model/logical-volume.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -40,13 +40,6 @@ function create(data: Data.LogicalVolume): ConfigModel.LogicalVolume { }; } -function createFromPartition(partition: ConfigModel.Partition): ConfigModel.LogicalVolume { - return { - ...partition, - lvName: partition.mountPath ? generateName(partition.mountPath) : undefined, - }; -} - function add( config: ConfigModel.Config, vgName: string, @@ -106,4 +99,12 @@ function remove(config: ConfigModel.Config, vgName: string, mountPath: string): return config; } -export default { generateName, create, createFromPartition, add, edit, remove }; +function convertToPartition(lv: ConfigModel.LogicalVolume): ConfigModel.Partition { + return { + mountPath: lv.mountPath, + filesystem: lv.filesystem, + size: lv.size, + }; +} + +export default { generateName, create, add, edit, remove, convertToPartition }; diff --git a/web/src/model/storage/config-model/md-raid.ts b/web/src/model/storage/config-model/md-raid.ts index d60a997d46..afc2e573de 100644 --- a/web/src/model/storage/config-model/md-raid.ts +++ b/web/src/model/storage/config-model/md-raid.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -35,16 +35,8 @@ function add(config: ConfigModel.Config, data: Data.MdRaid): ConfigModel.Config return config; } -function addFromDrive( - config: ConfigModel.Config, - oldName: string, - raid: Data.MdRaid, -): ConfigModel.Config { - return configModel.partitionable.convert(config, oldName, raid.name, "mdRaids"); -} - function remove(config: ConfigModel.Config, index: number): ConfigModel.Config { return configModel.partitionable.remove(config, "mdRaids", index); } -export default { find, add, addFromDrive, remove }; +export default { find, add, remove }; diff --git a/web/src/model/storage/config-model/partition.ts b/web/src/model/storage/config-model/partition.ts index d5a363a775..1a4648831b 100644 --- a/web/src/model/storage/config-model/partition.ts +++ b/web/src/model/storage/config-model/partition.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -42,14 +42,6 @@ function create(data: Data.Partition): ConfigModel.Partition { }; } -function createFromLogicalVolume(lv: ConfigModel.LogicalVolume): ConfigModel.Partition { - return { - mountPath: lv.mountPath, - filesystem: lv.filesystem, - size: lv.size, - }; -} - /** * Adds a new partition. * @@ -124,6 +116,15 @@ function remove( return config; } +function convertToLogicalVolume(partition: ConfigModel.Partition): ConfigModel.LogicalVolume { + return { + ...partition, + lvName: partition.mountPath + ? configModel.logicalVolume.generateName(partition.mountPath) + : undefined, + }; +} + function isNew(partition: ConfigModel.Partition): boolean { return !partition.name; } @@ -142,10 +143,10 @@ function isUsedBySpacePolicy(partition: ConfigModel.Partition): boolean { export default { create, - createFromLogicalVolume, add, edit, remove, + convertToLogicalVolume, isNew, isUsed, isReused, diff --git a/web/src/model/storage/config-model/partitionable.ts b/web/src/model/storage/config-model/partitionable.ts index 6d34825371..42c10cce61 100644 --- a/web/src/model/storage/config-model/partitionable.ts +++ b/web/src/model/storage/config-model/partitionable.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -192,6 +192,55 @@ function convert( return config; } +function convertToDrive( + config: ConfigModel.Config, + name: string, + driveData: Data.Drive, +): ConfigModel.Config { + return convert(config, name, driveData.name, "drives"); +} + +function convertToMdRaid( + config: ConfigModel.Config, + name: string, + mdRaidData: Data.MdRaid, +): ConfigModel.Config { + return convert(config, name, mdRaidData.name, "mdRaids"); +} + +function convertPartitionsToLogicalVolumes( + device: ConfigModel.Drive | ConfigModel.MdRaid, + volumeGroup: ConfigModel.VolumeGroup, +) { + if (!device.partitions) return; + + const newPartitions = device.partitions.filter((p) => !p.name); + const reusedPartitions = device.partitions.filter((p) => p.name); + device.partitions = [...reusedPartitions]; + const logicalVolumes = volumeGroup.logicalVolumes || []; + volumeGroup.logicalVolumes = [ + ...logicalVolumes, + ...newPartitions.map(configModel.partition.convertToLogicalVolume), + ]; +} + +function convertToVolumeGroup(config: ConfigModel.Config, devName: string): ConfigModel.Config { + config = configModel.clone(config); + + const device = all(config).find((d) => d.name === devName); + if (!device) return config; + + const volumeGroup = configModel.volumeGroup.create({ + vgName: configModel.volumeGroup.generateName(config), + targetDevices: [devName], + }); + convertPartitionsToLogicalVolumes(device, volumeGroup); + config.volumeGroups ||= []; + config.volumeGroups.push(volumeGroup); + + return config; +} + function setActions(device: ConfigModel.Drive, actions: Data.SpacePolicyAction[]) { device.partitions ||= []; @@ -272,7 +321,10 @@ export default { isReusingPartitions, remove, removeIfUnused, - convert, + convertToDrive, + convertToMdRaid, + convertPartitionsToLogicalVolumes, + convertToVolumeGroup, setSpacePolicy, setFilesystem, }; diff --git a/web/src/model/storage/config-model/volume-group.ts b/web/src/model/storage/config-model/volume-group.ts index 44eb08a45c..b45b12b235 100644 --- a/web/src/model/storage/config-model/volume-group.ts +++ b/web/src/model/storage/config-model/volume-group.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -24,22 +24,6 @@ import { sift } from "radashi"; import configModel from "~/model/storage/config-model"; import type { ConfigModel, Data } from "~/model/storage/config-model"; -function movePartitions( - device: ConfigModel.Drive | ConfigModel.MdRaid, - volumeGroup: ConfigModel.VolumeGroup, -) { - if (!device.partitions) return; - - const newPartitions = device.partitions.filter((p) => !p.name); - const reusedPartitions = device.partitions.filter((p) => p.name); - device.partitions = [...reusedPartitions]; - const logicalVolumes = volumeGroup.logicalVolumes || []; - volumeGroup.logicalVolumes = [ - ...logicalVolumes, - ...newPartitions.map(configModel.logicalVolume.createFromPartition), - ]; -} - function adjustSpacePolicies(config: ConfigModel.Config, targets: string[]) { const devices = configModel.partitionable.all(config); devices @@ -92,7 +76,7 @@ function add( configModel.partitionable .all(config) .filter((d) => data.targetDevices.includes(d.name)) - .forEach((d) => movePartitions(d, volumeGroup)); + .forEach((d) => configModel.partitionable.convertPartitionsToLogicalVolumes(d, volumeGroup)); } config.volumeGroups ||= []; @@ -153,23 +137,6 @@ function generateName(config: ConfigModel.Config): string { return `system${Math.max(...numbers) + 1}`; } -function addFromPartitionable(config: ConfigModel.Config, devName: string): ConfigModel.Config { - config = configModel.clone(config); - - const device = configModel.partitionable.all(config).find((d) => d.name === devName); - if (!device) return config; - - const volumeGroup = create({ - vgName: generateName(config), - targetDevices: [devName], - }); - movePartitions(device, volumeGroup); - config.volumeGroups ||= []; - config.volumeGroups.push(volumeGroup); - - return config; -} - function convertToPartitionable(config: ConfigModel.Config, vgName: string): ConfigModel.Config { config = configModel.clone(config); @@ -187,13 +154,14 @@ function convertToPartitionable(config: ConfigModel.Config, vgName: string): Con const partitions = device.partitions || []; device.partitions = [ ...partitions, - ...logicalVolumes.map(configModel.partition.createFromLogicalVolume), + ...logicalVolumes.map(configModel.logicalVolume.convertToPartition), ]; return config; } export default { + generateName, create, usedMountPaths, findIndex, @@ -201,6 +169,5 @@ export default { add, edit, remove, - addFromPartitionable, convertToPartitionable, }; diff --git a/web/src/model/storage/device.ts b/web/src/model/storage/device.ts index 6925273503..27dc9ed0d2 100644 --- a/web/src/model/storage/device.ts +++ b/web/src/model/storage/device.ts @@ -22,6 +22,7 @@ import type { Storage as System } from "~/model/system"; import type { Storage as Proposal } from "~/model/proposal"; +import { flat, sift } from "radashi"; type Device = System.Device | Proposal.Device; @@ -46,6 +47,9 @@ function isLogicalVolume(device: Device): boolean { } function deviceSystems(device: Device): string[] { + if (device.class === "volumeGroup") + return sift(flat(device.logicalVolumes.map((l) => l.block.systems))); + return device.block?.systems || []; } From 69e36efffce7943be5e022c2508c58077c2c96f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Tue, 31 Mar 2026 08:50:03 +0100 Subject: [PATCH 03/28] wip: allow reusing volume groups --- .../storage/DeviceSelectorModal.tsx | 2 +- .../components/storage/ProposalFailedInfo.tsx | 14 +- .../components/storage/SearchedDeviceMenu.tsx | 232 ++++++++---- .../storage/SearchedVolumeGroupMenu.tsx | 333 ++++++++++++++++++ .../components/storage/VolumeGroupEditor.tsx | 70 +++- web/src/components/storage/utils.ts | 2 +- .../components/storage/utils/partition.tsx | 27 +- .../components/storage/utils/volume-group.tsx | 6 +- web/src/hooks/model/storage/config-model.ts | 45 ++- web/src/model/storage/config-model.ts | 88 ++++- .../storage/config-model/logical-volume.ts | 10 +- .../storage/config-model/partitionable.ts | 29 +- .../storage/config-model/volume-group.ts | 75 +++- 13 files changed, 776 insertions(+), 157 deletions(-) create mode 100644 web/src/components/storage/SearchedVolumeGroupMenu.tsx diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx index aa4a3a3c44..5bfbb12827 100644 --- a/web/src/components/storage/DeviceSelectorModal.tsx +++ b/web/src/components/storage/DeviceSelectorModal.tsx @@ -116,7 +116,7 @@ const DeviceSelector = ({ ); }; -type DeviceSelectorModalProps = Omit & { +export type DeviceSelectorModalProps = Omit & { selected?: Storage.Device; devices: Storage.Device[]; onConfirm: (selection: Storage.Device[]) => void; diff --git a/web/src/components/storage/ProposalFailedInfo.tsx b/web/src/components/storage/ProposalFailedInfo.tsx index 057bc43909..cb1218a6be 100644 --- a/web/src/components/storage/ProposalFailedInfo.tsx +++ b/web/src/components/storage/ProposalFailedInfo.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -31,17 +31,9 @@ const Description = () => { const model = useConfigModel(); const partitions = model.drives.flatMap((d) => d.partitions || []); const logicalVolumes = model.volumeGroups.flatMap((vg) => vg.logicalVolumes || []); - - const newPartitions = partitions.filter((p) => !p.name); - - // FIXME: Currently, it's not possible to reuse a logical volume, so all - // volumes are treated as new. This code cannot be made future-proof due to an - // internal decision not to expose unused properties, even though "#name" is - // used to infer whether a "device" is new or not. - // const newLogicalVolumes = logicalVolumes.filter((lv) => !lv.name); - const isBootConfigured = !!model.boot?.configure; - const mountPaths = [newPartitions, logicalVolumes] + const mountPaths = [...partitions, ...logicalVolumes] + .filter((p) => !p.name) .flat() .map((d) => partitionUtils.pathWithSize(d)); diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx index 71f118c020..3c0afd8ca0 100644 --- a/web/src/components/storage/SearchedDeviceMenu.tsx +++ b/web/src/components/storage/SearchedDeviceMenu.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -22,25 +22,44 @@ import React, { useState } from "react"; import MenuButton, { CustomToggleProps, MenuButtonItem } from "~/components/core/MenuButton"; -import NewVgMenuOption from "./NewVgMenuOption"; import { useAvailableDevices } from "~/hooks/model/system/storage"; -import { - useConfigModel, - useConvertMdRaidToDrive, - useConvertDriveToMdRaid, -} from "~/hooks/model/storage/config-model"; +import { useConfigModel, useConvertDevice } from "~/hooks/model/storage/config-model"; import { deviceBaseName, formattedPath } from "~/components/storage/utils"; import configModel from "~/model/storage/config-model"; import { sprintf } from "sprintf-js"; -import { _, formatList } from "~/i18n"; -import DeviceSelectorModal from "./DeviceSelectorModal"; +import { _, n_, formatList } from "~/i18n"; +import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal"; import { MenuItemProps } from "@patternfly/react-core"; -import { isDrive } from "~/model/storage/device"; +import { isVolumeGroup } from "~/model/storage/device"; +import { isEmpty } from "radashi"; import type { Storage } from "~/model/system"; import type { ConfigModel } from "~/model/storage/config-model"; +import type { DeviceSelectorModalProps } from "~/components/storage/DeviceSelectorModal"; const baseName = (device: Storage.Device): string => deviceBaseName(device, true); +const targetDevices = ( + deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid, + config: ConfigModel.Config, + availableDevices: Storage.Device[], +): Storage.Device[] => { + return availableDevices.filter((availableDevice) => { + if (deviceConfig.name === availableDevice.name) return true; + + const availableDeviceConfig = configModel.findDevice(config, availableDevice.name); + + if (deviceConfig.filesystem) { + if (isVolumeGroup(availableDevice)) return false; + if (!availableDeviceConfig) return true; + return !configModel.partitionable.isUsed(config, availableDeviceConfig.name); + } else { + if (!availableDeviceConfig) return true; + if ("filesystem" in availableDeviceConfig) return !availableDeviceConfig.filesystem; + return true; + } + }); +}; + const useOnlyOneOption = ( config: ConfigModel.Config, device: ConfigModel.Drive | ConfigModel.MdRaid, @@ -190,6 +209,31 @@ const ChangeDeviceDescription = ({ modelDevice, device }: ChangeDeviceDescriptio } }; +const ReuseVgTitle = () => _("Change to an existing LVM volume group"); + +type ReuseVgDescriptionProps = { + deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid; +}; + +const ReuseVgDescription = ({ deviceConfig }: ReuseVgDescriptionProps) => { + const paths = deviceConfig.partitions + .filter((p) => !p.name) + .map((p) => formattedPath(p.mountPath)); + + if (paths.length) { + return sprintf( + n_( + // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a + // single mount point in the singular case). + "%s will be created as a logical volume", + "%s will be created as logical volumes", + paths.length, + ), + formatList(paths), + ); + } +}; + type ChangeDeviceMenuItemProps = { modelDevice: ConfigModel.Drive | ConfigModel.MdRaid; device: Storage.Device; @@ -218,40 +262,53 @@ const ChangeDeviceMenuItem = ({ ); }; -type RemoveEntryOptionProps = { +type ReuseVgMenuItemProps = { + deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid; + device: Storage.Device; +} & MenuItemProps; + +const ReuseVgMenuItem = ({ + deviceConfig, + device, + ...props +}: ReuseVgMenuItemProps): React.ReactNode => { + const config = useConfigModel(); + + const volumeGroups = targetDevices(deviceConfig, useConfigModel(), useAvailableDevices()).filter( + isVolumeGroup, + ); + const onlyOneOption = useOnlyOneOption(config, deviceConfig); + const isUsed = configModel.partitionable.isAddingPartitions(deviceConfig); + + // Reusing a volume group is only offered if it makes sense, that is: the device can be changed, + // and there is some available volume group that can be selected as target (e.g., the device is + // not configured to be directly formatted), and the device is configuring any partition. + if (onlyOneOption || isEmpty(volumeGroups) || !isUsed) return; + + return ( + } + {...props} + > + + + ); +}; + +type RemoveDeviceMenuItemProps = { device: ConfigModel.Drive | ConfigModel.MdRaid; onClick: (device: ConfigModel.Drive | ConfigModel.MdRaid) => void; }; -const RemoveEntryOption = ({ device, onClick }: RemoveEntryOptionProps): React.ReactNode => { +const RemoveDeviceMenuItem = ({ device, onClick }: RemoveDeviceMenuItemProps): React.ReactNode => { const config = useConfigModel(); - /* - * Pretty artificial logic used to decide whether the UI should display buttons to remove - * some drives. - */ - const hasAdditionalDrives = (config: ConfigModel.Config): boolean => { - const entries = config.drives.concat(config.mdRaids); - - if (entries.length <= 1) return false; - if (entries.length > 2) return true; - - // If there are only two drives, the following logic avoids the corner case in which first - // deleting one of them and then changing the boot settings can lead to zero disks. But it is far - // from being fully reasonable or understandable for the user. - const onlyToBoot = entries.find( - (e) => - configModel.boot.hasExplicitDevice(config, e.name) && - !configModel.partitionable.isUsed(config, e.name), - ); - return !onlyToBoot; - }; - // When no additional drives has been added, the "Do not use" button can be confusing so it is // omitted for all drives. - if (!hasAdditionalDrives(config)) return; + if (!configModel.hasAdditionalDevices(config)) return; - let description; + let description: string; const isExplicitBoot = configModel.boot.hasExplicitDevice(config, device.name); const hasPv = configModel.isTargetDevice(config, device.name); const isDisabled = isExplicitBoot || hasPv; @@ -287,22 +344,62 @@ const RemoveEntryOption = ({ device, onClick }: RemoveEntryOptionProps): React.R ); }; -const targetDevices = ( - modelDevice: ConfigModel.Drive | ConfigModel.MdRaid, - config: ConfigModel.Config, - availableDevices: Storage.Device[], -): Storage.Device[] => { - return availableDevices.filter((availableDev) => { - if (modelDevice.name === availableDev.name) return true; +type SearchedDeviceSelectorModalProps = Omit; - const collection = isDrive(availableDev) ? config.drives : config.mdRaids; - const device = collection.find((d) => d.name === availableDev.name); - if (!device) return true; +const SearchedDeviceSelectorModal = ({ + device, + deviceConfig, + ...deviceSelectorModalProps +}: SearchedDeviceSelectorModalProps): React.ReactNode => { + const availableTargets = targetDevices(deviceConfig, useConfigModel(), useAvailableDevices()); + const devices = availableTargets.filter((d) => !isVolumeGroup(d)); - if (modelDevice.filesystem) return !configModel.partitionable.isUsed(config, device.name); + return ( + } + description={} + selected={device} + devices={devices} + /> + ); +}; - return !device.filesystem; - }); +type SearchedVolumeGroupSelectorModalProps = Omit; + +const SearchedVolumeGroupSelectorModal = ({ + device, + deviceConfig, + ...deviceSelectorModalProps +}: SearchedVolumeGroupSelectorModalProps): React.ReactNode => { + const volumeGroups = targetDevices(deviceConfig, useConfigModel(), useAvailableDevices()).filter( + isVolumeGroup, + ); + + return ( + } + description={} + selected={device} + devices={volumeGroups} + /> + ); +}; + +type SearchedDeviceSelectorProps = Omit & { + device: Storage.Device; + deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid; + reuseVg: boolean; +}; + +const SearchedDeviceSelector = ({ + reuseVg, + ...modalProps +}: SearchedDeviceSelectorProps): React.ReactNode => { + if (reuseVg) return ; + + return ; }; export type SearchedDeviceMenuProps = { @@ -324,17 +421,12 @@ export default function SearchedDeviceMenu({ deleteFn, }: SearchedDeviceMenuProps): React.ReactNode { const [isSelectorOpen, setIsSelectorOpen] = useState(false); - const switchToDrive = useConvertMdRaidToDrive(); - const switchToMdRaid = useConvertDriveToMdRaid(); - const changeTargetFn = (device: Storage.Device) => { - const hook = isDrive(device) ? switchToDrive : switchToMdRaid; - hook(modelDevice.name, { name: device.name }); - }; - const devices = targetDevices(modelDevice, useConfigModel(), useAvailableDevices()); + const [reuseVg, setReuseVg] = useState(false); + const convertDevice = useConvertDevice(); - const onDeviceChange = ([drive]: Storage.Device[]) => { + const onDeviceChange = ([device]: Storage.Device[]) => { setIsSelectorOpen(false); - changeTargetFn(drive); + convertDevice(selected.name, device.name); }; return ( @@ -350,18 +442,28 @@ export default function SearchedDeviceMenu({ key="change" modelDevice={modelDevice} device={selected} - onClick={() => setIsSelectorOpen(true)} + onClick={() => { + setReuseVg(false); + setIsSelectorOpen(true); + }} + />, + { + setReuseVg(true); + setIsSelectorOpen(true); + }} />, - , - , + , ]} /> {isSelectorOpen && ( - } - description={} - selected={selected} - devices={devices} + setIsSelectorOpen(false)} /> diff --git a/web/src/components/storage/SearchedVolumeGroupMenu.tsx b/web/src/components/storage/SearchedVolumeGroupMenu.tsx new file mode 100644 index 0000000000..ed0512f5ca --- /dev/null +++ b/web/src/components/storage/SearchedVolumeGroupMenu.tsx @@ -0,0 +1,333 @@ +/* + * Copyright (c) [2026] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import MenuButton, { CustomToggleProps, MenuButtonItem } from "~/components/core/MenuButton"; +import { useAvailableDevices } from "~/hooks/model/system/storage"; +import { + useConfigModel, + useConvertDevice, + useDeleteVolumeGroup, +} from "~/hooks/model/storage/config-model"; +import { formattedPath } from "~/components/storage/utils"; +import configModel from "~/model/storage/config-model"; +import { sprintf } from "sprintf-js"; +import { _, n_, formatList } from "~/i18n"; +import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal"; +import { MenuItemProps } from "@patternfly/react-core"; +import { isEmpty, isNullish } from "radashi"; +import { isVolumeGroup } from "~/model/storage/device"; +import type { Storage } from "~/model/system"; +import type { ConfigModel } from "~/model/storage/config-model"; +import type { DeviceSelectorModalProps } from "~/components/storage/DeviceSelectorModal"; + +/** + * Filters devices that can be selected as target for the volume group. + */ +const targetDevices = ( + config: ConfigModel.Config, + availableDevices: Storage.Device[], +): Storage.Device[] => { + return availableDevices.filter((availableDevice) => { + const availableDeviceConfig = configModel.findDevice(config, availableDevice.name); + + // Allow to select the available device if it is not configured yet. + if (isNullish(availableDeviceConfig)) return true; + + // The available device cannot be selected if it is configured to be formatted. + if ("filesystem" in availableDeviceConfig) return !availableDeviceConfig.filesystem; + + return true; + }); +}; + +const isUnchangeable = (deviceConfig: ConfigModel.VolumeGroup): boolean => { + return configModel.volumeGroup.isReusingLogicalVolumes(deviceConfig); +}; + +type ChangeVolumeGroupTitleProps = { + deviceConfig: ConfigModel.VolumeGroup; +}; + +const ChangeVolumeGroupTitle = ({ deviceConfig }: ChangeVolumeGroupTitleProps) => { + if (isUnchangeable(deviceConfig)) { + return _("Selected volume group cannot be changed"); + } + + const mountPaths = configModel.volumeGroup.usedMountPaths(deviceConfig); + const hasMountPaths = !isEmpty(mountPaths.length); + + if (!hasMountPaths) { + return _("Change the volume group to configure"); + } + + if (mountPaths.includes("/")) { + return _("Change the volume group to install the system"); + } + + const newMountPaths = deviceConfig.logicalVolumes + .filter((l) => !l.name) + .map((l) => formattedPath(l.mountPath)); + + return sprintf( + // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a + // single mount point in the singular case). + _("Change the volume group to create %s"), + formatList(newMountPaths), + ); +}; + +type ChangeVolumeGroupDescriptionProps = { + deviceConfig: ConfigModel.VolumeGroup; +}; + +const ChangeVolumeGroupDescription = ({ deviceConfig }: ChangeVolumeGroupDescriptionProps) => { + const isReusingLogicalVolumes = configModel.volumeGroup.isReusingLogicalVolumes(deviceConfig); + + if (isReusingLogicalVolumes) { + // The current volume group will be the only option to choose from + return _("This uses existing logical volumes at the volume group"); + } +}; + +const ReuseDriveTitle = () => _("Change to an existing disk"); + +type ReuseDriveDescriptionProps = { + deviceConfig: ConfigModel.VolumeGroup; +}; + +const ReuseDriveDescription = ({ deviceConfig }: ReuseDriveDescriptionProps) => { + const paths = deviceConfig.logicalVolumes + .filter((l) => !l.name) + .map((l) => formattedPath(l.mountPath)); + + if (paths.length) { + return sprintf( + n_( + // TRANSLATORS: %s is a list of formatted mount points like '"/", "/var" and "swap"' (or a + // single mount point in the singular case). + "%s will be created as a partition", + "%s will be created as partitions", + paths.length, + ), + formatList(paths), + ); + } +}; + +type ChangeVolumeGroupMenuItemProps = { + deviceConfig: ConfigModel.VolumeGroup; + device: Storage.Device; +} & MenuItemProps; + +const ChangeVolumeGroupMenuItem = ({ + deviceConfig, + device, + ...props +}: ChangeVolumeGroupMenuItemProps): React.ReactNode => { + const unchangeable = isUnchangeable(deviceConfig); + + return ( + } + isDisabled={unchangeable} + {...props} + > + + + ); +}; + +type ReuseDiskMenuItemProps = { + deviceConfig: ConfigModel.VolumeGroup; + device: Storage.Device; +} & MenuItemProps; + +const ReuseDriveMenuItem = ({ + deviceConfig, + device, + ...props +}: ReuseDiskMenuItemProps): React.ReactNode => { + if (isUnchangeable(deviceConfig)) return; + + return ( + } + {...props} + > + + + ); +}; + +type RemoveVolumeGroupMenuItemProps = { + deviceConfig: ConfigModel.VolumeGroup; +}; + +const RemoveVolumeGroupMenuItem = ({ + deviceConfig, +}: RemoveVolumeGroupMenuItemProps): React.ReactNode => { + const config = useConfigModel(); + const deleteVolumeGroup = useDeleteVolumeGroup(); + + // When no additional devices has been added, the "Do not use" button can be confusing so it is + // omitted for all volume groups. + if (!configModel.hasAdditionalDevices(config)) return; + + const deleteFn = () => deleteVolumeGroup(deviceConfig.vgName, false); + const description = _("Remove the configuration for this volume group"); + + return ( + + {_("Do not use")} + + ); +}; + +type SearchedVolumeGroupSelectorModalProps = Omit; + +const SearchedVolumeGroupSelectorModal = ({ + device, + deviceConfig, + ...deviceSelectorModalProps +}: SearchedVolumeGroupSelectorModalProps): React.ReactNode => { + const volumeGroups = targetDevices(useConfigModel(), useAvailableDevices()).filter(isVolumeGroup); + + return ( + } + description={} + selected={device} + devices={volumeGroups} + /> + ); +}; + +type SearchedDriveSelectorModalProps = Omit; + +const SearchedDriveSelectorModal = ({ + device, + deviceConfig, + ...deviceSelectorModalProps +}: SearchedDriveSelectorModalProps): React.ReactNode => { + const devices = targetDevices(useConfigModel(), useAvailableDevices()).filter( + (d) => !isVolumeGroup(d), + ); + + return ( + } + description={} + selected={device} + devices={devices} + /> + ); +}; + +type SearchedDeviceSelectorProps = Omit & { + device: Storage.Device; + deviceConfig: ConfigModel.VolumeGroup; + reuseDrive: boolean; +}; + +const SearchedDeviceSelector = ({ + reuseDrive, + ...modalProps +}: SearchedDeviceSelectorProps): React.ReactNode => { + if (reuseDrive) return ; + + return ; +}; + +export type SearchedVolumeGroupMenuProps = { + deviceConfig: ConfigModel.VolumeGroup; + device: Storage.Device; + toggle?: React.ReactElement; +}; + +/** + * Menu that provides options for users to configure the device used by a configuration + * entry that represents a volume group previously existing in the system. + */ +export default function SearchedVolumeGroupMenu({ + deviceConfig, + device, + toggle, +}: SearchedVolumeGroupMenuProps): React.ReactNode { + const [isSelectorOpen, setIsSelectorOpen] = useState(false); + const [reuseDrive, setReuseDrive] = useState(false); + const convertDevice = useConvertDevice(); + + const onDeviceChange = ([targetDevice]: Storage.Device[]) => { + setIsSelectorOpen(false); + convertDevice(device.name, targetDevice.name); + }; + + return ( + <> + { + setReuseDrive(false); + setIsSelectorOpen(true); + }} + />, + { + setReuseDrive(true); + setIsSelectorOpen(true); + }} + />, + , + ]} + /> + {isSelectorOpen && ( + setIsSelectorOpen(false)} + /> + )} + + ); +} diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 120f65ef87..694bb89ff9 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2025] SUSE LLC + * Copyright (c) [2025-2026] SUSE LLC * * All Rights Reserved. * @@ -50,7 +50,7 @@ import MenuButton, { CustomToggleProps } from "~/components/core/MenuButton"; import ConfigEditorItem from "~/components/storage/ConfigEditorItem"; import Icon, { IconProps } from "~/components/layout/Icon"; import { STORAGE as PATHS } from "~/routes/paths"; -import { baseName, formattedPath } from "~/components/storage/utils"; +import { baseName, deviceLabel, formattedPath } from "~/components/storage/utils"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { generateEncodedPath } from "~/utils"; import { isEmpty } from "radashi"; @@ -58,13 +58,16 @@ import { sprintf } from "sprintf-js"; import { _, n_, formatList } from "~/i18n"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import SearchedVolumeGroupMenu from "~/components/storage/SearchedVolumeGroupMenu"; import { useConfigModel, useDeleteVolumeGroup, useDeleteLogicalVolume, } from "~/hooks/model/storage/config-model"; import configModel from "~/model/storage/config-model"; +import { useDevice } from "~/hooks/model/system/storage"; import type { ConfigModel } from "~/model/storage/config-model"; +import type { Storage as System } from "~/model/system"; const DeleteVgOption = ({ vg }: { vg: ConfigModel.VolumeGroup }) => { const config = useConfigModel(); @@ -72,7 +75,7 @@ const DeleteVgOption = ({ vg }: { vg: ConfigModel.VolumeGroup }) => { const lvs = vg.logicalVolumes.map((lv) => formattedPath(lv.mountPath)); const targetDevices = configModel.volumeGroup.filterTargetDevices(config, vg); const convert = targetDevices.length === 1 && !!lvs.length; - let description; + let description: string; if (lvs.length) { if (convert) { @@ -190,19 +193,31 @@ const LvRow = ({ lv, vg }) => { ); }; -const VgHeader = ({ vg }: { vg: ConfigModel.VolumeGroup }) => { - const title = vg.logicalVolumes.length - ? _("Create LVM volume group %s") - : _("Empty LVM volume group %s"); +type VgHeaderProps = { + deviceConfig: ConfigModel.VolumeGroup; + device: System.Device; +}; + +const VgHeader = ({ deviceConfig, device }: VgHeaderProps) => { + let title: string; + + if (device) { + title = sprintf(_("Use LVM volume group %s"), deviceLabel(device, true)); + } else { + title = deviceConfig.logicalVolumes.length + ? _("Create LVM volume group %s") + : _("Empty LVM volume group %s"); + } - return {sprintf(title, vg.vgName)}; + return {sprintf(title, deviceConfig.vgName)}; }; type VgMenuToggleProps = CustomToggleProps & { - vg: ConfigModel.VolumeGroup; + deviceConfig: ConfigModel.VolumeGroup; + device?: System.Device; }; -const VgMenuToggle = forwardRef(({ vg, ...props }: VgMenuToggleProps, ref) => { +const VgMenuToggle = forwardRef(({ deviceConfig, device, ...props }: VgMenuToggleProps, ref) => { return ( - ); -}); + + {summary()} + + + + + + ); + }, +); type SpacePolicyMenuProps = { - collection: "drives" | "mdRaids"; + collection: "drives" | "mdRaids" | "volumeGroups"; index: number; }; export default function SpacePolicyMenu({ collection, index }: SpacePolicyMenuProps) { - const navigate = useNavigate(); - const setSpacePolicy = useSetSpacePolicy(); - const deviceModel = usePartitionable(collection, index); - const device = useDevice(deviceModel.name); - const existingPartitions = device.partitions?.length; + const deviceConfig = useDeviceConfig(collection, index); + const device = useDevice(deviceConfig.name); + const hasVolumes = device && !isEmpty(device.partitions || device.logicalVolumes || []); - if (isEmpty(existingPartitions)) return; + if (!hasVolumes) return; - const onSpacePolicyChange = (spacePolicy: ConfigModel.SpacePolicy) => { - if (spacePolicy === "custom") { - return navigate(generateEncodedPath(PATHS.editSpacePolicy, { collection, index })); - } else { - setSpacePolicy(collection, index, { type: spacePolicy }); + const policies = (): SpacePolicy[] => { + switch (collection) { + case "drives": + case "mdRaids": { + return PARTITIONABLE_SPACE_POLICIES; + } + case "volumeGroups": { + return VOLUME_GROUP_SPACE_POLICIES; + } } }; - const currentPolicy = driveUtils.spacePolicyEntry(deviceModel); - return ( ( - + items={policies().map((policy) => ( + ))} - customToggle={} + customToggle={} /> ); } diff --git a/web/src/components/storage/SpacePolicySelectionPage.tsx b/web/src/components/storage/SpacePolicySelectionPage.tsx index 00ceee64f8..1854fb3bcd 100644 --- a/web/src/components/storage/SpacePolicySelectionPage.tsx +++ b/web/src/components/storage/SpacePolicySelectionPage.tsx @@ -26,62 +26,64 @@ import { sprintf } from "sprintf-js"; import { useNavigate, useParams } from "react-router"; import { Page, SubtleContent } from "~/components/core"; import SpaceActionsTable, { SpacePolicyAction } from "~/components/storage/SpaceActionsTable"; -import { createPartitionableLocation, deviceChildren } from "~/components/storage/utils"; -import { useDevices } from "~/hooks/model/system/storage"; -import { usePartitionable, useSetSpacePolicy } from "~/hooks/model/storage/config-model"; +import { deviceChildren } from "~/components/storage/utils"; +import { useDevice } from "~/hooks/model/system/storage"; +import { + useDevice as useDeviceConfig, + useSetSpacePolicy, +} from "~/hooks/model/storage/config-model"; import { toDevice } from "~/components/storage/device-utils"; import { STORAGE } from "~/routes/paths"; import { _ } from "~/i18n"; +import configModel from "~/model/storage/config-model"; +import type { Storage as System } from "~/model/system"; +import type { DeviceCollection } from "~/model/storage/config-model"; +import { isVolumeGroup } from "~/model/storage/device"; -import type { Storage as Proposal } from "~/model/proposal"; -import type { ConfigModel, Partitionable } from "~/model/storage/config-model"; +type Action = "delete" | "resizeIfNeeded"; -const partitionAction = (partition: ConfigModel.Partition) => { - if (partition.delete) return "delete"; - if (partition.resizeIfNeeded) return "resizeIfNeeded"; - - return undefined; -}; - -function useDeviceModelFromParams(): Partitionable.Device | null { +function useDeviceParams(): [DeviceCollection, number] { const { collection, index } = useParams(); - const location = createPartitionableLocation(collection, index); - const deviceModel = usePartitionable(location.collection, location.index); - return deviceModel; + return [collection as DeviceCollection, Number(index)]; } /** * Renders a page that allows the user to select the space policy and actions. */ export default function SpacePolicySelectionPage() { - const deviceModel = useDeviceModelFromParams(); - const devices = useDevices(); - const device = devices.find((d) => d.name === deviceModel.name); - const children = deviceChildren(device); + const [collection, index] = useDeviceParams(); + const deviceConfig = useDeviceConfig(collection, index); + const device = useDevice(deviceConfig.name); const setSpacePolicy = useSetSpacePolicy(); - const { collection, index } = useParams(); + const navigate = useNavigate(); + + const children = deviceChildren(device); - const partitionDeviceAction = (device: Proposal.Device) => { - const partition = deviceModel.partitions?.find((p) => p.name === device.name); + const volumeDeviceAction = (volumeDevice: System.Device): Action | null => { + const volumeConfig = configModel.device + .volumes(deviceConfig) + .find((v) => v.name === volumeDevice.name); - return partition ? partitionAction(partition) : undefined; + if (!volumeConfig) return null; + + if (volumeConfig.delete) return "delete"; + + if (volumeConfig.resizeIfNeeded) return "resizeIfNeeded"; }; const [actions, setActions] = useState( children - .filter((d) => toDevice(d) && partitionDeviceAction(toDevice(d))) + .filter((d) => toDevice(d) && volumeDeviceAction(toDevice(d))) .map( - (d: Proposal.Device): SpacePolicyAction => ({ + (d: System.Device): SpacePolicyAction => ({ deviceName: toDevice(d).name, - value: partitionDeviceAction(toDevice(d)), + value: volumeDeviceAction(toDevice(d)), }), ), ); - const navigate = useNavigate(); - - const deviceAction = (device: Proposal.Device | Proposal.UnusedSlot) => { + const deviceAction = (device: System.Device | System.UnusedSlot) => { if (toDevice(device) === undefined) return "keep"; return actions.find((a) => a.deviceName === toDevice(device).name)?.value || "keep"; @@ -96,16 +98,22 @@ export default function SpacePolicySelectionPage() { const onSubmit = (e) => { e.preventDefault(); - const location = createPartitionableLocation(collection, index); - if (!location) return; - setSpacePolicy(location.collection, location.index, { type: "custom", actions }); + setSpacePolicy(collection, index, { type: "custom", actions }); navigate(".."); }; - const description = _( - "Select what to do with each partition in order to find space for allocating the new system.", - ); + const description = (): string => { + if (isVolumeGroup(device)) { + return _( + "Select what to do with each logical volume in order to find space for allocating the new system.", + ); + } + + return _( + "Select what to do with each partition in order to find space for allocating the new system.", + ); + }; return ( - {description} + {description()}
{ ); }; -const EditVgOption = ({ vg }: { vg: ConfigModel.VolumeGroup }) => { +const EditVgOption = ({ index }: { index: number }) => { const navigate = useNavigate(); return ( @@ -122,17 +124,18 @@ const EditVgOption = ({ vg }: { vg: ConfigModel.VolumeGroup }) => { itemId="edit-volume-group" description={_("Modify settings and physical volumes")} role="menuitem" - onClick={() => navigate(generateEncodedPath(PATHS.volumeGroup.edit, { id: vg.vgName }))} + onClick={() => navigate(generateEncodedPath(PATHS.volumeGroup.edit, { id: index }))} > {_("Edit volume group")} ); }; -const LvRow = ({ lv, vg }) => { +const LvRow = ({ index, lv }) => { const navigate = useNavigate(); + const vg = useVolumeGroup(index); const editPath = generateEncodedPath(PATHS.volumeGroup.logicalVolume.edit, { - id: vg.vgName, + id: index, logicalVolumeId: lv.mountPath, }); const deleteLogicalVolume = useDeleteLogicalVolume(); @@ -243,7 +246,9 @@ const VgMenuToggle = forwardRef(({ deviceConfig, device, ...props }: VgMenuToggl ); }); -const NewVgMenu = ({ deviceConfig }: { deviceConfig: ConfigModel.VolumeGroup }) => { +const NewVgMenu = ({ index }: { index: number }) => { + const deviceConfig = useVolumeGroup(index); + return ( } items={[ - , + , , ]} /> ); }; -const ReusedVgMenu = ({ deviceConfig }: { deviceConfig: ConfigModel.VolumeGroup }) => { +const ReusedVgMenu = ({ index }: { index: number }) => { + const deviceConfig = useVolumeGroup(index); const device = useDevice(deviceConfig.name); return ( @@ -270,13 +276,15 @@ const ReusedVgMenu = ({ deviceConfig }: { deviceConfig: ConfigModel.VolumeGroup ); }; -const VgMenu = ({ vg }: { vg: ConfigModel.VolumeGroup }) => { - return vg.name ? : ; +const VgMenu = ({ index }: { index: number }) => { + const vg = useVolumeGroup(index); + + return vg.name ? : ; }; -const AddLvButton = ({ vg }: { vg: ConfigModel.VolumeGroup }) => { +const AddLvButton = ({ index }: { index: number }) => { const navigate = useNavigate(); - const newLvPath = generateEncodedPath(PATHS.volumeGroup.logicalVolume.add, { id: vg.vgName }); + const newLvPath = generateEncodedPath(PATHS.volumeGroup.logicalVolume.add, { id: index }); return (