diff --git a/src/api/operations.tsx b/src/api/operations.tsx index da982e403ac..b48dbaee8a5 100644 --- a/src/api/operations.tsx +++ b/src/api/operations.tsx @@ -38,3 +38,44 @@ export const cancelOperation = async (id: string): Promise => { method: "DELETE", }).then(handleResponse); }; + +export const waitForOperation = async ( + id: string, + member?: string, + maxTimeoutMs = 120000, +): Promise => { + const endpoint = `${ROOT_PATH}/1.0/operations/${encodeURIComponent(id)}`; + const startTime = Date.now(); + const memberPrefix = member ? `Member: ${member} - ` : ""; + + while (true) { + try { + const response = (await fetch(endpoint).then( + handleResponse, + )) as LxdApiResponse; + const operation = response.metadata; + + if (operation.status_code === 200) { + return; + } + + if (operation.status_code >= 400) { + throw new Error( + `${memberPrefix}${operation.err || `Operation ${id} failed with status ${operation.status}`}`, + ); + } + } catch (error) { + // Add memberPrefix to all other errors before re-throwing + if (error instanceof Error) { + throw new Error(`${memberPrefix}${error.message}`); + } + throw new Error(`${memberPrefix}Unknown error occurred`); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (Date.now() - startTime > maxTimeoutMs) { + throw new Error(`${memberPrefix}Operation timed out.`); + } + } +}; diff --git a/src/api/storage-pools.tsx b/src/api/storage-pools.tsx index 7d137e6fd95..f3a7e761669 100644 --- a/src/api/storage-pools.tsx +++ b/src/api/storage-pools.tsx @@ -12,9 +12,11 @@ import type { import type { LxdApiResponse } from "types/apiResponse"; import type { LxdClusterMember } from "types/cluster"; import type { ClusterSpecificValues } from "types/cluster"; +import type { LxdOperationResponse } from "types/operation"; import { addEntitlements } from "util/entitlements/api"; import { addTarget } from "util/target"; import { ROOT_PATH } from "util/rootPath"; +import { waitForOperation } from "api/operations"; export const storagePoolEntitlements = ["can_edit", "can_delete"]; @@ -101,17 +103,21 @@ export const fetchClusteredStoragePoolResources = async ( export const createPool = async ( pool: Partial, target?: string, -): Promise => { +): Promise => { const params = new URLSearchParams(); addTarget(params, target); - await fetch(`${ROOT_PATH}/1.0/storage-pools?${params.toString()}`, { + return fetch(`${ROOT_PATH}/1.0/storage-pools?${params.toString()}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(pool), - }).then(handleResponse); + }) + .then(handleResponse) + .then((data: LxdOperationResponse) => { + return data; + }); }; const getClusterAndMemberPoolPayload = (pool: LxdStoragePool) => { @@ -149,10 +155,11 @@ export const createClusteredPool = async ( sourcePerClusterMember?: ClusterSpecificValues, zfsPoolNamePerClusterMember?: ClusterSpecificValues, sizePerClusterMember?: ClusterSpecificValues, -): Promise => { +): Promise => { const { memberPoolPayload, clusterPoolPayload } = getClusterAndMemberPoolPayload(pool); - return Promise.allSettled( + + const results = await Promise.allSettled( clusterMembers.map(async (item) => { const clusteredMemberPool = { ...memberPoolPayload, @@ -163,23 +170,41 @@ export const createClusteredPool = async ( "zfs.pool_name": zfsPoolNamePerClusterMember?.[item.server_name], }, }; - return createPool(clusteredMemberPool, item.server_name); + const operation = await createPool(clusteredMemberPool, item.server_name); + return { operation, member: item.server_name }; }), - ) - .then(handleSettledResult) - .then(async () => { - return createPool(clusterPoolPayload); - }); + ); + + handleSettledResult(results); + + const operationsWithMembers = results + .filter( + ( + res, + ): res is PromiseFulfilledResult<{ + operation: LxdOperationResponse; + member: string; + }> => res.status === "fulfilled", + ) + .map((res) => res.value); + + await Promise.all( + operationsWithMembers.map(async ({ operation, member }) => { + await waitForOperation(operation.metadata.id, member); + }), + ); + + return createPool(clusterPoolPayload); }; export const updatePool = async ( pool: LxdStoragePool, target?: string, -): Promise => { +): Promise => { const params = new URLSearchParams(); addTarget(params, target); - await fetch( + return fetch( `${ROOT_PATH}/1.0/storage-pools/${encodeURIComponent(pool.name)}?${params.toString()}`, { method: "PATCH", @@ -188,7 +213,11 @@ export const updatePool = async ( }, body: JSON.stringify(pool), }, - ).then(handleResponse); + ) + .then(handleResponse) + .then((data: LxdOperationResponse) => { + return data; + }); }; export const updateClusteredPool = async ( @@ -197,10 +226,11 @@ export const updateClusteredPool = async ( sourcePerClusterMember?: ClusterSpecificValues, zfsPoolNamePerClusterMember?: ClusterSpecificValues, sizePerClusterMember?: ClusterSpecificValues, -): Promise => { +): Promise => { const { memberPoolPayload, clusterPoolPayload } = getClusterAndMemberPoolPayload(pool); - return Promise.allSettled( + + const results = await Promise.allSettled( clusterMembers.map(async (item) => { const clusteredMemberPool = { ...memberPoolPayload, @@ -220,32 +250,65 @@ export const updateClusteredPool = async ( clusteredMemberPool.config["zfs.pool_name"] = zfsPoolNamePerClusterMember[item.server_name]; } - return updatePool(clusteredMemberPool, item.server_name); + const operation = await updatePool(clusteredMemberPool, item.server_name); + return { operation, member: item.server_name }; }), - ) - .then(handleSettledResult) - .then(async () => updatePool(clusterPoolPayload)); + ); + + handleSettledResult(results); + + const operationsWithMembers = results + .filter( + ( + res, + ): res is PromiseFulfilledResult<{ + operation: LxdOperationResponse; + member: string; + }> => res.status === "fulfilled", + ) + .map((res) => res.value); + + await Promise.all( + operationsWithMembers.map(async ({ operation, member }) => { + await waitForOperation(operation.metadata.id, member); + }), + ); + + return updatePool(clusterPoolPayload); }; export const renameStoragePool = async ( oldName: string, newName: string, -): Promise => { - await fetch(`${ROOT_PATH}/1.0/storage-pools/${encodeURIComponent(oldName)}`, { - method: "POST", - headers: { - "Content-Type": "application/json", +): Promise => { + return fetch( + `${ROOT_PATH}/1.0/storage-pools/${encodeURIComponent(oldName)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: newName, + }), }, - body: JSON.stringify({ - name: newName, - }), - }).then(handleResponse); + ) + .then(handleResponse) + .then((data: LxdOperationResponse) => { + return data; + }); }; -export const deleteStoragePool = async (pool: string): Promise => { - await fetch(`${ROOT_PATH}/1.0/storage-pools/${encodeURIComponent(pool)}`, { +export const deleteStoragePool = async ( + pool: string, +): Promise => { + return fetch(`${ROOT_PATH}/1.0/storage-pools/${encodeURIComponent(pool)}`, { method: "DELETE", - }).then(handleResponse); + }) + .then(handleResponse) + .then((data: LxdOperationResponse) => { + return data; + }); }; export const fetchPoolFromClusterMembers = async ( diff --git a/src/context/useSupportedFeatures.tsx b/src/context/useSupportedFeatures.tsx index 68424313bad..e5b2d9bec29 100644 --- a/src/context/useSupportedFeatures.tsx +++ b/src/context/useSupportedFeatures.tsx @@ -50,5 +50,8 @@ export const useSupportedFeatures = () => { hasProjectDeleteOperation: apiExtensions.has("project_delete_operation"), hasRemoteDropSource: apiExtensions.has("storage_remote_drop_source"), hasClusteringControlPlane: apiExtensions.has("clustering_control_plane"), + hasStorageAndNetworkOperations: apiExtensions.has( + "storage_and_network_operations", + ), }; }; diff --git a/src/pages/storage/CreateStoragePool.tsx b/src/pages/storage/CreateStoragePool.tsx index ec6ee159e7b..429ce0e5c6d 100644 --- a/src/pages/storage/CreateStoragePool.tsx +++ b/src/pages/storage/CreateStoragePool.tsx @@ -33,6 +33,7 @@ import YamlSwitch from "components/forms/YamlSwitch"; import StoragePoolRichChip from "./StoragePoolRichChip"; import { ROOT_PATH } from "util/rootPath"; import { useSupportedFeatures } from "context/useSupportedFeatures"; +import { useEventQueue } from "context/eventQueue"; const CreateStoragePool: FC = () => { const navigate = useNavigate(); @@ -43,7 +44,9 @@ const CreateStoragePool: FC = () => { const [section, setSection] = useState(slugify(MAIN_CONFIGURATION)); const controllerState = useState(null); const { data: clusterMembers = [] } = useClusterMembers(); - const { hasRemoteDropSource } = useSupportedFeatures(); + const { hasRemoteDropSource, hasStorageAndNetworkOperations } = + useSupportedFeatures(); + const eventQueue = useEventQueue(); if (!project) { return <>Missing project; @@ -55,6 +58,27 @@ const CreateStoragePool: FC = () => { .required("This field is required"), }); + const notifySuccess = (poolName: string) => { + toastNotify.success( + <> + Storage pool{" "} + {" "} + created. + , + ); + }; + + const onSuccess = (storagePoolName: string) => { + queryClient.invalidateQueries({ + queryKey: [queryKeys.storage], + }); + navigate( + `${ROOT_PATH}/ui/project/${encodeURIComponent(project)}/storage/pools`, + ); + formik.setSubmitting(false); + notifySuccess(storagePoolName); + }; + const formik = useFormik({ initialValues: { isCreating: true, @@ -85,27 +109,38 @@ const CreateStoragePool: FC = () => { : async () => createPool(storagePool); mutation() - .then(() => { - queryClient.invalidateQueries({ - queryKey: [queryKeys.storage], - }); - navigate( - `${ROOT_PATH}/ui/project/${encodeURIComponent(project)}/storage/pools`, - ); - toastNotify.success( - <> - Storage pool{" "} - {" "} - created. - , - ); + .then((operation) => { + if (hasStorageAndNetworkOperations) { + toastNotify.info( + <> + Creation of storage pool{" "} + {" "} + has started. + , + ); + eventQueue.set( + operation.metadata.id, + () => { + onSuccess(storagePool.name); + }, + (msg) => { + formik.setSubmitting(false); + toastNotify.failure( + `Creation of storage pool ${storagePool.name} failed`, + new Error(msg), + ); + }, + ); + } else { + onSuccess(storagePool.name); + } }) .catch((e) => { formik.setSubmitting(false); - notify.failure("Storage pool creation failed", e); + notify.failure("Creation of storage pool failed", e); }); }, }); diff --git a/src/pages/storage/EditStoragePool.tsx b/src/pages/storage/EditStoragePool.tsx index a804eeede3a..3b88dfa6eb8 100644 --- a/src/pages/storage/EditStoragePool.tsx +++ b/src/pages/storage/EditStoragePool.tsx @@ -32,6 +32,8 @@ import FormSubmitBtn from "components/forms/FormSubmitBtn"; import { useStoragePoolEntitlements } from "util/entitlements/storage-pools"; import { usePoolFromClusterMembers } from "context/useStoragePools"; import StoragePoolRichChip from "./StoragePoolRichChip"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; +import { useEventQueue } from "context/eventQueue"; interface Props { pool: LxdStoragePool; @@ -51,6 +53,8 @@ const EditStoragePool: FC = ({ pool }) => { const { data: clusterMembers = [] } = useClusterMembers(); const [version, setVersion] = useState(0); const { canEditPool } = useStoragePoolEntitlements(); + const { hasStorageAndNetworkOperations } = useSupportedFeatures(); + const eventQueue = useEventQueue(); if (!project) { return <>Missing project; @@ -82,6 +86,40 @@ const EditStoragePool: FC = ({ pool }) => { ? undefined : "You do not have permission to edit this pool"; + const notifySuccess = (poolName: string) => { + toastNotify.success( + <> + Storage pool{" "} + {" "} + updated. + , + ); + }; + + const onSuccess = ( + storagePoolName: string, + values: StoragePoolFormValues, + ) => { + queryClient.invalidateQueries({ + queryKey: [queryKeys.storage], + predicate: (query) => + query.queryKey[0] === queryKeys.volumes || + query.queryKey[0] === queryKeys.storage, + }); + if (pool.driver === cephDriver && values.ceph_rbd_du === "false") { + // Clear the storage volume sizes from the cache. The sizes are not available + // after disabling `ceph_rbd_du` and the volume state queries will fail. So we + // remove the queries to avoid serving the size from a stale cache. + queryClient.removeQueries({ + predicate: (query) => + query.queryKey[0] === queryKeys.storage && + query.queryKey[1] === pool.name, + }); + } + formik.setSubmitting(false); + notifySuccess(storagePoolName); + }; + const formik = useFormik({ initialValues: toStoragePoolFormValues( pool, @@ -108,39 +146,44 @@ const EditStoragePool: FC = ({ pool }) => { : async () => updatePool(savedPool); mutation() - .then(() => { - toastNotify.success( - <> - Storage pool{" "} - {" "} - updated. - , - ); + .then((operation) => { + if (hasStorageAndNetworkOperations) { + toastNotify.info( + <> + Update of storage pool{" "} + {" "} + has started. + , + ); + eventQueue.set( + operation.metadata.id, + () => { + onSuccess(savedPool.name, values); + }, + (msg) => { + queryClient.invalidateQueries({ + queryKey: [queryKeys.storage], + predicate: (query) => + query.queryKey[0] === queryKeys.volumes || + query.queryKey[0] === queryKeys.storage, + }); + formik.setSubmitting(false); + toastNotify.failure( + `Update of storage pool ${savedPool.name} failed`, + new Error(msg), + ); + }, + ); + } else { + onSuccess(savedPool.name, values); + } }) .catch((e) => { - notify.failure("Storage pool update failed", e); - }) - .finally(() => { formik.setSubmitting(false); - queryClient.invalidateQueries({ - queryKey: [queryKeys.storage], - predicate: (query) => - query.queryKey[0] === queryKeys.volumes || - query.queryKey[0] === queryKeys.storage, - }); - if (pool.driver === cephDriver && values.ceph_rbd_du === "false") { - // Clear the storage volume sizes from the cache. The sizes are not available - // after disabling `ceph_rbd_du` and the volume state queries will fail. So we - // remove the queries to avoid serving the size from a stale cache. - queryClient.removeQueries({ - predicate: (query) => - query.queryKey[0] === queryKeys.storage && - query.queryKey[1] === pool.name, - }); - } + notify.failure("Storage pool update failed", e); }); }, }); diff --git a/src/pages/storage/StoragePoolHeader.tsx b/src/pages/storage/StoragePoolHeader.tsx index cdbf53b45bd..55cfeaa0f72 100644 --- a/src/pages/storage/StoragePoolHeader.tsx +++ b/src/pages/storage/StoragePoolHeader.tsx @@ -11,6 +11,8 @@ import { useFormik } from "formik"; import { renameStoragePool } from "api/storage-pools"; import { ROOT_PATH } from "util/rootPath"; import StoragePoolRichChip from "./StoragePoolRichChip"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; +import { useEventQueue } from "context/eventQueue"; interface Props { name: string; @@ -23,6 +25,8 @@ const StoragePoolHeader: FC = ({ name, pool, project }) => { const notify = useNotify(); const toastNotify = useToastNotification(); const controllerState = useState(null); + const { hasStorageAndNetworkOperations } = useSupportedFeatures(); + const eventQueue = useEventQueue(); const RenameSchema = Yup.object().shape({ name: Yup.string() @@ -30,6 +34,15 @@ const StoragePoolHeader: FC = ({ name, pool, project }) => { .required("This field is required"), }); + const notifySuccess = (poolName: string) => { + toastNotify.success( + <> + Storage pool {name} renamed to{" "} + + , + ); + }; + const formik = useFormik({ initialValues: { name, @@ -43,18 +56,37 @@ const StoragePoolHeader: FC = ({ name, pool, project }) => { return; } renameStoragePool(name, values.name) - .then(() => { + .then((operation) => { const url = `${ROOT_PATH}/ui/project/${encodeURIComponent(project)}/storage/pool/${encodeURIComponent(values.name)}`; navigate(url); - toastNotify.success( - <> - Storage pool {name} renamed to{" "} - - , - ); + + if (hasStorageAndNetworkOperations) { + toastNotify.info( + <> + Renaming of storage pool{" "} + {" "} + has started. + , + ); + eventQueue.set( + operation.metadata.id, + () => { + notifySuccess(values.name); + }, + (msg) => + toastNotify.failure( + `Renaming of storage pool ${values.name} failed`, + + new Error(msg), + ), + ); + } else { + notifySuccess(values.name); + } + formik.setFieldValue("isRenaming", false); }) .catch((e) => { diff --git a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx index 3a5df1b9524..196994558ff 100644 --- a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx +++ b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx @@ -16,6 +16,9 @@ import { queryKeys } from "util/queryKeys"; import ResourceLabel from "components/ResourceLabel"; import { useStoragePoolEntitlements } from "util/entitlements/storage-pools"; import { ROOT_PATH } from "util/rootPath"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; +import { useEventQueue } from "context/eventQueue"; +import StoragePoolRichChip from "../StoragePoolRichChip"; interface Props { pool: LxdStoragePool; @@ -35,23 +38,57 @@ const DeleteStoragePoolBtn: FC = ({ const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); const { canDeletePool } = useStoragePoolEntitlements(); + const { hasStorageAndNetworkOperations } = useSupportedFeatures(); + const eventQueue = useEventQueue(); + + const notifySuccess = (poolName: string) => { + toastNotify.success( + <> + Storage pool {" "} + deleted. + , + ); + }; + + const onSuccess = () => { + queryClient.invalidateQueries({ + queryKey: [queryKeys.storage], + }); + navigate( + `${ROOT_PATH}/ui/project/${encodeURIComponent(project)}/storage/pools`, + ); + notifySuccess(pool.name); + }; const handleDelete = () => { setLoading(true); deleteStoragePool(pool.name) - .then(() => { - queryClient.invalidateQueries({ - queryKey: [queryKeys.storage], - }); - navigate( - `${ROOT_PATH}/ui/project/${encodeURIComponent(project)}/storage/pools`, - ); - toastNotify.success( - <> - Storage pool {" "} - deleted. - , - ); + .then((operation) => { + if (hasStorageAndNetworkOperations) { + toastNotify.info( + <> + Deletion of storage pool{" "} + {" "} + has started. + , + ); + eventQueue.set( + operation.metadata.id, + () => { + setLoading(false); + onSuccess(); + }, + (msg) => { + setLoading(false); + toastNotify.failure( + `Deleting storage pool ${pool.name} failed`, + new Error(msg), + ); + }, + ); + } else { + onSuccess(); + } }) .catch((e) => { setLoading(false); diff --git a/src/types/operation.d.ts b/src/types/operation.d.ts index 01b62f37222..829e4f53d0b 100644 --- a/src/types/operation.d.ts +++ b/src/types/operation.d.ts @@ -21,7 +21,7 @@ export interface LxdOperation { storage_volume_snapshots?: string[]; }; status: LxdOperationStatus; - status_code: string; + status_code: number; updated_at: string; }