From 2cf1e792c65ac07f99c14c6ab18405188b8db7c2 Mon Sep 17 00:00:00 2001 From: Nkeiruka Date: Thu, 19 Sep 2024 17:22:31 +0100 Subject: [PATCH] feat: [WD-14769] Move custom storage volume to aother storage pool. Signed-off-by: Nkeiruka --- src/api/storage-pools.tsx | 18 ++ src/pages/storage/MigrateVolumeBtn.tsx | 133 ++++++++++++ src/pages/storage/MigrateVolumeModal.tsx | 192 ++++++++++++++++++ src/pages/storage/StoragePoolSelector.tsx | 6 + src/pages/storage/StorageVolumeHeader.tsx | 35 +++- .../actions/DeleteStorageVolumeBtn.tsx | 6 +- .../storage/forms/StorageVolumeFormMain.tsx | 33 +-- tests/helpers/storageVolume.ts | 30 ++- tests/storage.spec.ts | 13 ++ 9 files changed, 438 insertions(+), 28 deletions(-) create mode 100644 src/pages/storage/MigrateVolumeBtn.tsx create mode 100644 src/pages/storage/MigrateVolumeModal.tsx diff --git a/src/api/storage-pools.tsx b/src/api/storage-pools.tsx index 52ae4c6e3a..cbbe15ddff 100644 --- a/src/api/storage-pools.tsx +++ b/src/api/storage-pools.tsx @@ -363,3 +363,21 @@ export const deleteStorageVolume = ( .catch(reject); }); }; + +export const migrateStorageVolume = ( + volume: Partial, + targetPool: string, +): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/storage-pools/${volume.pool}/volumes/custom/${volume.name}`, { + method: "POST", + body: JSON.stringify({ + name: volume.name, + pool: targetPool, + }), + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; diff --git a/src/pages/storage/MigrateVolumeBtn.tsx b/src/pages/storage/MigrateVolumeBtn.tsx new file mode 100644 index 0000000000..876add9dc2 --- /dev/null +++ b/src/pages/storage/MigrateVolumeBtn.tsx @@ -0,0 +1,133 @@ +import { FC, useState } from "react"; +import { ActionButton, Icon } from "@canonical/react-components"; +import usePortal from "react-useportal"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { useEventQueue } from "context/eventQueue"; +import ItemName from "components/ItemName"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { LxdStorageVolume } from "types/storage"; +import MigrateVolumeModal from "./MigrateVolumeModal"; +import { migrateStorageVolume } from "api/storage-pools"; +import { useNavigate } from "react-router-dom"; + +interface Props { + storageVolume: LxdStorageVolume; + project: string; + classname?: string; + onClose?: () => void; +} + +const MigrateVolumeBtn: FC = ({ + storageVolume, + project, + classname, + onClose, +}) => { + const eventQueue = useEventQueue(); + const toastNotify = useToastNotification(); + const { openPortal, closePortal, isOpen, Portal } = usePortal(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [isVolumeLoading, setVolumeLoading] = useState(false); + + const handleSuccess = ( + newTarget: string, + storageVolume: LxdStorageVolume, + ) => { + toastNotify.success( + <> + Volume {" "} + successfully migrated to pool{" "} + + , + ); + + const oldVolumeUrl = `/ui/project/${storageVolume.project}/storage/pool/${storageVolume.pool}/volumes/${storageVolume.type}/${storageVolume.name}`; + const newVolumeUrl = `/ui/project/${storageVolume.project}/storage/pool/${newTarget}/volumes/${storageVolume.type}/${storageVolume.name}`; + if (window.location.pathname.startsWith(oldVolumeUrl)) { + navigate(newVolumeUrl); + } + }; + + const notifyFailure = ( + e: unknown, + storageVolume: string, + targetPool: string, + ) => { + setVolumeLoading(false); + toastNotify.failure( + `Migration failed for volume ${storageVolume} to pool ${targetPool}`, + e, + ); + }; + + const handleFailure = ( + msg: string, + storageVolume: string, + targetPool: string, + ) => { + notifyFailure(new Error(msg), storageVolume, targetPool); + }; + + const handleFinish = () => { + void queryClient.invalidateQueries({ + queryKey: [queryKeys.storage, storageVolume.name], + }); + setVolumeLoading(false); + }; + + const handleMigrate = (targetPool: string) => { + setVolumeLoading(true); + migrateStorageVolume(storageVolume, targetPool) + .then((operation) => { + eventQueue.set( + operation.metadata.id, + () => handleSuccess(targetPool, storageVolume), + (err) => handleFailure(err, storageVolume.name, targetPool), + handleFinish, + ); + toastNotify.info( + `Migration started for volume ${storageVolume.name} to pool ${targetPool}`, + ); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.storage, storageVolume.name, project], + }); + }) + .catch((e) => { + notifyFailure(e, storageVolume.name, targetPool); + }); + }; + + const handleClose = () => { + closePortal(); + onClose?.(); + }; + + return ( + <> + {isOpen && ( + + + + )} + + + Migrate + + + ); +}; + +export default MigrateVolumeBtn; diff --git a/src/pages/storage/MigrateVolumeModal.tsx b/src/pages/storage/MigrateVolumeModal.tsx new file mode 100644 index 0000000000..289e9b8430 --- /dev/null +++ b/src/pages/storage/MigrateVolumeModal.tsx @@ -0,0 +1,192 @@ +import { FC, KeyboardEvent, useState } from "react"; +import { + ActionButton, + Button, + MainTable, + Modal, +} from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import ScrollableTable from "components/ScrollableTable"; +import { LxdStorageVolume } from "types/storage"; +import { fetchStoragePools } from "api/storage-pools"; +import StoragePoolSize from "./StoragePoolSize"; + +interface Props { + close: () => void; + migrate: (target: string) => void; + storageVolume: LxdStorageVolume; +} + +const MigrateVolumeModal: FC = ({ close, migrate, storageVolume }) => { + const { data: pools = [] } = useQuery({ + queryKey: [queryKeys.storage], + queryFn: () => fetchStoragePools(storageVolume.project), + }); + + const [selectedPool, setSelectedPool] = useState(""); + + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + close(); + } + }; + + const handleMigrate = () => { + migrate(selectedPool); + close(); + }; + + const handleCancel = () => { + if (selectedPool) { + setSelectedPool(""); + return; + } + + close(); + }; + + const headers = [ + { content: "Name", sortKey: "name" }, + { content: "Driver", sortKey: "driver" }, + { content: "Status", sortKey: "status" }, + { content: "Size", sortKey: "size" }, + { "aria-label": "Actions", className: "actions" }, + ]; + + const rows = pools.map((pool) => { + const disableReason = + pool.name === storageVolume.pool + ? "Volume is located on this cluster member" + : ""; + + const selectPool = () => { + if (disableReason) { + return; + } + + setSelectedPool(pool.name); + }; + + return { + className: "u-row", + columns: [ + { + content: ( +
+ {pool.name} +
+ ), + role: "cell", + "aria-label": "Name", + onClick: selectPool, + }, + { + content: pool.driver, + role: "cell", + "aria-label": "Driver", + onClick: selectPool, + }, + { + content: pool.status, + role: "cell", + "aria-label": "Status", + onClick: selectPool, + }, + { + content: , + role: "cell", + "aria-label": "Size", + onClick: selectPool, + }, + { + content: ( + + ), + role: "cell", + "aria-label": "Actions", + className: "u-align--right", + onClick: selectPool, + }, + ], + sortData: { + name: pool.name.toLowerCase(), + driver: pool.driver.toLowerCase(), + status: pool.status?.toLowerCase(), + size: pool.config.size?.toLowerCase(), + }, + }; + }); + + const modalTitle = selectedPool + ? "Confirm migration" + : `Choose storage pool for volume ${storageVolume.name}`; + + const summary = ( +
+

+ This will migrate volume {storageVolume.name} to + storage pool {selectedPool}. +

+
+ ); + + return ( + + + handleMigrate()} + disabled={!selectedPool} + > + Migrate + + + } + onKeyDown={handleEscKey} + > + {selectedPool ? ( + summary + ) : ( +
+ + + +
+ )} +
+ ); +}; + +export default MigrateVolumeModal; diff --git a/src/pages/storage/StoragePoolSelector.tsx b/src/pages/storage/StoragePoolSelector.tsx index 1b9fd982bd..b02d32c43b 100644 --- a/src/pages/storage/StoragePoolSelector.tsx +++ b/src/pages/storage/StoragePoolSelector.tsx @@ -14,6 +14,8 @@ interface Props { setValue: (value: string) => void; selectProps?: SelectProps; hidePoolsWithUnsupportedDrivers?: boolean; + disabled?: boolean; + help?: string; } const StoragePoolSelector: FC = ({ @@ -22,6 +24,8 @@ const StoragePoolSelector: FC = ({ setValue, selectProps, hidePoolsWithUnsupportedDrivers = false, + disabled, + help, }) => { const notify = useNotify(); const { data: settings } = useSettings(); @@ -82,6 +86,8 @@ const StoragePoolSelector: FC = ({ onChange={(e) => setValue(e.target.value)} value={value} {...selectProps} + disabled={disabled} + help={help} /> ); }; diff --git a/src/pages/storage/StorageVolumeHeader.tsx b/src/pages/storage/StorageVolumeHeader.tsx index 7de87beaf3..6d0bd619b1 100644 --- a/src/pages/storage/StorageVolumeHeader.tsx +++ b/src/pages/storage/StorageVolumeHeader.tsx @@ -9,6 +9,7 @@ import { testDuplicateStorageVolumeName } from "util/storageVolume"; import { useNotify } from "@canonical/react-components"; import DeleteStorageVolumeBtn from "pages/storage/actions/DeleteStorageVolumeBtn"; import { useToastNotification } from "context/toastNotificationProvider"; +import MigrateVolumeBtn from "./MigrateVolumeBtn"; interface Props { volume: LxdStorageVolume; @@ -66,6 +67,8 @@ const StorageVolumeHeader: FC = ({ volume, project }) => { }, }); + const classname = "p-segmented-control__button"; + return ( = ({ volume, project }) => { , ]} controls={ - { - navigate(`/ui/project/${project}/storage/volumes`); - toastNotify.success(`Storage volume ${volume.name} deleted.`); - }} - /> +
+
+ + { + navigate(`/ui/project/${project}/storage/volumes`); + toastNotify.success(`Storage volume ${volume.name} deleted.`); + }} + classname={classname} + /> +
+
} isLoaded={true} formik={formik} diff --git a/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx b/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx index 1da52569c0..6439bf68b8 100644 --- a/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx +++ b/src/pages/storage/actions/DeleteStorageVolumeBtn.tsx @@ -16,6 +16,7 @@ interface Props { appearance?: string; hasIcon?: boolean; label?: string; + classname?: string; } const DeleteStorageVolumeBtn: FC = ({ @@ -25,6 +26,7 @@ const DeleteStorageVolumeBtn: FC = ({ appearance = "base", hasIcon = true, label, + classname, }) => { const notify = useNotify(); const [isLoading, setLoading] = useState(false); @@ -98,14 +100,14 @@ const DeleteStorageVolumeBtn: FC = ({ onConfirm: handleDelete, }} appearance={appearance} - className="has-icon u-no-margin--bottom" + className={classname} shiftClickEnabled showShiftClickHint disabled={Boolean(disabledReason)} onHoverText={disabledReason} > - {label && {label}} {hasIcon && } + {label && {label}} ); }; diff --git a/src/pages/storage/forms/StorageVolumeFormMain.tsx b/src/pages/storage/forms/StorageVolumeFormMain.tsx index d153014ceb..87ade80b5e 100644 --- a/src/pages/storage/forms/StorageVolumeFormMain.tsx +++ b/src/pages/storage/forms/StorageVolumeFormMain.tsx @@ -23,19 +23,24 @@ const StorageVolumeFormMain: FC = ({ formik, project }) => { - {formik.values.isCreating && ( - <> - - void formik.setFieldValue("pool", val)} - hidePoolsWithUnsupportedDrivers - /> - - )} + + void formik.setFieldValue("pool", val)} + hidePoolsWithUnsupportedDrivers + disabled={!formik.values.isCreating} + help={ + formik.values.isCreating + ? undefined + : "Use the migrate button in the header to move the volume to a different storage pool." + } + /> = ({ formik, project }) => { help={ formik.values.isCreating ? undefined - : "Click the name in the header to rename the volume" + : "Click the name in the header to rename the volume." } /> { return `playwright-volume-${randomNameSuffix()}`; @@ -26,7 +27,7 @@ export const createVolume = async ( export const deleteVolume = async (page: Page, volume: string) => { await visitVolume(page, volume); - await page.getByRole("button", { name: "Delete volume" }).click(); + await page.getByRole("button", { name: "Delete" }).click(); await page .getByRole("dialog", { name: "Confirm delete" }) .getByRole("button", { name: "Delete" }) @@ -53,3 +54,30 @@ export const saveVolume = async (page: Page, volume: string) => { await page.getByRole("button", { name: "Save changes" }).click(); await page.waitForSelector(`text=Storage volume ${volume} updated.`); }; + +export const migrateVolume = async ( + page: Page, + volume: string, + targetPool: string, +) => { + await visitVolume(page, volume); + await page.getByRole("button", { name: "Migrate", exact: true }).click(); + await page + .getByRole("dialog", { name: `Choose storage pool for volume ${volume}` }) + .locator("tr") + .filter({ hasText: targetPool }) + .getByRole("button") + .click(); + await page + .getByLabel("Confirm migration") + .getByRole("button", { name: "Migrate", exact: true }) + .click(); + + await page.waitForSelector( + `text=successfully migrated to pool ${targetPool}`, + ); + + await expect(page).toHaveURL( + `/ui/project/default/storage/pool/${targetPool}/volumes/custom/${volume}`, + ); +}; diff --git a/tests/storage.spec.ts b/tests/storage.spec.ts index 59bbf0cd76..d34774d664 100644 --- a/tests/storage.spec.ts +++ b/tests/storage.spec.ts @@ -10,6 +10,7 @@ import { createVolume, deleteVolume, editVolume, + migrateVolume, randomVolumeName, saveVolume, visitVolume, @@ -57,6 +58,18 @@ test("storage volume create, edit and remove", async ({ page }) => { await assertTextVisible(page, "size2GiB"); }); +test("storage volume migrate", async ({ page }) => { + const pool2 = randomPoolName(); + await createPool(page, pool2); + + await migrateVolume(page, volume, pool2); + await expect(page.getByRole("link", { name: pool2 })).toBeVisible(); + + //Migrate back to default so that the Pool can be deleted + await migrateVolume(page, volume, "default"); + await deletePool(page, pool2); +}); + test("storage volume edit snapshot configuration", async ({ page, lxdVersion,