diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 53e46fda8..287a07cb5 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -41,29 +41,40 @@ "Create DRPolicy": "Create DRPolicy", "Get a quick recovery in a remote or secondary cluster with a disaster recovery (DR) policy": "Get a quick recovery in a remote or secondary cluster with a disaster recovery (DR) policy", "Policy name": "Policy name", + "Enter a policy name": "Enter a policy name", "Connect clusters": "Connect clusters", "Enables mirroring/replication between two selected clusters, ensuring failover or relocation between the two clusters in the event of an outage or planned maintenance.": "Enables mirroring/replication between two selected clusters, ensuring failover or relocation between the two clusters in the event of an outage or planned maintenance.", "Note: If your cluster isn't visible on this list, verify its import status and refer to the steps outlined in the ACM documentation.": "Note: If your cluster isn't visible on this list, verify its import status and refer to the steps outlined in the ACM documentation.", - "Data Foundation {{ version }} or above must be installed on the managed clusters to setup connection for enabling replication/mirroring.": "Data Foundation {{ version }} or above must be installed on the managed clusters to setup connection for enabling replication/mirroring.", "Selected clusters": "Selected clusters", "An error occurred": "An error occurred", - "Region": "Region", - "Cluster search": "Cluster search", - "Cluster name": "Cluster name", - "Select cluster list": "Select cluster list", - "You cannot select this cluster as it has multiple storage instances.": "You cannot select this cluster as it has multiple storage instances.", - "Checkbox to select cluster": "Checkbox to select cluster", - "Existing DRPolicy detected": "Existing DRPolicy detected", - "A DRPolicy is already configured for selected managed clusters. You cannot create another DRPolicy using the same pair of clusters.": "A DRPolicy is already configured for selected managed clusters. You cannot create another DRPolicy using the same pair of clusters.", - "1 or more managed clusters are offline": "1 or more managed clusters are offline", - "The status for both the managed clusters must be available for creating a DR policy. To restore a cluster to an available state, refer to the instructions in the ACM documentation.": "The status for both the managed clusters must be available for creating a DR policy. To restore a cluster to an available state, refer to the instructions in the ACM documentation.", - "Cannot proceed with one or more selected clusters": "Cannot proceed with one or more selected clusters", - "We could not retrieve any information about the managed cluster {{names}}. Check the documentation for potential causes and follow the steps mentioned and try again.": "We could not retrieve any information about the managed cluster {{names}}. Check the documentation for potential causes and follow the steps mentioned and try again.", - "{{ names }} has either an unsupported Data Foundation version or the Data Foundation operator is missing, install or update to Data Foundation {{ version }} or the latest version to enable DR protection.": "{{ names }} has either an unsupported Data Foundation version or the Data Foundation operator is missing, install or update to Data Foundation {{ version }} or the latest version to enable DR protection.", - "{{ names }} is not connected to RHCS": "{{ names }} is not connected to RHCS", - "Sync schedule": "Sync schedule", + "Online": "Online", + "Offline": "Offline", + "Not Installed": "Not Installed", + "Unavailable": "Unavailable", + "Replication interval": "Replication interval", "Replication policy": "Replication policy", + "Unsupported peering configuration.": "Unsupported peering configuration.", + "The clusters you're trying to peer aren't compatible. It could be due to mismatched types (one with a client, the other without) or both using the same Data Foundation provider. Select clusters that are either the same type or have separate providers to continue.": "The clusters you're trying to peer aren't compatible. It could be due to mismatched types (one with a client, the other without) or both using the same Data Foundation provider. Select clusters that are either the same type or have separate providers to continue.", + "Existing DRPolicy detected.": "Existing DRPolicy detected.", + "A DRPolicy is already configured for selected managed clusters. You cannot create another DRPolicy using the same pair of clusters.": "A DRPolicy is already configured for selected managed clusters. You cannot create another DRPolicy using the same pair of clusters.", + "Data foundation must be {{version}} or above.": "Data foundation must be {{version}} or above.", + "Must be connected to RHCS.": "Must be connected to RHCS.", + "The cluster has multiple storage instances.": "The cluster has multiple storage instances.", + "Checks cannot be performed for the {{clusterName}}:": "Checks cannot be performed for the {{clusterName}}:", + "check unsuccessful on the {{clusterName}}:": "check unsuccessful on the {{clusterName}}:", + "checks unsuccessful on the {{clusterName}}:": "checks unsuccessful on the {{clusterName}}:", + "We could not retrieve any information about the managed cluster {{clusterName}}": "We could not retrieve any information about the managed cluster {{clusterName}}", + "Running checks to ensure that the selected managed cluster meets all necessary conditions so it can enroll in a Disaster Recovery policy.": "Running checks to ensure that the selected managed cluster meets all necessary conditions so it can enroll in a Disaster Recovery policy.", + "All disaster recovery prerequisites met for both clusters.": "All disaster recovery prerequisites met for both clusters.", + "Version mismatch across selected clusters": "Version mismatch across selected clusters", + "The selected clusters are running different versions of Data Foundation. Peering clusters with different versions can lead to potential issues and is not recommended. Ensure all clusters are upgraded to the same version before proceeding with peering to avoid operational risks.": "The selected clusters are running different versions of Data Foundation. Peering clusters with different versions can lead to potential issues and is not recommended. Ensure all clusters are upgraded to the same version before proceeding with peering to avoid operational risks.", + "1 or more clusters do not meet disaster recovery cluster prerequisites.": "1 or more clusters do not meet disaster recovery cluster prerequisites.", + "The selected managed cluster(s) does not meet all necessary conditions to be eligible for disaster recovery policy. Resolve the following issues to proceed with policy creation.": "The selected managed cluster(s) does not meet all necessary conditions to be eligible for disaster recovery policy. Resolve the following issues to proceed with policy creation.", "Information unavailable": "Information unavailable", + "Managed Cluster": "Managed Cluster", + "Availability status": "Availability status", + "Storage clients": "Storage clients", + "Region": "Region", "Disaster recovery": "Disaster recovery", "Policies": "Policies", "Protected applications": "Protected applications", @@ -126,7 +137,6 @@ "Define where to sync or replicate your application volumes and Kubernetes object using a disaster recovery policy.": "Define where to sync or replicate your application volumes and Kubernetes object using a disaster recovery policy.", "Kubernetes object replication interval": "Kubernetes object replication interval", "Define the interval for Kubernetes object replication": "Define the interval for Kubernetes object replication", - "Replication interval": "Replication interval", "Cluster:": "Cluster:", "Namespace:": "Namespace:", "Name:": "Name:", @@ -237,6 +247,7 @@ "Data Foundation status": "Data Foundation status", "The Data Foundation operator is the primary operator of Data Foundation": "The Data Foundation operator is the primary operator of Data Foundation", "Running": "Running", + "Cluster name": "Cluster name", "Used Capacity %": "Used Capacity %", "Used / Total": "Used / Total", "Storage System capacity": "Storage System capacity", @@ -1218,7 +1229,6 @@ "Unlimited": "Unlimited", "Edit storage quota": "Edit storage quota", "Delete storage client": "Delete storage client", - "Storage clients": "Storage clients", "Generate client onboarding token": "Generate client onboarding token", "Rotate signing keys": "Rotate signing keys", "Data Foundation version sync": "Data Foundation version sync", diff --git a/packages/mco/components/create-dr-policy/create-dr-policy.scss b/packages/mco/components/create-dr-policy/create-dr-policy.scss index ba23ea729..38356e6f4 100644 --- a/packages/mco/components/create-dr-policy/create-dr-policy.scss +++ b/packages/mco/components/create-dr-policy/create-dr-policy.scss @@ -1,9 +1,6 @@ -.mco-create-data-policy { +.mco-create-data-policy__body { margin: var(--pf-v5-global--spacer--lg); - max-width: 700px; - .pf-v5-c-form.pf-m-limit-width { - max-width: 33rem; - } + width: 90%; } .mco-create-data-policy__action-group { @@ -15,7 +12,7 @@ } .mco-create-data-policy__flex { - margin-right: var(--pf-v5-global--spacer--lg); + margin-right: var(--pf-v5-global--spacer--2xl); margin-bottom: var(--pf-v5-global--spacer--md); } @@ -27,3 +24,8 @@ margin-bottom: var(--pf-v5-global--spacer--xs); --pf-v5-global--FontSize--md: 0.8rem; } + +.mco-create-data-policy__text-input { + width: 67%; +} + diff --git a/packages/mco/components/create-dr-policy/create-dr-policy.tsx b/packages/mco/components/create-dr-policy/create-dr-policy.tsx index 67661b83c..570636cb8 100644 --- a/packages/mco/components/create-dr-policy/create-dr-policy.tsx +++ b/packages/mco/components/create-dr-policy/create-dr-policy.tsx @@ -1,17 +1,12 @@ import * as React from 'react'; -import { getDRPolicyResourceObj } from '@odf/mco/hooks'; -import { getMajorVersion, parseNamespaceName } from '@odf/mco/utils'; +import { getMajorVersion } from '@odf/mco/utils'; +import { getName } from '@odf/shared'; import { StatusBox } from '@odf/shared/generic/status-box'; import PageHeading from '@odf/shared/heading/page-heading'; import { useFetchCsv } from '@odf/shared/hooks/use-fetch-csv'; -import { K8sResourceKind } from '@odf/shared/types'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { referenceForModel } from '@odf/shared/utils'; -import { - getAPIVersionForModel, - k8sCreate, - useK8sWatchResource, -} from '@openshift-console/dynamic-plugin-sdk'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; import { Trans } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; import { @@ -36,97 +31,36 @@ import { MAX_ALLOWED_CLUSTERS, REPLICATION_TYPE, ODFMCO_OPERATOR, - RBD_IMAGE_FLATTEN_LABEL, } from '../../constants'; import { DRPolicyModel, MirrorPeerModel } from '../../models'; -import { DRPolicyKind, MirrorPeerKind } from '../../types'; +import { MirrorPeerKind } from '../../types'; +import { SelectClusterList } from './select-cluster-list'; +import { SelectReplicationType } from './select-replication-type'; +import { SelectedClusterValidation } from './selected-cluster-validator'; +import { SelectedClusterView } from './selected-cluster-view'; +import { createPolicyPromises } from './utils/k8s-utils'; import { drPolicyReducer, drPolicyInitialState, DRPolicyActionType, - ManagedClusterInfoType, DRPolicyAction, -} from './reducer'; -import { SelectClusterList } from './select-cluster-list'; -import { DRReplicationType } from './select-replication-type'; -import { SelectedCluster, checkForErrors } from './selected-cluster-view'; -import './create-dr-policy.scss'; +} from './utils/reducer'; import '../../style.scss'; +import './create-dr-policy.scss'; -const fetchMirrorPeer = ( - mirrorPeers: MirrorPeerKind[], - peerNames: string[] -): MirrorPeerKind => - mirrorPeers.find((mirrorPeer) => { - const existingPeerNames = - mirrorPeer?.spec?.items?.map((item) => item?.clusterName) ?? []; - return existingPeerNames.sort().join(',') === peerNames.sort().join(','); - }) || {}; - -const getPeerClustersRef = (clusters: ManagedClusterInfoType[]) => - clusters.map((cluster) => { - const { storageClusterNamespacedName } = - cluster?.odfInfo.storageClusterInfo; - const [storageClusterName, storageClusterNamesapce] = parseNamespaceName( - storageClusterNamespacedName - ); - return { - clusterName: cluster?.name, - storageClusterRef: { - name: storageClusterName, - namespace: storageClusterNamesapce, - }, - }; - }); +const getDRPolicyListPageLink = (url: string) => + url.replace(`/${referenceForModel(DRPolicyModel)}/~new`, ''); -const createDRPolicy = ( +const validateDRPolicyInputs = ( policyName: string, replicationType: REPLICATION_TYPE, - syncIntervalTime: string, - enableRBDImageFlatten: boolean, - peerNames: string[] -) => { - const drPolicyPayload: DRPolicyKind = { - apiVersion: getAPIVersionForModel(DRPolicyModel), - kind: DRPolicyModel.kind, - metadata: { name: policyName }, - spec: { - replicationClassSelector: enableRBDImageFlatten - ? { matchLabels: RBD_IMAGE_FLATTEN_LABEL } - : {}, - schedulingInterval: - replicationType === REPLICATION_TYPE.ASYNC ? syncIntervalTime : '0m', - drClusters: peerNames, - }, - }; - return k8sCreate({ - model: DRPolicyModel, - data: drPolicyPayload, - }); -}; - -const createMirrorPeer = ( - selectedClusters: ManagedClusterInfoType[], - replicationType: REPLICATION_TYPE -) => { - const mirrorPeerPayload: MirrorPeerKind = { - apiVersion: getAPIVersionForModel(MirrorPeerModel), - kind: MirrorPeerModel.kind, - metadata: { generateName: 'mirrorpeer-' }, - spec: { - manageS3: true, - type: replicationType, - items: getPeerClustersRef(selectedClusters), - }, - }; - return k8sCreate({ - model: MirrorPeerModel, - data: mirrorPeerPayload, - }); -}; - -const getDRPolicyListPageLink = (url: string) => - url.replace(`/${referenceForModel(DRPolicyModel)}/~new`, ''); + clusterCount: number, + isClusterSelectionValid: boolean +) => + !!policyName && + !!replicationType && + !!isClusterSelectionValid && + clusterCount === MAX_ALLOWED_CLUSTERS; const AdvancedSettings: React.FC = ({ enableRBDImageFlatten, @@ -190,39 +124,13 @@ const CreateDRPolicy: React.FC<{}> = () => { namespaced: false, }); - const [drPolicies, policyLoaded, policyLoadedError] = useK8sWatchResource< - DRPolicyKind[] - >(getDRPolicyResourceObj()); - const [csv] = useFetchCsv({ specName: ODFMCO_OPERATOR, }); const odfMCOVersion = getMajorVersion(csv?.spec?.version); const onCreate = () => { - const promises: Promise[] = []; - const peerNames = state.selectedClusters.map((cluster) => cluster?.name); - promises.push( - createDRPolicy( - state.policyName, - state.replicationType, - state.syncIntervalTime, - state.enableRBDImageFlatten, - peerNames - ) - ); - - const mirrorPeer: MirrorPeerKind = - mirrorPeerLoaded && - !mirrorPeerLoadError && - fetchMirrorPeer(mirrorPeers, peerNames); - - if (Object.keys(mirrorPeer).length === 0) { - promises.push( - createMirrorPeer(state.selectedClusters, state.replicationType) - ); - } - + const promises = createPolicyPromises(state, mirrorPeers); Promise.all(promises) .then(() => { navigate(getDRPolicyListPageLink(url)); @@ -238,14 +146,8 @@ const CreateDRPolicy: React.FC<{}> = () => { payload: strVal, }); - const areDRPolicyInputsValid = () => - !!state.policyName && - !!state.replicationType && - state.selectedClusters.length === MAX_ALLOWED_CLUSTERS && - !checkForErrors(state.selectedClusters); - - const loaded = mirrorPeerLoaded && policyLoaded; - const loadedError = mirrorPeerLoadError || policyLoadedError; + const loaded = mirrorPeerLoaded; + const loadedError = mirrorPeerLoadError; return ( <> @@ -259,21 +161,26 @@ const CreateDRPolicy: React.FC<{}> = () => { {loaded && !loadedError ? ( -
- + + setPolicyName(strVal)} isRequired /> - + {t( 'Enables mirroring/replication between two selected clusters, ensuring failover or relocation between the two clusters in the event of an outage or planned maintenance.' @@ -298,54 +205,58 @@ const CreateDRPolicy: React.FC<{}> = () => { - {!!odfMCOVersion && ( - - - - )} - {!!state.selectedClusters.length && ( - - {state.selectedClusters.map((c, i) => ( - - ))} - - )} - - {state.replicationType === REPLICATION_TYPE.ASYNC && ( - - )} - {errorMessage && ( - - - {errorMessage} - - + {state.selectedClusters.length === 2 && ( + <> + + + + {state.isClusterSelectionValid && ( + <> + + {state.selectedClusters.map((c, i) => ( + + ))} + + + {state.replicationType === REPLICATION_TYPE.ASYNC && ( + + + + )} + {errorMessage && ( + + + {errorMessage} + + + )} + + )} + )} diff --git a/packages/mco/components/create-dr-policy/select-cluster-list.scss b/packages/mco/components/create-dr-policy/select-cluster-list.scss deleted file mode 100644 index 273306582..000000000 --- a/packages/mco/components/create-dr-policy/select-cluster-list.scss +++ /dev/null @@ -1,21 +0,0 @@ -.mco-select-cluster-list { - border: 1px solid var(--pf-v5-global--BorderColor--light-100); - margin-top: var(--pf-v5-global--spacer--md); -} - -.mco-select-cluster-list__data-list { - --pf-v5-c-data-list__item--BorderBottomWidth: 0; - max-height: 300px; - min-height: 200px; - overflow-y: auto; - padding: var(--pf-v5-global--spacer--md) var(--pf-v5-global--spacer--md) - var(--pf-v5-global--spacer--md) 0; -} - -.mco-select-cluster-list__filter-toolbar-item { - width: 12rem; -} - -.mco-select-cluster-list__search-toolbar-item { - width: 15rem; -} diff --git a/packages/mco/components/create-dr-policy/select-cluster-list.tsx b/packages/mco/components/create-dr-policy/select-cluster-list.tsx index e9e13af49..3b8168b89 100644 --- a/packages/mco/components/create-dr-policy/select-cluster-list.tsx +++ b/packages/mco/components/create-dr-policy/select-cluster-list.tsx @@ -1,204 +1,181 @@ import * as React from 'react'; import { getManagedClusterResourceObj } from '@odf/mco/hooks'; -import { ODFInfoYamlObject } from '@odf/mco/types'; +import { getName } from '@odf/shared/selectors'; +import { RowComponentType } from '@odf/shared/table'; import { - getMajorVersion, - ValidateManagedClusterCondition, - getValueFromClusterClaim, - isMinimumSupportedODFVersion, - getManagedClusterViewName, - getNameNamespace, -} from '@odf/mco/utils'; -import { StatusBox } from '@odf/shared/generic/status-box'; -import { getName, getNamespace } from '@odf/shared/selectors'; -import { ConfigMapKind } from '@odf/shared/types'; + SelectableTable, + TABLE_VARIANT, +} from '@odf/shared/table/selectable-table'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; -import { referenceForModel } from '@odf/shared/utils'; -import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; -import { Select, SelectOption } from '@patternfly/react-core/deprecated'; -import { safeLoad } from 'js-yaml'; +import { getPageRange, referenceForModel } from '@odf/shared/utils'; import { - DataList, - DataListItem, - DataListItemRow, - DataListCheck, - DataListItemCells, - DataListCell, - Toolbar, - ToolbarContent, - ToolbarItem, - SearchInput, - DataListCheckProps, - TextContent, - TextVariants, + GreenCheckCircleIcon, + ListPageFilter, + RedExclamationCircleIcon, + StatusIconAndText, + useK8sWatchResource, + useListPageFilter, +} from '@openshift-console/dynamic-plugin-sdk'; +import cn from 'classnames'; +import { + Grid, + GridItem, + Pagination, + PaginationVariant, Text, - Tooltip, } from '@patternfly/react-core'; +import { Td } from '@patternfly/react-table'; import { - MAX_ALLOWED_CLUSTERS, - MANAGED_CLUSTER_REGION_CLAIM, - MANAGED_CLUSTER_JOINED, - MANAGED_CLUSTER_CONDITION_AVAILABLE, MCO_CREATED_BY_LABEL_KEY, MCO_CREATED_BY_MC_CONTROLLER, } from '../../constants'; import { ACMManagedClusterViewModel } from '../../models'; import { ACMManagedClusterKind, ACMManagedClusterViewKind } from '../../types'; +import { + COLUMN_NAMES, + COUNT_PER_PAGE_NUMBER, + getColumnHelper, + getColumns, + getManagedClusterInfoTypes, + INITIAL_PAGE_NUMBER, + isRowSelectable, +} from './utils/cluster-list-utils'; import { DRPolicyAction, DRPolicyActionType, ManagedClusterInfoType, - ODFConfigInfoType, -} from './reducer'; -import './select-cluster-list.scss'; - -const getFilteredClusters = ( - clusters: ManagedClusterInfoType[], - region: string, - name: string -) => { - let filteredClusters = clusters; +} from './utils/reducer'; - if (region) - filteredClusters = filteredClusters.filter((c) => c.region === region); - if (name) - filteredClusters = filteredClusters.filter((c) => c.name.includes(name)); - return filteredClusters; +const ClusterRow: React.FC> = ({ + row: cluster, +}) => { + const { t } = useCustomTranslation(); + const { odfInfo, region, isManagedClusterAvailable } = cluster; + const clientName = !!odfInfo?.storageClusterInfo?.clientInfo?.name; + const odfVersion = odfInfo?.odfVersion; + return ( + <> + + {getName(cluster)} + + + {isManagedClusterAvailable ? ( + } + title={t('Online')} + /> + ) : ( + } + title={t('Offline')} + /> + )} + + + + {odfVersion || t('Not Installed')} + + + + + {clientName ? clientName : t('Unavailable')} + + + + + {region || t('Unavailable')} + + + + ); }; -const getODFInfo = ( - requiredODFVersion: string, - odfInfoConfigData: { [key: string]: string } -): ODFConfigInfoType => { - try { - // Managed cluster with multiple StorageSystems is not currently supported for DR - // ToDo: Update this once we add support for multiple clusters - const odfInfoKey = Object.keys(odfInfoConfigData)[0]; - const odfInfoYaml = odfInfoConfigData[odfInfoKey]; - const odfInfo: ODFInfoYamlObject = safeLoad(odfInfoYaml); - - const storageClusterName = odfInfo?.storageCluster?.namespacedName?.name; - const storageClusterNamespace = - odfInfo?.storageCluster?.namespacedName?.namespace; - const storageSystemName = odfInfo?.storageSystemName; - - const odfVersion = odfInfo?.version; - const storageClusterCount = Object.keys(odfInfoConfigData).length; - const storageClusterNamespacedName = getNameNamespace( - storageClusterName, - storageClusterNamespace - ); - const storageSystemNamespacedName = getNameNamespace( - storageSystemName, - storageClusterNamespace - ); - const cephFSID = odfInfo?.storageCluster?.cephClusterFSID; - - return { - odfVersion, - isValidODFVersion: isMinimumSupportedODFVersion( - getMajorVersion(odfVersion), - requiredODFVersion - ), - storageClusterCount, - storageClusterInfo: { - storageClusterNamespacedName, - storageSystemNamespacedName, - cephFSID, - }, - }; - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); +const PaginatedClusterTable: React.FC = ({ + selectedClusters, + clusters, + isLoaded, + error, + onChange, +}) => { + const { t } = useCustomTranslation(); + const [page, setPage] = React.useState(INITIAL_PAGE_NUMBER); + const [perPage, setPerPage] = React.useState(COUNT_PER_PAGE_NUMBER); + const [data, filteredData, onFilterChange] = useListPageFilter(clusters); + const paginatedData: ManagedClusterInfoType[] = React.useMemo(() => { + const [start, end] = getPageRange(page, perPage); + return filteredData.slice(start, end) || []; + }, [filteredData, page, perPage]); - return { - odfVersion: '', - isValidODFVersion: false, - storageClusterCount: 0, - storageClusterInfo: { - storageClusterNamespacedName: '', - storageSystemNamespacedName: '', - cephFSID: '', - }, - }; - } + return ( + <> + + + + + + setPage(newPage)} + onPerPageSelect={(_event, newPerPage, newPage) => { + setPerPage(newPerPage); + setPage(newPage); + }} + /> + + + + columns={getColumns(t)} + rows={paginatedData} + RowComponent={ClusterRow} + selectedRows={selectedClusters} + setSelectedRows={onChange} + loaded={isLoaded} + loadError={error} + variant={TABLE_VARIANT.DEFAULT} + isColumnSelectableHidden + isRowSelectable={(cluster) => + isRowSelectable(cluster, selectedClusters) + } + /> + + ); }; -const filterRegions = (filteredClusters: ManagedClusterInfoType[]) => - filteredClusters?.reduce((acc, cluster) => { - if (!acc.includes(cluster?.region) && cluster?.region !== '') { - acc.push(cluster?.region); - } - return acc; - }, []); - -const getManagedClusterInfo = ( - cluster: ACMManagedClusterKind, - requiredODFVersion: string, - odfInfoConfigData: { [key: string]: string } -): ManagedClusterInfoType => ({ - name: getName(cluster), - namesapce: getNamespace(cluster), - region: getValueFromClusterClaim( - cluster?.status?.clusterClaims, - MANAGED_CLUSTER_REGION_CLAIM - ), - isManagedClusterAvailable: ValidateManagedClusterCondition( - cluster, - MANAGED_CLUSTER_CONDITION_AVAILABLE - ), - odfInfo: getODFInfo(requiredODFVersion, odfInfoConfigData), -}); - -const getManagedClusterInfoTypes = ( - managedClusters: ACMManagedClusterKind[], - mcvs: ACMManagedClusterViewKind[], - requiredODFVersion: string -): ManagedClusterInfoType[] => - managedClusters?.reduce((acc, cluster) => { - if (ValidateManagedClusterCondition(cluster, MANAGED_CLUSTER_JOINED)) { - // OCS creates a ConfigMap on the managed clusters, with details about StorageClusters, Clients. - // MCO creates ManagedClusterView on the hub cluster, referencing that ConfigMap. - const managedClusterName = getName(cluster); - const mcv = - mcvs.find( - (obj: ACMManagedClusterViewKind) => - getName(obj) === getManagedClusterViewName(managedClusterName) && - getNamespace(obj) === managedClusterName - ) || {}; - const odfInfoConfigData = - (mcv.status?.result as ConfigMapKind)?.data || {}; - return [ - ...acc, - getManagedClusterInfo(cluster, requiredODFVersion, odfInfoConfigData), - ]; - } - - return acc; - }, []); - -const isChecked = (clusters: ManagedClusterInfoType[], clusterName: string) => - clusters?.some((cluster) => cluster?.name === clusterName); - -const isDisabled = ( - clusters: ManagedClusterInfoType[], - clusterName: string, - storageClusterCount: number -) => - (clusters.length === MAX_ALLOWED_CLUSTERS && - !clusters.some((cluster) => cluster?.name === clusterName)) || - storageClusterCount > 1; - export const SelectClusterList: React.FC = ({ selectedClusters, requiredODFVersion, dispatch, }) => { - const { t } = useCustomTranslation(); - const [isRegionOpen, setIsRegionOpen] = React.useState(false); - const [region, setRegion] = React.useState(''); - const [nameSearch, setNameSearch] = React.useState(''); - const [managedClusters, loaded, loadError] = useK8sWatchResource< ACMManagedClusterKind[] >(getManagedClusterResourceObj()); @@ -228,148 +205,27 @@ export const SelectClusterList: React.FC = ({ return []; }, [requiredODFVersion, managedClusters, mcvs, allLoaded, anyError]); - const filteredClusters: ManagedClusterInfoType[] = React.useMemo( - () => getFilteredClusters(clusters, region, nameSearch), - [clusters, region, nameSearch] - ); - - const onChange: DataListCheckProps['onChange'] = (event, checked) => { - const selectedClusterInfo = filteredClusters.find( - (filteredCluster) => filteredCluster.name === event.currentTarget.id - ); - const selectedClusterList = checked - ? [...selectedClusters, selectedClusterInfo] - : selectedClusters.filter( - (cluster) => cluster?.name !== selectedClusterInfo.name - ); + const onChange = (selectedClusterList: ManagedClusterInfoType[]) => { dispatch({ type: DRPolicyActionType.SET_SELECTED_CLUSTERS, payload: selectedClusterList, }); - }; - - // **Note: PatternFly change the fn signature - // From: (value: string, event: React.FormEvent) => void - // To: (_event: React.FormEvent, value: string) => void - // both cases need to be handled for backwards compatibility - const onSearch = (input: any) => { - const searchValue = - typeof input === 'string' - ? input - : (input.target as HTMLInputElement)?.value; - setNameSearch(searchValue); - }; - - const onSelect = (selection: string, isPlaceholder: boolean) => { - setRegion(isPlaceholder ? '' : selection); - setIsRegionOpen(false); + if (selectedClusterList.length < 2) { + dispatch({ + type: DRPolicyActionType.SET_CLUSTER_SELECTION_VALIDATION, + payload: false, + }); + } }; return ( -
- - - - - - - setNameSearch('')} - /> - - - - - - {filteredClusters.map((filteredCluster) => ( - - - 1 - ? 'mouseenter' - : 'manual' - } - > - <> - - - - - {filteredCluster.name} - - - {filteredCluster.region} - - - , - ]} - /> - - - - - ))} - - -
+ ); }; @@ -378,3 +234,11 @@ type SelectClusterListProps = { requiredODFVersion: string; dispatch: React.Dispatch; }; + +type PaginatedClusterTableProps = { + selectedClusters: ManagedClusterInfoType[]; + clusters: ManagedClusterInfoType[]; + isLoaded: boolean; + error: any; + onChange: (selectedClusterList: ManagedClusterInfoType[]) => void; +}; diff --git a/packages/mco/components/create-dr-policy/select-replication-type.tsx b/packages/mco/components/create-dr-policy/select-replication-type.tsx index f9fcce86d..01bf97b18 100644 --- a/packages/mco/components/create-dr-policy/select-replication-type.tsx +++ b/packages/mco/components/create-dr-policy/select-replication-type.tsx @@ -1,16 +1,9 @@ import * as React from 'react'; -import { DRPolicyKind } from '@odf/mco/types'; -import { getReplicationType, parseSyncInterval } from '@odf/mco/utils'; +import { parseSyncInterval } from '@odf/mco/utils'; import { SingleSelectDropdown } from '@odf/shared/dropdown'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { RequestSizeInput } from '@odf/shared/utils/RequestSizeInput'; -import { TFunction } from 'i18next'; -import { - FormGroup, - Alert, - AlertVariant, - SelectOption, -} from '@patternfly/react-core'; +import { FormGroup, SelectOption } from '@patternfly/react-core'; import { REPLICATION_TYPE, REPLICATION_DISPLAY_TEXT, @@ -20,8 +13,8 @@ import { DRPolicyAction, DRPolicyActionType, ManagedClusterInfoType, -} from './reducer'; -import '../../style.scss'; +} from './utils/reducer'; +import './create-dr-policy.scss'; export const MIN_VALUE = 1; @@ -30,97 +23,6 @@ export const normalizeSyncTimeValue = (value: number) => { return syncTimeValue < MIN_VALUE ? MIN_VALUE : syncTimeValue; }; -const checkSyncPolicyAlreadyExists = ( - drPolicies: DRPolicyKind[], - selectedClusters: string[] -): boolean => - drPolicies.some((drPolicy) => { - const { drClusters, schedulingInterval } = drPolicy.spec; - const isSyncPolicy = - getReplicationType(schedulingInterval) === REPLICATION_TYPE.SYNC; - return ( - isSyncPolicy && - drClusters.every((cluster) => selectedClusters.includes(cluster)) - ); - }); - -const getClusterErrorInfo = ( - selectedClusters: ManagedClusterInfoType[] -): ClusterErrorType => - selectedClusters.reduce( - (acc, cluster) => { - const { isValidODFVersion, storageClusterInfo } = cluster?.odfInfo || {}; - if (!cluster.isManagedClusterAvailable) { - acc.unavailableClusters.push(cluster.name); - } - if (!storageClusterInfo?.storageSystemNamespacedName) { - acc.clustersWithUnsupportedODF.push(cluster.name); - } - if (!isValidODFVersion) { - acc.clustersWithoutODF.push(cluster.name); - } - if (!storageClusterInfo?.cephFSID) { - acc.clustersWithUnsuccessfulODF.push(cluster.name); - } - return acc; - }, - { - unavailableClusters: [], - clustersWithUnsupportedODF: [], - clustersWithoutODF: [], - clustersWithUnsuccessfulODF: [], - } - ); - -const getErrorMessage = ( - selectedClusters: ManagedClusterInfoType[], - requiredODFVersion: string, - isSyncPolicyFound: boolean, - t: TFunction -): ErrorMessageType => { - const clusterErrorInfo = getClusterErrorInfo(selectedClusters); - if (isSyncPolicyFound) { - return { - message: t('Existing DRPolicy detected'), - description: t( - 'A DRPolicy is already configured for selected managed clusters. You cannot create another DRPolicy using the same pair of clusters.' - ), - }; - } else if (!!clusterErrorInfo.unavailableClusters.length) { - return { - message: t('1 or more managed clusters are offline'), - description: t( - 'The status for both the managed clusters must be available for creating a DR policy. To restore a cluster to an available state, refer to the instructions in the ACM documentation.' - ), - }; - } else if (!!clusterErrorInfo.clustersWithUnsupportedODF.length) { - return { - message: t('Cannot proceed with one or more selected clusters'), - description: t( - 'We could not retrieve any information about the managed cluster {{names}}. Check the documentation for potential causes and follow the steps mentioned and try again.', - { names: clusterErrorInfo.clustersWithUnsupportedODF.join(' & ') } - ), - }; - } else if (!!clusterErrorInfo.clustersWithoutODF.length) { - return { - message: t( - '{{ names }} has either an unsupported Data Foundation version or the Data Foundation operator is missing, install or update to Data Foundation {{ version }} or the latest version to enable DR protection.', - { - names: clusterErrorInfo.clustersWithoutODF.join(' & '), - version: requiredODFVersion, - } - ), - }; - } else if (!!clusterErrorInfo.clustersWithUnsuccessfulODF.length) { - return { - message: t('{{ names }} is not connected to RHCS', { - names: clusterErrorInfo.clustersWithUnsuccessfulODF.join(' & '), - }), - }; - } - return null; -}; - const SyncSchedule: React.FC = ({ syncIntervalTime, dispatch, @@ -140,7 +42,7 @@ const SyncSchedule: React.FC = ({ return ( = ({ ); }; -export const DRReplicationType: React.FC = ({ +export const SelectReplicationType: React.FC = ({ selectedClusters, syncIntervalTime, replicationType, - requiredODFVersion, - drPolicies, dispatch, }) => { const { t } = useCustomTranslation(); - const isSyncPolicyFound = - replicationType === REPLICATION_TYPE.SYNC && - checkSyncPolicyAlreadyExists( - drPolicies, - selectedClusters.map((cluster) => cluster.name) - ); - - const errorMessage = getErrorMessage( - selectedClusters, - requiredODFVersion, - isSyncPolicyFound, - t - ); - React.useEffect(() => { - if (selectedClusters.length === 2) { - // DR replication type - const cephFSIDs = selectedClusters.reduce((acc, cluster) => { - const { storageClusterInfo } = cluster?.odfInfo || {}; - if (storageClusterInfo?.cephFSID !== '') { - acc.add(storageClusterInfo?.cephFSID); - } - return acc; - }, new Set()); - dispatch({ - type: DRPolicyActionType.SET_REPLICATION_TYPE, - payload: - cephFSIDs.size <= 1 ? REPLICATION_TYPE.SYNC : REPLICATION_TYPE.ASYNC, - }); - } else { - dispatch({ - type: DRPolicyActionType.SET_REPLICATION_TYPE, - payload: null, - }); - } + // Set replication type when two cluster are selected + const cephFSID1 = + selectedClusters[0]?.odfInfo?.storageClusterInfo?.cephFSID; + const cephFSID2 = + selectedClusters[2]?.odfInfo?.storageClusterInfo?.cephFSID; + dispatch({ + type: DRPolicyActionType.SET_REPLICATION_TYPE, + payload: + cephFSID1 === cephFSID2 + ? REPLICATION_TYPE.SYNC + : REPLICATION_TYPE.ASYNC, + }); }, [selectedClusters, dispatch]); const replicationDropdownItems = Object.entries( @@ -216,42 +94,26 @@ export const DRReplicationType: React.FC = ({ return ( <> - {!!errorMessage ? ( - - {errorMessage?.description} - - ) : ( - <> - {replicationType && ( - - - - )} - {replicationType === REPLICATION_TYPE.ASYNC && ( - - - - )} - + + + + {replicationType === REPLICATION_TYPE.ASYNC && ( + + + )} ); @@ -262,23 +124,9 @@ type SyncScheduleProps = { dispatch: React.Dispatch; }; -type DRReplicationTypeProps = { +type SelectReplicationTypeProps = { selectedClusters: ManagedClusterInfoType[]; syncIntervalTime: string; replicationType: REPLICATION_TYPE; - requiredODFVersion: string; - drPolicies: DRPolicyKind[]; dispatch: React.Dispatch; }; - -type ClusterErrorType = { - unavailableClusters: string[]; - clustersWithUnsupportedODF: string[]; - clustersWithoutODF: string[]; - clustersWithUnsuccessfulODF: string[]; -}; - -type ErrorMessageType = { - message?: string; - description?: string; -}; diff --git a/packages/mco/components/create-dr-policy/selected-cluster-validator.tsx b/packages/mco/components/create-dr-policy/selected-cluster-validator.tsx new file mode 100644 index 000000000..c219fc07b --- /dev/null +++ b/packages/mco/components/create-dr-policy/selected-cluster-validator.tsx @@ -0,0 +1,376 @@ +import * as React from 'react'; +import { pluralize } from '@odf/core/components/utils'; +import { REPLICATION_TYPE } from '@odf/mco/constants'; +import { getDRPolicyResourceObj } from '@odf/mco/hooks'; +import { DRPolicyKind } from '@odf/mco/types'; +import { getReplicationType } from '@odf/mco/utils'; +import { getName, StatusBox } from '@odf/shared'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { + StatusIconAndText, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { TFunction } from 'react-i18next'; +import { + Alert, + AlertVariant, + Spinner, + Text, + TextVariants, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; +import { + DRPolicyAction, + DRPolicyActionType, + ManagedClusterInfoType, +} from './utils/reducer'; +import './create-dr-policy.scss'; +import '../../style.scss'; + +const checkSyncPolicyExists = ( + clusters: string[], + drPolicies: DRPolicyKind[] +): boolean => + drPolicies.some((drPolicy) => { + const { drClusters, schedulingInterval } = drPolicy.spec; + const isSyncPolicy = + getReplicationType(schedulingInterval) === REPLICATION_TYPE.SYNC; + return ( + isSyncPolicy && drClusters.every((cluster) => clusters.includes(cluster)) + ); + }); + +const checkClientToClientPeering = ( + clusters: ManagedClusterInfoType[] +): boolean => { + const odfConfigInfo1 = clusters[0]?.odfInfo?.storageClusterInfo; + const odfConfigInfo2 = clusters[1]?.odfInfo?.storageClusterInfo; + if (!!odfConfigInfo1?.clientInfo && !!odfConfigInfo2?.clientInfo) { + // Clients from same provider are not supported for DR. + return odfConfigInfo1.cephFSID === odfConfigInfo2.cephFSID; + } + return false; +}; + +const checkClientToODFPeering = (clusters: ManagedClusterInfoType[]): boolean => + // ODF cluster to client peering is not supported for DR. + !!clusters[0]?.odfInfo?.storageClusterInfo?.clientInfo !== + !!clusters[1]?.odfInfo?.storageClusterInfo?.clientInfo; + +const validateClusterSelection = ( + clusters: ManagedClusterInfoType[], + drPolicies: DRPolicyKind[] +): ValidationType => { + const validation: ValidationType = clusters.reduce( + (acc, cluster) => { + const name = getName(cluster); + const { odfInfo } = cluster; + const { isValidODFVersion, storageClusterInfo, storageClusterCount } = + cluster?.odfInfo || {}; + if (!odfInfo || storageClusterCount === 0) { + acc.clusterValidation.clustersWithoutODF.push(name); + } + if (!isValidODFVersion) { + acc.clusterValidation.clustersWithUnsupportedODF.push(name); + } + if (!storageClusterInfo?.cephFSID) { + acc.clusterValidation.clustersWithUnsuccessfullODF.push(name); + } + if (storageClusterCount > 1) { + acc.clusterValidation.clustersWithMultipleStorageInstances.push(name); + } + return acc; + }, + { + clusterValidation: { + clustersWithUnsupportedODF: [], + clustersWithoutODF: [], + clustersWithUnsuccessfullODF: [], + clustersWithMultipleStorageInstances: [], + }, + } as ValidationType + ); + + validation.peeringValidation = { + syncPolicyFound: checkSyncPolicyExists(clusters.map(getName), drPolicies), + unSupportedPeering: + checkClientToClientPeering(clusters) || checkClientToODFPeering(clusters), + }; + + return validation; +}; + +const getPeeringValidationMessage = ( + peeringValidation: PeeringValidationType, + t: TFunction +): PeeringValidationMessageType => { + if (peeringValidation.unSupportedPeering) { + return { + title: t('Unsupported peering configuration.'), + description: t( + "The clusters you're trying to peer aren't compatible. " + + 'It could be due to mismatched types (one with a client, the other without) ' + + 'or both using the same Data Foundation provider. Select clusters that are either ' + + 'the same type or have separate providers to continue.' + ), + }; + } else if (peeringValidation.syncPolicyFound) { + return { + title: t('Existing DRPolicy detected.'), + description: t( + 'A DRPolicy is already configured for selected managed clusters. ' + + 'You cannot create another DRPolicy using the same pair of clusters.' + ), + }; + } +}; + +const getClusterValidationMessage = ( + clusterName: string, + clusterValidation: ClusterValidationType, + requiredODFVersion: string, + t: TFunction +): string[] => { + const errorMessages: string[] = []; + + clusterValidation?.clustersWithUnsupportedODF.includes(clusterName) && + errorMessages.push( + t('Data foundation must be {{version}} or above.', { + version: requiredODFVersion, + }) + ); + + clusterValidation?.clustersWithUnsuccessfullODF.includes(clusterName) && + errorMessages.push(t('Must be connected to RHCS.')); + + clusterValidation?.clustersWithMultipleStorageInstances.includes( + clusterName + ) && errorMessages.push(t('The cluster has multiple storage instances.')); + + return errorMessages; +}; + +const ClusterValidationMessage: React.FC = ({ + clusterName, + clusterValidation, + requiredODFVersion, +}) => { + const { t } = useCustomTranslation(); + const errorMessages: string[] = getClusterValidationMessage( + clusterName, + clusterValidation, + requiredODFVersion, + t + ); + const isClusterWithoutODF = + clusterValidation.clustersWithoutODF.includes(clusterName); + const title = isClusterWithoutODF + ? t('Checks cannot be performed for the {{clusterName}}:', { clusterName }) + : pluralize( + errorMessages.length, + t('check unsuccessful on the {{clusterName}}:', { clusterName }), + t('checks unsuccessful on the {{clusterName}}:', { clusterName }) + ); + + return ( +
+ {title} + {isClusterWithoutODF ? ( + + ) : ( + errorMessages.map((errorMessage) => ( + } + title={errorMessage} + /> + )) + )} +
+ ); +}; + +const PeeringValidationMessage: React.FC = ({ + peeringValidation, +}) => { + const { t } = useCustomTranslation(); + const peeringValidationMessage: PeeringValidationMessageType = + getPeeringValidationMessage(peeringValidation, t); + return ( + + {peeringValidationMessage.description} + + ); +}; + +export const SelectedClusterValidation: React.FC = + ({ selectedClusters, requiredODFVersion, dispatch }) => { + const { t } = useCustomTranslation(); + + const [drPolicies, policyLoaded, policyLoadError] = useK8sWatchResource< + DRPolicyKind[] + >(getDRPolicyResourceObj()); + + let isSelectionValid: boolean = false; + + React.useEffect(() => { + if (policyLoaded && !policyLoadError) { + dispatch({ + type: DRPolicyActionType.SET_CLUSTER_SELECTION_VALIDATION, + payload: isSelectionValid, + }); + } + }, [isSelectionValid, policyLoaded, policyLoadError, dispatch]); + + const validations: ValidationType = validateClusterSelection( + selectedClusters, + drPolicies + ); + + const { peeringValidation, clusterValidation } = validations; + + const invalidClusters: string[] = Array.from( + new Set([].concat(...Object.values(clusterValidation))) + ); + + const isInvalidPeering: boolean = Object.values(peeringValidation).some( + (validation) => validation + ); + + isSelectionValid = !isInvalidPeering && !invalidClusters.length; + + // Warning + const isVersionMismatch = + selectedClusters[0].odfInfo?.odfVersion !== + selectedClusters[1].odfInfo?.odfVersion; + + return ( + } + title={t( + 'Running checks to ensure that the selected managed cluster meets ' + + 'all necessary conditions so it can enroll in a Disaster Recovery policy.' + )} + /> + } + > + {isSelectionValid ? ( + <> + + {isVersionMismatch && ( + + {t( + 'The selected clusters are running different versions of Data Foundation. ' + + 'Peering clusters with different versions can lead to potential issues and is not recommended. ' + + 'Ensure all clusters are upgraded to the same version before proceeding with peering to avoid operational risks.' + )} + + )} + + ) : ( + <> + {isInvalidPeering ? ( + + ) : ( + <> + + {t( + 'The selected managed cluster(s) does not meet all necessary conditions ' + + 'to be eligible for disaster recovery policy. Resolve the following ' + + 'issues to proceed with policy creation.' + )} + + {invalidClusters.map((clusterName) => ( + + ))} + + )} + + )} + + ); + }; + +type SelectedClusterValidationProps = { + selectedClusters: ManagedClusterInfoType[]; + requiredODFVersion: string; + dispatch: React.Dispatch; +}; + +type PeeringValidationType = { + unSupportedPeering: boolean; + syncPolicyFound: boolean; +}; + +type ClusterValidationType = { + clustersWithUnsupportedODF: string[]; + clustersWithoutODF: string[]; + clustersWithUnsuccessfullODF: string[]; + clustersWithMultipleStorageInstances: string[]; +}; + +type ValidationType = { + clusterValidation: ClusterValidationType; + peeringValidation: PeeringValidationType; +}; + +type ClusterValidationMessageProps = { + clusterName: string; + clusterValidation: ClusterValidationType; + requiredODFVersion: string; +}; + +type PeeringValidationMessageProps = { + peeringValidation: PeeringValidationType; +}; + +type PeeringValidationMessageType = { + title: string; + description?: React.ReactNode; +}; diff --git a/packages/mco/components/create-dr-policy/selected-cluster-view.tsx b/packages/mco/components/create-dr-policy/selected-cluster-view.tsx index 1a70fdfa8..029c3c0d2 100644 --- a/packages/mco/components/create-dr-policy/selected-cluster-view.tsx +++ b/packages/mco/components/create-dr-policy/selected-cluster-view.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { parseNamespaceName } from '@odf/mco/utils'; -import { RedExclamationCircleIcon } from '@odf/shared/status/icons'; +import { getName } from '@odf/shared/selectors'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { Text, @@ -10,54 +10,36 @@ import { Flex, FlexItem, } from '@patternfly/react-core'; -import { ManagedClusterInfoType } from './reducer'; +import { ManagedClusterInfoType } from './utils/reducer'; import './create-dr-policy.scss'; -type SelectedClusterProps = { - id: number; +type SelectedClusterViewProps = { + index: number; cluster: ManagedClusterInfoType; }; -export const checkForErrors = (clusters: ManagedClusterInfoType[]) => - clusters.some((cluster) => { - const { isManagedClusterAvailable, odfInfo } = cluster; - const { cephFSID, storageSystemNamespacedName } = - odfInfo.storageClusterInfo; - const [storageSystemName] = parseNamespaceName(storageSystemNamespacedName); - return ( - !isManagedClusterAvailable || - !odfInfo?.isValidODFVersion || - !storageSystemName || - !cephFSID - ); - }); - -export const SelectedCluster: React.FC = ({ - id, +export const SelectedClusterView: React.FC = ({ + index, cluster, }) => { const { t } = useCustomTranslation(); - const { name, region, odfInfo } = cluster; + const { region, odfInfo } = cluster; const [storageSystemName] = parseNamespaceName( odfInfo.storageClusterInfo.storageSystemNamespacedName ); - const anyError = checkForErrors([cluster]); return ( - - {id} + + {index} - - {name}   - {!!anyError && } - + {getName(cluster)} {!!storageSystemName ? ( <> {region} diff --git a/packages/mco/components/create-dr-policy/utils/cluster-list-utils.ts b/packages/mco/components/create-dr-policy/utils/cluster-list-utils.ts new file mode 100644 index 000000000..590527e90 --- /dev/null +++ b/packages/mco/components/create-dr-policy/utils/cluster-list-utils.ts @@ -0,0 +1,219 @@ +import { ConnectedClient, ODFInfoYamlObject } from '@odf/mco/types'; +import { + getMajorVersion, + ValidateManagedClusterCondition, + getValueFromClusterClaim, + isMinimumSupportedODFVersion, + getNameNamespace, +} from '@odf/mco/utils'; +import { getLabel, getName, getNamespace } from '@odf/shared/selectors'; +import { ConfigMapKind } from '@odf/shared/types'; +import { sortRows } from '@odf/shared/utils'; +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { safeLoad } from 'js-yaml'; +import * as _ from 'lodash-es'; +import { TFunction } from 'react-i18next'; +import { + MAX_ALLOWED_CLUSTERS, + MANAGED_CLUSTER_REGION_CLAIM, + MANAGED_CLUSTER_JOINED, + MANAGED_CLUSTER_CONDITION_AVAILABLE, + CLUSTER_ID, +} from '../../../constants'; +import { + ACMManagedClusterKind, + ACMManagedClusterViewKind, +} from '../../../types'; +import { ManagedClusterInfoType, ODFConfigInfoType } from '../utils/reducer'; + +export const INITIAL_PAGE_NUMBER = 1; +export const COUNT_PER_PAGE_NUMBER = 10; + +export enum COLUMN_NAMES { + ManagedCluster, + AvailabilityStatus, + DataFoundation, + StorageClients, + Region, +} + +export const getColumns = (t: TFunction) => [ + { + columnName: t('Managed Cluster'), + sortFunction: (a, b, c) => sortRows(a, b, c, 'metadata.name'), + }, + { + columnName: t('Availability status'), + sortFunction: (a, b, c) => sortRows(a, b, c, 'isManagedClusterAvailable'), + }, + { + columnName: t('Data Foundation'), + sortFunction: (a, b, c) => sortRows(a, b, c, 'odfInfo.odfVersion'), + }, + { + columnName: t('Storage clients'), + sortFunction: (a, b, c) => sortRows(a, b, c, 'metadata.name'), + }, + { + columnName: t('Region'), + sortFunction: (a, b, c) => sortRows(a, b, c, 'region'), + }, +]; + +export const getColumnHelper = (name: COLUMN_NAMES, t: TFunction) => { + const columns = getColumns(t); + switch (name) { + case COLUMN_NAMES.ManagedCluster: + return columns[0]; + case COLUMN_NAMES.AvailabilityStatus: + return columns[1]; + case COLUMN_NAMES.DataFoundation: + return columns[2]; + case COLUMN_NAMES.StorageClients: + return columns[3]; + case COLUMN_NAMES.Region: + return columns[4]; + } +}; + +const getODFInfo = ( + requiredODFVersion: string, + odfInfoConfigData: { [key: string]: string } +): [ODFConfigInfoType, ConnectedClient[]] => { + try { + // Managed cluster with multiple StorageSystems is not currently supported for DR + // ToDo: Update this once we add support for multiple clusters + const odfInfoKey = Object.keys(odfInfoConfigData)[0]; + const odfInfoYaml = odfInfoConfigData[odfInfoKey]; + const odfInfo: ODFInfoYamlObject = safeLoad(odfInfoYaml); + const storageClusterName = odfInfo?.storageCluster?.namespacedName?.name; + const storageClusterNamespace = + odfInfo?.storageCluster?.namespacedName?.namespace; + const storageSystemName = odfInfo?.storageSystemName; + + const odfVersion = getMajorVersion(odfInfo?.version); + const storageClusterCount = Object.keys(odfInfoConfigData).length; + const storageClusterNamespacedName = getNameNamespace( + storageClusterName, + storageClusterNamespace + ); + const storageSystemNamespacedName = getNameNamespace( + storageSystemName, + storageClusterNamespace + ); + const cephFSID = odfInfo?.storageCluster?.cephClusterFSID; + + const isValidODFVersion = isMinimumSupportedODFVersion( + odfVersion, + requiredODFVersion + ); + + return [ + { + odfVersion, + isValidODFVersion, + storageClusterCount, + storageClusterInfo: { + storageClusterNamespacedName, + storageSystemNamespacedName, + cephFSID, + }, + }, + odfInfo?.clients || [], + ]; + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + + return [ + { + odfVersion: '', + isValidODFVersion: false, + storageClusterCount: 0, + storageClusterInfo: { + storageClusterNamespacedName: '', + storageSystemNamespacedName: '', + cephFSID: '', + }, + }, + [], + ]; + } +}; + +const getManagedClusterInfo = ( + cluster: ACMManagedClusterKind, + clusterToODFInfoMap: ClusterToODFInfoMap +): ManagedClusterInfoType => { + const clusterId = getLabel(cluster, CLUSTER_ID); + const clusterName = getName(cluster); + return { + id: clusterId, + metadata: cluster.metadata, + region: getValueFromClusterClaim( + cluster?.status?.clusterClaims, + MANAGED_CLUSTER_REGION_CLAIM + ), + isManagedClusterAvailable: ValidateManagedClusterCondition( + cluster, + MANAGED_CLUSTER_CONDITION_AVAILABLE + ), + odfInfo: + clusterToODFInfoMap?.[clusterId] || clusterToODFInfoMap?.[clusterName], + }; +}; + +const clusterToODFInfoMapping = ( + mcvs: ACMManagedClusterViewKind[], + requiredODFVersion: string +): ClusterToODFInfoMap => + mcvs?.reduce((acc, mcv) => { + const odfInfoConfig = mcv.status?.result as ConfigMapKind; + const odfInfoConfigData = odfInfoConfig?.data || {}; + const [odfInfo, clients] = getODFInfo( + requiredODFVersion, + odfInfoConfigData + ); + if (!!clients.length) { + // Copying the ODF config from provider cluster to client managed clusters. + // Client managed clusters will use cluster id as key to find the ODF config. + clients.forEach((client) => { + const odfInfoCopy = _.cloneDeep(odfInfo); + odfInfoCopy.storageClusterInfo.clientInfo = client; + acc[client.clusterId] = odfInfoCopy; + }); + } else { + // Non-client managed clusters will use cluster name as key to find the ODF config. + acc[getNamespace(mcv)] = odfInfo; + } + return acc; + }, {} as ClusterToODFInfoMap); + +export const getManagedClusterInfoTypes = ( + managedClusters: ACMManagedClusterKind[], + mcvs: ACMManagedClusterViewKind[], + requiredODFVersion: string +): ManagedClusterInfoType[] => { + const clusterIdToODFInfoMap = clusterToODFInfoMapping( + mcvs, + requiredODFVersion + ); + return managedClusters?.reduce((acc, cluster) => { + if (ValidateManagedClusterCondition(cluster, MANAGED_CLUSTER_JOINED)) + return [...acc, getManagedClusterInfo(cluster, clusterIdToODFInfoMap)]; + return acc; + }, []); +}; + +export const isRowSelectable = ( + cluster: K8sResourceCommon, + selectedClusters: ManagedClusterInfoType[] +) => + selectedClusters.length < MAX_ALLOWED_CLUSTERS || + !!selectedClusters.find( + (selectedCluster) => getName(selectedCluster) === getName(cluster) + ); + +type ClusterToODFInfoMap = { + [clusterId in string]: ODFConfigInfoType; +}; diff --git a/packages/mco/components/create-dr-policy/utils/k8s-utils.ts b/packages/mco/components/create-dr-policy/utils/k8s-utils.ts new file mode 100644 index 000000000..70e1c732b --- /dev/null +++ b/packages/mco/components/create-dr-policy/utils/k8s-utils.ts @@ -0,0 +1,131 @@ +import { RBD_IMAGE_FLATTEN_LABEL, REPLICATION_TYPE } from '@odf/mco/constants'; +import { DRPolicyModel, MirrorPeerModel } from '@odf/mco/models'; +import { DRPolicyKind, MirrorPeerKind } from '@odf/mco/types'; +import { parseNamespaceName } from '@odf/mco/utils'; +import { getName } from '@odf/shared'; +import { + getAPIVersionForModel, + k8sCreate, + K8sResourceKind, +} from '@openshift-console/dynamic-plugin-sdk'; +import { DRPolicyState, ManagedClusterInfoType } from './reducer'; + +const getODFPeers = (cluster: ManagedClusterInfoType) => { + const storageClusterInfo = cluster?.odfInfo?.storageClusterInfo; + if (!!storageClusterInfo?.clientInfo) { + return [storageClusterInfo.clientInfo?.name, '']; + } + return parseNamespaceName(storageClusterInfo?.storageClusterNamespacedName); +}; + +const getPeerClustersRef = (clusters: ManagedClusterInfoType[]) => + clusters.map((cluster) => { + const [storageClusterName, storageClusterNamesapce] = getODFPeers(cluster); + return { + clusterName: getName(cluster), + storageClusterRef: { + name: storageClusterName, + namespace: storageClusterNamesapce, + }, + }; + }); + +const fetchMirrorPeer = ( + mirrorPeers: MirrorPeerKind[], + peerNames: string[], + odfPeerNames: string[] +): MirrorPeerKind => + mirrorPeers.find((mirrorPeer) => { + const existingPeerNames = + mirrorPeer.spec?.items?.map((item) => item.clusterName) ?? []; + const existingODFPeerNames = + mirrorPeer.spec?.items?.map( + (item) => + `${item.storageClusterRef.name},${ + item.storageClusterRef?.namespace || '' + }` + ) ?? []; + return ( + existingPeerNames.sort().join(',') === peerNames.sort().join(',') && + existingODFPeerNames.sort().join(',') === odfPeerNames.sort().join(',') + ); + }); + +const createMirrorPeer = ( + selectedClusters: ManagedClusterInfoType[], + replicationType: REPLICATION_TYPE +): Promise => { + const mirrorPeerPayload: MirrorPeerKind = { + apiVersion: getAPIVersionForModel(MirrorPeerModel), + kind: MirrorPeerModel.kind, + metadata: { generateName: 'mirrorpeer-' }, + spec: { + manageS3: true, + type: replicationType, + items: getPeerClustersRef(selectedClusters), + }, + }; + return k8sCreate({ + model: MirrorPeerModel, + data: mirrorPeerPayload, + }); +}; + +const createDRPolicy = ( + policyName: string, + replicationType: REPLICATION_TYPE, + syncIntervalTime: string, + enableRBDImageFlatten: boolean, + peerNames: string[] +): Promise => { + const drPolicyPayload: DRPolicyKind = { + apiVersion: getAPIVersionForModel(DRPolicyModel), + kind: DRPolicyModel.kind, + metadata: { name: policyName }, + spec: { + replicationClassSelector: enableRBDImageFlatten + ? { matchLabels: RBD_IMAGE_FLATTEN_LABEL } + : {}, + schedulingInterval: + replicationType === REPLICATION_TYPE.ASYNC ? syncIntervalTime : '0m', + drClusters: peerNames, + }, + }; + return k8sCreate({ + model: DRPolicyModel, + data: drPolicyPayload, + }); +}; + +export const createPolicyPromises = ( + state: DRPolicyState, + mirrorPeers: MirrorPeerKind[] +): Promise[] => { + const promises: Promise[] = []; + const peerNames = state.selectedClusters.map(getName); + promises.push( + createDRPolicy( + state.policyName, + state.replicationType, + state.syncIntervalTime, + state.enableRBDImageFlatten, + peerNames + ) + ); + const odfPeerNames: string[] = state.selectedClusters.map((cluster) => + getODFPeers(cluster).join(',') + ); + const mirrorPeer: MirrorPeerKind = fetchMirrorPeer( + mirrorPeers, + peerNames, + odfPeerNames + ); + + if (!mirrorPeer) { + promises.push( + createMirrorPeer(state.selectedClusters, state.replicationType) + ); + } + + return promises; +}; diff --git a/packages/mco/components/create-dr-policy/reducer.ts b/packages/mco/components/create-dr-policy/utils/reducer.ts similarity index 75% rename from packages/mco/components/create-dr-policy/reducer.ts rename to packages/mco/components/create-dr-policy/utils/reducer.ts index b43f1c58f..2e02c74b8 100644 --- a/packages/mco/components/create-dr-policy/reducer.ts +++ b/packages/mco/components/create-dr-policy/utils/reducer.ts @@ -1,4 +1,6 @@ import { REPLICATION_TYPE } from '@odf/mco/constants'; +import { ConnectedClient } from '@odf/mco/types'; +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; export type StorageClusterInfoType = { // Namespaced storage cluster name. @@ -7,6 +9,9 @@ export type StorageClusterInfoType = { storageSystemNamespacedName: string; // Ceph FSID to determine RDR/MDR. cephFSID: string; + // ToDo: Use list type after ODF starts supporting + // multiple clients per managed cluster + clientInfo?: ConnectedClient; }; export type ODFConfigInfoType = { @@ -20,16 +25,17 @@ export type ODFConfigInfoType = { storageClusterCount: number; }; -export type ManagedClusterInfoType = { - // Name of the managed cluster in ACM. - name: string; - // Namespace of the managed cluster deployed in ACM. - namesapce: string; +// Using K8sResourceCommon to reuse shared components +export type ManagedClusterInfoType = K8sResourceCommon & { + // Cluster id + id: string; // The cloud region where the cluster is deployed. region?: string; // Cluster is offline / online. isManagedClusterAvailable: boolean; // ODF cluster info. + // ToDo: Use list type after ODF starts supporting + // multiple ODF clusters per managed cluster odfInfo?: ODFConfigInfoType; }; @@ -42,7 +48,10 @@ export type DRPolicyState = { syncIntervalTime: string; // Selected managed cluster for DRPolicy paring. selectedClusters: ManagedClusterInfoType[]; + // For RBD cloned PVC enableRBDImageFlatten: boolean; + // Any error to block the creation + isClusterSelectionValid: boolean; }; export enum DRPolicyActionType { @@ -52,6 +61,7 @@ export enum DRPolicyActionType { SET_SELECTED_CLUSTERS = 'SET_SELECTED_CLUSTERS', UPDATE_SELECTED_CLUSTERS = 'UPDATE_SELECTED_CLUSTERS', SET_RBD_IMAGE_FLATTEN = 'SET_RBD_IMAGE_FLATTEN', + SET_CLUSTER_SELECTION_VALIDATION = 'SET_CLUSTER_SELECTION_VALIDATION', } export const drPolicyInitialState: DRPolicyState = { @@ -60,6 +70,7 @@ export const drPolicyInitialState: DRPolicyState = { syncIntervalTime: '5m', selectedClusters: [], enableRBDImageFlatten: false, + isClusterSelectionValid: false, }; export type DRPolicyAction = @@ -70,7 +81,11 @@ export type DRPolicyAction = type: DRPolicyActionType.SET_SELECTED_CLUSTERS; payload: ManagedClusterInfoType[]; } - | { type: DRPolicyActionType.SET_RBD_IMAGE_FLATTEN; payload: boolean }; + | { type: DRPolicyActionType.SET_RBD_IMAGE_FLATTEN; payload: boolean } + | { + type: DRPolicyActionType.SET_CLUSTER_SELECTION_VALIDATION; + payload: boolean; + }; export const drPolicyReducer = ( state: DRPolicyState, @@ -107,6 +122,12 @@ export const drPolicyReducer = ( enableRBDImageFlatten: action.payload, }; } + case DRPolicyActionType.SET_CLUSTER_SELECTION_VALIDATION: { + return { + ...state, + isClusterSelectionValid: action.payload, + }; + } default: return state; } diff --git a/packages/mco/constants/acm.ts b/packages/mco/constants/acm.ts index 09061b2d7..c295dbf43 100644 --- a/packages/mco/constants/acm.ts +++ b/packages/mco/constants/acm.ts @@ -48,3 +48,6 @@ export const LABEL_SPLIT_CHAR = '='; export const DR_BLOCK_LISTED_LABELS = ['app.kubernetes.io/instance']; export const ACM_OPERATOR_SPEC_NAME = 'advanced-cluster-management'; + +// Managed cluster cluster id label key +export const CLUSTER_ID = 'clusterID'; diff --git a/packages/shared/src/table/selectable-table.tsx b/packages/shared/src/table/selectable-table.tsx index 14203bf8e..bad812635 100644 --- a/packages/shared/src/table/selectable-table.tsx +++ b/packages/shared/src/table/selectable-table.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { StatusBox } from '@odf/shared/generic/status-box'; import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; -import { TdSelectType } from '@patternfly/react-table/dist/esm/components/Table/base/types'; import { Table, Tbody, @@ -16,6 +15,11 @@ import { useSortList } from '../hooks/sort-list'; import { getUID } from '../selectors'; import { TableColumnProps, RowComponentType } from './composable-table'; +export enum TABLE_VARIANT { + DEFAULT = 'default', + COMPACT = 'compact', +} + const hasNoDeletionTimestamp: IsRowSelectable = ( row: T ) => !row?.metadata?.deletionTimestamp; @@ -51,7 +55,7 @@ export const SelectableTable: SelectableTableProps = < rows, RowComponent, extraProps, - isSelectableHidden, + isColumnSelectableHidden, loaded, loadError, emptyRowMessage, @@ -59,6 +63,7 @@ export const SelectableTable: SelectableTableProps = < borders, className, isRowSelectable, + variant, }) => { const { onSort, @@ -109,14 +114,16 @@ export const SelectableTable: SelectableTableProps = < className={className} translate={null} aria-label="Selectable table" - variant="compact" borders={borders} + variant={ + variant !== TABLE_VARIANT.DEFAULT ? TABLE_VARIANT.COMPACT : undefined + } > @@ -179,8 +182,7 @@ type TableProps = { selectedRows: T[]; setSelectedRows: (selectedRows: T[]) => void; extraProps?: any; - // A temporary prop for MCO to hide disable DR - isSelectableHidden?: boolean; + isColumnSelectableHidden?: boolean; loaded: boolean; loadError?: any; emptyRowMessage?: React.FC; @@ -190,6 +192,7 @@ type TableProps = { /** Additional classes added to the Table */ className?: string; isRowSelectable?: IsRowSelectable; + variant?: TABLE_VARIANT; }; type SelectableTableProps = (