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 (
+ );
+ },
+);
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()}
}
+ title="Select"
onCancel={onCancelMock}
onConfirm={onConfirmMock}
/>,
);
+ screen.getByText("Introductory text");
+ });
- const table = screen.getByRole("grid");
- const sortByDeviceButton = within(table).getByRole("button", { name: "Device" });
+ describe("initial tab", () => {
+ it("opens the Disks tab by default", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "Disks" })).toHaveAttribute("aria-selected", "true");
+ });
- expect(getColumnValues(table, "Device")).toEqual(["/dev/sda", "/dev/sdb"]);
+ it("opens the tab matching initialTab", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true");
+ });
- await user.click(sortByDeviceButton);
+ it("opens the tab containing the selected device", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "LVM" })).toHaveAttribute("aria-selected", "true");
+ });
+ });
- expect(getColumnValues(table, "Device")).toEqual(["/dev/sdb", "/dev/sda"]);
+ describe("sideEffectsAlert", () => {
+ it("shows the disks alert in the footer when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ Disk selection note}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ screen.getByText("Disk selection note");
+ });
+
+ it("shows the RAID alert in the footer when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ RAID selection note}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ const mdRow = screen.getByRole("row", { name: /md0/ });
+ await user.click(within(mdRow).getByRole("radio"));
+ screen.getByText("RAID selection note");
+ });
+
+ it("shows the LVM alert in the footer when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ LVM selection note}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ const vgRow = screen.getByRole("row", { name: /vg0/ });
+ await user.click(within(vgRow).getByRole("radio"));
+ screen.getByText("LVM selection note");
+ });
+
+ it("does not show the alert when the selection matches the given device", () => {
+ installerRender(
+ Disk selection note}
+ title="Select"
+ onCancel={onCancelMock}
+ onConfirm={onConfirmMock}
+ />,
+ );
+ expect(screen.queryByText("Disk selection note")).toBeNull();
+ });
});
- it("allows sorting by device size", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("empty states", () => {
+ it("shows an empty state in the Disks tab when no disks are given", () => {
+ installerRender(
+ ,
+ );
+ screen.getByText("No disks found");
+ });
- const table = screen.getByRole("grid");
- const sortBySizeButton = within(table).getByRole("button", { name: "Size" });
+ it("shows an empty state in the RAID tab when no RAID devices are given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByText("No RAID devices found");
+ });
- // By default, table is sorted by device name. Switch sorting to size in asc direction
- await user.click(sortBySizeButton);
+ it("shows an empty state in the LVM tab when no volume groups are given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByText("No LVM volume groups found");
+ });
- expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]);
+ it("shows the create link in the empty LVM state when newVolumeGroupLinkText is given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByRole("link", { name: "Define a new LVM" });
+ });
- // Now keep sorting by size, but in desc direction
- await user.click(sortBySizeButton);
+ it("does not show a create link in the empty LVM state when newVolumeGroupLinkText is not given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ expect(screen.queryByRole("link", { name: /create/i })).toBeNull();
+ });
- expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]);
+ it("does not show a create link in the empty RAID state", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ expect(screen.queryByRole("link", { name: /create/i })).toBeNull();
+ });
});
- it("triggers onCancel callback when users selects `Cancel` action", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("LVM tab with volume groups", () => {
+ it("shows the create link when newVolumeGroupLinkText is given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ screen.getByRole("link", { name: "Define a new LVM" });
+ });
- const cancelAction = screen.getByRole("button", { name: "Cancel" });
- await user.click(cancelAction);
- expect(onCancelMock).toHaveBeenCalled();
+ it("does not show a create link when newVolumeGroupLinkText is not given", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "LVM" }));
+ expect(screen.queryByRole("link", { name: /create/i })).toBeNull();
+ });
});
- it("triggers `onCancel` callback when users selects `Cancel` action", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("autoSelectOnTabChange", () => {
+ it("auto-selects the first device of the new tab by default", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("button", { name: /Add.*md0/ });
+ });
+
+ it("clears the selection when switching to an empty tab by default", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("button", { name: "Change" });
+ });
- const cancelAction = screen.getByRole("button", { name: "Cancel" });
- await user.click(cancelAction);
- expect(onCancelMock).toHaveBeenCalled();
+ it("keeps the current selection when false", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("tab", { name: "RAID" }));
+ screen.getByRole("button", { name: /Add.*sda/ });
+ });
});
- it("triggers `onConfirm` callback with selected devices when users selects `Confirm` action", async () => {
- const { user } = plainRender(
- ,
- );
+ describe("actions", () => {
+ it("triggers onCancel when user selects Cancel", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onCancelMock).toHaveBeenCalled();
+ });
+
+ it("shows 'Add' when there is no prior device", () => {
+ installerRender(
+ ,
+ );
+ screen.getByRole("button", { name: /Add/ });
+ });
+
+ it("shows 'Keep' when the selection matches the given device", () => {
+ installerRender(
+ ,
+ );
+ screen.getByRole("button", { name: /Keep/ });
+ });
+
+ it("shows 'Change to' when the selection differs from the given device", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ screen.getByRole("button", { name: /Change to/ });
+ });
+
+ it("shows a 'Select a device' hint when no devices are available", () => {
+ installerRender(
+ ,
+ );
+ screen.getByText("Select a device");
+ });
- const sdbRow = screen.getByRole("row", { name: /\/dev\/sdb/ });
- const sdbRadio = within(sdbRow).getByRole("radio");
- await user.click(sdbRadio);
- const confirmAction = screen.getByRole("button", { name: "Confirm" });
- await user.click(confirmAction);
- expect(onConfirmMock).toHaveBeenCalledWith([sdb]);
+ it("triggers onConfirm with the selected device when the user confirms", async () => {
+ const { user } = installerRender(
+ ,
+ );
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ await user.click(screen.getByRole("button", { name: /Change to/ }));
+ expect(onConfirmMock).toHaveBeenCalledWith([sdb]);
+ });
});
});
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index 5bfbb12827..b3d8be0034 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -20,135 +20,350 @@
* find current contact information at www.suse.com.
*/
-import React, { useState } from "react";
-import { ButtonProps, Flex, Label } from "@patternfly/react-core";
-import Popup, { PopupProps } from "~/components/core/Popup";
-import SelectableDataTable, {
- SortedBy,
- SelectableDataTableProps,
-} from "~/components/core/SelectableDataTable";
+import React, { useId, useState } from "react";
+import { first } from "radashi";
+import { sprintf } from "sprintf-js";
import {
- typeDescription,
- contentDescription,
- filesystemLabels,
-} from "~/components/storage/utils/device";
-import { deviceSize } from "~/components/storage/utils";
-import { sortCollection } from "~/utils";
+ ButtonProps,
+ EmptyState,
+ EmptyStateActions,
+ EmptyStateBody,
+ EmptyStateFooter,
+ Flex,
+ HelperText,
+ HelperTextItem,
+ PageSection,
+ Stack,
+ Tab,
+ Tabs,
+} from "@patternfly/react-core";
+import Annotation from "~/components/core/Annotation";
+import Link from "~/components/core/Link";
+import NestedContent from "~/components/core/NestedContent";
+import Popup from "~/components/core/Popup";
+import SubtleContent from "~/components/core/SubtleContent";
+import DrivesTable from "~/components/storage/DrivesTable";
+import MdRaidsTable from "~/components/storage/MdRaidsTable";
+import VolumeGroupsTable from "~/components/storage/VolumeGroupsTable";
+import { STORAGE } from "~/routes/paths";
+import { deviceLabel } from "~/components/storage/utils";
import { _ } from "~/i18n";
-import { deviceSystems } from "~/model/storage/device";
+
+import type { PopupProps } from "~/components/core/Popup";
import type { Storage } from "~/model/system";
-type DeviceSelectorProps = {
- devices: Storage.Device[];
- selectedDevices?: Storage.Device[];
- onSelectionChange: SelectableDataTableProps["onSelectionChange"];
- selectionMode?: SelectableDataTableProps["selectionMode"];
-};
+import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing";
-const size = (device: Storage.Device) => {
- const bytes = device.volumeGroup?.size || device.block?.size || 0;
- return deviceSize(bytes);
+/** Identifies which tab is active in {@link DeviceSelectorModal}. */
+export type TabKey = "disks" | "mdRaids" | "volumeGroups";
+
+/** Props for {@link DeviceSelectorModal}. */
+export type DeviceSelectorModalProps = Omit & {
+ /** General information shown at the top of the modal, above the tabs. */
+ intro?: React.ReactNode;
+ /** Tab to open initially. Takes precedence over the tab derived from {@link selected}. */
+ initialTab?: TabKey;
+ /** Currently selected device. Determines the initial tab and initial selection. */
+ selected?: Storage.Device;
+ /** Available disks. */
+ disks?: Storage.Device[];
+ /** Available software RAID devices. */
+ mdRaids?: Storage.Device[];
+ /** Available LVM volume groups. */
+ volumeGroups?: Storage.Device[];
+ /** Side effects of selecting a disk. Only shown when the selection differs from {@link selected}. */
+ disksSideEffects?: React.ReactNode;
+ /** Side effects of selecting a RAID device. Only shown when the selection differs from {@link selected}. */
+ mdRaidsSideEffects?: React.ReactNode;
+ /** Side effects of selecting a volume group. Only shown when the selection differs from {@link selected}. */
+ volumeGroupsSideEffects?: React.ReactNode;
+ /**
+ * Label for the "create a new volume group" link in the LVM tab.
+ * When set, the link is shown with this text. When not set, no link is shown.
+ */
+ newVolumeGroupLinkText?: string;
+ /**
+ * Whether switching tabs auto-selects the first device of the new tab,
+ * or clears the selection when the tab is empty. Defaults to `true`.
+ */
+ autoSelectOnTabChange?: boolean;
+ /** Called with the new selection when the user confirms. */
+ onConfirm: (selection: Storage.Device[]) => void;
+ /** Called when the user cancels. */
+ onCancel: ButtonProps["onClick"];
};
-const description = (device: Storage.Device) => {
- const model = device.drive?.model;
- if (model && model.length) return model;
+const TABS: Record = { disks: 0, mdRaids: 1, volumeGroups: 2 };
- return typeDescription(device);
-};
+/** Empty state shown in a tab when no devices of that type are available. */
+const NoDevicesFound = ({
+ title,
+ body,
+ action,
+}: {
+ title: string;
+ body: string;
+ action?: React.ReactNode;
+}) => (
+
+ {body}
+ {action && (
+
+ {action}
+
+ )}
+
+);
-const details = (device: Storage.Device) => {
+/**
+ * Subtle contextual sentence with an inline link embedded via bracket notation.
+ * The link position and text are extracted from `sentence` using `[text]`
+ * markers.
+ */
+const TabIntro = ({ sentence, linkTo }: { sentence: string; linkTo?: string }) => {
+ const [before, linkText, after] = sentence.split(/[[\]]/);
return (
-
- {contentDescription(device)}
- {deviceSystems(device).map((s, i) => (
-
- ))}
- {filesystemLabels(device).map((s, i) => (
-
- ))}
-
+
+ {before}
+
+ {linkText}
+
+ {after}
+
);
};
-// TODO: document
-const DeviceSelector = ({
- devices,
- selectedDevices,
- onSelectionChange,
- selectionMode = "single",
-}: DeviceSelectorProps) => {
- const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
-
- const columns = [
- { name: _("Device"), value: (device: Storage.Device) => device.name, sortingKey: "name" },
- {
- name: _("Size"),
- value: size,
- sortingKey: (d: Storage.Device) => d.block.size,
- pfTdProps: { style: { width: "10ch" } },
- },
- { name: _("Description"), value: description },
- { name: _("Current content"), value: details },
- ];
-
- // Sorting
- const sortingKey = columns[sortedBy.index].sortingKey;
- const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+/**
+ * Wrapper for a tab's scrollable content area. Renders `children` when given,
+ * or falls back to {@link NoDevicesFound} built from the `empty*` props.
+ */
+const TabContent = ({
+ emptyTitle,
+ emptyBody,
+ emptyAction,
+ children,
+}: {
+ emptyTitle: string;
+ emptyBody: string;
+ emptyAction?: React.ReactNode;
+ children?: React.ReactNode;
+}) => (
+
+
+ {children || }
+
+
+);
- return (
- <>
-
- >
- );
-};
+/**
+ * Returns the tab index to activate when the modal opens.
+ *
+ * Resolution order:
+ * 1. Explicit `initialTab` key.
+ * 2. Tab that contains `selected`.
+ * 3. First tab (index 0).
+ */
+function getInitialTabIndex(
+ initialTab?: TabKey,
+ selected?: Storage.Device,
+ deviceLists?: Storage.Device[][],
+): number {
+ if (initialTab) return TABS[initialTab];
-export type DeviceSelectorModalProps = Omit & {
- selected?: Storage.Device;
- devices: Storage.Device[];
- onConfirm: (selection: Storage.Device[]) => void;
- onCancel: ButtonProps["onClick"];
-};
+ if (selected && deviceLists) {
+ const index = deviceLists.findIndex((list) => list.some((d) => d.sid === selected.sid));
+ return index !== -1 ? index : 0;
+ }
+ return 0;
+}
+
+/**
+ * Modal for selecting a storage device across three categories: disks,
+ * software RAID devices, and LVM volume groups.
+ *
+ * The confirm button label reflects the state of the selection:
+ *
+ * - "Add X" when there is no prior device and one is selected,
+ * - "Keep X" when the selection matches {@link
+ * DeviceSelectorModalProps.selected},
+ * - "Change to X" when a different device is picked,
+ * - "Add" or "Change" when no device is selected (e.g. after switching to an
+ * empty tab).
+ *
+ * An optional side-effects alert is displayed near the confirm button when the
+ * user switches to a different device. Both the alert and the "Select a device"
+ * hint are live regions linked to the confirm button via `aria-describedby` so
+ * assistive technologies announce changes.
+ */
export default function DeviceSelectorModal({
- selected = undefined,
+ selected: previousDevice,
+ initialTab,
onConfirm,
onCancel,
- devices,
+ intro,
+ disks = [],
+ mdRaids = [],
+ volumeGroups = [],
+ disksSideEffects,
+ mdRaidsSideEffects,
+ volumeGroupsSideEffects,
+ newVolumeGroupLinkText,
+ autoSelectOnTabChange = true,
...popupProps
}: DeviceSelectorModalProps): React.ReactNode {
- // FIXME: improve initial selection handling
+ const confirmHintId = useId();
+ const [activeTab, setActiveTab] = useState(() =>
+ getInitialTabIndex(initialTab, previousDevice, [disks, mdRaids, volumeGroups]),
+ );
const [selectedDevices, setSelectedDevices] = useState(
- selected ? [selected] : [devices[0]],
+ previousDevice ? [previousDevice] : [...disks, ...mdRaids, ...volumeGroups].slice(0, 1),
);
+ const tabLists = [disks, mdRaids, volumeGroups];
+
+ const currentDevice = selectedDevices[0];
+ const deviceSideEffectsAlert =
+ currentDevice &&
+ [
+ { list: disks, alert: disksSideEffects },
+ { list: mdRaids, alert: mdRaidsSideEffects },
+ { list: volumeGroups, alert: volumeGroupsSideEffects },
+ ].find(({ list }) => list.some((d) => d.sid === currentDevice.sid))?.alert;
+
+ const deviceInInitialTab =
+ currentDevice && tabLists[activeTab].some((d) => d.sid === currentDevice.sid);
+
+ const onTabClick = (_, tabIndex: number) => {
+ setActiveTab(tabIndex);
+ if (autoSelectOnTabChange) {
+ const device = first(tabLists[tabIndex]);
+ setSelectedDevices(device ? [device] : []);
+ }
+ };
- const onAccept = () => {
- selectedDevices !== Array(selected) && onConfirm(selectedDevices);
+ const confirmLabel = (): string => {
+ if (!currentDevice) return previousDevice ? _("Change") : _("Add");
+ // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)"
+ if (!previousDevice) return sprintf(_("Add %s"), deviceLabel(currentDevice));
+ // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)"
+ if (currentDevice.sid === previousDevice.sid)
+ return sprintf(_("Keep %s"), deviceLabel(currentDevice));
+ // TRANSLATORS: %s is replaced by a device label, e.g. "sda (512 GiB)"
+ return sprintf(_("Change to %s"), deviceLabel(currentDevice));
};
return (
-
-
+
+
+ {intro}
+
+
+
+
+ {disks.length > 0 && (
+
+ )}
+
+
+
+
+ {mdRaids.length > 0 && (
+
+ )}
+
+
+
+ {newVolumeGroupLinkText}
+ )
+ }
+ >
+ {volumeGroups.length > 0 && (
+ <>
+ {newVolumeGroupLinkText && (
+
+ )}
+
+ >
+ )}
+
+
+
+
+
-
-
+
+ {!currentDevice && (
+
+ {_("Select a device")}
+
+ )}
+ {currentDevice && currentDevice.sid !== previousDevice?.sid && deviceSideEffectsAlert && (
+
+
+ {deviceSideEffectsAlert}
+
+
+ )}
+
+ onConfirm(selectedDevices)}
+ isDisabled={!currentDevice}
+ aria-describedby={confirmHintId}
+ >
+ {confirmLabel()}
+
+
+
+
);
diff --git a/web/src/components/storage/DrivesTable.test.tsx b/web/src/components/storage/DrivesTable.test.tsx
new file mode 100644
index 0000000000..ad2fa0b507
--- /dev/null
+++ b/web/src/components/storage/DrivesTable.test.tsx
@@ -0,0 +1,134 @@
+/*
+ * 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 from "react";
+import { screen, within } from "@testing-library/react";
+import { getColumnValues, plainRender } from "~/test-utils";
+import type { Storage } from "~/model/system";
+import DrivesTable from "./DrivesTable";
+
+const sda: Storage.Device = {
+ sid: 59,
+ class: "drive",
+ name: "/dev/sda",
+ description: "SDA drive",
+ drive: {
+ model: "Micron 1100 SATA",
+ vendor: "Micron",
+ bus: "SATA",
+ busId: "",
+ transport: "sata",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 1024,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const sdb: Storage.Device = {
+ sid: 62,
+ class: "drive",
+ name: "/dev/sdb",
+ description: "SDB drive",
+ drive: {
+ model: "Samsung Evo 8 Pro",
+ vendor: "Samsung",
+ bus: "USB",
+ busId: "",
+ transport: "usb",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 2048,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const onSelectionChangeMock = jest.fn();
+
+describe("DrivesTable", () => {
+ it("renders Device, Size, Description, and Current content columns", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ within(table).getByRole("columnheader", { name: "Device" });
+ within(table).getByRole("columnheader", { name: "Size" });
+ within(table).getByRole("columnheader", { name: "Description" });
+ within(table).getByRole("columnheader", { name: "Current content" });
+ });
+
+ it("renders a row per device", () => {
+ plainRender();
+ screen.getByRole("row", { name: /sda/ });
+ screen.getByRole("row", { name: /sdb/ });
+ });
+
+ it("allows sorting by device name", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Device" });
+
+ expect(getColumnValues(table, "Device")).toEqual(["sda", "sdb"]);
+
+ await user.click(sortButton);
+
+ expect(getColumnValues(table, "Device")).toEqual(["sdb", "sda"]);
+ });
+
+ it("allows sorting by size", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Size" });
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]);
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]);
+ });
+
+ it("calls onSelectionChange when a device is selected", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const sdbRow = screen.getByRole("row", { name: /sdb/ });
+ await user.click(within(sdbRow).getByRole("radio"));
+ expect(onSelectionChangeMock).toHaveBeenCalledWith([sdb]);
+ });
+});
diff --git a/web/src/components/storage/DrivesTable.tsx b/web/src/components/storage/DrivesTable.tsx
new file mode 100644
index 0000000000..5a528081f1
--- /dev/null
+++ b/web/src/components/storage/DrivesTable.tsx
@@ -0,0 +1,124 @@
+/*
+ * 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 { Flex, Label } from "@patternfly/react-core";
+import SelectableDataTable from "~/components/core/SelectableDataTable";
+import { deviceBaseName, deviceSize } from "~/components/storage/utils";
+import {
+ typeDescription,
+ contentDescription,
+ filesystemLabels,
+} from "~/components/storage/utils/device";
+import { sortCollection } from "~/utils";
+import { deviceSystems } from "~/model/storage/device";
+import { _ } from "~/i18n";
+
+import type { SortedBy, SelectableDataTableProps } from "~/components/core/SelectableDataTable";
+import type { Storage } from "~/model/system";
+
+/** Props for {@link DrivesTable}. */
+type DrivesTableProps = {
+ /** Available drives. */
+ devices: Storage.Device[];
+ /** Currently selected drives. */
+ selectedDevices?: Storage.Device[];
+ /** Called when the selection changes. */
+ onSelectionChange: SelectableDataTableProps["onSelectionChange"];
+ /** Selection mode. Defaults to `"single"`. */
+ selectionMode?: SelectableDataTableProps["selectionMode"];
+};
+
+const size = (device: Storage.Device) => {
+ const bytes = device.volumeGroup?.size || device.block?.size || 0;
+ return deviceSize(bytes);
+};
+
+const description = (device: Storage.Device) => {
+ const model = device.drive?.model;
+ if (model && model.length) return model;
+
+ return typeDescription(device);
+};
+
+const details = (device: Storage.Device) => {
+ return (
+
+ {contentDescription(device)}
+ {deviceSystems(device).map((s, i) => (
+
+ ))}
+ {filesystemLabels(device).map((s, i) => (
+
+ ))}
+
+ );
+};
+
+/**
+ * Table for selecting among available drives.
+ */
+export default function DrivesTable({
+ devices,
+ selectedDevices,
+ onSelectionChange,
+ selectionMode = "single",
+}: DrivesTableProps) {
+ const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
+
+ const columns = [
+ {
+ name: _("Device"),
+ value: (device: Storage.Device) => deviceBaseName(device),
+ sortingKey: "name",
+ pfTdProps: { style: { width: "15ch" } },
+ },
+ {
+ name: _("Size"),
+ value: size,
+ sortingKey: (d: Storage.Device) => d.block.size,
+ pfTdProps: { style: { width: "10ch" } },
+ },
+ { name: _("Description"), value: description },
+ { name: _("Current content"), value: details },
+ ];
+
+ const sortingKey = columns[sortedBy.index].sortingKey;
+ const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/storage/MdRaidsTable.test.tsx b/web/src/components/storage/MdRaidsTable.test.tsx
new file mode 100644
index 0000000000..13747ee6d5
--- /dev/null
+++ b/web/src/components/storage/MdRaidsTable.test.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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 from "react";
+import { screen, within } from "@testing-library/react";
+import { getColumnValues, plainRender } from "~/test-utils";
+import type { Storage } from "~/model/system";
+import MdRaidsTable from "./MdRaidsTable";
+
+const sda: Storage.Device = {
+ sid: 1,
+ class: "drive",
+ name: "/dev/sda",
+ description: "SDA",
+ drive: {
+ model: "",
+ vendor: "",
+ bus: "SATA",
+ busId: "",
+ transport: "",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 10240,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const sdb: Storage.Device = { ...sda, sid: 2, name: "/dev/sdb", description: "SDB" };
+const sdc: Storage.Device = { ...sda, sid: 3, name: "/dev/sdc", description: "SDC" };
+const sdd: Storage.Device = { ...sda, sid: 4, name: "/dev/sdd", description: "SDD" };
+const sde: Storage.Device = { ...sda, sid: 5, name: "/dev/sde", description: "SDE" };
+
+jest.mock("~/hooks/model/system/storage", () => ({
+ ...jest.requireActual("~/hooks/model/system/storage"),
+ useDevices: () => [sda, sdb, sdc, sdd, sde],
+}));
+
+const md0: Storage.Device = {
+ sid: 70,
+ class: "mdRaid",
+ name: "/dev/md0",
+ description: "MD RAID 0",
+ md: { level: "raid1", devices: [1, 2] },
+ block: {
+ start: 0,
+ size: 10240,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const md1: Storage.Device = {
+ sid: 71,
+ class: "mdRaid",
+ name: "/dev/md1",
+ description: "MD RAID 1",
+ md: { level: "raid5", devices: [3, 4, 5] },
+ block: {
+ start: 0,
+ size: 20480,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const onSelectionChangeMock = jest.fn();
+
+describe("MdRaidsTable", () => {
+ it("renders Device, Size, Level, and Members columns", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ within(table).getByRole("columnheader", { name: "Device" });
+ within(table).getByRole("columnheader", { name: "Size" });
+ within(table).getByRole("columnheader", { name: "Level" });
+ within(table).getByRole("columnheader", { name: "Members" });
+ });
+
+ it("renders a row per RAID device", () => {
+ plainRender();
+ screen.getByRole("row", { name: /md0/ });
+ screen.getByRole("row", { name: /md1/ });
+ });
+
+ it("renders the RAID level in uppercase", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ expect(getColumnValues(table, "Level")).toEqual(["RAID1", "RAID5"]);
+ });
+
+ it("renders the member names", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ expect(getColumnValues(table, "Members")).toEqual([
+ "/dev/sda, /dev/sdb",
+ "/dev/sdc, /dev/sdd, /dev/sde",
+ ]);
+ });
+
+ it("allows sorting by device name", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Device" });
+
+ expect(getColumnValues(table, "Device")).toEqual(["md0", "md1"]);
+
+ await user.click(sortButton);
+
+ expect(getColumnValues(table, "Device")).toEqual(["md1", "md0"]);
+ });
+
+ it("allows sorting by size", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Size" });
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["10 KiB", "20 KiB"]);
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["20 KiB", "10 KiB"]);
+ });
+
+ it("calls onSelectionChange when a device is selected", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const md1Row = screen.getByRole("row", { name: /md1/ });
+ await user.click(within(md1Row).getByRole("radio"));
+ expect(onSelectionChangeMock).toHaveBeenCalledWith([md1]);
+ });
+});
diff --git a/web/src/components/storage/MdRaidsTable.tsx b/web/src/components/storage/MdRaidsTable.tsx
new file mode 100644
index 0000000000..4a653d1010
--- /dev/null
+++ b/web/src/components/storage/MdRaidsTable.tsx
@@ -0,0 +1,97 @@
+/*
+ * 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 SelectableDataTable from "~/components/core/SelectableDataTable";
+import { useDevices } from "~/hooks/model/system/storage";
+import { deviceBaseName, deviceSize } from "~/components/storage/utils";
+import { sortCollection } from "~/utils";
+import { _ } from "~/i18n";
+
+import type { Storage } from "~/model/system";
+import type { SelectableDataTableProps, SortedBy } from "~/components/core/SelectableDataTable";
+
+/** Props for {@link MdRaidsTable}. */
+type MdRaidsTableProps = {
+ /** Available software RAID devices. */
+ devices: Storage.Device[];
+ /** Currently selected devices. */
+ selectedDevices?: Storage.Device[];
+ /** Called when the selection changes. */
+ onSelectionChange: SelectableDataTableProps["onSelectionChange"];
+ /** Selection mode. Defaults to `"single"`. */
+ selectionMode?: SelectableDataTableProps["selectionMode"];
+};
+
+const level = (device: Storage.Device): string => device.md.level.toUpperCase();
+
+const memberNames = (device: Storage.Device, systemDevices: Storage.Device[]): string =>
+ device.md.devices.map((sid) => systemDevices.find((d) => d.sid === sid)?.name || sid).join(", ");
+
+/**
+ * Table for selecting among available software RAID devices.
+ */
+export default function MdRaidsTable({
+ devices,
+ selectedDevices,
+ onSelectionChange,
+ selectionMode = "single",
+}: MdRaidsTableProps) {
+ const systemDevices = useDevices();
+ const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
+
+ const columns = [
+ {
+ name: _("Device"),
+ value: (device: Storage.Device) => deviceBaseName(device),
+ sortingKey: "name",
+ pfTdProps: { style: { width: "15ch" } },
+ },
+ {
+ name: _("Size"),
+ value: (device: Storage.Device) => deviceSize(device.block.size),
+ sortingKey: (d: Storage.Device) => d.block.size,
+ pfTdProps: { style: { width: "10ch" } },
+ },
+ { name: _("Level"), value: level, sortingKey: level },
+ {
+ name: _("Members"),
+ value: (device: Storage.Device) => memberNames(device, systemDevices),
+ },
+ ];
+
+ const sortingKey = columns[sortedBy.index].sortingKey;
+ const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/storage/VolumeGroupsTable.test.tsx b/web/src/components/storage/VolumeGroupsTable.test.tsx
new file mode 100644
index 0000000000..67cc4ab571
--- /dev/null
+++ b/web/src/components/storage/VolumeGroupsTable.test.tsx
@@ -0,0 +1,161 @@
+/*
+ * 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 from "react";
+import { screen, within } from "@testing-library/react";
+import { getColumnValues, plainRender } from "~/test-utils";
+import type { Storage } from "~/model/system";
+import VolumeGroupsTable from "./VolumeGroupsTable";
+
+const sda: Storage.Device = {
+ sid: 1,
+ class: "drive",
+ name: "/dev/sda",
+ description: "SDA",
+ drive: {
+ model: "",
+ vendor: "",
+ bus: "SATA",
+ busId: "",
+ transport: "",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 10240,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+const sdb: Storage.Device = { ...sda, sid: 2, name: "/dev/sdb", description: "SDB" };
+const sdc: Storage.Device = { ...sda, sid: 3, name: "/dev/sdc", description: "SDC" };
+const sdd: Storage.Device = { ...sda, sid: 4, name: "/dev/sdd", description: "SDD" };
+const sde: Storage.Device = { ...sda, sid: 5, name: "/dev/sde", description: "SDE" };
+
+jest.mock("~/hooks/model/system/storage", () => ({
+ ...jest.requireActual("~/hooks/model/system/storage"),
+ useDevices: () => [sda, sdb, sdc, sdd, sde],
+}));
+
+const vg0: Storage.Device = {
+ sid: 80,
+ class: "volumeGroup",
+ name: "/dev/vg0",
+ description: "Volume group 0",
+ volumeGroup: { size: 51200, physicalVolumes: [1, 2] },
+ logicalVolumes: [
+ { sid: 81, name: "/dev/vg0/lv0", class: "logicalVolume" },
+ { sid: 82, name: "/dev/vg0/lv1", class: "logicalVolume" },
+ ],
+};
+
+const vg1: Storage.Device = {
+ sid: 83,
+ class: "volumeGroup",
+ name: "/dev/vg1",
+ description: "Volume group 1",
+ volumeGroup: { size: 102400, physicalVolumes: [3, 4, 5] },
+ logicalVolumes: [{ sid: 84, name: "/dev/vg1/lv0", class: "logicalVolume" }],
+};
+
+const onSelectionChangeMock = jest.fn();
+
+describe("VolumeGroupsTable", () => {
+ it("renders Device, Size, Logical volumes, and Physical volumes columns", () => {
+ plainRender(
+ ,
+ );
+ const table = screen.getByRole("grid");
+ within(table).getByRole("columnheader", { name: "Device" });
+ within(table).getByRole("columnheader", { name: "Size" });
+ within(table).getByRole("columnheader", { name: "Logical volumes" });
+ within(table).getByRole("columnheader", { name: "Physical volumes" });
+ });
+
+ it("renders a row per volume group", () => {
+ plainRender(
+ ,
+ );
+ screen.getByRole("row", { name: /vg0/ });
+ screen.getByRole("row", { name: /vg1/ });
+ });
+
+ it("renders the logical volume names", () => {
+ plainRender(
+ ,
+ );
+ const table = screen.getByRole("grid");
+ expect(getColumnValues(table, "Logical volumes")).toEqual(["lv0, lv1", "lv0"]);
+ });
+
+ it("renders the physical volume names", () => {
+ plainRender(
+ ,
+ );
+ const table = screen.getByRole("grid");
+ expect(getColumnValues(table, "Physical volumes")).toEqual(["sda, sdb", "sdc, sdd, sde"]);
+ });
+
+ it("allows sorting by device name", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Device" });
+
+ expect(getColumnValues(table, "Device")).toEqual(["vg0", "vg1"]);
+
+ await user.click(sortButton);
+
+ expect(getColumnValues(table, "Device")).toEqual(["vg1", "vg0"]);
+ });
+
+ it("allows sorting by size", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const table = screen.getByRole("grid");
+ const sortButton = within(table).getByRole("button", { name: "Size" });
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["50 KiB", "100 KiB"]);
+
+ await user.click(sortButton);
+ expect(getColumnValues(table, "Size")).toEqual(["100 KiB", "50 KiB"]);
+ });
+
+ it("calls onSelectionChange when a device is selected", async () => {
+ const { user } = plainRender(
+ ,
+ );
+
+ const vg1Row = screen.getByRole("row", { name: /vg1/ });
+ await user.click(within(vg1Row).getByRole("radio"));
+ expect(onSelectionChangeMock).toHaveBeenCalledWith([vg1]);
+ });
+});
diff --git a/web/src/components/storage/VolumeGroupsTable.tsx b/web/src/components/storage/VolumeGroupsTable.tsx
new file mode 100644
index 0000000000..5559382a80
--- /dev/null
+++ b/web/src/components/storage/VolumeGroupsTable.tsx
@@ -0,0 +1,108 @@
+/*
+ * 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 SelectableDataTable from "~/components/core/SelectableDataTable";
+import { useDevices } from "~/hooks/model/system/storage";
+import { deviceBaseName, deviceSize } from "~/components/storage/utils";
+import { sortCollection } from "~/utils";
+import { _ } from "~/i18n";
+
+import type { Storage } from "~/model/system";
+import type { SelectableDataTableProps, SortedBy } from "~/components/core/SelectableDataTable";
+
+/** Props for {@link VolumeGroupsTable}. */
+type VolumeGroupsTableProps = {
+ /** Available LVM volume groups. */
+ devices: Storage.Device[];
+ /** Currently selected volume groups. */
+ selectedDevices?: Storage.Device[];
+ /** Called when the selection changes. */
+ onSelectionChange: SelectableDataTableProps["onSelectionChange"];
+ /** Selection mode. Defaults to `"single"`. */
+ selectionMode?: SelectableDataTableProps["selectionMode"];
+};
+
+const logicalVolumeNames = (device: Storage.Device): string =>
+ device.logicalVolumes.map((lv) => deviceBaseName(lv)).join(", ");
+
+const physicalVolumeNames = (device: Storage.Device, systemDevices: Storage.Device[]): string =>
+ device.volumeGroup.physicalVolumes
+ .map((sid) => {
+ const pv = systemDevices.find((d) => d.sid === sid);
+ return pv ? deviceBaseName(pv) : sid;
+ })
+ .join(", ");
+
+/**
+ * Table for selecting among available LVM volume groups.
+ *
+ * Displays device name, size, logical volume names, and physical volume names.
+ */
+export default function VolumeGroupsTable({
+ devices,
+ selectedDevices,
+ onSelectionChange,
+ selectionMode = "single",
+}: VolumeGroupsTableProps) {
+ const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
+ const systemDevices = useDevices();
+
+ const columns = [
+ {
+ name: _("Device"),
+ value: (device: Storage.Device) => deviceBaseName(device),
+ sortingKey: "name",
+ pfTdProps: { style: { width: "15ch" } },
+ },
+ {
+ name: _("Size"),
+ value: (device: Storage.Device) => deviceSize(device.volumeGroup?.size ?? 0),
+ sortingKey: (d: Storage.Device) => d.volumeGroup?.size ?? 0,
+ pfTdProps: { style: { width: "10ch" } },
+ },
+ {
+ name: _("Logical volumes"),
+ value: logicalVolumeNames,
+ },
+ {
+ name: _("Physical volumes"),
+ value: (device: Storage.Device) => physicalVolumeNames(device, systemDevices),
+ },
+ ];
+
+ const sortingKey = columns[sortedBy.index].sortingKey;
+ const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+
+ return (
+
+ );
+}
From 5e093721fcc22dfb6508e8b2672fda724d9c8cf6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 15:04:42 +0100
Subject: [PATCH 08/28] fix(web): do not render Annotation when children is
empty
---
web/src/components/core/Annotation.test.tsx | 7 ++++++-
web/src/components/core/Annotation.tsx | 4 +++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/web/src/components/core/Annotation.test.tsx b/web/src/components/core/Annotation.test.tsx
index 67436501f0..75c3bab92e 100644
--- a/web/src/components/core/Annotation.test.tsx
+++ b/web/src/components/core/Annotation.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -48,4 +48,9 @@ describe("Annotation", () => {
const content = screen.getByText("Configured for installation only");
expect(content.tagName).toBe("STRONG");
});
+
+ it("renders nothing when children is empty", () => {
+ const { container } = plainRender({undefined});
+ expect(container).toBeEmptyDOMElement();
+ });
});
diff --git a/web/src/components/core/Annotation.tsx b/web/src/components/core/Annotation.tsx
index a5f27e4370..46b5a5f7ef 100644
--- a/web/src/components/core/Annotation.tsx
+++ b/web/src/components/core/Annotation.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -45,6 +45,8 @@ type AnnotationProps = React.PropsWithChildren<{
* ```
*/
export default function Annotation({ icon = "emergency", children }: AnnotationProps) {
+ if (!children) return null;
+
return (
{children}
From 60adac41dd550bb9e185aa2fc24846c746ddd0c0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 15:07:30 +0100
Subject: [PATCH 09/28] refactor: web: adapt device menu callers to tabbed
DeviceSelectorModal
ConfigureDeviceMenu now passes devices split by category (disks,
mdRaids, volumeGroups) and uses the intro and newVolumeGroupLinkText
props.
SearchedDeviceMenu drops ReuseVgMenuItem and the ChangeDeviceDescription
component, which are no longer needed after the modal gained full tabbed
support and side-effects props.
SearchedVolumeGroupMenu replaces two separate selector modals with a
single unified DeviceSelectorModal covering all device categories
---
.../storage/ConfigureDeviceMenu.test.tsx | 81 ++++++-
.../storage/ConfigureDeviceMenu.tsx | 17 +-
.../components/storage/SearchedDeviceMenu.tsx | 199 +++++-------------
.../storage/SearchedVolumeGroupMenu.tsx | 138 ++++--------
4 files changed, 181 insertions(+), 254 deletions(-)
diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
index b7e7c929ba..67dc0b6ef6 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
@@ -53,6 +53,22 @@ const vdb: Storage.Device = {
},
};
+const md0: Storage.Device = {
+ sid: 61,
+ class: "mdRaid",
+ name: "/dev/md0",
+ description: "MD RAID 0",
+ md: { level: "raid1", devices: [59, 60] },
+ block: {
+ start: 0,
+ size: 2e12,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
const vdaDrive: ConfigModel.Drive = {
name: "/dev/vda",
spacePolicy: "delete",
@@ -68,10 +84,12 @@ const vdbDrive: ConfigModel.Drive = {
const mockAddDrive = jest.fn();
const mockAddReusedMdRaid = jest.fn();
const mockUseModel = jest.fn();
+const mockUseAvailableDevices = jest.fn();
jest.mock("~/hooks/model/system/storage", () => ({
...jest.requireActual("~/hooks/model/system/storage"),
- useAvailableDevices: () => [vda, vdb],
+ useAvailableDevices: () => mockUseAvailableDevices(),
+ useDevices: () => [],
}));
jest.mock("~/hooks/model/storage/config-model", () => ({
@@ -84,6 +102,7 @@ jest.mock("~/hooks/model/storage/config-model", () => ({
describe("ConfigureDeviceMenu", () => {
beforeEach(() => {
mockUseModel.mockReturnValue({ drives: [], mdRaids: [] });
+ mockUseAvailableDevices.mockReturnValue([vda, vdb]);
});
it("renders an initially closed menu ", async () => {
@@ -113,13 +132,41 @@ describe("ConfigureDeviceMenu", () => {
const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" });
await user.click(disksMenuItem);
const dialog = screen.getByRole("dialog", { name: /Select a disk/ });
- const confirmButton = screen.getByRole("button", { name: "Confirm" });
- const vdaItemRow = within(dialog).getByRole("row", { name: /\/dev\/vda/ });
+ const confirmButton = screen.getByRole("button", { name: /Add/ });
+ const vdaItemRow = within(dialog).getByRole("row", { name: /vda/ });
const vdaItemRadio = within(vdaItemRow).getByRole("radio");
await user.click(vdaItemRadio);
await user.click(confirmButton);
expect(mockAddDrive).toHaveBeenCalledWith({ name: "/dev/vda", spacePolicy: "keep" });
});
+
+ it("shows intro text in the device selector", async () => {
+ const { user } = installerRender();
+ const toggler = screen.getByRole("button", { name: /More devices/ });
+ await user.click(toggler);
+ await user.click(screen.getByRole("menuitem", { name: "Add device menu" }));
+ within(screen.getByRole("dialog")).getByText("Start configuring a basic installation");
+ });
+
+ it("allows canceling the device selector without adding any device", async () => {
+ const { user } = installerRender();
+ const toggler = screen.getByRole("button", { name: /More devices/ });
+ await user.click(toggler);
+ await user.click(screen.getByRole("menuitem", { name: "Add device menu" }));
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(mockAddDrive).not.toHaveBeenCalled();
+ });
+
+ it("shows a link to create a new volume group in the LVM tab", async () => {
+ const { user } = installerRender();
+ const toggler = screen.getByRole("button", { name: /More devices/ });
+ await user.click(toggler);
+ await user.click(screen.getByRole("menuitem", { name: "Add device menu" }));
+ const dialog = screen.getByRole("dialog");
+ await user.click(within(dialog).getByRole("tab", { name: "LVM" }));
+ within(dialog).getByRole("link", { name: "Define a new LVM on top of one or several disks" });
+ });
});
describe("but some disks are already configured", () => {
@@ -134,11 +181,11 @@ describe("ConfigureDeviceMenu", () => {
const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" });
await user.click(disksMenuItem);
const dialog = screen.getByRole("dialog", { name: /Select another disk/ });
- const confirmButton = screen.getByRole("button", { name: "Confirm" });
- expect(screen.queryByRole("row", { name: /vda$/ })).toBeNull();
- const vdaItemRow = within(dialog).getByRole("row", { name: /\/dev\/vdb/ });
- const vdaItemRadio = within(vdaItemRow).getByRole("radio");
- await user.click(vdaItemRadio);
+ const confirmButton = screen.getByRole("button", { name: /Add/ });
+ expect(screen.queryByRole("row", { name: /vda/ })).toBeNull();
+ const vdbItemRow = within(dialog).getByRole("row", { name: /vdb/ });
+ const vdbItemRadio = within(vdbItemRow).getByRole("radio");
+ await user.click(vdbItemRadio);
await user.click(confirmButton);
expect(mockAddDrive).toHaveBeenCalledWith({ name: "/dev/vdb", spacePolicy: "keep" });
});
@@ -156,6 +203,24 @@ describe("ConfigureDeviceMenu", () => {
await user.click(toggler);
const disksMenuItem = screen.getByRole("menuitem", { name: "Add device menu" });
expect(disksMenuItem).toBeDisabled();
+ within(disksMenuItem).getByText("Already using all available disks");
+ });
+ });
+
+ describe("when there are MD RAID devices available", () => {
+ beforeEach(() => {
+ mockUseAvailableDevices.mockReturnValue([vda, md0]);
+ });
+
+ it("allows adding an MD RAID device", async () => {
+ const { user } = installerRender();
+ const toggler = screen.getByRole("button", { name: /More devices/ });
+ await user.click(toggler);
+ await user.click(screen.getByRole("menuitem", { name: "Add device menu" }));
+ const dialog = screen.getByRole("dialog");
+ await user.click(within(dialog).getByRole("tab", { name: "RAID" }));
+ await user.click(screen.getByRole("button", { name: /Add/ }));
+ expect(mockAddReusedMdRaid).toHaveBeenCalledWith({ name: "/dev/md0", spacePolicy: "keep" });
});
});
});
diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx
index 5594776e56..960d246f38 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.tsx
@@ -30,7 +30,7 @@ import { STORAGE as PATHS } from "~/routes/paths";
import { sprintf } from "sprintf-js";
import { _, n_ } from "~/i18n";
import DeviceSelectorModal from "./DeviceSelectorModal";
-import { isDrive } from "~/model/storage/device";
+import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device";
import { Icon } from "../layout";
import type { Storage } from "~/model/system";
@@ -131,7 +131,10 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
const usedDevicesNames = config.drives.concat(config.mdRaids).map((d) => d.name);
const usedDevicesCount = usedDevicesNames.length;
- const devices = allDevices.filter((d) => !usedDevicesNames.includes(d.name));
+ const availableDevices = allDevices.filter((d) => !usedDevicesNames.includes(d.name));
+ const disks = availableDevices.filter(isDrive);
+ const mdRaids = availableDevices.filter(isMd);
+ const volumeGroups = availableDevices.filter(isVolumeGroup);
const withRaids = !!allDevices.filter((d) => !isDrive(d)).length;
const addDevice = (device: Storage.Device) => {
@@ -157,7 +160,7 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
,
@@ -172,15 +175,17 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
]}
>
- {/** TODO: choose one, "add" or "add_circle", and remove the other at Icon.tsx */}
{_("More devices")}
{deviceSelectorOpen && (
}
- description={}
+ intro={}
+ newVolumeGroupLinkText={lvmDescription}
onCancel={closeDeviceSelector}
onConfirm={([device]) => {
addDevice(device);
diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx
index fe121330b8..e5eb9c9601 100644
--- a/web/src/components/storage/SearchedDeviceMenu.tsx
+++ b/web/src/components/storage/SearchedDeviceMenu.tsx
@@ -21,22 +21,20 @@
*/
import React, { useState } from "react";
-import MenuButton, { CustomToggleProps, MenuButtonItem } from "~/components/core/MenuButton";
+import { sprintf } from "sprintf-js";
+import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton";
+import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal";
+import configModel from "~/model/storage/config-model";
+import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device";
import { useAvailableDevices } from "~/hooks/model/system/storage";
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 { _, n_, formatList } from "~/i18n";
-import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal";
-import { MenuItemProps } from "@patternfly/react-core";
-import { isVolumeGroup } from "~/model/storage/device";
-import { isEmpty } from "radashi";
+
+import type { MenuItemProps } from "@patternfly/react-core";
+import type { CustomToggleProps } from "~/components/core/MenuButton";
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,
@@ -116,14 +114,21 @@ const ChangeDeviceTitle = ({ modelDevice }: ChangeDeviceTitleProps) => {
);
};
-type ChangeDeviceDescriptionProps = {
- modelDevice: ConfigModel.Drive | ConfigModel.MdRaid;
- device: Storage.Device;
-};
-
-const ChangeDeviceDescription = ({ modelDevice, device }: ChangeDeviceDescriptionProps) => {
- const config = useConfigModel();
- const name = baseName(device);
+/**
+ * Returns a string describing the side effects of moving away from
+ * `modelDevice`, or `undefined` when there are no notable side effects.
+ *
+ * A plain function (not a component) because a React element's emptiness cannot
+ * be checked without rendering it, making it difficult for callers to decide
+ * whether to render anything at all (e.g. {@link Annotation} guards against no
+ * children to avoid displaying just an icon with no text)
+ */
+const changeDeviceSideEffect = (
+ modelDevice: ConfigModel.Drive | ConfigModel.MdRaid,
+ device: Storage.Device,
+ config: ConfigModel.Config,
+): string | undefined => {
+ const name = deviceBaseName(device, true);
const volumeGroups = configModel.partitionable.filterVolumeGroups(config, modelDevice);
const isExplicitBoot = configModel.boot.hasExplicitDevice(config, modelDevice.name);
const isBoot = configModel.boot.hasDevice(config, modelDevice.name);
@@ -209,13 +214,17 @@ 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) => {
+/**
+ * Returns a string describing what will be created as logical volumes when
+ * reusing a volume group, or `undefined` when no new partitions are being
+ * added.
+ *
+ * A plain function (not a component) for the same reason as {@link
+ * changeDeviceSideEffect}.
+ */
+const reuseVgSideEffect = (
+ deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid,
+): string | undefined => {
const paths = deviceConfig.partitions
.filter((p) => !p.name)
.map((p) => formattedPath(p.mountPath));
@@ -251,51 +260,12 @@ const ChangeDeviceMenuItem = ({
const onlyOneOption = useOnlyOneOption(config, modelDevice);
return (
- }
- isDisabled={onlyOneOption}
- {...props}
- >
+
);
};
-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;
@@ -344,64 +314,6 @@ const RemoveDeviceMenuItem = ({ device, onClick }: RemoveDeviceMenuItemProps): R
);
};
-type SearchedDeviceSelectorModalProps = Omit;
-
-const SearchedDeviceSelectorModal = ({
- device,
- deviceConfig,
- ...deviceSelectorModalProps
-}: SearchedDeviceSelectorModalProps): React.ReactNode => {
- const availableTargets = targetDevices(deviceConfig, useConfigModel(), useAvailableDevices());
- const devices = availableTargets.filter((d) => !isVolumeGroup(d));
-
- return (
- }
- description={}
- selected={device}
- devices={devices}
- />
- );
-};
-
-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 = {
selected: Storage.Device;
modelDevice: ConfigModel.Drive | ConfigModel.MdRaid;
@@ -420,9 +332,19 @@ export default function SearchedDeviceMenu({
toggle,
deleteFn,
}: SearchedDeviceMenuProps): React.ReactNode {
- const [isSelectorOpen, setIsSelectorOpen] = useState(false);
- const [reuseVg, setReuseVg] = useState(false);
+ const config = useConfigModel();
const convertDevice = useConvertDevice();
+ const [isSelectorOpen, setIsSelectorOpen] = useState(false);
+ const availableTargets = targetDevices(modelDevice, config, useAvailableDevices());
+ const disks = availableTargets.filter(isDrive);
+ const mdRaids = availableTargets.filter(isMd);
+ const volumeGroups = availableTargets.filter(isVolumeGroup);
+ const diskSelectionSideEffect = changeDeviceSideEffect(modelDevice, selected, config);
+ const vgSelectionSideEffect = reuseVgSideEffect(modelDevice);
+
+ const openSelector = () => {
+ setIsSelectorOpen(true);
+ };
const onDeviceChange = ([device]: Storage.Device[]) => {
setIsSelectorOpen(false);
@@ -442,28 +364,21 @@ export default function SearchedDeviceMenu({
key="change"
modelDevice={modelDevice}
device={selected}
- onClick={() => {
- setReuseVg(false);
- setIsSelectorOpen(true);
- }}
- />,
- {
- setReuseVg(true);
- setIsSelectorOpen(true);
- }}
+ onClick={() => openSelector()}
/>,
,
]}
/>
{isSelectorOpen && (
- }
+ selected={selected}
+ disks={disks}
+ mdRaids={mdRaids}
+ volumeGroups={volumeGroups}
+ disksSideEffects={diskSelectionSideEffect}
+ mdRaidsSideEffects={diskSelectionSideEffect}
+ volumeGroupsSideEffects={vgSelectionSideEffect}
onConfirm={onDeviceChange}
onCancel={() => setIsSelectorOpen(false)}
/>
diff --git a/web/src/components/storage/SearchedVolumeGroupMenu.tsx b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
index 0e563a2fbe..6e0729f39c 100644
--- a/web/src/components/storage/SearchedVolumeGroupMenu.tsx
+++ b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
@@ -21,21 +21,23 @@
*/
import React, { useState } from "react";
-import MenuButton, { CustomToggleProps, MenuButtonItem } from "~/components/core/MenuButton";
-import { useAvailableDevices } from "~/hooks/model/system/storage";
+import { sprintf } from "sprintf-js";
+import { isEmpty, isNullish } from "radashi";
+import { MenuItemProps } from "@patternfly/react-core";
+import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton";
+import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal";
+import configModel from "~/model/storage/config-model";
+import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device";
import {
useConfigModel,
useConvertDevice,
useDeleteVolumeGroup,
} from "~/hooks/model/storage/config-model";
+import { useAvailableDevices } from "~/hooks/model/system/storage";
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 { CustomToggleProps } from "~/components/core/MenuButton";
import type { Storage } from "~/model/system";
import type { ConfigModel } from "~/model/storage/config-model";
import type { DeviceSelectorModalProps } from "~/components/storage/DeviceSelectorModal";
@@ -96,11 +98,11 @@ const ChangeVolumeGroupTitle = ({ deviceConfig }: ChangeVolumeGroupTitleProps) =
);
};
-type ChangeVolumeGroupDescriptionProps = {
+type VolumeGroupSelectionSideEffectProps = {
deviceConfig: ConfigModel.VolumeGroup;
};
-const ChangeVolumeGroupDescription = ({ deviceConfig }: ChangeVolumeGroupDescriptionProps) => {
+const VolumeGroupSelectionSideEffect = ({ deviceConfig }: VolumeGroupSelectionSideEffectProps) => {
const isReusingLogicalVolumes = configModel.volumeGroup.isReusingLogicalVolumes(deviceConfig);
if (isReusingLogicalVolumes) {
@@ -109,13 +111,11 @@ const ChangeVolumeGroupDescription = ({ deviceConfig }: ChangeVolumeGroupDescrip
}
};
-const ReuseDriveTitle = () => _("Change to an existing disk");
-
-type ReuseDriveDescriptionProps = {
+type DiskSelectionSideEffectProps = {
deviceConfig: ConfigModel.VolumeGroup;
};
-const ReuseDriveDescription = ({ deviceConfig }: ReuseDriveDescriptionProps) => {
+const DiskSelectionSideEffect = ({ deviceConfig }: DiskSelectionSideEffectProps) => {
const paths = deviceConfig.logicalVolumes
.filter((l) => !l.name)
.map((l) => formattedPath(l.mountPath));
@@ -149,7 +149,7 @@ const ChangeVolumeGroupMenuItem = ({
return (
}
+ description={}
isDisabled={unchangeable}
{...props}
>
@@ -158,29 +158,6 @@ const ChangeVolumeGroupMenuItem = ({
);
};
-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;
};
@@ -205,63 +182,35 @@ const RemoveVolumeGroupMenuItem = ({
);
};
-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 SearchedDeviceSelectorProps = Omit<
+ DeviceSelectorModalProps,
+ "disks" | "mdRaids" | "volumeGroups" | "selected"
+> & {
+ device: Storage.Device;
+ deviceConfig: ConfigModel.VolumeGroup;
};
-type SearchedDriveSelectorModalProps = Omit;
-
-const SearchedDriveSelectorModal = ({
+const SearchedDeviceSelector = ({
device,
deviceConfig,
...deviceSelectorModalProps
-}: SearchedDriveSelectorModalProps): React.ReactNode => {
- const devices = targetDevices(useConfigModel(), useAvailableDevices()).filter(
- (d) => !isVolumeGroup(d),
- );
+}: SearchedDeviceSelectorProps): React.ReactNode => {
+ const availableTargets = targetDevices(useConfigModel(), useAvailableDevices());
+ const disks = availableTargets.filter(isDrive);
+ const mdRaids = availableTargets.filter(isMd);
+ const volumeGroups = availableTargets.filter(isVolumeGroup);
return (
}
- description={}
selected={device}
- devices={devices}
+ disks={disks}
+ mdRaids={mdRaids}
+ volumeGroups={volumeGroups}
/>
);
};
-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;
@@ -278,9 +227,12 @@ export default function SearchedVolumeGroupMenu({
toggle,
}: SearchedVolumeGroupMenuProps): React.ReactNode {
const [isSelectorOpen, setIsSelectorOpen] = useState(false);
- const [reuseDrive, setReuseDrive] = useState(false);
const convertDevice = useConvertDevice();
+ const openSelector = () => {
+ setIsSelectorOpen(true);
+ };
+
const onDeviceChange = ([targetDevice]: Storage.Device[]) => {
setIsSelectorOpen(false);
convertDevice(device.name, targetDevice.name);
@@ -291,7 +243,6 @@ export default function SearchedVolumeGroupMenu({
{
- setReuseDrive(false);
- setIsSelectorOpen(true);
- }}
- />,
- {
- setReuseDrive(true);
- setIsSelectorOpen(true);
- }}
+ onClick={() => openSelector()}
/>,
{isSelectorOpen && (
}
device={device}
- reuseDrive={reuseDrive}
+ deviceConfig={deviceConfig}
+ disksSideEffects={}
+ mdRaidsSideEffects={}
+ volumeGroupsSideEffects={}
onConfirm={onDeviceChange}
onCancel={() => setIsSelectorOpen(false)}
/>
From 3877fc96c342f607b02b422d0cdcab02340668d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 16:59:46 +0100
Subject: [PATCH 10/28] Recover menu option for creating new VG
---
web/src/components/storage/SearchedDeviceMenu.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx
index e5eb9c9601..7141ef86aa 100644
--- a/web/src/components/storage/SearchedDeviceMenu.tsx
+++ b/web/src/components/storage/SearchedDeviceMenu.tsx
@@ -23,6 +23,7 @@
import React, { useState } from "react";
import { sprintf } from "sprintf-js";
import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton";
+import NewVgMenuOption from "~/components/storage/NewVgMenuOption";
import DeviceSelectorModal from "~/components/storage/DeviceSelectorModal";
import configModel from "~/model/storage/config-model";
import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device";
@@ -366,6 +367,7 @@ export default function SearchedDeviceMenu({
device={selected}
onClick={() => openSelector()}
/>,
+ ,
,
]}
/>
From 7d2a6bd460fc68613dbbb086ac0b9d50f7946750 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 17:21:14 +0100
Subject: [PATCH 11/28] Do not offer already configured devices
---
web/src/components/storage/ConfigureDeviceMenu.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx
index 960d246f38..82149277f0 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.tsx
@@ -31,6 +31,7 @@ import { sprintf } from "sprintf-js";
import { _, n_ } from "~/i18n";
import DeviceSelectorModal from "./DeviceSelectorModal";
import { isDrive, isMd, isVolumeGroup } from "~/model/storage/device";
+import configModel from "~/model/storage/config-model";
import { Icon } from "../layout";
import type { Storage } from "~/model/system";
@@ -129,7 +130,7 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
const addReusedMdRaid = useAddMdRaid();
const allDevices = useAvailableDevices();
- const usedDevicesNames = config.drives.concat(config.mdRaids).map((d) => d.name);
+ const usedDevicesNames = configModel.devices(config).map((d) => d.name);
const usedDevicesCount = usedDevicesNames.length;
const availableDevices = allDevices.filter((d) => !usedDevicesNames.includes(d.name));
const disks = availableDevices.filter(isDrive);
From cffacb222acddcf9bda5236538d66b4dd1ec4bfd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 17:44:57 +0100
Subject: [PATCH 12/28] Fix sid of the physical volumes
---
.../to_json_conversions/volume_group.rb | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb
index 0bc8b47f3e..68d5f3d73f 100644
--- a/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb
+++ b/service/lib/agama/storage/devicegraph_conversions/to_json_conversions/volume_group.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Copyright (c) [2025] SUSE LLC
+# Copyright (c) [2025-2026] SUSE LLC
#
# All Rights Reserved.
#
@@ -53,7 +53,7 @@ def lvm_vg_size
#
# @return [Array]
def lvm_vg_pvs
- storage_device.lvm_pvs.map(&:sid)
+ storage_device.lvm_pvs.map(&:plain_blk_device).map(&:sid)
end
end
end
From 5f6ccf047ef90666ff06b75ee0f7d607685f47d2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 17:45:24 +0100
Subject: [PATCH 13/28] Show names of the MD members and PVs
---
web/src/components/storage/MdRaidsTable.tsx | 11 ++++++++---
web/src/components/storage/VolumeGroupsTable.tsx | 4 ++--
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/web/src/components/storage/MdRaidsTable.tsx b/web/src/components/storage/MdRaidsTable.tsx
index 4a653d1010..9bc8a32143 100644
--- a/web/src/components/storage/MdRaidsTable.tsx
+++ b/web/src/components/storage/MdRaidsTable.tsx
@@ -22,7 +22,7 @@
import React, { useState } from "react";
import SelectableDataTable from "~/components/core/SelectableDataTable";
-import { useDevices } from "~/hooks/model/system/storage";
+import { useFlattenDevices } from "~/hooks/model/system/storage";
import { deviceBaseName, deviceSize } from "~/components/storage/utils";
import { sortCollection } from "~/utils";
import { _ } from "~/i18n";
@@ -45,7 +45,12 @@ type MdRaidsTableProps = {
const level = (device: Storage.Device): string => device.md.level.toUpperCase();
const memberNames = (device: Storage.Device, systemDevices: Storage.Device[]): string =>
- device.md.devices.map((sid) => systemDevices.find((d) => d.sid === sid)?.name || sid).join(", ");
+ device.md.devices
+ .map((sid) => {
+ const pv = systemDevices.find((d) => d.sid === sid);
+ return pv ? deviceBaseName(pv) : sid;
+ })
+ .join(", ");
/**
* Table for selecting among available software RAID devices.
@@ -56,7 +61,7 @@ export default function MdRaidsTable({
onSelectionChange,
selectionMode = "single",
}: MdRaidsTableProps) {
- const systemDevices = useDevices();
+ const systemDevices = useFlattenDevices();
const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
const columns = [
diff --git a/web/src/components/storage/VolumeGroupsTable.tsx b/web/src/components/storage/VolumeGroupsTable.tsx
index 5559382a80..bb279835d7 100644
--- a/web/src/components/storage/VolumeGroupsTable.tsx
+++ b/web/src/components/storage/VolumeGroupsTable.tsx
@@ -22,7 +22,7 @@
import React, { useState } from "react";
import SelectableDataTable from "~/components/core/SelectableDataTable";
-import { useDevices } from "~/hooks/model/system/storage";
+import { useFlattenDevices } from "~/hooks/model/system/storage";
import { deviceBaseName, deviceSize } from "~/components/storage/utils";
import { sortCollection } from "~/utils";
import { _ } from "~/i18n";
@@ -65,7 +65,7 @@ export default function VolumeGroupsTable({
selectionMode = "single",
}: VolumeGroupsTableProps) {
const [sortedBy, setSortedBy] = useState({ index: 0, direction: "asc" });
- const systemDevices = useDevices();
+ const systemDevices = useFlattenDevices();
const columns = [
{
From 6ad31f09b71c3f05b72827dcac56b779ba529049 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 21:04:32 +0100
Subject: [PATCH 14/28] refactor(web): derive initial tab from the effective
initial device
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously, activeTab was initialized before selectedDevices using
previousDevice, which is undefined when no selected prop is given. When
no previous device exists, the first available device is auto-selected
across all tabs — but the tab still defaulted to 0 (Disks), leaving the
confirm button and the active tab out of sync.
Extract initialDevice as a plain variable shared by both state
initializers, so the initial tab always reflects where the actual
initial selection lives, regardless of whether a prior device was given.
---
.../components/storage/DeviceSelectorModal.test.tsx | 13 +++++++++++++
web/src/components/storage/DeviceSelectorModal.tsx | 9 +++++----
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/web/src/components/storage/DeviceSelectorModal.test.tsx b/web/src/components/storage/DeviceSelectorModal.test.tsx
index 59fc95738c..68651e0ad7 100644
--- a/web/src/components/storage/DeviceSelectorModal.test.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.test.tsx
@@ -29,6 +29,7 @@ import DeviceSelectorModal from "./DeviceSelectorModal";
jest.mock("~/hooks/model/system/storage", () => ({
...jest.requireActual("~/hooks/model/system/storage"),
useDevices: () => [],
+ useFlattenDevices: () => [],
}));
const sda: Storage.Device = {
@@ -187,6 +188,18 @@ describe("DeviceSelectorModal", () => {
);
expect(screen.getByRole("tab", { name: "LVM" })).toHaveAttribute("aria-selected", "true");
});
+
+ it("opens the tab of the auto-selected device when no device is given", () => {
+ installerRender(
+ ,
+ );
+ expect(screen.getByRole("tab", { name: "RAID" })).toHaveAttribute("aria-selected", "true");
+ });
});
describe("sideEffectsAlert", () => {
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index b3d8be0034..453fb7820e 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -213,11 +213,12 @@ export default function DeviceSelectorModal({
...popupProps
}: DeviceSelectorModalProps): React.ReactNode {
const confirmHintId = useId();
- const [activeTab, setActiveTab] = useState(() =>
- getInitialTabIndex(initialTab, previousDevice, [disks, mdRaids, volumeGroups]),
- );
+ const initialDevice = previousDevice ?? first([...disks, ...mdRaids, ...volumeGroups]);
const [selectedDevices, setSelectedDevices] = useState(
- previousDevice ? [previousDevice] : [...disks, ...mdRaids, ...volumeGroups].slice(0, 1),
+ initialDevice ? [initialDevice] : [],
+ );
+ const [activeTab, setActiveTab] = useState(() =>
+ getInitialTabIndex(initialTab, initialDevice, [disks, mdRaids, volumeGroups]),
);
const tabLists = [disks, mdRaids, volumeGroups];
From d3f70b8a1b6c02dd4892e0a90b07d41271e10dee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 21:19:10 +0100
Subject: [PATCH 15/28] feat(web): add current content column to MdRaidsTable
Extend MdRaidsTable with a "Current content" column that shows the
content of each member device using the new DeviceContent component.
Add tests for both, fix the useFlattenDevices mock, and document
DeviceContent.
---
.../components/storage/DeviceContent.test.tsx | 74 +++++++++++++++++++
web/src/components/storage/DeviceContent.tsx | 51 +++++++++++++
web/src/components/storage/DrivesTable.tsx | 31 +-------
.../components/storage/MdRaidsTable.test.tsx | 16 +++-
web/src/components/storage/MdRaidsTable.tsx | 17 +++++
5 files changed, 158 insertions(+), 31 deletions(-)
create mode 100644 web/src/components/storage/DeviceContent.test.tsx
create mode 100644 web/src/components/storage/DeviceContent.tsx
diff --git a/web/src/components/storage/DeviceContent.test.tsx b/web/src/components/storage/DeviceContent.test.tsx
new file mode 100644
index 0000000000..19a48bd7b8
--- /dev/null
+++ b/web/src/components/storage/DeviceContent.test.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 from "react";
+import { screen } from "@testing-library/react";
+import { plainRender } from "~/test-utils";
+import type { Storage } from "~/model/system";
+import DeviceContent from "./DeviceContent";
+
+const disk: Storage.Device = {
+ sid: 1,
+ class: "drive",
+ name: "/dev/sda",
+ description: "ACME Disk",
+ drive: {
+ model: "ACME",
+ vendor: "",
+ bus: "SATA",
+ busId: "",
+ transport: "",
+ driver: [],
+ info: { dellBoss: false, sdCard: false },
+ },
+ block: {
+ start: 0,
+ size: 512e9,
+ active: true,
+ encrypted: false,
+ systems: [],
+ shrinking: { supported: false },
+ },
+};
+
+describe("DeviceContent", () => {
+ it("renders the content description", () => {
+ plainRender();
+ screen.getByText("ACME Disk");
+ });
+
+ it("renders installed system names as labels", () => {
+ const device: Storage.Device = {
+ ...disk,
+ block: { ...disk.block, systems: ["Windows 11", "openSUSE Leap 15.6"] },
+ };
+ plainRender();
+ screen.getByText("Windows 11");
+ screen.getByText("openSUSE Leap 15.6");
+ });
+
+ it("renders filesystem labels", () => {
+ const device: Storage.Device = { ...disk, filesystem: { sid: 100, type: "ext4", label: "root" } };
+ plainRender();
+ screen.getByText("root");
+ });
+});
diff --git a/web/src/components/storage/DeviceContent.tsx b/web/src/components/storage/DeviceContent.tsx
new file mode 100644
index 0000000000..42e7429b31
--- /dev/null
+++ b/web/src/components/storage/DeviceContent.tsx
@@ -0,0 +1,51 @@
+/*
+ * 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 from "react";
+import { Flex, Label } from "@patternfly/react-core";
+import { deviceSystems } from "~/model/storage/device";
+import { contentDescription, filesystemLabels } from "~/components/storage/utils/device";
+
+import type { Storage } from "~/model/system";
+
+/**
+ * Displays a summary of a storage device's current content: a textual
+ * description (e.g. partition table info or filesystem type), installed
+ * system names, and filesystem labels.
+ */
+export default function DeviceContent({ device }: { device: Storage.Device }) {
+ return (
+
+ {contentDescription(device)}
+ {deviceSystems(device).map((s, i) => (
+
+ ))}
+ {filesystemLabels(device).map((s, i) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/storage/DrivesTable.tsx b/web/src/components/storage/DrivesTable.tsx
index 5a528081f1..99d6bd6e70 100644
--- a/web/src/components/storage/DrivesTable.tsx
+++ b/web/src/components/storage/DrivesTable.tsx
@@ -21,20 +21,15 @@
*/
import React, { useState } from "react";
-import { Flex, Label } from "@patternfly/react-core";
import SelectableDataTable from "~/components/core/SelectableDataTable";
+import DeviceContent from "~/components/storage/DeviceContent";
import { deviceBaseName, deviceSize } from "~/components/storage/utils";
-import {
- typeDescription,
- contentDescription,
- filesystemLabels,
-} from "~/components/storage/utils/device";
+import { typeDescription } from "~/components/storage/utils/device";
import { sortCollection } from "~/utils";
-import { deviceSystems } from "~/model/storage/device";
import { _ } from "~/i18n";
-import type { SortedBy, SelectableDataTableProps } from "~/components/core/SelectableDataTable";
import type { Storage } from "~/model/system";
+import type { SortedBy, SelectableDataTableProps } from "~/components/core/SelectableDataTable";
/** Props for {@link DrivesTable}. */
type DrivesTableProps = {
@@ -60,24 +55,6 @@ const description = (device: Storage.Device) => {
return typeDescription(device);
};
-const details = (device: Storage.Device) => {
- return (
-
- {contentDescription(device)}
- {deviceSystems(device).map((s, i) => (
-
- ))}
- {filesystemLabels(device).map((s, i) => (
-
- ))}
-
- );
-};
-
/**
* Table for selecting among available drives.
*/
@@ -103,7 +80,7 @@ export default function DrivesTable({
pfTdProps: { style: { width: "10ch" } },
},
{ name: _("Description"), value: description },
- { name: _("Current content"), value: details },
+ { name: _("Current content"), value: (d: Storage.Device) => },
];
const sortingKey = columns[sortedBy.index].sortingKey;
diff --git a/web/src/components/storage/MdRaidsTable.test.tsx b/web/src/components/storage/MdRaidsTable.test.tsx
index 13747ee6d5..6afa213d3f 100644
--- a/web/src/components/storage/MdRaidsTable.test.tsx
+++ b/web/src/components/storage/MdRaidsTable.test.tsx
@@ -57,7 +57,7 @@ const sde: Storage.Device = { ...sda, sid: 5, name: "/dev/sde", description: "SD
jest.mock("~/hooks/model/system/storage", () => ({
...jest.requireActual("~/hooks/model/system/storage"),
- useDevices: () => [sda, sdb, sdc, sdd, sde],
+ useFlattenDevices: () => [sda, sdb, sdc, sdd, sde],
}));
const md0: Storage.Device = {
@@ -95,13 +95,14 @@ const md1: Storage.Device = {
const onSelectionChangeMock = jest.fn();
describe("MdRaidsTable", () => {
- it("renders Device, Size, Level, and Members columns", () => {
+ it("renders Device, Size, Level, Members, and Current content columns", () => {
plainRender();
const table = screen.getByRole("grid");
within(table).getByRole("columnheader", { name: "Device" });
within(table).getByRole("columnheader", { name: "Size" });
within(table).getByRole("columnheader", { name: "Level" });
within(table).getByRole("columnheader", { name: "Members" });
+ within(table).getByRole("columnheader", { name: "Current content" });
});
it("renders a row per RAID device", () => {
@@ -116,12 +117,19 @@ describe("MdRaidsTable", () => {
expect(getColumnValues(table, "Level")).toEqual(["RAID1", "RAID5"]);
});
+ it("renders the current content of each member device", () => {
+ plainRender();
+ const md0Row = screen.getByRole("row", { name: /md0/ });
+ within(md0Row).getByText("SDA");
+ within(md0Row).getByText("SDB");
+ });
+
it("renders the member names", () => {
plainRender();
const table = screen.getByRole("grid");
expect(getColumnValues(table, "Members")).toEqual([
- "/dev/sda, /dev/sdb",
- "/dev/sdc, /dev/sdd, /dev/sde",
+ "sda, sdb",
+ "sdc, sdd, sde",
]);
});
diff --git a/web/src/components/storage/MdRaidsTable.tsx b/web/src/components/storage/MdRaidsTable.tsx
index 9bc8a32143..5ac817432b 100644
--- a/web/src/components/storage/MdRaidsTable.tsx
+++ b/web/src/components/storage/MdRaidsTable.tsx
@@ -21,7 +21,9 @@
*/
import React, { useState } from "react";
+import { Stack } from "@patternfly/react-core";
import SelectableDataTable from "~/components/core/SelectableDataTable";
+import DeviceContent from "~/components/storage/DeviceContent";
import { useFlattenDevices } from "~/hooks/model/system/storage";
import { deviceBaseName, deviceSize } from "~/components/storage/utils";
import { sortCollection } from "~/utils";
@@ -52,6 +54,17 @@ const memberNames = (device: Storage.Device, systemDevices: Storage.Device[]): s
})
.join(", ");
+const content = (device: Storage.Device, systemDevices: Storage.Device[]) => {
+ return (
+
+ {device.md.devices.map((sid) => {
+ const pv = systemDevices.find((d) => d.sid === sid);
+ return pv ? : null;
+ })}
+
+ );
+};
+
/**
* Table for selecting among available software RAID devices.
*/
@@ -82,6 +95,10 @@ export default function MdRaidsTable({
name: _("Members"),
value: (device: Storage.Device) => memberNames(device, systemDevices),
},
+ {
+ name: _("Current content"),
+ value: (device: Storage.Device) => content(device, systemDevices),
+ },
];
const sortingKey = columns[sortedBy.index].sortingKey;
From 5fa930b58bfbb9a39356129b3d0996c03b72d045 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?=
Date: Thu, 9 Apr 2026 21:26:59 +0100
Subject: [PATCH 16/28] fix(web): please linters
---
web/src/components/storage/ConfigureDeviceMenu.test.tsx | 6 ++++--
web/src/components/storage/MdRaidsTable.test.tsx | 5 +----
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
index 67dc0b6ef6..3f69465e73 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -165,7 +165,9 @@ describe("ConfigureDeviceMenu", () => {
await user.click(screen.getByRole("menuitem", { name: "Add device menu" }));
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByRole("tab", { name: "LVM" }));
- within(dialog).getByRole("link", { name: "Define a new LVM on top of one or several disks" });
+ within(dialog).getByRole("link", {
+ name: "Define a new LVM on top of one or several disks",
+ });
});
});
diff --git a/web/src/components/storage/MdRaidsTable.test.tsx b/web/src/components/storage/MdRaidsTable.test.tsx
index 6afa213d3f..599335c313 100644
--- a/web/src/components/storage/MdRaidsTable.test.tsx
+++ b/web/src/components/storage/MdRaidsTable.test.tsx
@@ -127,10 +127,7 @@ describe("MdRaidsTable", () => {
it("renders the member names", () => {
plainRender();
const table = screen.getByRole("grid");
- expect(getColumnValues(table, "Members")).toEqual([
- "sda, sdb",
- "sdc, sdd, sde",
- ]);
+ expect(getColumnValues(table, "Members")).toEqual(["sda, sdb", "sdc, sdd, sde"]);
});
it("allows sorting by device name", async () => {
From 362df04762fbbcab7f5180ef9e8dfe3015a8a777 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, 10 Apr 2026 07:14:35 +0100
Subject: [PATCH 17/28] Fix selector for reusing a VG
---
.../storage/ConfigureDeviceMenu.tsx | 19 ++++++++++++++-----
.../components/storage/VolumeGroupEditor.tsx | 6 +++++-
.../storage/config-model/volume-group.ts | 2 +-
3 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx
index 82149277f0..9af9d1ea05 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -25,7 +25,12 @@ import { useNavigate } from "react-router";
import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton";
import { Divider, Flex, MenuItemProps } from "@patternfly/react-core";
import { useAvailableDevices } from "~/hooks/model/system/storage";
-import { useConfigModel, useAddDrive, useAddMdRaid } from "~/hooks/model/storage/config-model";
+import {
+ useConfigModel,
+ useAddDrive,
+ useAddMdRaid,
+ useAddVolumeGroup,
+} from "~/hooks/model/storage/config-model";
import { STORAGE as PATHS } from "~/routes/paths";
import { sprintf } from "sprintf-js";
import { _, n_ } from "~/i18n";
@@ -127,7 +132,8 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
const config = useConfigModel();
const addDrive = useAddDrive();
- const addReusedMdRaid = useAddMdRaid();
+ const addMdRaid = useAddMdRaid();
+ const addVolumeGroup = useAddVolumeGroup();
const allDevices = useAvailableDevices();
const usedDevicesNames = configModel.devices(config).map((d) => d.name);
@@ -139,8 +145,11 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
const withRaids = !!allDevices.filter((d) => !isDrive(d)).length;
const addDevice = (device: Storage.Device) => {
- const hook = isDrive(device) ? addDrive : addReusedMdRaid;
- hook({ name: device.name, spacePolicy: "keep" });
+ if (isDrive(device)) addDrive({ name: device.name, spacePolicy: "keep" });
+
+ if (isMd(device)) addMdRaid({ name: device.name, spacePolicy: "keep" });
+
+ if (isVolumeGroup(device)) addVolumeGroup({ name: device.name, spacePolicy: "keep" }, false);
};
const lvmDescription = allDevices.length
diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx
index 5ed569f74b..c75b161d17 100644
--- a/web/src/components/storage/VolumeGroupEditor.tsx
+++ b/web/src/components/storage/VolumeGroupEditor.tsx
@@ -317,7 +317,11 @@ const LogicalVolumes = ({ index }: { index: number }) => {
};
if (isEmpty(vg.logicalVolumes)) {
- return ;
+ return (
+
+
+
+ );
}
const description = n_(
diff --git a/web/src/model/storage/config-model/volume-group.ts b/web/src/model/storage/config-model/volume-group.ts
index 239c50251d..65e0972c00 100644
--- a/web/src/model/storage/config-model/volume-group.ts
+++ b/web/src/model/storage/config-model/volume-group.ts
@@ -76,7 +76,7 @@ function add(
moveContent: boolean,
): ConfigModel.Config {
config = configModel.clone(config);
- adjustSpacePolicies(config, data.targetDevices);
+ adjustSpacePolicies(config, data.targetDevices || []);
const volumeGroup = create(data);
From b9e994d096c01173c634a31df9cdd58dec319555 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, 10 Apr 2026 07:29:47 +0100
Subject: [PATCH 18/28] Some small text changes
- Make sentence more generic (device vs disk).
---
web/src/components/storage/SearchedDeviceMenu.tsx | 8 ++++----
web/src/components/storage/SearchedVolumeGroupMenu.tsx | 6 +++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx
index 7141ef86aa..384c4306c0 100644
--- a/web/src/components/storage/SearchedDeviceMenu.tsx
+++ b/web/src/components/storage/SearchedDeviceMenu.tsx
@@ -89,18 +89,18 @@ const ChangeDeviceTitle = ({ modelDevice }: ChangeDeviceTitleProps) => {
if (modelDevice.filesystem) {
// TRANSLATORS: %s is a formatted mount point like '"/home"'
- return sprintf(_("Change the disk to format as %s"), formattedPath(modelDevice.mountPath));
+ return sprintf(_("Change the device to format as %s"), formattedPath(modelDevice.mountPath));
}
const mountPaths = configModel.partitionable.usedMountPaths(modelDevice);
const hasMountPaths = mountPaths.length > 0;
if (!hasMountPaths) {
- return _("Change the disk to configure");
+ return _("Change the device to configure");
}
if (mountPaths.includes("/")) {
- return _("Change the disk to install the system");
+ return _("Change the device to install the system");
}
const newMountPaths = modelDevice.partitions
@@ -110,7 +110,7 @@ const ChangeDeviceTitle = ({ modelDevice }: ChangeDeviceTitleProps) => {
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 disk to create %s"),
+ _("Change the device to create %s"),
formatList(newMountPaths),
);
};
diff --git a/web/src/components/storage/SearchedVolumeGroupMenu.tsx b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
index 6e0729f39c..dcb9c2dbc7 100644
--- a/web/src/components/storage/SearchedVolumeGroupMenu.tsx
+++ b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
@@ -79,11 +79,11 @@ const ChangeVolumeGroupTitle = ({ deviceConfig }: ChangeVolumeGroupTitleProps) =
const hasMountPaths = !isEmpty(mountPaths.length);
if (!hasMountPaths) {
- return _("Change the volume group to configure");
+ return _("Change the device to configure");
}
if (mountPaths.includes("/")) {
- return _("Change the volume group to install the system");
+ return _("Change the device to install the system");
}
const newMountPaths = deviceConfig.logicalVolumes
@@ -93,7 +93,7 @@ const ChangeVolumeGroupTitle = ({ deviceConfig }: ChangeVolumeGroupTitleProps) =
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"),
+ _("Change the device to create %s"),
formatList(newMountPaths),
);
};
From c13ae33b6fd5c8fea540495de0ac7d5da6b60fe0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?=
Date: Fri, 10 Apr 2026 07:53:19 +0100
Subject: [PATCH 19/28] fix(web): adapt tests to the volume group reuse changes
By adding missing mocks needed after recent storage
refactoring for allowing to reuse LVM VGs.
Also please linters.
---
web/src/components/storage/ConfigureDeviceMenu.test.tsx | 2 ++
web/src/components/storage/DeviceContent.test.tsx | 5 ++++-
web/src/components/storage/DeviceEditorContent.test.tsx | 4 +++-
web/src/components/storage/DeviceSelectorModal.tsx | 2 +-
web/src/components/storage/DriveEditor.test.tsx | 3 ++-
web/src/components/storage/SpacePolicyMenu.test.tsx | 6 +++---
web/src/components/storage/VolumeGroupsTable.test.tsx | 2 +-
7 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
index 3f69465e73..c418945f82 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
@@ -90,6 +90,7 @@ jest.mock("~/hooks/model/system/storage", () => ({
...jest.requireActual("~/hooks/model/system/storage"),
useAvailableDevices: () => mockUseAvailableDevices(),
useDevices: () => [],
+ useFlattenDevices: () => [],
}));
jest.mock("~/hooks/model/storage/config-model", () => ({
@@ -97,6 +98,7 @@ jest.mock("~/hooks/model/storage/config-model", () => ({
useConfigModel: () => mockUseModel(),
useAddDrive: () => mockAddDrive,
useAddMdRaid: () => mockAddReusedMdRaid,
+ useAddVolumeGroup: () => jest.fn(),
}));
describe("ConfigureDeviceMenu", () => {
diff --git a/web/src/components/storage/DeviceContent.test.tsx b/web/src/components/storage/DeviceContent.test.tsx
index 19a48bd7b8..006c77c816 100644
--- a/web/src/components/storage/DeviceContent.test.tsx
+++ b/web/src/components/storage/DeviceContent.test.tsx
@@ -67,7 +67,10 @@ describe("DeviceContent", () => {
});
it("renders filesystem labels", () => {
- const device: Storage.Device = { ...disk, filesystem: { sid: 100, type: "ext4", label: "root" } };
+ const device: Storage.Device = {
+ ...disk,
+ filesystem: { sid: 100, type: "ext4", label: "root" },
+ };
plainRender();
screen.getByText("root");
});
diff --git a/web/src/components/storage/DeviceEditorContent.test.tsx b/web/src/components/storage/DeviceEditorContent.test.tsx
index 74a60ed9b8..2e14d11f97 100644
--- a/web/src/components/storage/DeviceEditorContent.test.tsx
+++ b/web/src/components/storage/DeviceEditorContent.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -42,6 +42,8 @@ const mockConfigModel = jest.fn();
jest.mock("~/hooks/model/storage/config-model", () => ({
useConfigModel: () => mockConfigModel(),
+ usePartitionable: (_collection: string, index: number) =>
+ mockConfigModel()?.drives[index] ?? null,
}));
const driveWithPartitions: ConfigModel.Drive = {
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index 453fb7820e..69e7b1b778 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -257,7 +257,7 @@ export default function DeviceSelectorModal({
({
useAddDriveFromMdRaid: jest.fn(),
useAddMdRaidFromDrive: jest.fn(),
useDeleteDrive: () => mockDeleteDrive,
- useAddVolumeGroupFromPartitionable: () => mockAddVolumeGroupFromPartitionable,
+ useConvertPartitionableToVolumeGroup: () => mockAddVolumeGroupFromPartitionable,
+ useConvertDevice: () => jest.fn(),
}));
const mockSystemDevice = jest.fn();
diff --git a/web/src/components/storage/SpacePolicyMenu.test.tsx b/web/src/components/storage/SpacePolicyMenu.test.tsx
index b307de704b..2dd6b09e56 100644
--- a/web/src/components/storage/SpacePolicyMenu.test.tsx
+++ b/web/src/components/storage/SpacePolicyMenu.test.tsx
@@ -41,12 +41,12 @@ jest.mock("~/hooks/model/system/storage", () => ({
}));
const mockConfigModel = jest.fn();
-const mockPartitionable = jest.fn();
+const mockDeviceConfig = jest.fn();
const mockSetSpacePolicy = jest.fn();
jest.mock("~/hooks/model/storage/config-model", () => ({
useConfigModel: () => mockConfigModel(),
- usePartitionable: () => mockPartitionable(),
+ useDevice: () => mockDeviceConfig(),
useSetSpacePolicy: () => mockSetSpacePolicy,
}));
@@ -66,7 +66,7 @@ describe("SpacePolicyMenu", () => {
beforeEach(() => {
mockSystemDevice.mockReturnValue(vda);
mockConfigModel.mockReturnValue({ drives: [deviceModel] });
- mockPartitionable.mockReturnValue(deviceModel);
+ mockDeviceConfig.mockReturnValue(deviceModel);
});
it("should render the SpacePolicyMenu with correct initial state", async () => {
diff --git a/web/src/components/storage/VolumeGroupsTable.test.tsx b/web/src/components/storage/VolumeGroupsTable.test.tsx
index 67cc4ab571..a3225a7b2a 100644
--- a/web/src/components/storage/VolumeGroupsTable.test.tsx
+++ b/web/src/components/storage/VolumeGroupsTable.test.tsx
@@ -57,7 +57,7 @@ const sde: Storage.Device = { ...sda, sid: 5, name: "/dev/sde", description: "SD
jest.mock("~/hooks/model/system/storage", () => ({
...jest.requireActual("~/hooks/model/system/storage"),
- useDevices: () => [sda, sdb, sdc, sdd, sde],
+ useFlattenDevices: () => [sda, sdb, sdc, sdd, sde],
}));
const vg0: Storage.Device = {
From 43ab145324f1c370bedb3e636b45c3e3bf3b0015 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, 10 Apr 2026 09:34:48 +0100
Subject: [PATCH 20/28] Fix current content of MD RAIDs
---
.../components/storage/MdRaidsTable.test.tsx | 3 +--
web/src/components/storage/MdRaidsTable.tsx | 17 +----------------
2 files changed, 2 insertions(+), 18 deletions(-)
diff --git a/web/src/components/storage/MdRaidsTable.test.tsx b/web/src/components/storage/MdRaidsTable.test.tsx
index 599335c313..d18eacfa44 100644
--- a/web/src/components/storage/MdRaidsTable.test.tsx
+++ b/web/src/components/storage/MdRaidsTable.test.tsx
@@ -120,8 +120,7 @@ describe("MdRaidsTable", () => {
it("renders the current content of each member device", () => {
plainRender();
const md0Row = screen.getByRole("row", { name: /md0/ });
- within(md0Row).getByText("SDA");
- within(md0Row).getByText("SDB");
+ within(md0Row).getByText("MD RAID 0");
});
it("renders the member names", () => {
diff --git a/web/src/components/storage/MdRaidsTable.tsx b/web/src/components/storage/MdRaidsTable.tsx
index 5ac817432b..2a7d7f7292 100644
--- a/web/src/components/storage/MdRaidsTable.tsx
+++ b/web/src/components/storage/MdRaidsTable.tsx
@@ -21,7 +21,6 @@
*/
import React, { useState } from "react";
-import { Stack } from "@patternfly/react-core";
import SelectableDataTable from "~/components/core/SelectableDataTable";
import DeviceContent from "~/components/storage/DeviceContent";
import { useFlattenDevices } from "~/hooks/model/system/storage";
@@ -54,17 +53,6 @@ const memberNames = (device: Storage.Device, systemDevices: Storage.Device[]): s
})
.join(", ");
-const content = (device: Storage.Device, systemDevices: Storage.Device[]) => {
- return (
-
- {device.md.devices.map((sid) => {
- const pv = systemDevices.find((d) => d.sid === sid);
- return pv ? : null;
- })}
-
- );
-};
-
/**
* Table for selecting among available software RAID devices.
*/
@@ -95,10 +83,7 @@ export default function MdRaidsTable({
name: _("Members"),
value: (device: Storage.Device) => memberNames(device, systemDevices),
},
- {
- name: _("Current content"),
- value: (device: Storage.Device) => content(device, systemDevices),
- },
+ { name: _("Current content"), value: (d: Storage.Device) => },
];
const sortingKey = columns[sortedBy.index].sortingKey;
From a7e1e4411954d6b9ccf2fcb7e7949e45aac34888 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, 10 Apr 2026 11:00:31 +0100
Subject: [PATCH 21/28] Simplify text
---
web/src/components/storage/DeviceSelectorModal.tsx | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index 69e7b1b778..dc857cae58 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -320,11 +320,7 @@ export default function DeviceSelectorModal({
<>
{newVolumeGroupLinkText && (
)}
From 3bd1dd2c523924b4235174ba0067180b2fae39f9 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, 10 Apr 2026 11:01:44 +0100
Subject: [PATCH 22/28] Adapt text for adding LVs
---
web/src/components/storage/VolumeGroupEditor.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx
index c75b161d17..4244772a73 100644
--- a/web/src/components/storage/VolumeGroupEditor.tsx
+++ b/web/src/components/storage/VolumeGroupEditor.tsx
@@ -284,13 +284,16 @@ const VgMenu = ({ index }: { index: number }) => {
const AddLvButton = ({ index }: { index: number }) => {
const navigate = useNavigate();
+ const volumeGroupConfig = useVolumeGroup(index);
+
const newLvPath = generateEncodedPath(PATHS.volumeGroup.logicalVolume.add, { id: index });
return (
navigate(newLvPath)}>
{/** TODO: choose one, "add" or "add_circle", and remove the other at Icon.tsx */}
- {_("Add logical volume")}
+
+ {volumeGroupConfig.name ? _("Add or use logical volume") : _("Add logical volume")}
);
From 1c6718412358e8fc75ab376c935d07c7065a8371 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, 10 Apr 2026 11:02:19 +0100
Subject: [PATCH 23/28] Fix validation of LV name
---
web/src/components/storage/LogicalVolumePage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx
index 4223125e57..ee742c3b9e 100644
--- a/web/src/components/storage/LogicalVolumePage.tsx
+++ b/web/src/components/storage/LogicalVolumePage.tsx
@@ -318,7 +318,7 @@ function useMountPointError(value: FormValue): Error | undefined {
}
function checkLogicalVolumeName(value: FormValue): Error | undefined {
- if (value.name?.length) return;
+ if (value.target !== NEW_LOGICAL_VOLUME || value.name?.length) return;
return {
id: "logicalVolumeName",
From 91c730353d38eaa133856728060ef4ed3595e35d 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, 10 Apr 2026 11:02:43 +0100
Subject: [PATCH 24/28] Remove props
- The menu content was partially hidden
---
web/src/components/storage/SearchedDeviceMenu.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/web/src/components/storage/SearchedDeviceMenu.tsx b/web/src/components/storage/SearchedDeviceMenu.tsx
index 384c4306c0..e7f76e36a3 100644
--- a/web/src/components/storage/SearchedDeviceMenu.tsx
+++ b/web/src/components/storage/SearchedDeviceMenu.tsx
@@ -357,7 +357,6 @@ export default function SearchedDeviceMenu({
Date: Sat, 11 Apr 2026 11:00:52 +0100
Subject: [PATCH 25/28] web: Adjust size of DeviceSelectorModal
---
web/src/components/storage/DeviceSelectorModal.tsx | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index dc857cae58..c3b00131e8 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -52,8 +52,6 @@ import { _ } from "~/i18n";
import type { PopupProps } from "~/components/core/Popup";
import type { Storage } from "~/model/system";
-import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing";
-
/** Identifies which tab is active in {@link DeviceSelectorModal}. */
export type TabKey = "disks" | "mdRaids" | "volumeGroups";
@@ -260,9 +258,7 @@ export default function DeviceSelectorModal({
description={_("Use the tabs to browse disks, RAID devices and LVM volume groups.")}
elementToFocus={deviceInInitialTab ? "input[type=radio]:checked" : undefined}
{...popupProps}
- className={[sizingStyles.h_100vhOnMd, sizingStyles.h_75vhOnLg, sizingStyles.h_50vhOnXl].join(
- " ",
- )}
+ style={{ height: "70dvh" }}
>
{intro}
From f8a3bd1fb74c100c8f2d7ea264189f43b2c9a015 Mon Sep 17 00:00:00 2001
From: Ancor Gonzalez Sosa
Date: Mon, 13 Apr 2026 14:16:18 +0100
Subject: [PATCH 26/28] web: Adjust texts when changing the device of a storage
definition
---
.../storage/DeviceSelectorModal.tsx | 5 ++-
.../components/storage/SearchedDeviceMenu.tsx | 43 ++++++++++---------
.../storage/SearchedVolumeGroupMenu.tsx | 15 +++++--
web/src/model/storage/config-model/boot.ts | 7 +++
4 files changed, 44 insertions(+), 26 deletions(-)
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index c3b00131e8..eacd3a6f2c 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -75,6 +75,8 @@ export type DeviceSelectorModalProps = Omit
deviceBaseName(device, true);
+
const targetDevices = (
deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid,
config: ConfigModel.Config,
@@ -115,21 +117,14 @@ const ChangeDeviceTitle = ({ modelDevice }: ChangeDeviceTitleProps) => {
);
};
-/**
- * Returns a string describing the side effects of moving away from
- * `modelDevice`, or `undefined` when there are no notable side effects.
- *
- * A plain function (not a component) because a React element's emptiness cannot
- * be checked without rendering it, making it difficult for callers to decide
- * whether to render anything at all (e.g. {@link Annotation} guards against no
- * children to avoid displaying just an icon with no text)
- */
-const changeDeviceSideEffect = (
- modelDevice: ConfigModel.Drive | ConfigModel.MdRaid,
- device: Storage.Device,
- config: ConfigModel.Config,
-): string | undefined => {
- const name = deviceBaseName(device, true);
+type ChangeDeviceDescriptionProps = {
+ modelDevice: ConfigModel.Drive | ConfigModel.MdRaid;
+ device: Storage.Device;
+};
+
+const ChangeDeviceDescription = ({ modelDevice, device }: ChangeDeviceDescriptionProps) => {
+ const config = useConfigModel();
+ const name = baseName(device);
const volumeGroups = configModel.partitionable.filterVolumeGroups(config, modelDevice);
const isExplicitBoot = configModel.boot.hasExplicitDevice(config, modelDevice.name);
const isBoot = configModel.boot.hasDevice(config, modelDevice.name);
@@ -220,8 +215,10 @@ const changeDeviceSideEffect = (
* reusing a volume group, or `undefined` when no new partitions are being
* added.
*
- * A plain function (not a component) for the same reason as {@link
- * changeDeviceSideEffect}.
+ * A plain function (not a component) because a React element's emptiness cannot
+ * be checked without rendering it, making it difficult for callers to decide
+ * whether to render anything at all (e.g. {@link Annotation} guards against no
+ * children to avoid displaying just an icon with no text)
*/
const reuseVgSideEffect = (
deviceConfig: ConfigModel.Drive | ConfigModel.MdRaid,
@@ -261,7 +258,12 @@ const ChangeDeviceMenuItem = ({
const onlyOneOption = useOnlyOneOption(config, modelDevice);
return (
-
+ }
+ isDisabled={onlyOneOption}
+ {...props}
+ >
);
@@ -340,7 +342,6 @@ export default function SearchedDeviceMenu({
const disks = availableTargets.filter(isDrive);
const mdRaids = availableTargets.filter(isMd);
const volumeGroups = availableTargets.filter(isVolumeGroup);
- const diskSelectionSideEffect = changeDeviceSideEffect(modelDevice, selected, config);
const vgSelectionSideEffect = reuseVgSideEffect(modelDevice);
const openSelector = () => {
@@ -373,13 +374,13 @@ export default function SearchedDeviceMenu({
{isSelectorOpen && (
}
+ intro={}
selected={selected}
disks={disks}
mdRaids={mdRaids}
volumeGroups={volumeGroups}
- disksSideEffects={diskSelectionSideEffect}
- mdRaidsSideEffects={diskSelectionSideEffect}
volumeGroupsSideEffects={vgSelectionSideEffect}
+ volumeGroupsEmptyTitle={_("Volume groups cannot be formatted")}
onConfirm={onDeviceChange}
onCancel={() => setIsSelectorOpen(false)}
/>
diff --git a/web/src/components/storage/SearchedVolumeGroupMenu.tsx b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
index dcb9c2dbc7..ab95b3cc54 100644
--- a/web/src/components/storage/SearchedVolumeGroupMenu.tsx
+++ b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
@@ -98,17 +98,24 @@ const ChangeVolumeGroupTitle = ({ deviceConfig }: ChangeVolumeGroupTitleProps) =
);
};
-type VolumeGroupSelectionSideEffectProps = {
+type ChangeVolumeGroupDescriptionProps = {
deviceConfig: ConfigModel.VolumeGroup;
};
-const VolumeGroupSelectionSideEffect = ({ deviceConfig }: VolumeGroupSelectionSideEffectProps) => {
+const ChangeVolumeGroupDescription = ({ deviceConfig }: ChangeVolumeGroupDescriptionProps) => {
+ const config = useConfigModel();
const isReusingLogicalVolumes = configModel.volumeGroup.isReusingLogicalVolumes(deviceConfig);
+ const mountPaths = configModel.volumeGroup.usedMountPaths(deviceConfig);
+ const bootFollowsRoot = configModel.boot.isFollowingRoot(config);
if (isReusingLogicalVolumes) {
// The current volume group will be the only option to choose from
return _("This uses existing logical volumes at the volume group");
}
+
+ if (mountPaths.includes("/") && bootFollowsRoot) {
+ return _("Partitions needed for booting will also be adapted");
+ }
};
type DiskSelectionSideEffectProps = {
@@ -149,7 +156,7 @@ const ChangeVolumeGroupMenuItem = ({
return (
}
+ description={}
isDisabled={unchangeable}
{...props}
>
@@ -261,11 +268,11 @@ export default function SearchedVolumeGroupMenu({
{isSelectorOpen && (
}
+ intro={}
device={device}
deviceConfig={deviceConfig}
disksSideEffects={}
mdRaidsSideEffects={}
- volumeGroupsSideEffects={}
onConfirm={onDeviceChange}
onCancel={() => setIsSelectorOpen(false)}
/>
diff --git a/web/src/model/storage/config-model/boot.ts b/web/src/model/storage/config-model/boot.ts
index 86ffaa75e3..e7b98945cd 100644
--- a/web/src/model/storage/config-model/boot.ts
+++ b/web/src/model/storage/config-model/boot.ts
@@ -43,6 +43,12 @@ function hasExplicitDevice(config: ConfigModel.Config, deviceName: string): bool
return hasDevice(config, deviceName) && !isDefault(config);
}
+function isFollowingRoot(config: ConfigModel.Config): boolean {
+ if (!config.boot?.configure) return false;
+
+ return config.boot.device?.default;
+}
+
function setBoot(config: ConfigModel.Config, boot: ConfigModel.Boot): ConfigModel.Config {
config = configModel.clone(config);
const device = findDevice(config);
@@ -90,6 +96,7 @@ export default {
isDefault,
hasDevice,
hasExplicitDevice,
+ isFollowingRoot,
setDevice,
setDefault,
disable,
From 6f61600f0bdf661096f2c8479bd175aee3d2af94 Mon Sep 17 00:00:00 2001
From: Ancor Gonzalez Sosa
Date: Mon, 13 Apr 2026 14:38:18 +0100
Subject: [PATCH 27/28] web: Adjust texts for adding new storage definitions
---
.../storage/ConfigureDeviceMenu.tsx | 46 +++++++++++++------
.../storage/DeviceSelectorModal.tsx | 23 +++++++++-
2 files changed, 53 insertions(+), 16 deletions(-)
diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx
index 9af9d1ea05..3e4b68867c 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.tsx
@@ -43,30 +43,32 @@ import type { Storage } from "~/model/system";
type AddDeviceMenuItemProps = {
/** Whether some of the available devices is an MD RAID */
withRaids: boolean;
+ /** Whether some of the available devices is an LVM volume group */
+ withLvm: boolean;
/** Available devices to be chosen */
devices: Storage.Device[];
- /** The total amount of drives and RAIDs already configured */
+ /** The total amount of devices (drives, RAIDs and VGs) already configured */
usedCount: number;
} & MenuItemProps;
-const AddDeviceTitle = ({ withRaids, usedCount }) => {
- if (withRaids) {
- if (usedCount === 0) return _("Select a device to define partitions or to mount");
- return _("Select another device to define partitions or to mount");
+const AddDeviceTitle = ({ withRaids, withLvm, usedCount }) => {
+ if (withRaids || withLvm) {
+ if (usedCount === 0) return _("Select an existing device");
+ return _("Select another existing device");
}
- if (usedCount === 0) return _("Select a disk to define partitions or to mount");
- return _("Select another disk to define partitions or to mount");
+ if (usedCount === 0) return _("Select a disk");
+ return _("Select another disk");
};
-const AddDeviceDescription = ({ withRaids, usedCount, isDisabled = false }) => {
+const AddDeviceDescription = ({ withRaids, withLvm, usedCount, isDisabled = false }) => {
if (isDisabled) {
- if (withRaids) return _("Already using all available devices");
+ if (withRaids || withLvm) return _("Already using all available devices");
return _("Already using all available disks");
}
if (usedCount) {
- if (withRaids)
+ if (withRaids || withLvm)
return sprintf(
n_(
"Extend the installation beyond the currently selected device",
@@ -94,6 +96,7 @@ const AddDeviceDescription = ({ withRaids, usedCount, isDisabled = false }) => {
*/
const AddDeviceMenuItem = ({
withRaids,
+ withLvm,
usedCount,
devices,
onClick,
@@ -107,13 +110,14 @@ const AddDeviceMenuItem = ({
description={
}
onClick={onClick}
>
-
+
>
);
@@ -142,7 +146,8 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
const disks = availableDevices.filter(isDrive);
const mdRaids = availableDevices.filter(isMd);
const volumeGroups = availableDevices.filter(isVolumeGroup);
- const withRaids = !!allDevices.filter((d) => !isDrive(d)).length;
+ const withRaids = !!allDevices.filter((d) => isMd(d)).length;
+ const withLvm = !!allDevices.filter((d) => isVolumeGroup(d)).length;
const addDevice = (device: Storage.Device) => {
if (isDrive(device)) addDrive({ name: device.name, spacePolicy: "keep" });
@@ -172,6 +177,7 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
usedCount={usedDevicesCount}
devices={availableDevices}
withRaids={withRaids}
+ withLvm={withLvm}
onClick={openDeviceSelector}
/>,
,
@@ -193,9 +199,19 @@ export default function ConfigureDeviceMenu(): React.ReactNode {
disks={disks}
mdRaids={mdRaids}
volumeGroups={volumeGroups}
- title={}
- intro={}
- newVolumeGroupLinkText={lvmDescription}
+ title={
+
+ }
+ intro={
+
+ }
+ disksIntro={_("Choose a disk to define partitions or to mount")}
+ mdRaidsIntro={_("Choose a RAID device to define partitions or to mount")}
+ volumeGroupsIntro={_("Choose a volume group to define logical volumes")}
onCancel={closeDeviceSelector}
onConfirm={([device]) => {
addDevice(device);
diff --git a/web/src/components/storage/DeviceSelectorModal.tsx b/web/src/components/storage/DeviceSelectorModal.tsx
index eacd3a6f2c..188ed34c28 100644
--- a/web/src/components/storage/DeviceSelectorModal.tsx
+++ b/web/src/components/storage/DeviceSelectorModal.tsx
@@ -75,6 +75,12 @@ export type DeviceSelectorModalProps = Omit (
- {children || }
+ {children ? (
+ <>
+ {intro}
+ {children}
+ >
+ ) : (
+
+ )}
);
@@ -208,6 +223,9 @@ export default function DeviceSelectorModal({
disksSideEffects,
mdRaidsSideEffects,
volumeGroupsSideEffects,
+ disksIntro,
+ mdRaidsIntro,
+ volumeGroupsIntro,
volumeGroupsEmptyTitle,
newVolumeGroupLinkText,
autoSelectOnTabChange = true,
@@ -281,6 +299,7 @@ export default function DeviceSelectorModal({
{disks.length > 0 && (
{mdRaids.length > 0 && (
{newVolumeGroupLinkText}
From 0a29263d615871ab39b95c8896868a53e4dfb732 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, 13 Apr 2026 15:51:02 +0100
Subject: [PATCH 28/28] Remove test
---
.../storage/ConfigureDeviceMenu.test.tsx | 18 +++---------------
1 file changed, 3 insertions(+), 15 deletions(-)
diff --git a/web/src/components/storage/ConfigureDeviceMenu.test.tsx b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
index c418945f82..d724e8ebd0 100644
--- a/web/src/components/storage/ConfigureDeviceMenu.test.tsx
+++ b/web/src/components/storage/ConfigureDeviceMenu.test.tsx
@@ -103,7 +103,7 @@ jest.mock("~/hooks/model/storage/config-model", () => ({
describe("ConfigureDeviceMenu", () => {
beforeEach(() => {
- mockUseModel.mockReturnValue({ drives: [], mdRaids: [] });
+ mockUseModel.mockReturnValue({ drives: [], mdRaids: [], volumeGroups: [] });
mockUseAvailableDevices.mockReturnValue([vda, vdb]);
});
@@ -159,23 +159,11 @@ describe("ConfigureDeviceMenu", () => {
expect(screen.queryByRole("dialog")).toBeNull();
expect(mockAddDrive).not.toHaveBeenCalled();
});
-
- it("shows a link to create a new volume group in the LVM tab", async () => {
- const { user } = installerRender();
- const toggler = screen.getByRole("button", { name: /More devices/ });
- await user.click(toggler);
- await user.click(screen.getByRole("menuitem", { name: "Add device menu" }));
- const dialog = screen.getByRole("dialog");
- await user.click(within(dialog).getByRole("tab", { name: "LVM" }));
- within(dialog).getByRole("link", {
- name: "Define a new LVM on top of one or several disks",
- });
- });
});
describe("but some disks are already configured", () => {
beforeEach(() => {
- mockUseModel.mockReturnValue({ drives: [vdaDrive], mdRaids: [] });
+ mockUseModel.mockReturnValue({ drives: [vdaDrive], mdRaids: [], volumeGroups: [] });
});
it("allows users to add a new drive to an unused disk", async () => {
@@ -198,7 +186,7 @@ describe("ConfigureDeviceMenu", () => {
describe("when there are no more unused disks", () => {
beforeEach(() => {
- mockUseModel.mockReturnValue({ drives: [vdaDrive, vdbDrive], mdRaids: [] });
+ mockUseModel.mockReturnValue({ drives: [vdaDrive, vdbDrive], mdRaids: [], volumeGroups: [] });
});
it("renders the disks menu as disabled with an informative label", async () => {