From 114dc76e4e5a028ef0e0dc01e1f408238e07ae9c Mon Sep 17 00:00:00 2001 From: Nkeiruka Date: Wed, 22 Jan 2025 20:10:24 +0000 Subject: [PATCH] feat: [WD-18264] CMS fields for storage pool source Signed-off-by: Nkeiruka --- src/api/networks.tsx | 15 +- src/api/storage-pools.tsx | 52 ++++++- src/components/forms/ClusterSpecificInput.tsx | 137 ++++++++++++++++++ src/pages/storage/CreateStoragePool.tsx | 7 +- src/pages/storage/EditStoragePool.tsx | 28 +++- .../StoragePoolClusteredSourceSelector.tsx | 41 ++++++ src/pages/storage/forms/StoragePoolForm.tsx | 22 +-- .../storage/forms/StoragePoolFormMain.tsx | 42 ++++-- src/types/storage.d.ts | 4 + src/util/helpers.tsx | 9 ++ src/util/storagePoolForm.tsx | 14 +- 11 files changed, 324 insertions(+), 47 deletions(-) create mode 100644 src/components/forms/ClusterSpecificInput.tsx create mode 100644 src/pages/storage/forms/StoragePoolClusteredSourceSelector.tsx diff --git a/src/api/networks.tsx b/src/api/networks.tsx index e330fa4b67..723252ecfd 100644 --- a/src/api/networks.tsx +++ b/src/api/networks.tsx @@ -1,4 +1,8 @@ -import { handleEtagResponse, handleResponse } from "util/helpers"; +import { + constructMemberError, + handleEtagResponse, + handleResponse, +} from "util/helpers"; import type { LxdNetwork, LXDNetworkOnClusterMember, @@ -29,15 +33,6 @@ export const fetchNetworks = ( }); }; -const constructMemberError = ( - result: PromiseRejectedResult, - member: string, -) => { - const reason = result.reason as Error; - const message = `Error from cluster member ${member}: ${reason.message}`; - return new Error(message); -}; - export const fetchNetworksFromClusterMembers = ( project: string, clusterMembers: LxdClusterMember[], diff --git a/src/api/storage-pools.tsx b/src/api/storage-pools.tsx index af8b3b37b7..f66be47ef8 100644 --- a/src/api/storage-pools.tsx +++ b/src/api/storage-pools.tsx @@ -1,10 +1,12 @@ import { + constructMemberError, handleEtagResponse, handleResponse, handleSettledResult, } from "util/helpers"; import { LxdStoragePool, + LXDStoragePoolOnClusterMember, LxdStoragePoolResources, LxdStorageVolume, LxdStorageVolumeState, @@ -14,6 +16,7 @@ import type { LxdApiResponse } from "types/apiResponse"; import type { LxdOperationResponse } from "types/operation"; import axios, { AxiosResponse } from "axios"; import type { LxdClusterMember } from "types/cluster"; +import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; export const fetchStoragePool = ( pool: string, @@ -66,7 +69,7 @@ export const createPool = ( }); }; -const getClusterAndMemberPools = (pool: Partial) => { +const getClusterAndMemberPoolPayload = (pool: Partial) => { const memberSpecificConfigKeys = new Set([ "source", "size", @@ -97,11 +100,21 @@ const getClusterAndMemberPools = (pool: Partial) => { export const createClusteredPool = ( pool: LxdStoragePool, clusterMembers: LxdClusterMember[], + sourcePerClusterMember?: ClusterSpecificValues, ): Promise => { - const { memberPool, clusterPool } = getClusterAndMemberPools(pool); + const { memberPool, clusterPool } = getClusterAndMemberPoolPayload(pool); return new Promise((resolve, reject) => { Promise.allSettled( - clusterMembers.map((item) => createPool(memberPool, item.server_name)), + clusterMembers.map((item) => { + const clusteredMemberPool = { + ...memberPool, + config: { + ...memberPool.config, + source: sourcePerClusterMember?.[item.server_name], + }, + }; + return createPool(clusteredMemberPool, item.server_name); + }), ) .then(handleSettledResult) .then(() => { @@ -132,7 +145,7 @@ export const updateClusteredPool = ( pool: Partial, clusterMembers: LxdClusterMember[], ): Promise => { - const { memberPool, clusterPool } = getClusterAndMemberPools(pool); + const { memberPool, clusterPool } = getClusterAndMemberPoolPayload(pool); return new Promise((resolve, reject) => { Promise.allSettled( clusterMembers.map(async (item) => @@ -174,6 +187,37 @@ export const deleteStoragePool = (pool: string): Promise => { }); }; +export const fetchPoolFromClusterMembers = ( + poolName: string, + clusterMembers: LxdClusterMember[], +): Promise => { + return new Promise((resolve, reject) => { + Promise.allSettled( + clusterMembers.map((member) => { + return fetchStoragePool(poolName, member.server_name); + }), + ) + .then((results) => { + const poolOnMembers: LXDStoragePoolOnClusterMember[] = []; + for (let i = 0; i < clusterMembers.length; i++) { + const memberName = clusterMembers[i].server_name; + const result = results[i]; + if (result.status === "rejected") { + reject(constructMemberError(result, memberName)); + } + if (result.status === "fulfilled") { + const promise = results[ + i + ] as PromiseFulfilledResult; + poolOnMembers.push({ ...promise.value, memberName: memberName }); + } + } + resolve(poolOnMembers); + }) + .catch(reject); + }); +}; + export const fetchStorageVolumes = ( pool: string, project: string, diff --git a/src/components/forms/ClusterSpecificInput.tsx b/src/components/forms/ClusterSpecificInput.tsx new file mode 100644 index 0000000000..250c2d3ea5 --- /dev/null +++ b/src/components/forms/ClusterSpecificInput.tsx @@ -0,0 +1,137 @@ +import { FC, Fragment, useEffect, useRef, useState } from "react"; +import { CheckboxInput, Input } from "@canonical/react-components"; +import ResourceLink from "components/ResourceLink"; +import FormEditButton from "components/FormEditButton"; +import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; + +interface Props { + id: string; + isReadOnly: boolean; + onChange: (value: ClusterSpecificValues) => void; + toggleReadOnly: () => void; + memberNames: string[]; + values?: ClusterSpecificValues; + canToggleSpecific?: boolean; + isDefaultSpecific?: boolean; + clusterMemberLinkTarget?: (member: string) => string; + disabledGlobalInput?: boolean; + disabledClusteredInput?: boolean; +} + +const ClusterSpecificInput: FC = ({ + values, + id, + isReadOnly, + memberNames, + onChange, + toggleReadOnly, + canToggleSpecific = true, + isDefaultSpecific = false, + clusterMemberLinkTarget = () => "/ui/cluster", + disabledGlobalInput = false, + disabledClusteredInput = false, +}) => { + const [isSpecific, setIsSpecific] = useState(isDefaultSpecific); + const firstValue = Object.values(values ?? {})[0]; + const isUserActionRef = useRef(false); + + useEffect(() => { + if (!isUserActionRef.current && values && Object.keys(values).length > 0) { + const newDefaultSpecific = !Object.values(values).every( + (item) => item === Object.values(values)[0], + ); + setIsSpecific(newDefaultSpecific); + } + isUserActionRef.current = false; + }, [values]); + + const setValueForAllMembers = (value: string) => { + const update: ClusterSpecificValues = {}; + memberNames.forEach((member) => (update[member] = value)); + onChange(update); + }; + + const setValueForMember = (value: string, member: string) => { + const update = { + ...values, + [member]: value, + }; + onChange(update); + }; + + return ( +
+ {canToggleSpecific && !isReadOnly && ( + { + isUserActionRef.current = true; + if (isSpecific) { + setValueForAllMembers(values?.[memberNames[0]] ?? ""); + } + setIsSpecific((val) => !val); + }} + /> + )} + {isSpecific && ( +
+ {memberNames.map((item) => { + const activeValue = values?.[item]; + + return ( + +
+ +
+
+ {isReadOnly ? ( + <> + {activeValue} + + + ) : ( + setValueForMember(e.target.value, item)} + disabled={disabledClusteredInput} + /> + )} +
+
+ ); + })} +
+ )} + {!isSpecific && ( +
+ {isReadOnly ? ( + <> + {firstValue} + + + ) : ( + setValueForAllMembers(e.target.value)} + disabled={disabledGlobalInput} + /> + )} +
+ )} +
+ ); +}; + +export default ClusterSpecificInput; diff --git a/src/pages/storage/CreateStoragePool.tsx b/src/pages/storage/CreateStoragePool.tsx index fcd41d70c1..4fe3e6ea4b 100644 --- a/src/pages/storage/CreateStoragePool.tsx +++ b/src/pages/storage/CreateStoragePool.tsx @@ -66,7 +66,12 @@ const CreateStoragePool: FC = () => { const mutation = clusterMembers.length > 0 - ? () => createClusteredPool(storagePool, clusterMembers) + ? () => + createClusteredPool( + storagePool, + clusterMembers, + values.sourcePerClusterMember, + ) : () => createPool(storagePool); mutation() diff --git a/src/pages/storage/EditStoragePool.tsx b/src/pages/storage/EditStoragePool.tsx index b744fefa6e..049917ac14 100644 --- a/src/pages/storage/EditStoragePool.tsx +++ b/src/pages/storage/EditStoragePool.tsx @@ -1,7 +1,8 @@ -import { FC, useState } from "react"; +import { FC, useEffect, useState } from "react"; import { Button, useNotify } from "@canonical/react-components"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { + fetchPoolFromClusterMembers, fetchStoragePool, updateClusteredPool, updatePool, @@ -49,11 +50,24 @@ const EditStoragePool: FC = ({ pool }) => { const controllerState = useState(null); const { data: clusterMembers = [] } = useClusterMembers(); const [version, setVersion] = useState(0); + const isClustered = clusterMembers.length > 0; if (!project) { return <>Missing project; } + const { data: poolOnMembers = [], error } = useQuery({ + queryKey: [queryKeys.storage, pool.name, queryKeys.cluster], + queryFn: () => fetchPoolFromClusterMembers(pool.name, clusterMembers), + enabled: isClustered, + }); + + useEffect(() => { + if (error) { + notify.failure("Loading network from cluster members failed", error); + } + }, [error]); + const StoragePoolSchema = Yup.object().shape({ name: Yup.string() .test( @@ -67,7 +81,7 @@ const EditStoragePool: FC = ({ pool }) => { }); const formik = useFormik({ - initialValues: toStoragePoolFormValues(pool), + initialValues: toStoragePoolFormValues(pool, poolOnMembers), validationSchema: StoragePoolSchema, enableReinitialize: true, onSubmit: (values) => { @@ -95,7 +109,9 @@ const EditStoragePool: FC = ({ pool }) => { ); const member = clusterMembers[0]?.server_name ?? undefined; const updatedPool = await fetchStoragePool(values.name, member); - void formik.setValues(toStoragePoolFormValues(updatedPool)); + void formik.setValues( + toStoragePoolFormValues(updatedPool, poolOnMembers), + ); }) .catch((e) => { notify.failure("Storage pool update failed", e); @@ -143,7 +159,9 @@ const EditStoragePool: FC = ({ pool }) => { appearance="base" onClick={() => { setVersion((old) => old + 1); - void formik.setValues(toStoragePoolFormValues(pool)); + void formik.setValues( + toStoragePoolFormValues(pool, poolOnMembers), + ); }} > Cancel diff --git a/src/pages/storage/forms/StoragePoolClusteredSourceSelector.tsx b/src/pages/storage/forms/StoragePoolClusteredSourceSelector.tsx new file mode 100644 index 0000000000..314e5bd8f5 --- /dev/null +++ b/src/pages/storage/forms/StoragePoolClusteredSourceSelector.tsx @@ -0,0 +1,41 @@ +import { Label } from "@canonical/react-components"; +import ClusterSpecificInput from "components/forms/ClusterSpecificInput"; +import { FormikProps } from "formik"; +import { FC } from "react"; +import { ensureEditMode } from "util/instanceEdit"; +import { StoragePoolFormValues } from "./StoragePoolForm"; +import { useClusterMembers } from "context/useClusterMembers"; +import { focusField } from "util/formFields"; + +interface Props { + formik: FormikProps; +} + +const StoragePoolClusteredSourceSelector: FC = ({ formik }) => { + const { data: clusterMembers = [] } = useClusterMembers(); + const memberNames: string[] = []; + clusterMembers.forEach((member) => memberNames.push(member.server_name)); + + return ( + <> + + { + void formik.setFieldValue("sourcePerClusterMember", value); + }} + toggleReadOnly={() => { + ensureEditMode(formik); + focusField("sourcePerClusterMember"); + }} + memberNames={memberNames} + disabledGlobalInput={!formik.values.isCreating} + disabledClusteredInput={!formik.values.isCreating} + /> + + ); +}; + +export default StoragePoolClusteredSourceSelector; diff --git a/src/pages/storage/forms/StoragePoolForm.tsx b/src/pages/storage/forms/StoragePoolForm.tsx index 9a3dc9d225..e02ba37fea 100644 --- a/src/pages/storage/forms/StoragePoolForm.tsx +++ b/src/pages/storage/forms/StoragePoolForm.tsx @@ -41,16 +41,10 @@ import StoragePoolFormZFS from "./StoragePoolFormZFS"; import { useSettings } from "context/useSettings"; import { ensureEditMode } from "util/instanceEdit"; import StoragePoolFormCephFS from "pages/storage/forms/StoragePoolFormCephFS"; +import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; export interface StoragePoolFormValues { - isCreating: boolean; - readOnly: boolean; - name: string; - description: string; - driver: string; - source: string; - entityType: "storagePool"; - size?: string; + barePool?: LxdStoragePool; ceph_cluster_name?: string; ceph_osd_pg_num?: string; ceph_rbd_clone_copy?: string; @@ -62,6 +56,11 @@ export interface StoragePoolFormValues { cephfs_osd_pg_num?: string; cephfs_path?: string; cephfs_user_name?: string; + description: string; + driver: string; + entityType: "storagePool"; + isCreating: boolean; + name: string; powerflex_clone_copy?: string; powerflex_domain?: string; powerflex_gateway?: string; @@ -75,11 +74,14 @@ export interface StoragePoolFormValues { pure_gateway?: string; pure_gateway_verify?: string; pure_mode?: string; + readOnly: boolean; + size?: string; + source: string; + sourcePerClusterMember?: ClusterSpecificValues; + yaml?: string; zfs_clone_copy?: string; zfs_export?: string; zfs_pool_name?: string; - yaml?: string; - barePool?: LxdStoragePool; } interface Props { diff --git a/src/pages/storage/forms/StoragePoolFormMain.tsx b/src/pages/storage/forms/StoragePoolFormMain.tsx index 1c26cc8950..fd0a4dcc37 100644 --- a/src/pages/storage/forms/StoragePoolFormMain.tsx +++ b/src/pages/storage/forms/StoragePoolFormMain.tsx @@ -11,6 +11,7 @@ import { powerFlex, pureStorage, cephFSDriver, + lvmDriver, } from "util/storageOptions"; import { StoragePoolFormValues } from "./StoragePoolForm"; import DiskSizeSelector from "components/forms/DiskSizeSelector"; @@ -20,6 +21,8 @@ import { useSettings } from "context/useSettings"; import ScrollableForm from "components/ScrollableForm"; import { ensureEditMode } from "util/instanceEdit"; import { optionTrueFalse } from "util/instanceOptions"; +import StoragePoolClusteredSourceSelector from "./StoragePoolClusteredSourceSelector"; +import { isClusteredServer } from "util/settings"; interface Props { formik: FormikProps; @@ -43,9 +46,12 @@ const StoragePoolFormMain: FC = ({ formik }) => { const isCephDriver = formik.values.driver === cephDriver; const isCephFSDriver = formik.values.driver === cephFSDriver; const isDirDriver = formik.values.driver === dirDriver; + const isLvmDriver = formik.values.driver === lvmDriver; const isPowerFlexDriver = formik.values.driver === powerFlex; const isPureDriver = formik.values.driver === pureStorage; + const isZfsDriver = formik.values.driver == zfsDriver; const storageDriverOptions = getStorageDriverOptions(settings); + const canClusterSource = !isDirDriver && !isZfsDriver && !isLvmDriver; return ( @@ -124,22 +130,26 @@ const StoragePoolFormMain: FC = ({ formik }) => { disabled={formik.values.driver === dirDriver} /> )} - {!isPureDriver && !isPowerFlexDriver && ( - - )} + {!isPureDriver && + !isPowerFlexDriver && + (canClusterSource && isClusteredServer(settings) ? ( + + ) : ( + + ))} {isPowerFlexDriver && ( <> { const jsonString = JSON.stringify(data); return btoa(jsonString); }; + +export const constructMemberError = ( + result: PromiseRejectedResult, + member: string, +) => { + const reason = result.reason as Error; + const message = `Error from cluster member ${member}: ${reason.message}`; + return new Error(message); +}; diff --git a/src/util/storagePoolForm.tsx b/src/util/storagePoolForm.tsx index 213174afe8..72e9a6ed5d 100644 --- a/src/util/storagePoolForm.tsx +++ b/src/util/storagePoolForm.tsx @@ -1,9 +1,20 @@ -import type { LxdStoragePool } from "types/storage"; +import type { + LxdStoragePool, + LXDStoragePoolOnClusterMember, +} from "types/storage"; import { StoragePoolFormValues } from "pages/storage/forms/StoragePoolForm"; +import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; export const toStoragePoolFormValues = ( pool: LxdStoragePool, + poolOnMembers?: LXDStoragePoolOnClusterMember[], ): StoragePoolFormValues => { + const sourcePerClusterMember: ClusterSpecificValues = {}; + poolOnMembers?.forEach( + (item) => + (sourcePerClusterMember[item.memberName] = item.config?.source ?? ""), + ); + return { readOnly: true, isCreating: false, @@ -40,6 +51,7 @@ export const toStoragePoolFormValues = ( zfs_clone_copy: pool.config?.["zfs.clone_copy"], zfs_export: pool.config?.["zfs.export"], zfs_pool_name: pool.config?.["zfs.pool_name"], + sourcePerClusterMember, barePool: pool, }; };