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
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}
diff --git a/web/src/components/core/Popup.test.tsx b/web/src/components/core/Popup.test.tsx
index 8e11362f70..953eab3a74 100644
--- a/web/src/components/core/Popup.test.tsx
+++ b/web/src/components/core/Popup.test.tsx
@@ -172,6 +172,13 @@ describe("Popup.SecondaryAction", () => {
const button = screen.queryByRole("button", { name: "Do something" });
expect(button.classList.contains("pf-m-secondary")).toBe(true);
});
+
+ it("renders a 'link' button when asLink is set", async () => {
+ installerRender(Do something);
+
+ const button = screen.queryByRole("button", { name: "Do something" });
+ expect(button.classList.contains("pf-m-link")).toBe(true);
+ });
});
describe("Popup.AncillaryAction", () => {
@@ -234,4 +241,14 @@ describe("Popup.Cancel", () => {
expect(button.classList.contains("pf-m-secondary")).toBe(true);
});
});
+
+ describe("when asLink is set", () => {
+ it("renders a 'link' button", async () => {
+ installerRender();
+
+ const button = screen.queryByRole("button", { name: "Cancel" });
+ expect(button).not.toBeNull();
+ expect(button.classList.contains("pf-m-link")).toBe(true);
+ });
+ });
});
diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx
index 91ed9906e2..b5839b1900 100644
--- a/web/src/components/core/Popup.tsx
+++ b/web/src/components/core/Popup.tsx
@@ -36,7 +36,7 @@ import { fork } from "radashi";
import { _, TranslatedString } from "~/i18n";
type ButtonWithoutVariantProps = Omit;
-type PredefinedAction = React.PropsWithChildren;
+type PredefinedAction = React.PropsWithChildren;
export type PopupProps = {
/** The dialog title */
title?: ModalHeaderProps["title"];
@@ -122,8 +122,8 @@ const Confirm = ({ children = _("Confirm"), ...actionProps }: PredefinedAction)
* Dismiss
*
*/
-const SecondaryAction = ({ children, ...actionProps }: PredefinedAction) => (
-
+const SecondaryAction = ({ children, asLink, ...actionProps }: PredefinedAction) => (
+
{children}
);
diff --git a/web/src/components/layout/Icon.tsx b/web/src/components/layout/Icon.tsx
index 08e3c5a3a8..b1fd3004cb 100644
--- a/web/src/components/layout/Icon.tsx
+++ b/web/src/components/layout/Icon.tsx
@@ -60,6 +60,7 @@ import MoreVert from "@bolderIcons/more_vert.svg?component";
import NetworkWifi from "@icons/network_wifi.svg?component";
import NetworkWifi1Bar from "@icons/network_wifi_1_bar.svg?component";
import NetworkWifi3Bar from "@icons/network_wifi_3_bar.svg?component";
+import NotificationsActive from "@icons/notifications_active.svg?component";
import Report from "@icons/report.svg?component";
import RestartAlt from "@icons/restart_alt.svg?component";
import SearchOff from "@icons/search_off.svg?component";
@@ -109,6 +110,7 @@ const icons = {
network_wifi: NetworkWifi,
network_wifi_1_bar: NetworkWifi1Bar,
network_wifi_3_bar: NetworkWifi3Bar,
+ notifications_ative: NotificationsActive,
report: Report,
restart_alt: RestartAlt,
search_off: SearchOff,
diff --git a/web/src/components/storage/ConfigEditor.tsx b/web/src/components/storage/ConfigEditor.tsx
index eeb604e971..38289a28e2 100644
--- a/web/src/components/storage/ConfigEditor.tsx
+++ b/web/src/components/storage/ConfigEditor.tsx
@@ -70,8 +70,8 @@ export default function ConfigEditor() {
<>
{/* FIXME add arial label */}
}
+ 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"]);
+ 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");
+ });
});
- it("allows sorting by device size", async () => {
- const { user } = plainRender(
- ,
- );
+ 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();
+ });
+ });
- const table = screen.getByRole("grid");
- const sortBySizeButton = within(table).getByRole("button", { name: "Size" });
+ describe("empty states", () => {
+ it("shows an empty state in the Disks tab when no disks are given", () => {
+ installerRender(
+ ,
+ );
+ screen.getByText("No disks 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 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");
+ });
- expect(getColumnValues(table, "Size")).toEqual(["1 KiB", "2 KiB"]);
+ 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");
+ });
- // Now keep sorting by size, but in desc direction
- await user.click(sortBySizeButton);
+ 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" });
+ });
- expect(getColumnValues(table, "Size")).toEqual(["2 KiB", "1 KiB"]);
+ 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();
+ });
+
+ 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 9639ad8fc7..188ed34c28 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.
*
@@ -20,134 +20,367 @@
* 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"];
-};
+/** Identifies which tab is active in {@link DeviceSelectorModal}. */
+export type TabKey = "disks" | "mdRaids" | "volumeGroups";
-const size = (device: Storage.Device) => {
- return deviceSize(device.block.size);
+/** 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;
+ /** General information at the top of the Disks tab, if there is any disk. */
+ disksIntro?: React.ReactNode;
+ /** General information at the top of the RAID tab, if there is any MD RAID. */
+ mdRaidsIntro?: React.ReactNode;
+ /** General information at the top of the LVM tab, if there is any volume group. */
+ volumeGroupsIntro?: React.ReactNode;
+ /** Title of the 'empty state' displayed when there are no LVMs to select from. */
+ volumeGroupsEmptyTitle?: string;
+ /**
+ * 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,
+ intro,
+ children,
+}: {
+ emptyTitle: string;
+ emptyBody: string;
+ emptyAction?: React.ReactNode;
+ intro?: React.ReactNode;
+ children?: React.ReactNode;
+}) => (
+
+
+ {children ? (
+ <>
+ {intro}
+ {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];
-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,
+ disksIntro,
+ mdRaidsIntro,
+ volumeGroupsIntro,
+ volumeGroupsEmptyTitle,
+ newVolumeGroupLinkText,
+ autoSelectOnTabChange = true,
...popupProps
}: DeviceSelectorModalProps): React.ReactNode {
- // FIXME: improve initial selection handling
+ const confirmHintId = useId();
+ const initialDevice = previousDevice ?? first([...disks, ...mdRaids, ...volumeGroups]);
const [selectedDevices, setSelectedDevices] = useState(
- selected ? [selected] : [devices[0]],
+ initialDevice ? [initialDevice] : [],
);
+ const [activeTab, setActiveTab] = useState(() =>
+ getInitialTabIndex(initialTab, initialDevice, [disks, mdRaids, volumeGroups]),
+ );
+ 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/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx
index e04b7ea246..7cc42fd47c 100644
--- a/web/src/components/storage/DriveEditor.test.tsx
+++ b/web/src/components/storage/DriveEditor.test.tsx
@@ -43,7 +43,8 @@ jest.mock("~/hooks/model/storage/config-model", () => ({
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/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..99d6bd6e70
--- /dev/null
+++ b/web/src/components/storage/DrivesTable.tsx
@@ -0,0 +1,101 @@
+/*
+ * 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 DeviceContent from "~/components/storage/DeviceContent";
+import { deviceBaseName, deviceSize } from "~/components/storage/utils";
+import { typeDescription } from "~/components/storage/utils/device";
+import { sortCollection } from "~/utils";
+import { _ } from "~/i18n";
+
+import type { Storage } from "~/model/system";
+import type { SortedBy, SelectableDataTableProps } from "~/components/core/SelectableDataTable";
+
+/** 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);
+};
+
+/**
+ * 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: (d: Storage.Device) => },
+ ];
+
+ const sortingKey = columns[sortedBy.index].sortingKey;
+ const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx
index 697d476f49..ee742c3b9e 100644
--- a/web/src/components/storage/LogicalVolumePage.tsx
+++ b/web/src/components/storage/LogicalVolumePage.tsx
@@ -20,16 +20,11 @@
* find current contact information at www.suse.com.
*/
-/**
- * @fixme This code was done in a hurry for including LVM managent in SLE16 beta3. It must be
- * completely refactored. There are a lot of duplications with PartitionPage. Both PartitionPage
- * and LogicalVolumePage should be adapted to share as much functionality as possible.
- */
-
-import React, { useCallback, useEffect, useId, useMemo, useState } from "react";
-import { useParams, useNavigate } from "react-router";
+import React, { useState } from "react";
+import { useParams, useNavigate, useLocation } from "react-router";
import {
ActionGroup,
+ Divider,
Flex,
FlexItem,
Form,
@@ -37,44 +32,50 @@ import {
FormHelperText,
HelperText,
HelperTextItem,
+ Label,
SelectGroup,
SelectList,
SelectOption,
SelectOptionProps,
+ Split,
+ SplitItem,
Stack,
- StackItem,
TextInput,
} from "@patternfly/react-core";
import { Page, SelectWrapper as Select, SubtleContent } from "~/components/core/";
import { SelectWrapperProps as SelectProps } from "~/components/core/SelectWrapper";
import SelectTypeaheadCreatable from "~/components/core/SelectTypeaheadCreatable";
import AutoSizeText from "~/components/storage/AutoSizeText";
-import { deviceSize, filesystemLabel, parseToBytes } from "~/components/storage/utils";
+import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect";
+import ResourceNotFound from "~/components/core/ResourceNotFound";
import configModel from "~/model/storage/config-model";
+import { useVolumeTemplate, useDevice } from "~/hooks/model/system/storage";
import {
- useSolvedConfigModel,
useConfigModel,
+ useSolvedConfigModel,
useMissingMountPaths,
- useVolumeGroup,
+ useVolumeGroup as useConfigModelVolumeGroup,
useAddLogicalVolume,
useEditLogicalVolume,
} from "~/hooks/model/storage/config-model";
-import { useVolumeTemplate } from "~/hooks/model/system/storage";
+import { deviceSize, deviceLabel, filesystemLabel, parseToBytes } from "~/components/storage/utils";
+import { _ } from "~/i18n";
+import { sprintf } from "sprintf-js";
import { STORAGE as PATHS, STORAGE } from "~/routes/paths";
import { unique } from "radashi";
import { compact } from "~/utils";
-import { sprintf } from "sprintf-js";
-import { _ } from "~/i18n";
-import SizeModeSelect, { SizeMode, SizeRange } from "~/components/storage/SizeModeSelect";
-import type { ConfigModel, Data } from "~/model/storage/config-model";
+import type { ConfigModel } from "~/model/storage/config-model";
import type { Storage as System } from "~/model/system";
const NO_VALUE = "";
+const NEW_LOGICAL_VOLUME = "new";
+const REUSE_FILESYSTEM = "reuse";
type SizeOptionValue = "" | SizeMode;
type FormValue = {
mountPoint: string;
name: string;
+ target: string;
filesystem: string;
filesystemLabel: string;
sizeOption: SizeOptionValue;
@@ -93,7 +94,26 @@ type ErrorsHandler = {
getVisibleError: (id: string) => Error | undefined;
};
-function toData(value: FormValue): Data.LogicalVolume {
+function configuredLogicalVolumes(
+ volumeGroupConfig: ConfigModel.VolumeGroup,
+): ConfigModel.LogicalVolume[] {
+ if (volumeGroupConfig.spacePolicy === "custom")
+ return volumeGroupConfig.logicalVolumes.filter(
+ (l) =>
+ !configModel.volume.isNew(l) &&
+ (configModel.volume.isUsed(l) || configModel.volume.isUsedBySpacePolicy(l)),
+ );
+
+ return volumeGroupConfig.logicalVolumes.filter(configModel.volume.isReused);
+}
+
+function createLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume {
+ const name = (): string | undefined => {
+ if (value.target === NO_VALUE || value.target === NEW_LOGICAL_VOLUME) return undefined;
+
+ return value.target;
+ };
+
const filesystemType = (): ConfigModel.FilesystemType | undefined => {
if (value.filesystem === NO_VALUE) return undefined;
@@ -107,11 +127,14 @@ function toData(value: FormValue): Data.LogicalVolume {
return value.filesystem as ConfigModel.FilesystemType;
};
- const filesystem = (): Data.Filesystem | undefined => {
+ const filesystem = (): ConfigModel.Filesystem | undefined => {
+ if (value.filesystem === REUSE_FILESYSTEM) return { reuse: true, default: true };
+
const type = filesystemType();
if (type === undefined) return undefined;
return {
+ default: false,
type,
label: value.filesystemLabel,
};
@@ -131,26 +154,32 @@ function toData(value: FormValue): Data.LogicalVolume {
return {
mountPath: value.mountPoint,
lvName: value.name,
+ name: name(),
filesystem: filesystem(),
size: size(),
};
}
-function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue {
- const mountPoint = (): string => logicalVolume.mountPath || NO_VALUE;
+function createFormValue(logicalVolumeConfig: ConfigModel.LogicalVolume): FormValue {
+ const mountPoint = (): string => logicalVolumeConfig.mountPath || NO_VALUE;
+
+ const target = (): string => logicalVolumeConfig.name || NEW_LOGICAL_VOLUME;
const filesystem = (): string => {
- const fs = logicalVolume.filesystem;
- if (!fs.type) return NO_VALUE;
+ const fsConfig = logicalVolumeConfig.filesystem;
+ if (fsConfig.reuse) return REUSE_FILESYSTEM;
+ if (!fsConfig.type) return NO_VALUE;
- return fs.type;
+ return fsConfig.type;
};
- const filesystemLabel = (): string => logicalVolume.filesystem?.label || NO_VALUE;
+ const filesystemLabel = (): string => logicalVolumeConfig.filesystem?.label || NO_VALUE;
const sizeOption = (): SizeOptionValue => {
- const size = logicalVolume.size;
- if (!size || size.default) return "auto";
+ const reuse = logicalVolumeConfig.name !== undefined;
+ const sizeConfig = logicalVolumeConfig.size;
+ if (reuse) return NO_VALUE;
+ if (!sizeConfig || sizeConfig.default) return "auto";
return "custom";
};
@@ -160,24 +189,49 @@ function toFormValue(logicalVolume: ConfigModel.LogicalVolume): FormValue {
return {
mountPoint: mountPoint(),
- name: logicalVolume.lvName,
+ name: logicalVolumeConfig.lvName,
+ target: target(),
filesystem: filesystem(),
filesystemLabel: filesystemLabel(),
sizeOption: sizeOption(),
- minSize: size(logicalVolume.size?.min),
- maxSize: size(logicalVolume.size?.max),
+ minSize: size(logicalVolumeConfig.size?.min),
+ maxSize: size(logicalVolumeConfig.size?.max),
};
}
+function useVolumeGroupConfig(): ConfigModel.VolumeGroup | null {
+ const { id: index } = useParams();
+
+ return useConfigModelVolumeGroup(Number(index)) ?? null;
+}
+
+function useVolumeGroup(): System.Device {
+ const volumeGroupConfig = useVolumeGroupConfig();
+ return useDevice(volumeGroupConfig.name);
+}
+
+function useLogicalVolume(target: string): System.Device | null {
+ const volumeGroup = useVolumeGroup();
+
+ if (target === NEW_LOGICAL_VOLUME) return null;
+
+ const logicalVolumes = volumeGroup.logicalVolumes || [];
+ return logicalVolumes.find((p: System.Device) => p.name === target);
+}
+
+function useLogicalVolumeFilesystem(target: string): string | null {
+ const logicalVolume = useLogicalVolume(target);
+ return logicalVolume?.filesystem?.type || null;
+}
+
function useDefaultFilesystem(mountPoint: string): string {
const volume = useVolumeTemplate(mountPoint);
return volume.fsType;
}
-function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null {
- const { id: vgName, logicalVolumeId: mountPath } = useParams();
- const volumeGroup = useVolumeGroup(vgName);
-
+function useInitialLogicalVolumeConfig(): ConfigModel.LogicalVolume | null {
+ const { logicalVolumeId: mountPath } = useParams();
+ const volumeGroup = useVolumeGroupConfig();
if (!volumeGroup || !mountPath) return null;
const logicalVolume = volumeGroup.logicalVolumes.find((l) => l.mountPath === mountPath);
@@ -185,23 +239,41 @@ function useInitialLogicalVolume(): ConfigModel.LogicalVolume | null {
}
function useInitialFormValue(): FormValue | null {
- const logicalVolume = useInitialLogicalVolume();
- const value = useMemo(() => (logicalVolume ? toFormValue(logicalVolume) : null), [logicalVolume]);
+ const logicalVolumeConfig = useInitialLogicalVolumeConfig();
+
+ const value = React.useMemo(
+ () => (logicalVolumeConfig ? createFormValue(logicalVolumeConfig) : null),
+ [logicalVolumeConfig],
+ );
+
return value;
}
/** Unused predefined mount points. Includes the currently used mount point when editing. */
function useUnusedMountPoints(): string[] {
- const missingMountPaths = useMissingMountPaths();
- const initialLogicalVolume = useInitialLogicalVolume();
- return compact([initialLogicalVolume?.mountPath, ...missingMountPaths]);
+ const unusedMountPaths = useMissingMountPaths();
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
+ return compact([initialLogicalVolumeConfig?.mountPath, ...unusedMountPaths]);
+}
+
+/** Unused logical volumes. Includes the currently used logical volume when editing (if any). */
+function useUnusedLogicalVolumes(): System.Device[] {
+ const volumeGroup = useVolumeGroup();
+ const allLogicalVolumes = volumeGroup.logicalVolumes || [];
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
+ const volumeGroupConfig = useVolumeGroupConfig();
+ const configuredNames = configuredLogicalVolumes(volumeGroupConfig)
+ .filter((l) => l.name !== initialLogicalVolumeConfig?.name)
+ .map((l) => l.name);
+
+ return allLogicalVolumes.filter((l) => !configuredNames.includes(l.name));
}
function useUsableFilesystems(mountPoint: string): string[] {
const volume = useVolumeTemplate(mountPoint);
const defaultFilesystem = useDefaultFilesystem(mountPoint);
- const usableFilesystems = useMemo(() => {
+ const usableFilesystems = React.useMemo(() => {
const volumeFilesystems = (): string[] => {
return volume.outline.fsTypes;
};
@@ -215,7 +287,7 @@ function useUsableFilesystems(mountPoint: string): string[] {
function useMountPointError(value: FormValue): Error | undefined {
const config = useConfigModel();
const mountPoints = config ? configModel.usedMountPaths(config) : [];
- const initialLogicalVolume = useInitialLogicalVolume();
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
const mountPoint = value.mountPoint;
if (mountPoint === NO_VALUE) {
@@ -235,7 +307,7 @@ function useMountPointError(value: FormValue): Error | undefined {
}
// Exclude itself when editing
- const initialMountPoint = initialLogicalVolume?.mountPath;
+ const initialMountPoint = initialLogicalVolumeConfig?.mountPath;
if (mountPoint !== initialMountPoint && mountPoints.includes(mountPoint)) {
return {
id: "mountPoint",
@@ -246,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",
@@ -255,7 +327,7 @@ function checkLogicalVolumeName(value: FormValue): Error | undefined {
};
}
-function checkSize(value: FormValue): Error | undefined {
+function checkSizeError(value: FormValue): Error | undefined {
if (value.sizeOption !== "custom") return;
const min = value.minSize;
@@ -268,7 +340,7 @@ function checkSize(value: FormValue): Error | undefined {
};
}
- const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$/;
+ const regexp = /^[0-9]+(\.[0-9]+)?(\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])$/;
const validMin = regexp.test(min);
const validMax = max ? regexp.test(max) : true;
@@ -285,7 +357,7 @@ function checkSize(value: FormValue): Error | undefined {
if (validMin) {
return {
id: "customSize",
- message: _("The maximum must be a number optionally followed by a unit like GiB or GB"),
+ message: _("The maximum must be a number followed by a unit like GiB or GB"),
isVisible: true,
};
}
@@ -293,14 +365,14 @@ function checkSize(value: FormValue): Error | undefined {
if (validMax) {
return {
id: "customSize",
- message: _("The minimum must be a number optionally followed by a unit like GiB or GB"),
+ message: _("The minimum must be a number followed by a unit like GiB or GB"),
isVisible: true,
};
}
return {
id: "customSize",
- message: _("Size limits must be numbers optionally followed by a unit like GiB or GB"),
+ message: _("Size limits must be numbers followed by a unit like GiB or GB"),
isVisible: true,
};
}
@@ -308,7 +380,7 @@ function checkSize(value: FormValue): Error | undefined {
function useErrors(value: FormValue): ErrorsHandler {
const mountPointError = useMountPointError(value);
const nameError = checkLogicalVolumeName(value);
- const sizeError = checkSize(value);
+ const sizeError = checkSizeError(value);
const errors = compact([mountPointError, nameError, sizeError]);
const getError = (id: string): Error | undefined => errors.find((e) => e.id === id);
@@ -321,24 +393,36 @@ function useErrors(value: FormValue): ErrorsHandler {
return { errors, getError, getVisibleError };
}
-function useSolvedModel(value: FormValue): ConfigModel.Config | null {
- const { id: vgName, logicalVolumeId: mountPath } = useParams();
+function useSolvedConfig(value: FormValue): ConfigModel.Config | null {
+ const { id: index } = useParams();
+ const volumeGroupConfig = useVolumeGroupConfig();
const config = useConfigModel();
- const { getError } = useErrors(value);
- const mountPointError = getError("mountPoint");
- const data = toData(value);
+ const { errors } = useErrors(value);
+ const initialLogicalVolumeConfig = useInitialLogicalVolumeConfig();
+ const logicalVolumeConfig = createLogicalVolumeConfig(value);
+ logicalVolumeConfig.size = undefined;
// Avoid recalculating the solved model because changes in label.
- if (data.filesystem) data.filesystem.label = undefined;
+ if (logicalVolumeConfig.filesystem) logicalVolumeConfig.filesystem.label = undefined;
// Avoid recalculating the solved model because changes in name.
- data.lvName = undefined;
+ logicalVolumeConfig.lvName = undefined;
let sparseModel: ConfigModel.Config | undefined;
- if (data.filesystem && !mountPointError) {
- if (mountPath) {
- sparseModel = configModel.logicalVolume.edit(config, vgName, mountPath, data);
+ if (
+ volumeGroupConfig &&
+ !errors.length &&
+ value.target === NEW_LOGICAL_VOLUME &&
+ value.filesystem !== NO_VALUE
+ ) {
+ if (initialLogicalVolumeConfig) {
+ sparseModel = configModel.logicalVolume.edit(
+ config,
+ Number(index),
+ initialLogicalVolumeConfig.mountPath,
+ logicalVolumeConfig,
+ );
} else {
- sparseModel = configModel.logicalVolume.add(config, vgName, data);
+ sparseModel = configModel.logicalVolume.add(config, Number(index), logicalVolumeConfig);
}
}
@@ -346,11 +430,17 @@ function useSolvedModel(value: FormValue): ConfigModel.Config | null {
return solvedModel;
}
-function useSolvedLogicalVolume(value: FormValue): ConfigModel.LogicalVolume | undefined {
- const { id: vgName } = useParams();
- const config = useSolvedModel(value);
- const volumeGroup = config?.volumeGroups?.find((v) => v.vgName === vgName);
- return volumeGroup?.logicalVolumes?.find((l) => l.mountPath === value.mountPoint);
+function useSolvedLogicalVolumeConfig(value: FormValue): ConfigModel.LogicalVolume | undefined {
+ const volumeGroupConfig = useVolumeGroupConfig();
+ const solvedConfig = useSolvedConfig(value);
+ if (!solvedConfig) return;
+
+ const solvedVolumeGroupConfig = configModel.volumeGroup.findByName(
+ solvedConfig,
+ volumeGroupConfig.vgName,
+ );
+
+ return configModel.device.findVolumeByMountPath(solvedVolumeGroupConfig, value.mountPoint);
}
function useSolvedSizes(value: FormValue): SizeRange {
@@ -362,45 +452,123 @@ function useSolvedSizes(value: FormValue): SizeRange {
maxSize: NO_VALUE,
};
- const logicalVolume = useSolvedLogicalVolume(valueWithoutSizes);
+ const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(valueWithoutSizes);
- const solvedSizes = useMemo(() => {
- const min = logicalVolume?.size?.min;
- const max = logicalVolume?.size?.max;
+ const solvedSizes = React.useMemo(() => {
+ const min = solvedLogicalVolumeConfig?.size?.min;
+ const max = solvedLogicalVolumeConfig?.size?.max;
return {
min: min ? deviceSize(min) : NO_VALUE,
max: max ? deviceSize(max) : NO_VALUE,
};
- }, [logicalVolume]);
+ }, [solvedLogicalVolumeConfig]);
return solvedSizes;
}
function useAutoRefreshFilesystem(handler, value: FormValue) {
- const { mountPoint } = value;
+ const { mountPoint, target } = value;
const defaultFilesystem = useDefaultFilesystem(mountPoint);
+ const usableFilesystems = useUsableFilesystems(mountPoint);
+ const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target);
- useEffect(() => {
+ React.useEffect(() => {
// Reset filesystem if there is no mount point yet.
if (mountPoint === NO_VALUE) handler(NO_VALUE);
// Select default filesystem for the mount point.
- if (mountPoint !== NO_VALUE) handler(defaultFilesystem);
- }, [handler, mountPoint, defaultFilesystem]);
+ if (mountPoint !== NO_VALUE && target === NEW_LOGICAL_VOLUME) handler(defaultFilesystem);
+ // Select default filesystem for the mount point if the logical volume has no filesystem.
+ if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && !logicalVolumeFilesystem)
+ handler(defaultFilesystem);
+ // Reuse the filesystem from the logical volume if possible.
+ if (mountPoint !== NO_VALUE && target !== NEW_LOGICAL_VOLUME && logicalVolumeFilesystem) {
+ const reuse = usableFilesystems.includes(logicalVolumeFilesystem);
+ handler(reuse ? REUSE_FILESYSTEM : defaultFilesystem);
+ }
+ }, [handler, mountPoint, target, defaultFilesystem, usableFilesystems, logicalVolumeFilesystem]);
}
function useAutoRefreshSize(handler, value: FormValue) {
+ const target = value.target;
const solvedSizes = useSolvedSizes(value);
- useEffect(() => {
- handler("auto", solvedSizes.min, solvedSizes.max);
- }, [handler, solvedSizes]);
+ React.useEffect(() => {
+ const sizeOption = target === NEW_LOGICAL_VOLUME ? "auto" : "";
+ handler(sizeOption, solvedSizes.min, solvedSizes.max);
+ }, [handler, target, solvedSizes]);
}
function mountPointSelectOptions(mountPoints: string[]): SelectOptionProps[] {
return mountPoints.map((p) => ({ value: p, children: p }));
}
+type TargetOptionLabelProps = {
+ value: string;
+};
+
+function TargetOptionLabel({ value }: TargetOptionLabelProps): React.ReactNode {
+ const device = useVolumeGroup();
+ const logicalVolume = useLogicalVolume(value);
+
+ if (value === NEW_LOGICAL_VOLUME) {
+ // TRANSLATORS: %s is a disk name with its size (eg. "sda, 10 GiB"
+ return sprintf(_("As a new logical volume on %s"), deviceLabel(device, true));
+ } else {
+ return sprintf(_("Using logical volume %s"), deviceLabel(logicalVolume, true));
+ }
+}
+
+type LogicalVolumeDescriptionProps = {
+ logicalVolume: System.Device;
+};
+
+function LogicalVolumeDescription({
+ logicalVolume,
+}: LogicalVolumeDescriptionProps): React.ReactNode {
+ const label = logicalVolume.filesystem?.label;
+
+ return (
+
+ {logicalVolume.description}
+ {label && (
+
+
+
+ )}
+
+ );
+}
+
+function TargetOptions(): React.ReactNode {
+ const logicalVolumes = useUnusedLogicalVolumes();
+
+ return (
+
+
+
+
+
+
+ {logicalVolumes.map((logicalVolume, index) => (
+ }
+ >
+ {deviceLabel(logicalVolume)}
+
+ ))}
+ {logicalVolumes.length === 0 && (
+ {_("There are not usable logical volumes")}
+ )}
+
+
+ );
+}
+
type LogicalVolumeNameProps = {
id?: string;
value: FormValue;
@@ -444,37 +612,58 @@ function LogicalVolumeName({
type FilesystemOptionLabelProps = {
value: string;
+ target: string;
volume: System.Volume;
};
-function FilesystemOptionLabel({ value }: FilesystemOptionLabelProps): React.ReactNode {
+function FilesystemOptionLabel({ value, target }: FilesystemOptionLabelProps): React.ReactNode {
+ const logicalVolume = useLogicalVolume(target);
+ const filesystem = logicalVolume?.filesystem?.type;
+
if (value === NO_VALUE) return _("Waiting for a mount point");
+ // TRANSLATORS: %s is a filesystem type, like Btrfs
+ if (value === REUSE_FILESYSTEM && filesystem)
+ return sprintf(_("Current %s"), filesystemLabel(filesystem));
+
return filesystemLabel(value);
}
type FilesystemOptionsProps = {
mountPoint: string;
+ target: string;
};
-function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactNode {
+function FilesystemOptions({ mountPoint, target }: FilesystemOptionsProps): React.ReactNode {
+ const volume = useVolumeTemplate(mountPoint);
const defaultFilesystem = useDefaultFilesystem(mountPoint);
const usableFilesystems = useUsableFilesystems(mountPoint);
- const volume = useVolumeTemplate(mountPoint);
-
- const defaultOptText =
- mountPoint !== NO_VALUE && volume.mountPath
- ? sprintf(_("Default file system for %s"), mountPoint)
- : _("Default file system for generic logical volumes");
+ const logicalVolumeFilesystem = useLogicalVolumeFilesystem(target);
+ const canReuse = logicalVolumeFilesystem && usableFilesystems.includes(logicalVolumeFilesystem);
- const formatText = _("Format logical volume as");
+ const defaultOptText = volume.mountPath
+ ? sprintf(_("Default file system for %s"), mountPoint)
+ : _("Default file system for generic logical volume");
+ const formatText = logicalVolumeFilesystem
+ ? _("Destroy current data and format logical volume as")
+ : _("Format logical volume as");
return (
{mountPoint === NO_VALUE && (
-
+
)}
+ {mountPoint !== NO_VALUE && canReuse && (
+
+
+
+ )}
+ {mountPoint !== NO_VALUE && canReuse && usableFilesystems.length && }
{mountPoint !== NO_VALUE && (
{usableFilesystems.map((fsType, index) => (
@@ -483,7 +672,7 @@ function FilesystemOptions({ mountPoint }: FilesystemOptionsProps): React.ReactN
value={fsType}
description={fsType === defaultFilesystem && defaultOptText}
>
-
+
))}
@@ -496,6 +685,7 @@ type FilesystemSelectProps = {
id?: string;
value: string;
mountPoint: string;
+ target: string;
onChange: SelectProps["onChange"];
};
@@ -503,6 +693,7 @@ function FilesystemSelect({
id,
value,
mountPoint,
+ target,
onChange,
}: FilesystemSelectProps): React.ReactNode {
const volume = useVolumeTemplate(mountPoint);
@@ -512,11 +703,11 @@ function FilesystemSelect({
}
+ label={}
onChange={onChange}
isDisabled={mountPoint === NO_VALUE}
>
-
+
);
}
@@ -546,46 +737,63 @@ type AutoSizeInfoProps = {
function AutoSizeInfo({ value }: AutoSizeInfoProps): React.ReactNode {
const volume = useVolumeTemplate(value.mountPoint);
- const logicalVolume = useSolvedLogicalVolume(value);
- const size = logicalVolume?.size;
+ const solvedLogicalVolumeConfig = useSolvedLogicalVolumeConfig(value);
+ const size = solvedLogicalVolumeConfig?.size;
if (!size) return;
return (
-
+
);
}
-export default function LogicalVolumePage() {
+const LogicalVolumeForm = () => {
+ const { id: index } = useParams();
const navigate = useNavigate();
- const headingId = useId();
- const { id: vgName } = useParams();
- const addLogicalVolume = useAddLogicalVolume();
- const editLogicalVolume = useEditLogicalVolume();
+ const location = useLocation();
const [mountPoint, setMountPoint] = useState(NO_VALUE);
const [name, setName] = useState(NO_VALUE);
+ const [target, setTarget] = useState(NEW_LOGICAL_VOLUME);
const [filesystem, setFilesystem] = useState(NO_VALUE);
const [filesystemLabel, setFilesystemLabel] = useState(NO_VALUE);
const [sizeOption, setSizeOption] = useState(NO_VALUE);
const [minSize, setMinSize] = useState(NO_VALUE);
const [maxSize, setMaxSize] = useState(NO_VALUE);
- // Filesystem and size selectors should not be auto refreshed before the user interacts with the
- // mount point selector.
+ // Filesystem and size selectors should not be auto refreshed before the user interacts with other
+ // selectors like the mount point or the target selectors.
const [autoRefreshFilesystem, setAutoRefreshFilesystem] = useState(false);
const [autoRefreshSize, setAutoRefreshSize] = useState(false);
const initialValue = useInitialFormValue();
- const value = { mountPoint, name, filesystem, filesystemLabel, sizeOption, minSize, maxSize };
+ const value = {
+ mountPoint,
+ name,
+ target,
+ filesystem,
+ filesystemLabel,
+ sizeOption,
+ minSize,
+ maxSize,
+ };
const { errors, getVisibleError } = useErrors(value);
+
+ const volumeGroupConfig = useVolumeGroupConfig();
+ const volumeGroup = useVolumeGroup();
+ const logicalVolume = useLogicalVolume(target);
+
const unusedMountPoints = useUnusedMountPoints();
+ const addLogicalVolume = useAddLogicalVolume();
+ const editLogicalVolume = useEditLogicalVolume();
+
// Initializes the form values if there is an initial value (i.e., when editing a logical volume).
React.useEffect(() => {
if (initialValue) {
setMountPoint(initialValue.mountPoint);
setName(initialValue.name);
+ setTarget(initialValue.target);
setFilesystem(initialValue.filesystem);
setFilesystemLabel(initialValue.filesystemLabel);
setSizeOption(initialValue.sizeOption);
@@ -595,6 +803,7 @@ export default function LogicalVolumePage() {
}, [
initialValue,
setMountPoint,
+ setTarget,
setFilesystem,
setFilesystemLabel,
setSizeOption,
@@ -602,14 +811,14 @@ export default function LogicalVolumePage() {
setMaxSize,
]);
- const refreshFilesystemHandler = useCallback(
+ const refreshFilesystemHandler = React.useCallback(
(filesystem: string) => autoRefreshFilesystem && setFilesystem(filesystem),
[autoRefreshFilesystem, setFilesystem],
);
useAutoRefreshFilesystem(refreshFilesystemHandler, value);
- const refreshSizeHandler = useCallback(
+ const refreshSizeHandler = React.useCallback(
(sizeOption: SizeOptionValue, minSize: string, maxSize: string) => {
if (autoRefreshSize) {
setSizeOption(sizeOption);
@@ -627,10 +836,15 @@ export default function LogicalVolumePage() {
setAutoRefreshFilesystem(true);
setAutoRefreshSize(true);
setMountPoint(value);
- setName(configModel.logicalVolume.generateName(value));
}
};
+ const changeTarget = (value: string) => {
+ setAutoRefreshFilesystem(true);
+ setAutoRefreshSize(true);
+ setTarget(value);
+ };
+
const changeFilesystem = (value: string) => {
setAutoRefreshFilesystem(false);
setAutoRefreshSize(false);
@@ -649,18 +863,19 @@ export default function LogicalVolumePage() {
};
const onSubmit = () => {
- const data = toData(value);
+ const logicalVolumeConfig = createLogicalVolumeConfig(value);
- if (initialValue) editLogicalVolume(vgName, initialValue.mountPoint, data);
- else addLogicalVolume(vgName, data);
+ if (initialValue)
+ editLogicalVolume(Number(index), initialValue.mountPoint, logicalVolumeConfig);
+ else addLogicalVolume(Number(index), logicalVolumeConfig);
- navigate(PATHS.root);
+ navigate({ pathname: PATHS.root, search: location.search });
};
const isFormValid = errors.length === 0;
const mountPointError = getVisibleError("mountPoint");
const usedMountPt = mountPointError ? NO_VALUE : mountPoint;
- const showLabel = filesystem !== NO_VALUE && usedMountPt !== NO_VALUE;
+ const showLabel = filesystem !== NO_VALUE && filesystem !== REUSE_FILESYSTEM;
const sizeMode: SizeMode = sizeOption === "" ? "auto" : sizeOption;
const sizeRange: SizeRange = { min: minSize, max: maxSize };
@@ -668,45 +883,58 @@ export default function LogicalVolumePage() {
-
);
+};
+
+export default function LogicalVolumePage() {
+ const volumeGroupConfig = useVolumeGroupConfig();
+
+ if (!volumeGroupConfig)
+ return ;
+
+ return ;
}
diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx
index 9ad6c3d6ee..a16c0f7b12 100644
--- a/web/src/components/storage/LvmPage.tsx
+++ b/web/src/components/storage/LvmPage.tsx
@@ -41,7 +41,7 @@ import { contentDescription, filesystemLabels, typeDescription } from "./utils/d
import { STORAGE } from "~/routes/paths";
import { sprintf } from "sprintf-js";
import { _ } from "~/i18n";
-import { deviceSystems, isDrive } from "~/model/storage/device";
+import { deviceSystems, isDrive, isMd } from "~/model/storage/device";
import configModel from "~/model/storage/config-model";
import {
useConfigModel,
@@ -50,23 +50,21 @@ import {
useEditVolumeGroup,
} from "~/hooks/model/storage/config-model";
import type { ConfigModel, Data } from "~/model/storage/config-model";
-import type { Storage } from "~/model/system";
+import type { Storage as System } from "~/model/system";
/**
* Hook that returns the devices that can be selected as target to automatically create LVM PVs.
*
* Filters out devices that are going to be directly formatted.
*/
-function useLvmTargetDevices(): Storage.Device[] {
+function useLvmTargetDevices(): System.Device[] {
const availableDevices = useAvailableDevices();
const config = useConfigModel();
const targetDevices = useMemo(() => {
- return availableDevices.filter((candidate) => {
- const collection = isDrive(candidate) ? config.drives : config.mdRaids;
- const device = collection.find((d) => d.name === candidate.name);
- return !device || !device.filesystem;
- });
+ return availableDevices
+ .filter((d) => isDrive(d) || isMd(d))
+ .filter((d) => !configModel.partitionable.findByName(config, d.name)?.filesystem);
}, [availableDevices, config]);
return targetDevices;
@@ -84,7 +82,7 @@ function vgNameError(
return sprintf(_("Volume group '%s' already exists. Enter a different name."), vgName);
}
-function targetDevicesError(targetDevices: Storage.Device[]): string | undefined {
+function targetDevicesError(targetDevices: System.Device[]): string | undefined {
if (!targetDevices.length) return _("Select at least one disk.");
}
@@ -95,15 +93,15 @@ function targetDevicesError(targetDevices: Storage.Device[]): string | undefined
* model.VolumeGroup (build data.VolumeGroup from model.VolumeGroup).
*/
export default function LvmPage() {
- const { id } = useParams();
+ const { id: index } = useParams();
const navigate = useNavigate();
const config = useConfigModel();
- const volumeGroup = useVolumeGroup(id);
+ const volumeGroup = useVolumeGroup(Number(index));
const addVolumeGroup = useAddVolumeGroup();
const editVolumeGroup = useEditVolumeGroup();
const allDevices = useLvmTargetDevices();
const [name, setName] = useState("");
- const [selectedDevices, setSelectedDevices] = useState([]);
+ const [selectedDevices, setSelectedDevices] = useState([]);
const [moveMountPoints, setMoveMountPoints] = useState(true);
const [errors, setErrors] = useState([]);
diff --git a/web/src/components/storage/MdRaidsTable.test.tsx b/web/src/components/storage/MdRaidsTable.test.tsx
new file mode 100644
index 0000000000..d18eacfa44
--- /dev/null
+++ b/web/src/components/storage/MdRaidsTable.test.tsx
@@ -0,0 +1,171 @@
+/*
+ * 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"),
+ useFlattenDevices: () => [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, 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", () => {
+ 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 current content of each member device", () => {
+ plainRender();
+ const md0Row = screen.getByRole("row", { name: /md0/ });
+ within(md0Row).getByText("MD RAID 0");
+ });
+
+ it("renders the member names", () => {
+ plainRender();
+ const table = screen.getByRole("grid");
+ expect(getColumnValues(table, "Members")).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(["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..2a7d7f7292
--- /dev/null
+++ b/web/src/components/storage/MdRaidsTable.tsx
@@ -0,0 +1,104 @@
+/*
+ * 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 DeviceContent from "~/components/storage/DeviceContent";
+import { useFlattenDevices } 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) => {
+ const pv = systemDevices.find((d) => d.sid === sid);
+ return pv ? deviceBaseName(pv) : sid;
+ })
+ .join(", ");
+
+/**
+ * Table for selecting among available software RAID devices.
+ */
+export default function MdRaidsTable({
+ devices,
+ selectedDevices,
+ onSelectionChange,
+ selectionMode = "single",
+}: MdRaidsTableProps) {
+ const systemDevices = useFlattenDevices();
+ 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),
+ },
+ { name: _("Current content"), value: (d: Storage.Device) => },
+ ];
+
+ const sortingKey = columns[sortedBy.index].sortingKey;
+ const sortedDevices = sortCollection(devices, sortedBy.direction, sortingKey);
+
+ return (
+
+ );
+}
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/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 97660e4910..d160878c47 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.
*
@@ -21,26 +21,46 @@
*/
import React, { useState } from "react";
-import MenuButton, { CustomToggleProps, MenuButtonItem } from "~/components/core/MenuButton";
-import NewVgMenuOption from "./NewVgMenuOption";
+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";
import { useAvailableDevices } from "~/hooks/model/system/storage";
-import {
- useConfigModel,
- useAddDriveFromMdRaid,
- useAddMdRaidFromDrive,
-} 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 { MenuItemProps } from "@patternfly/react-core";
-import { isDrive } from "~/model/storage/device";
+import { _, n_, formatList } from "~/i18n";
+
+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";
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.findDeviceByName(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,
@@ -71,18 +91,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
@@ -92,7 +112,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),
);
};
@@ -190,6 +210,37 @@ const ChangeDeviceDescription = ({ modelDevice, device }: ChangeDeviceDescriptio
}
};
+/**
+ * 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) 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,
+): string | undefined => {
+ 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 +269,19 @@ const ChangeDeviceMenuItem = ({
);
};
-type RemoveEntryOptionProps = {
+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,24 +317,6 @@ 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;
-
- const collection = isDrive(availableDev) ? config.drives : config.mdRaids;
- const device = collection.find((d) => d.name === availableDev.name);
- if (!device) return true;
-
- if (modelDevice.filesystem) return !configModel.partitionable.isUsed(config, device.name);
-
- return !device.filesystem;
- });
-};
-
export type SearchedDeviceMenuProps = {
selected: Storage.Device;
modelDevice: ConfigModel.Drive | ConfigModel.MdRaid;
@@ -323,18 +335,22 @@ export default function SearchedDeviceMenu({
toggle,
deleteFn,
}: SearchedDeviceMenuProps): React.ReactNode {
+ const config = useConfigModel();
+ const convertDevice = useConvertDevice();
const [isSelectorOpen, setIsSelectorOpen] = useState(false);
- const switchToDrive = useAddDriveFromMdRaid();
- const switchToMdRaid = useAddMdRaidFromDrive();
- const changeTargetFn = (device: Storage.Device) => {
- const hook = isDrive(device) ? switchToDrive : switchToMdRaid;
- hook(modelDevice.name, { name: device.name });
+ const availableTargets = targetDevices(modelDevice, config, useAvailableDevices());
+ const disks = availableTargets.filter(isDrive);
+ const mdRaids = availableTargets.filter(isMd);
+ const volumeGroups = availableTargets.filter(isVolumeGroup);
+ const vgSelectionSideEffect = reuseVgSideEffect(modelDevice);
+
+ const openSelector = () => {
+ setIsSelectorOpen(true);
};
- const devices = targetDevices(modelDevice, useConfigModel(), useAvailableDevices());
- const onDeviceChange = ([drive]: Storage.Device[]) => {
+ const onDeviceChange = ([device]: Storage.Device[]) => {
setIsSelectorOpen(false);
- changeTargetFn(drive);
+ convertDevice(selected.name, device.name);
};
return (
@@ -342,7 +358,6 @@ export default function SearchedDeviceMenu({
setIsSelectorOpen(true)}
+ onClick={() => openSelector()}
/>,
,
- ,
+ ,
]}
/>
{isSelectorOpen && (
}
- description={}
+ intro={}
selected={selected}
- devices={devices}
+ disks={disks}
+ mdRaids={mdRaids}
+ volumeGroups={volumeGroups}
+ 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
new file mode 100644
index 0000000000..ab95b3cc54
--- /dev/null
+++ b/web/src/components/storage/SearchedVolumeGroupMenu.tsx
@@ -0,0 +1,282 @@
+/*
+ * 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 { 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 { _, n_, formatList } from "~/i18n";
+
+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";
+
+/**
+ * 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.findDeviceByName(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 device to configure");
+ }
+
+ if (mountPaths.includes("/")) {
+ return _("Change the device 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 device to create %s"),
+ formatList(newMountPaths),
+ );
+};
+
+type ChangeVolumeGroupDescriptionProps = {
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+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 = {
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+const DiskSelectionSideEffect = ({ deviceConfig }: DiskSelectionSideEffectProps) => {
+ 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 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 SearchedDeviceSelectorProps = Omit<
+ DeviceSelectorModalProps,
+ "disks" | "mdRaids" | "volumeGroups" | "selected"
+> & {
+ device: Storage.Device;
+ deviceConfig: ConfigModel.VolumeGroup;
+};
+
+const SearchedDeviceSelector = ({
+ device,
+ deviceConfig,
+ ...deviceSelectorModalProps
+}: SearchedDeviceSelectorProps): React.ReactNode => {
+ const availableTargets = targetDevices(useConfigModel(), useAvailableDevices());
+ const disks = availableTargets.filter(isDrive);
+ const mdRaids = availableTargets.filter(isMd);
+ const volumeGroups = availableTargets.filter(isVolumeGroup);
+
+ 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 convertDevice = useConvertDevice();
+
+ const openSelector = () => {
+ setIsSelectorOpen(true);
+ };
+
+ const onDeviceChange = ([targetDevice]: Storage.Device[]) => {
+ setIsSelectorOpen(false);
+ convertDevice(device.name, targetDevice.name);
+ };
+
+ return (
+ <>
+ openSelector()}
+ />,
+ ,
+ ]}
+ />
+ {isSelectorOpen && (
+ }
+ intro={}
+ device={device}
+ deviceConfig={deviceConfig}
+ disksSideEffects={}
+ mdRaidsSideEffects={}
+ onConfirm={onDeviceChange}
+ onCancel={() => setIsSelectorOpen(false)}
+ />
+ )}
+ >
+ );
+}
diff --git a/web/src/components/storage/SpaceActionsTable.tsx b/web/src/components/storage/SpaceActionsTable.tsx
index 65c71951cc..0cedefe0c9 100644
--- a/web/src/components/storage/SpaceActionsTable.tsx
+++ b/web/src/components/storage/SpaceActionsTable.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2024] SUSE LLC
+ * Copyright (c) [2024-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -40,6 +40,7 @@ import { Icon } from "~/components/layout";
import { TreeTableColumn } from "~/components/core/TreeTable";
import { Table, Td, Th, Tr, Thead, Tbody } from "@patternfly/react-table";
import { useConfigModel } from "~/hooks/model/storage/config-model";
+import configModel from "~/model/storage/config-model";
import { supportShrink } from "~/model/storage/device";
import type { Storage as Proposal } from "~/model/proposal";
import type { ConfigModel } from "~/model/storage/config-model";
@@ -49,19 +50,15 @@ export type SpacePolicyAction = {
value: "delete" | "resizeIfNeeded";
};
-const isUsedPartition = (partition: ConfigModel.Partition): boolean => {
- return partition.filesystem !== undefined;
-};
-
// FIXME: there is too much logic here. This is one of those cases that should be considered
// when restructuring the hooks and queries.
const useReusedPartition = (name: string): ConfigModel.Partition | undefined => {
- const model = useConfigModel();
+ const config = useConfigModel();
- if (!model || !name) return;
+ if (!config || !name) return;
- const allPartitions = model.drives.flatMap((d) => d.partitions);
- return allPartitions.find((p) => p.name === name && isUsedPartition(p));
+ const volumes = configModel.devices(config).flatMap((d) => configModel.device.volumes(d));
+ return volumes.find((v) => v.name === name && configModel.volume.isUsed(v));
};
/**
@@ -223,7 +220,7 @@ export default function SpaceActionsTable({
}: SpaceActionsTableProps) {
const columns: TreeTableColumn[] = [
{
- name: _("proposal.Device"),
+ name: _("Device"),
value: (item: Proposal.UnusedSlot | Proposal.Device) => ,
},
{
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/SpacePolicyMenu.tsx b/web/src/components/storage/SpacePolicyMenu.tsx
index 6759f36e0c..1347e5bcee 100644
--- a/web/src/components/storage/SpacePolicyMenu.tsx
+++ b/web/src/components/storage/SpacePolicyMenu.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -26,22 +26,65 @@ import MenuButton, { CustomToggleProps } from "~/components/core/MenuButton";
import Text from "~/components/core/Text";
import Icon from "~/components/layout/Icon";
import { useNavigate } from "react-router";
-import { SPACE_POLICIES } from "~/components/storage/utils";
+import {
+ PARTITIONABLE_SPACE_POLICIES,
+ VOLUME_GROUP_SPACE_POLICIES,
+} from "~/components/storage/utils";
import { STORAGE as PATHS } from "~/routes/paths";
import * as driveUtils from "~/components/storage/utils/drive";
+import * as volumeGroupUtils from "~/components/storage/utils/volume-group";
import { generateEncodedPath } from "~/utils";
import { isEmpty } from "radashi";
-import { usePartitionable, useSetSpacePolicy } from "~/hooks/model/storage/config-model";
+import {
+ useDevice as useDeviceConfig,
+ useSetSpacePolicy,
+} from "~/hooks/model/storage/config-model";
import { useDevice } from "~/hooks/model/system/storage";
import type { ConfigModel } from "~/model/storage/config-model";
+import type { SpacePolicy } from "~/components/storage/utils";
+
+type PolicyItemProps = {
+ policy: SpacePolicy;
+ collection: "drives" | "mdRaids" | "volumeGroups";
+ index: number;
+};
+
+const PolicyItem = ({ policy, collection, index }: PolicyItemProps) => {
+ const navigate = useNavigate();
+ const setSpacePolicy = useSetSpacePolicy();
+ const deviceConfig = useDeviceConfig(collection, index);
+
+ const changePolicy = () => {
+ if (policy.id === "custom") {
+ return navigate(generateEncodedPath(PATHS.editSpacePolicy, { collection, index }));
+ } else {
+ setSpacePolicy(collection, index, { type: policy.id });
+ }
+ };
+
+ const description = (): string | null => {
+ switch (collection) {
+ case "drives":
+ case "mdRaids": {
+ return driveUtils.contentActionsDescription(deviceConfig as ConfigModel.Drive, policy.id);
+ }
+ case "volumeGroups": {
+ return volumeGroupUtils.contentActionsDescription(
+ deviceConfig as ConfigModel.VolumeGroup,
+ policy.id,
+ );
+ }
+ }
+ };
+
+ const isSelected = policy.id === deviceConfig.spacePolicy;
-const PolicyItem = ({ policy, modelDevice, isSelected, onClick }) => {
return (
onClick(policy.id)}
+ description={description()}
+ onClick={changePolicy}
>
{policy.label}
@@ -49,66 +92,82 @@ const PolicyItem = ({ policy, modelDevice, isSelected, onClick }) => {
};
type SpacePolicyMenuToggleProps = CustomToggleProps & {
- drive: ConfigModel.Drive;
+ collection: "drives" | "volumeGroups" | "mdRaids";
+ index: number;
};
-const SpacePolicyMenuToggle = forwardRef(({ drive, ...props }: SpacePolicyMenuToggleProps, ref) => {
- return (
-
- );
-});
+
+ {summary()}
+
+
+
+
+
+ );
+ },
+);
type SpacePolicyMenuProps = {
- collection: "drives" | "mdRaids";
+ collection: "drives" | "mdRaids" | "volumeGroups";
index: number;
};
export default function SpacePolicyMenu({ collection, index }: SpacePolicyMenuProps) {
- const navigate = useNavigate();
- const setSpacePolicy = useSetSpacePolicy();
- const deviceModel = usePartitionable(collection, index);
- const device = useDevice(deviceModel.name);
- const existingPartitions = device.partitions?.length;
+ const deviceConfig = useDeviceConfig(collection, index);
+ const device = useDevice(deviceConfig.name);
+ const hasVolumes = device && !isEmpty(device.partitions || device.logicalVolumes || []);
- if (isEmpty(existingPartitions)) return;
+ if (!hasVolumes) return;
- const onSpacePolicyChange = (spacePolicy: ConfigModel.SpacePolicy) => {
- if (spacePolicy === "custom") {
- return navigate(generateEncodedPath(PATHS.editSpacePolicy, { collection, index }));
- } else {
- setSpacePolicy(collection, index, { type: spacePolicy });
+ const policies = (): SpacePolicy[] => {
+ switch (collection) {
+ case "drives":
+ case "mdRaids": {
+ return PARTITIONABLE_SPACE_POLICIES;
+ }
+ case "volumeGroups": {
+ return VOLUME_GROUP_SPACE_POLICIES;
+ }
}
};
- const currentPolicy = driveUtils.spacePolicyEntry(deviceModel);
-
return (
(
-
+ items={policies().map((policy) => (
+
))}
- customToggle={}
+ customToggle={}
/>
);
}
diff --git a/web/src/components/storage/SpacePolicySelectionPage.tsx b/web/src/components/storage/SpacePolicySelectionPage.tsx
index 00ceee64f8..1854fb3bcd 100644
--- a/web/src/components/storage/SpacePolicySelectionPage.tsx
+++ b/web/src/components/storage/SpacePolicySelectionPage.tsx
@@ -26,62 +26,64 @@ import { sprintf } from "sprintf-js";
import { useNavigate, useParams } from "react-router";
import { Page, SubtleContent } from "~/components/core";
import SpaceActionsTable, { SpacePolicyAction } from "~/components/storage/SpaceActionsTable";
-import { createPartitionableLocation, deviceChildren } from "~/components/storage/utils";
-import { useDevices } from "~/hooks/model/system/storage";
-import { usePartitionable, useSetSpacePolicy } from "~/hooks/model/storage/config-model";
+import { deviceChildren } from "~/components/storage/utils";
+import { useDevice } from "~/hooks/model/system/storage";
+import {
+ useDevice as useDeviceConfig,
+ useSetSpacePolicy,
+} from "~/hooks/model/storage/config-model";
import { toDevice } from "~/components/storage/device-utils";
import { STORAGE } from "~/routes/paths";
import { _ } from "~/i18n";
+import configModel from "~/model/storage/config-model";
+import type { Storage as System } from "~/model/system";
+import type { DeviceCollection } from "~/model/storage/config-model";
+import { isVolumeGroup } from "~/model/storage/device";
-import type { Storage as Proposal } from "~/model/proposal";
-import type { ConfigModel, Partitionable } from "~/model/storage/config-model";
+type Action = "delete" | "resizeIfNeeded";
-const partitionAction = (partition: ConfigModel.Partition) => {
- if (partition.delete) return "delete";
- if (partition.resizeIfNeeded) return "resizeIfNeeded";
-
- return undefined;
-};
-
-function useDeviceModelFromParams(): Partitionable.Device | null {
+function useDeviceParams(): [DeviceCollection, number] {
const { collection, index } = useParams();
- const location = createPartitionableLocation(collection, index);
- const deviceModel = usePartitionable(location.collection, location.index);
- return deviceModel;
+ return [collection as DeviceCollection, Number(index)];
}
/**
* Renders a page that allows the user to select the space policy and actions.
*/
export default function SpacePolicySelectionPage() {
- const deviceModel = useDeviceModelFromParams();
- const devices = useDevices();
- const device = devices.find((d) => d.name === deviceModel.name);
- const children = deviceChildren(device);
+ const [collection, index] = useDeviceParams();
+ const deviceConfig = useDeviceConfig(collection, index);
+ const device = useDevice(deviceConfig.name);
const setSpacePolicy = useSetSpacePolicy();
- const { collection, index } = useParams();
+ const navigate = useNavigate();
+
+ const children = deviceChildren(device);
- const partitionDeviceAction = (device: Proposal.Device) => {
- const partition = deviceModel.partitions?.find((p) => p.name === device.name);
+ const volumeDeviceAction = (volumeDevice: System.Device): Action | null => {
+ const volumeConfig = configModel.device
+ .volumes(deviceConfig)
+ .find((v) => v.name === volumeDevice.name);
- return partition ? partitionAction(partition) : undefined;
+ if (!volumeConfig) return null;
+
+ if (volumeConfig.delete) return "delete";
+
+ if (volumeConfig.resizeIfNeeded) return "resizeIfNeeded";
};
const [actions, setActions] = useState(
children
- .filter((d) => toDevice(d) && partitionDeviceAction(toDevice(d)))
+ .filter((d) => toDevice(d) && volumeDeviceAction(toDevice(d)))
.map(
- (d: Proposal.Device): SpacePolicyAction => ({
+ (d: System.Device): SpacePolicyAction => ({
deviceName: toDevice(d).name,
- value: partitionDeviceAction(toDevice(d)),
+ value: volumeDeviceAction(toDevice(d)),
}),
),
);
- const navigate = useNavigate();
-
- const deviceAction = (device: Proposal.Device | Proposal.UnusedSlot) => {
+ const deviceAction = (device: System.Device | System.UnusedSlot) => {
if (toDevice(device) === undefined) return "keep";
return actions.find((a) => a.deviceName === toDevice(device).name)?.value || "keep";
@@ -96,16 +98,22 @@ export default function SpacePolicySelectionPage() {
const onSubmit = (e) => {
e.preventDefault();
- const location = createPartitionableLocation(collection, index);
- if (!location) return;
- setSpacePolicy(location.collection, location.index, { type: "custom", actions });
+ setSpacePolicy(collection, index, { type: "custom", actions });
navigate("..");
};
- const description = _(
- "Select what to do with each partition in order to find space for allocating the new system.",
- );
+ const description = (): string => {
+ if (isVolumeGroup(device)) {
+ return _(
+ "Select what to do with each logical volume in order to find space for allocating the new system.",
+ );
+ }
+
+ return _(
+ "Select what to do with each partition in order to find space for allocating the new system.",
+ );
+ };
return (
- {description}
+ {description()}