diff --git a/web/src/components/storage/ConfigEditorMenu.test.tsx b/web/src/components/storage/ConfigEditorMenu.test.tsx index 8fd8124006..d28568b246 100644 --- a/web/src/components/storage/ConfigEditorMenu.test.tsx +++ b/web/src/components/storage/ConfigEditorMenu.test.tsx @@ -68,7 +68,7 @@ it("allows users to change the boot options", async () => { const { user, menu } = await openMenu(); const bootItem = within(menu).getByRole("menuitem", { name: /boot options/ }); await user.click(bootItem); - expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.bootDevice); + expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.editBootDevice); }); it("allows users to reset the config", async () => { diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 2f3238243c..095a01737c 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -64,7 +64,7 @@ export default function ConfigEditorMenu() { navigate(PATHS.lvm.create)} + onClick={() => navigate(PATHS.volumeGroup.add)} description={_("Extend the installation using LVM")} > {_("Add LVM volume group")} @@ -72,7 +72,7 @@ export default function ConfigEditorMenu() { navigate(PATHS.bootDevice)} + onClick={() => navigate(PATHS.editBootDevice)} description={_("Select the disk to configure partitions for booting")} > {_("Change boot options")} diff --git a/web/src/components/storage/DriveEditor.test.tsx b/web/src/components/storage/DriveEditor.test.tsx index ffe6bef35e..320d6c19a4 100644 --- a/web/src/components/storage/DriveEditor.test.tsx +++ b/web/src/components/storage/DriveEditor.test.tsx @@ -222,7 +222,7 @@ describe("PartitionMenuItem", () => { name: "Edit swap", }); await user.click(editSwapButton); - expect(mockNavigateFn).toHaveBeenCalledWith("/storage/devices/sda/partitions/swap/edit"); + expect(mockNavigateFn).toHaveBeenCalledWith("/storage/drives/sda/partitions/swap/edit"); }); }); diff --git a/web/src/components/storage/DriveEditor.tsx b/web/src/components/storage/DriveEditor.tsx index b3e72be0e0..a239338f85 100644 --- a/web/src/components/storage/DriveEditor.tsx +++ b/web/src/components/storage/DriveEditor.tsx @@ -84,7 +84,7 @@ const SpacePolicySelector = ({ drive, driveDevice }: DriveEditorProps) => { const { setSpacePolicy } = useDrive(drive.name); const onSpacePolicyChange = (spacePolicy: apiModel.SpacePolicy) => { if (spacePolicy === "custom") { - return navigate(generatePath(PATHS.findSpace, { id: baseName(drive.name) })); + return navigate(generatePath(PATHS.drive.editSpacePolicy, { id: baseName(drive.name) })); } else { setSpacePolicy(spacePolicy); } @@ -476,7 +476,9 @@ const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { itemId="add-partition" description={_("Add another partition or mount an existing one")} role="menuitem" - onClick={() => navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) }))} + onClick={() => + navigate(generatePath(PATHS.drive.partition.add, { id: baseName(drive.name) })) + } > {_("Add or use partition")} @@ -490,7 +492,7 @@ const PartitionsNoContentSelector = ({ drive, toggleAriaLabel }) => { const PartitionMenuItem = ({ driveName, mountPath }) => { const drive = useDrive(driveName); const partition = drive.getPartition(mountPath); - const editPath = generatePath(PATHS.editPartition, { + const editPath = generatePath(PATHS.drive.partition.edit, { id: baseName(driveName), partitionId: encodeURIComponent(mountPath), }); @@ -525,7 +527,9 @@ const PartitionsWithContentSelector = ({ drive, toggleAriaLabel }) => { key="add-partition" itemId="add-partition" description={_("Add another partition or mount an existing one")} - onClick={() => navigate(generatePath(PATHS.addPartition, { id: baseName(drive.name) }))} + onClick={() => + navigate(generatePath(PATHS.drive.partition.add, { id: baseName(drive.name) })) + } > {_("Add or use partition")} diff --git a/web/src/components/storage/EncryptionSection.test.tsx b/web/src/components/storage/EncryptionSection.test.tsx index a56a6c6c03..f2f70243db 100644 --- a/web/src/components/storage/EncryptionSection.test.tsx +++ b/web/src/components/storage/EncryptionSection.test.tsx @@ -79,6 +79,6 @@ describe("EncryptionSection", () => { it("renders a link for navigating to encryption settings", () => { plainRender(); const editLink = screen.getByRole("link", { name: "Edit" }); - expect(editLink).toHaveAttribute("href", STORAGE.encryption); + expect(editLink).toHaveAttribute("href", STORAGE.editEncryption); }); }); diff --git a/web/src/components/storage/EncryptionSection.tsx b/web/src/components/storage/EncryptionSection.tsx index 22974b4bc1..7f4c9dddd7 100644 --- a/web/src/components/storage/EncryptionSection.tsx +++ b/web/src/components/storage/EncryptionSection.tsx @@ -47,7 +47,7 @@ export default function EncryptionSection() { the new file systems, including data, programs, and system files.", )} pfCardBodyProps={{ isFilled: true }} - actions={{_("Edit")}} + actions={{_("Edit")}} > diff --git a/web/src/components/storage/LvmPage.test.tsx b/web/src/components/storage/LvmPage.test.tsx new file mode 100644 index 0000000000..4c437be9a1 --- /dev/null +++ b/web/src/components/storage/LvmPage.test.tsx @@ -0,0 +1,307 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; +import { installerRender, mockParams } from "~/test-utils"; +import { model, StorageDevice } from "~/types/storage"; +import { gib } from "./utils"; +import LvmPage from "./LvmPage"; + +const sda1: StorageDevice = { + sid: 69, + name: "/dev/sda1", + description: "Swap partition", + isDrive: false, + type: "partition", + size: gib(2), + shrinking: { unsupported: ["Resizing is not supported"] }, + start: 1, +}; + +const sda: StorageDevice = { + sid: 59, + isDrive: true, + type: "disk", + vendor: "Micron", + model: "Micron 1100 SATA", + driver: ["ahci", "mmcblk"], + bus: "IDE", + busId: "", + transport: "usb", + dellBOSS: false, + sdCard: true, + active: true, + name: "/dev/sda", + size: 1024, + shrinking: { unsupported: ["Resizing is not supported"] }, + systems: [], + partitionTable: { + type: "gpt", + partitions: [sda1], + unpartitionedSize: 0, + unusedSlots: [{ start: 3, size: gib(2) }], + }, + udevIds: ["ata-Micron_1100_SATA_512GB_12563", "scsi-0ATA_Micron_1100_SATA_512GB"], + udevPaths: ["pci-0000:00-12", "pci-0000:00-12-ata"], + description: "", +}; + +const mockSdaDrive: model.Drive = { + name: "/dev/sda", + spacePolicy: "delete", + partitions: [ + { + mountPath: "swap", + size: { + min: gib(2), + default: false, // false: user provided, true: calculated + }, + filesystem: { default: false, type: "swap" }, + }, + { + mountPath: "/home", + size: { + min: gib(16), + default: true, + }, + filesystem: { default: false, type: "xfs" }, + }, + ], + isUsed: true, + getVolumeGroups: () => [], +}; + +const mockRootVolumeGroup: model.VolumeGroup = { + vgName: "fakeRootVg", + getTargetDevices: () => [mockSdaDrive], + logicalVolumes: [], +}; + +const mockHomeVolumeGroup: model.VolumeGroup = { + vgName: "fakeHomeVg", + getTargetDevices: () => [mockSdaDrive], + logicalVolumes: [], +}; + +const mockAddVolumeGroup = jest.fn(); +const mockEditVolumeGroup = jest.fn(); + +let mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [], +}; + +const mockUseAllDevices = [sda]; + +jest.mock("~/queries/issues", () => ({ + ...jest.requireActual("~/queries/issues"), + useIssuesChanges: jest.fn(), + useIssues: () => [], +})); + +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useAvailableDevices: () => mockUseAllDevices, + useDevices: () => [sda], +})); + +jest.mock("~/hooks/storage/model", () => ({ + ...jest.requireActual("~/hooks/storage/model"), + __esModule: true, + useVolumeGroup: (id: string) => (id ? mockRootVolumeGroup : null), + default: () => mockUseModel, +})); + +jest.mock("~/hooks/storage/add-volume-group", () => ({ + ...jest.requireActual("~/hooks/storage/add-volume-group"), + __esModule: true, + default: () => mockAddVolumeGroup, +})); + +jest.mock("~/hooks/storage/edit-volume-group", () => ({ + ...jest.requireActual("~/hooks/storage/edit-volume-group"), + __esModule: true, + default: () => mockEditVolumeGroup, +})); + +describe("LvmPage", () => { + describe("when creating a new volume group", () => { + it("allows configuring a new LVM volume group (without moving mount points)", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const moveMountPointsCheckbox = screen.getByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Clear default value for name + await user.clear(name); + await user.type(name, "root-vg"); + await user.click(sdaCheckbox); + // By default move mount points should be checked + expect(moveMountPointsCheckbox).toBeChecked(); + await user.click(moveMountPointsCheckbox); + expect(moveMountPointsCheckbox).not.toBeChecked(); + await user.click(acceptButton); + expect(mockAddVolumeGroup).toHaveBeenCalledWith("root-vg", ["/dev/sda"], false); + }); + + it("allows configuring a new LVM volume group (moving mount points)", async () => { + const { user } = installerRender(); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const moveMountPointsCheckbox = screen.getByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + await user.click(sdaCheckbox); + expect(moveMountPointsCheckbox).toBeChecked(); + await user.click(acceptButton); + expect(mockAddVolumeGroup).toHaveBeenCalledWith("system", ["/dev/sda"], true); + }); + + it("performs basic validations", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Let's clean the default given name + await user.clear(name); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText(/Enter a name/); + screen.getByText(/Select at least one disk/); + + // Type a name + await user.type(name, "root-vg"); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + screen.getByText(/Select at least one disk/); + + // Select a disk + expect(sdaCheckbox).not.toBeChecked(); + await user.click(sdaCheckbox); + expect(sdaCheckbox).toBeChecked(); + await user.click(acceptButton); + expect(screen.queryByText("Warning alert:")).toBeNull(); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + expect(screen.queryByText(/Select at least one disk/)).toBeNull(); + }); + + describe("when there are LVM volume groups", () => { + beforeEach(() => { + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [mockRootVolumeGroup], + }; + }); + + it("does not pre-fill the name input", () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + expect(name).toHaveValue(""); + }); + }); + + describe("when there are no LVM volume groups yet", () => { + beforeEach(() => { + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [], + }; + }); + + it("pre-fills the name input with 'system'", () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + expect(name).toHaveValue("system"); + }); + }); + }); + + describe("when editing", () => { + beforeEach(() => { + mockParams({ id: "fakeRootVg" }); + mockUseModel = { + drives: [mockSdaDrive], + volumeGroups: [mockRootVolumeGroup, mockHomeVolumeGroup], + }; + }); + + it("performs basic validations", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const disks = screen.getByRole("group", { name: "Disks" }); + const sdaCheckbox = within(disks).getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + + // Let's clean the default given name + await user.clear(name); + await user.click(sdaCheckbox); + expect(name).toHaveValue(""); + expect(sdaCheckbox).not.toBeChecked(); + await user.click(acceptButton); + screen.getByText("Warning alert:"); + screen.getByText(/Enter a name/); + screen.getByText(/Select at least one disk/); + // Enter a name already in use + await user.type(name, "fakeHomeVg"); + await user.click(acceptButton); + expect(screen.queryByText(/Enter a name/)).toBeNull(); + screen.getByText(/Enter a different name/); + }); + + it("pre-fills form with the current volume group configuration", async () => { + installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const sdaCheckbox = screen.getByRole("checkbox", { name: "/dev/sda, 1 KiB" }); + expect(name).toHaveValue("fakeRootVg"); + expect(sdaCheckbox).toBeChecked(); + }); + + it("does not offer option for moving mount points", () => { + installerRender(); + expect( + screen.queryByRole("checkbox", { + name: /Move the mount points currently configured at the selected disks to logical volumes/, + }), + ).toBeNull(); + }); + + it("triggers the hook for updating the volume group when user accepts changes", async () => { + const { user } = installerRender(); + const name = screen.getByRole("textbox", { name: "Name" }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.clear(name); + await user.type(name, "updatedRootVg"); + await user.click(acceptButton); + expect(mockEditVolumeGroup).toHaveBeenCalledWith("fakeRootVg", "updatedRootVg", ["/dev/sda"]); + }); + }); +}); diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index 18bb9c2ff5..dd1420f263 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -21,7 +21,7 @@ */ import React, { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { ActionGroup, Alert, @@ -37,36 +37,41 @@ import { import { Page, SubtleContent } from "~/components/core"; import { useAvailableDevices } from "~/queries/storage"; import { StorageDevice, model } from "~/types/storage"; -import useModel from "~/hooks/storage/model"; +import useModel, { useVolumeGroup } from "~/hooks/storage/model"; import useAddVolumeGroup from "~/hooks/storage/add-volume-group"; +import useEditVolumeGroup from "~/hooks/storage/edit-volume-group"; import { deviceLabel } from "./utils"; import { contentDescription, filesystemLabels, typeDescription } from "./utils/device"; import { STORAGE as PATHS } from "~/routes/paths"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; -function checkErrors(model: model.Model, vgName: string, targetDevices: StorageDevice[]): string[] { - const vgNameError = (): string | undefined => { - if (!vgName.length) return sprintf(_("Name is empty"), vgName); +function vgNameError( + vgName: string, + model: model.Model, + volumeGroup?: model.VolumeGroup, +): string | undefined { + if (!vgName.length) return _("Enter a name for the volume group."); - const exist = model.volumeGroups.some((v) => v.vgName === vgName); - if (exist) return sprintf(_("'%s' already exists"), vgName); - }; - - const targetDevicesError = (): string | undefined => { - if (!targetDevices.length) return _("No disk is selected"); - }; + const exist = model.volumeGroups.some((v) => v.vgName === vgName); + if (exist && vgName !== volumeGroup?.vgName) + return sprintf(_("Volume group '%s' already exists. Enter a different name."), vgName); +} - return [vgNameError(), targetDevicesError()].filter((d) => d); +function targetDevicesError(targetDevices: StorageDevice[]): string | undefined { + if (!targetDevices.length) return _("Select at least one disk."); } /** - * Form for creating a LVM volume group + * Form for configuring a LVM volume group. */ export default function LvmPage() { + const { id } = useParams(); const navigate = useNavigate(); const model = useModel(); + const volumeGroup = useVolumeGroup(id); const addVolumeGroup = useAddVolumeGroup(); + const editVolumeGroup = useEditVolumeGroup(); const allDevices = useAvailableDevices(); const [name, setName] = useState(""); const [selectedDevices, setSelectedDevices] = useState([]); @@ -74,10 +79,18 @@ export default function LvmPage() { const [errors, setErrors] = useState([]); useEffect(() => { - if (model && !model.volumeGroups.length) setName("system"); - }, [model]); + if (volumeGroup) { + setName(volumeGroup.vgName); + const targetNames = volumeGroup.getTargetDevices().map((d) => d.name); + const targetDevices = allDevices.filter((d) => targetNames.includes(d.name)); + setSelectedDevices(targetDevices); + } else if (model && !model.volumeGroups.length) { + setName("system"); + } + }, [model, volumeGroup, allDevices]); const updateName = (_, value) => setName(value); + const updateSelectedDevices = (value) => { setSelectedDevices( selectedDevices.includes(value) @@ -86,19 +99,28 @@ export default function LvmPage() { ); }; + const checkErrors = (): string[] => { + return [vgNameError(name, model, volumeGroup), targetDevicesError(selectedDevices)].filter( + (e) => e, + ); + }; + const onSubmit = (e) => { e.preventDefault(); - const errors = checkErrors(model, name, selectedDevices); + const errors = checkErrors(); setErrors(errors); if (errors.length) return; - addVolumeGroup( - name, - selectedDevices.map((d) => d.name), - moveMountPoints, - ); + const selectedDeviceNames = selectedDevices.map((d) => d.name); + + if (!volumeGroup) { + addVolumeGroup(name, selectedDeviceNames, moveMountPoints); + } else { + editVolumeGroup(volumeGroup.vgName, name, selectedDeviceNames); + } + navigate(PATHS.root); }; @@ -111,7 +133,7 @@ export default function LvmPage() {
{errors.length > 0 && ( - + {errors.map((e, i) => (

{e}

))} @@ -157,17 +179,19 @@ export default function LvmPage() { ))} - - setMoveMountPoints(!moveMountPoints)} - /> - + {!volumeGroup && ( + + setMoveMountPoints(v)} + /> + + )} diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 5b1402c26e..03a52689ad 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -21,8 +21,10 @@ */ import React from "react"; +import { useNavigate, generatePath } from "react-router-dom"; import { _ } from "~/i18n"; import { sprintf } from "sprintf-js"; +import { STORAGE as PATHS } from "~/routes/paths"; import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; import { contentDescription } from "~/components/storage/utils/volume-group"; @@ -53,10 +55,18 @@ const RemoveVgOption = ({ vg }: { vg: model.VolumeGroup }) => { ); }; -const EditVgOption = () => { +const EditVgOption = ({ vg }: { vg: model.VolumeGroup }) => { + const navigate = useNavigate(); + return ( - - {_("Edit volume group")} + navigate(generatePath(PATHS.volumeGroup.edit, { id: vg.vgName }))} + > + {_("Edit volume group")} ); }; @@ -65,7 +75,7 @@ const VgMenu = ({ vg }: { vg: model.VolumeGroup }) => { return ( {vg.vgName}}> - + diff --git a/web/src/hooks/storage/add-volume-group.ts b/web/src/hooks/storage/add-volume-group.ts index cdd15902b4..fa970ee515 100644 --- a/web/src/hooks/storage/add-volume-group.ts +++ b/web/src/hooks/storage/add-volume-group.ts @@ -22,49 +22,19 @@ import useApiModel from "~/hooks/storage/api-model"; import useUpdateApiModel from "~/hooks/storage/update-api-model"; +import { addVolumeGroup } from "~/hooks/storage/helpers/volume-group"; import { QueryHookOptions } from "~/types/queries"; -import { apiModel } from "~/api/storage/types"; -function toLogicalVolume(partition: apiModel.Partition) { - return { ...partition }; -} - -function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup) { - if (!drive.partitions) return; - - const newPartitions = drive.partitions.filter((p) => !p.name); - const reusedPartitions = drive.partitions.filter((p) => p.name); - drive.partitions = [...reusedPartitions]; - const logicalVolumes = volumeGroup.logicalVolumes || []; - volumeGroup.logicalVolumes = [...logicalVolumes, ...newPartitions.map(toLogicalVolume)]; -} - -function addVolumeGroup( - apiModel: apiModel.Config, +export type AddVolumeGroupFn = ( vgName: string, targetDevices: string[], moveContent: boolean, -): apiModel.Config { - const volumeGroup = { vgName, targetDevices }; - if (moveContent) { - (apiModel.drives || []) - .filter((d) => targetDevices.includes(d.name)) - .forEach((d) => movePartitions(d, volumeGroup)); - } - apiModel.volumeGroups ||= []; - apiModel.volumeGroups.push(volumeGroup); - return apiModel; -} +) => void; -type AddVolumeGroupFn = (vgName: string, targetDevices: string[], moveContent: boolean) => void; - -function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { +export default function useAddVolumeGroup(options?: QueryHookOptions): AddVolumeGroupFn { const apiModel = useApiModel(options); const updateApiModel = useUpdateApiModel(); return (vgName: string, targetDevices: string[], moveContent: boolean) => { updateApiModel(addVolumeGroup(apiModel, vgName, targetDevices, moveContent)); }; } - -export { useAddVolumeGroup as default }; -export type { AddVolumeGroupFn }; diff --git a/web/src/hooks/storage/api-model.ts b/web/src/hooks/storage/api-model.ts index 5f387be88b..afc4260dc6 100644 --- a/web/src/hooks/storage/api-model.ts +++ b/web/src/hooks/storage/api-model.ts @@ -20,23 +20,14 @@ * find current contact information at www.suse.com. */ -import { useMemo } from "react"; import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { configModelQuery } from "~/queries/storage/config-model"; import { apiModel } from "~/api/storage/types"; import { QueryHookOptions } from "~/types/queries"; -function useApiModel(options?: QueryHookOptions): apiModel.Config | null { +export default function useApiModel(options?: QueryHookOptions): apiModel.Config | null { const query = configModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); - - const apiModel = useMemo((): apiModel.Config | null => { - // Returns a copy. - return data ? JSON.parse(JSON.stringify(data)) : null; - }, [data]); - - return apiModel; + return data || null; } - -export { useApiModel as default }; diff --git a/web/src/hooks/storage/edit-volume-group.ts b/web/src/hooks/storage/edit-volume-group.ts new file mode 100644 index 0000000000..bd8402b325 --- /dev/null +++ b/web/src/hooks/storage/edit-volume-group.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import useApiModel from "~/hooks/storage/api-model"; +import useUpdateApiModel from "~/hooks/storage/update-api-model"; +import { editVolumeGroup } from "~/hooks/storage/helpers/volume-group"; +import { QueryHookOptions } from "~/types/queries"; + +export type EditVolumeGroupFn = ( + odlVgName: string, + VgName: string, + targetDevices: string[], +) => void; + +export default function useEditVolumeGroup(options?: QueryHookOptions): EditVolumeGroupFn { + const apiModel = useApiModel(options); + const updateApiModel = useUpdateApiModel(); + return (oldVgName: string, vgName: string, targetDevices: string[]) => { + updateApiModel(editVolumeGroup(apiModel, oldVgName, vgName, targetDevices)); + }; +} diff --git a/web/src/hooks/storage/helpers/build-model.ts b/web/src/hooks/storage/helpers/build-model.ts new file mode 100644 index 0000000000..ef43c1c99e --- /dev/null +++ b/web/src/hooks/storage/helpers/build-model.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ +import { apiModel } from "~/api/storage/types"; +import { model } from "~/types/storage"; + +const findDrive = (model: model.Model, name: string): model.Drive | undefined => { + return model.drives.find((d) => d.name === name); +}; + +function buildDrive( + apiDrive: apiModel.Drive, + apiModel: apiModel.Config, + model: model.Model, +): model.Drive { + const getVolumeGroups = (): model.VolumeGroup[] => { + return model.volumeGroups.filter((v) => + v.getTargetDevices().some((d) => d.name === apiDrive.name), + ); + }; + + const isExplicitBoot = (): boolean => { + return ( + apiModel.boot?.configure && + !apiModel.boot.device?.default && + apiModel.boot.device?.name === apiDrive.name + ); + }; + + const isTargetDevice = (): boolean => { + const targetDevices = (apiModel.volumeGroups || []).flatMap((v) => v.targetDevices || []); + return targetDevices.includes(apiDrive.name); + }; + + const isUsed = (): boolean => { + return ( + isExplicitBoot() || + isTargetDevice() || + apiDrive.mountPath !== undefined || + apiDrive.partitions?.some((p) => p.mountPath) + ); + }; + + return { + ...apiDrive, + isUsed: isUsed(), + getVolumeGroups, + }; +} + +function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.LogicalVolume { + return { ...logicalVolumeData }; +} + +function buildVolumeGroup( + apiVolumeGroup: apiModel.VolumeGroup, + model: model.Model, +): model.VolumeGroup { + const buildLogicalVolumes = (): model.LogicalVolume[] => { + return (apiVolumeGroup.logicalVolumes || []).map(buildLogicalVolume); + }; + + const findTargetDevices = (): model.Drive[] => { + return (apiVolumeGroup.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); + }; + + return { + ...apiVolumeGroup, + logicalVolumes: buildLogicalVolumes(), + getTargetDevices: findTargetDevices, + }; +} + +export default function buildModel(apiModel: apiModel.Config): model.Model { + const model: model.Model = { + drives: [], + volumeGroups: [], + }; + + const buildDrives = (): model.Drive[] => { + return (apiModel.drives || []).map((d) => buildDrive(d, apiModel, model)); + }; + + const buildVolumeGroups = (): model.VolumeGroup[] => { + return (apiModel.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); + }; + + // Important! Modify the model object instead of assigning a new one. + model.drives = buildDrives(); + model.volumeGroups = buildVolumeGroups(); + return model; +} diff --git a/web/src/hooks/storage/helpers/copy-api-model.ts b/web/src/hooks/storage/helpers/copy-api-model.ts new file mode 100644 index 0000000000..049a48de2f --- /dev/null +++ b/web/src/hooks/storage/helpers/copy-api-model.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { apiModel } from "~/api/storage/types"; + +export default function copyApiModel(apiModel: apiModel.Config): apiModel.Config { + return JSON.parse(JSON.stringify(apiModel)); +} diff --git a/web/src/hooks/storage/helpers/drive.ts b/web/src/hooks/storage/helpers/drive.ts new file mode 100644 index 0000000000..ac6e0bda88 --- /dev/null +++ b/web/src/hooks/storage/helpers/drive.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { apiModel } from "~/api/storage/types"; +import { model } from "~/types/storage"; +import copyApiModel from "~/hooks/storage/helpers/copy-api-model"; +import buildModel from "~/hooks/storage/helpers/build-model"; + +function buildDrive(apiModel: apiModel.Config, name: string): model.Drive | undefined { + const model = buildModel(apiModel); + return model.drives.find((d) => d.name === name); +} + +function deleteIfUnused(apiModel: apiModel.Config, name: string): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const index = (apiModel.drives || []).findIndex((d) => d.name === name); + if (index === -1) return apiModel; + + const drive = buildDrive(apiModel, name); + if (!drive || drive.isUsed) return apiModel; + + apiModel.drives.splice(index, 1); + return apiModel; +} + +export { deleteIfUnused }; diff --git a/web/src/hooks/storage/helpers/volume-group.ts b/web/src/hooks/storage/helpers/volume-group.ts new file mode 100644 index 0000000000..d14713d458 --- /dev/null +++ b/web/src/hooks/storage/helpers/volume-group.ts @@ -0,0 +1,85 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { apiModel } from "~/api/storage/types"; +import copyApiModel from "~/hooks/storage/helpers/copy-api-model"; +import { deleteIfUnused } from "~/hooks/storage/helpers/drive"; + +function toLogicalVolume(partition: apiModel.Partition) { + return { ...partition }; +} + +function movePartitions(drive: apiModel.Drive, volumeGroup: apiModel.VolumeGroup) { + if (!drive.partitions) return; + + const newPartitions = drive.partitions.filter((p) => !p.name); + const reusedPartitions = drive.partitions.filter((p) => p.name); + drive.partitions = [...reusedPartitions]; + const logicalVolumes = volumeGroup.logicalVolumes || []; + volumeGroup.logicalVolumes = [...logicalVolumes, ...newPartitions.map(toLogicalVolume)]; +} + +function addVolumeGroup( + apiModel: apiModel.Config, + vgName: string, + targetDevices: string[], + moveContent: boolean, +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const volumeGroup = { vgName, targetDevices }; + + if (moveContent) { + (apiModel.drives || []) + .filter((d) => targetDevices.includes(d.name)) + .forEach((d) => movePartitions(d, volumeGroup)); + } + + apiModel.volumeGroups ||= []; + apiModel.volumeGroups.push(volumeGroup); + + return apiModel; +} + +function editVolumeGroup( + apiModel: apiModel.Config, + oldVgName: string, + vgName: string, + targetDevices: string[], +): apiModel.Config { + apiModel = copyApiModel(apiModel); + + const index = (apiModel.volumeGroups || []).findIndex((v) => v.vgName === oldVgName); + if (index === -1) return apiModel; + + const oldVolumeGroup = apiModel.volumeGroups[index]; + const newVolumeGroup = { ...oldVolumeGroup, vgName, targetDevices }; + + apiModel.volumeGroups.splice(index, 1, newVolumeGroup); + (oldVolumeGroup.targetDevices || []).forEach((d) => { + apiModel = deleteIfUnused(apiModel, d); + }); + + return apiModel; +} + +export { addVolumeGroup, editVolumeGroup }; diff --git a/web/src/hooks/storage/model.ts b/web/src/hooks/storage/model.ts index 630f82500d..ad0b83b930 100644 --- a/web/src/hooks/storage/model.ts +++ b/web/src/hooks/storage/model.ts @@ -22,70 +22,10 @@ import { useMemo } from "react"; import useApiModel from "~/hooks/storage/api-model"; +import buildModel from "~/hooks/storage/helpers/build-model"; import { QueryHookOptions } from "~/types/queries"; -import { apiModel } from "~/api/storage/types"; import { model } from "~/types/storage"; -const findDrive = (model: model.Model, name: string): model.Drive | undefined => { - return model.drives.find((d) => d.name === name); -}; - -function buildDrive(apiDrive: apiModel.Drive, model: model.Model): model.Drive { - const findVolumeGroups = (targetName: string): model.VolumeGroup[] => { - return model.volumeGroups.filter((v) => - v.getTargetDevices().some((d) => d.name === targetName), - ); - }; - - return { - ...apiDrive, - getVolumeGroups: () => findVolumeGroups(apiDrive.name), - }; -} - -function buildLogicalVolume(logicalVolumeData: apiModel.LogicalVolume): model.LogicalVolume { - return { ...logicalVolumeData }; -} - -function buildVolumeGroup( - apiVolumeGroup: apiModel.VolumeGroup, - model: model.Model, -): model.VolumeGroup { - const buildLogicalVolumes = (): model.LogicalVolume[] => { - return (apiVolumeGroup.logicalVolumes || []).map(buildLogicalVolume); - }; - - const findTargetDevices = (): model.Drive[] => { - return (apiVolumeGroup.targetDevices || []).map((d) => findDrive(model, d)).filter((d) => d); - }; - - return { - ...apiVolumeGroup, - logicalVolumes: buildLogicalVolumes(), - getTargetDevices: findTargetDevices, - }; -} - -function buildModel(apiModel: apiModel.Config): model.Model { - const model: model.Model = { - drives: [], - volumeGroups: [], - }; - - const buildDrives = (): model.Drive[] => { - return (apiModel.drives || []).map((d) => buildDrive(d, model)); - }; - - const buildVolumeGroups = (): model.VolumeGroup[] => { - return (apiModel.volumeGroups || []).map((v) => buildVolumeGroup(v, model)); - }; - - // Important! Modify the model object instead of assigning a new one. - model.drives = buildDrives(); - model.volumeGroups = buildVolumeGroups(); - return model; -} - function useModel(options?: QueryHookOptions): model.Model | null { const apiModel = useApiModel(options); diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 33bc7af712..ed37ed75ee 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -71,15 +71,20 @@ const SOFTWARE = { const STORAGE = { root: "/storage", - bootDevice: "/storage/select-boot-device", - encryption: "/storage/encryption", - addPartition: "/storage/devices/:id/partitions/new", - editPartition: "/storage/devices/:id/partitions/:partitionId/edit", - findSpace: "/storage/devices/:id/space/edit", - iscsi: "/storage/iscsi", - lvm: { - create: "/storage/lvm/new", + editBootDevice: "/storage/boot-device/edit", + editEncryption: "/storage/encryption/edit", + drive: { + editSpacePolicy: "/storage/drives/:id/space-policy/edit", + partition: { + add: "/storage/drives/:id/partitions/add", + edit: "/storage/drives/:id/partitions/:partitionId/edit", + }, + }, + volumeGroup: { + add: "/storage/volume-groups/add", + edit: "/storage/volume-groups/:id/edit", }, + iscsi: "/storage/iscsi", dasd: "/storage/dasd", zfcp: { root: "/storage/zfcp", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 06e6de1e7d..cbaf786eb8 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -47,29 +47,33 @@ const routes = (): Route => ({ element: , }, { - path: PATHS.bootDevice, + path: PATHS.editBootDevice, element: , }, { - path: PATHS.lvm.create, - element: , - }, - { - path: PATHS.encryption, + path: PATHS.editEncryption, element: , }, { - path: PATHS.findSpace, + path: PATHS.drive.editSpacePolicy, element: , }, { - path: PATHS.addPartition, + path: PATHS.drive.partition.add, element: , }, { - path: PATHS.editPartition, + path: PATHS.drive.partition.edit, element: , }, + { + path: PATHS.volumeGroup.add, + element: , + }, + { + path: PATHS.volumeGroup.edit, + element: , + }, { path: PATHS.iscsi, element: , diff --git a/web/src/types/storage/model.ts b/web/src/types/storage/model.ts index 587744c128..955d09e85b 100644 --- a/web/src/types/storage/model.ts +++ b/web/src/types/storage/model.ts @@ -28,6 +28,7 @@ type Model = { }; interface Drive extends apiModel.Drive { + isUsed: boolean; getVolumeGroups: () => VolumeGroup[]; }