From 8366043db48afa494ee2e180e3640f6de8ce2786 Mon Sep 17 00:00:00 2001 From: Ugo Palatucci Date: Mon, 4 Nov 2024 17:05:25 +0100 Subject: [PATCH] CNV-36070: Storageclass migration select volumes --- locales/en/plugin__kubevirt-plugin.json | 4 + .../migrate/VirtualMachineMigration.tsx | 25 +++++- .../tabs/VirtualMachineMigrationDetails.tsx | 76 ++++++++++++++++++ .../components/SelectMigrationDisksTable.tsx | 77 +++++++++++++++++++ .../migrate/tabs/components/constants.ts | 6 ++ .../migrate/tabs/components/utils.ts | 34 ++++++++ src/views/virtualmachines/migrate/utils.ts | 21 +++-- 7 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationDetails.tsx create mode 100644 src/views/virtualmachines/migrate/tabs/components/SelectMigrationDisksTable.tsx create mode 100644 src/views/virtualmachines/migrate/tabs/components/constants.ts create mode 100644 src/views/virtualmachines/migrate/tabs/components/utils.ts diff --git a/locales/en/plugin__kubevirt-plugin.json b/locales/en/plugin__kubevirt-plugin.json index d110859ff..2f96f695a 100644 --- a/locales/en/plugin__kubevirt-plugin.json +++ b/locales/en/plugin__kubevirt-plugin.json @@ -730,6 +730,7 @@ "Migrate VirtualMachine storage to a different StorageClass.": "Migrate VirtualMachine storage to a different StorageClass.", "Migration": "Migration", "Migration chart": "Migration chart", + "Migration details": "Migration details", "Migration metrics": "Migration metrics", "Migration Toolkit for Virtualization": "Migration Toolkit for Virtualization", "MigrationPolicies": "MigrationPolicies", @@ -1077,12 +1078,14 @@ "Select StorageClass": "Select StorageClass", "Select target node": "Select target node", "Select the destination storage for the VirtualMachine storage migration.": "Select the destination storage for the VirtualMachine storage migration.", + "Select the storage to migrate for": "Select the storage to migrate for", "Select this option if you use an on-premise subscription service": "Select this option if you use an on-premise subscription service", "Select volume to boot from": "Select volume to boot from", "Select VolumeSnapshot": "Select VolumeSnapshot", "Select workloads that must have all the following expressions.": "Select workloads that must have all the following expressions.", "Selected columns will appear in the table.": "Selected columns will appear in the table.", "Selected StorageClass is different from StorageClass of the source": "Selected StorageClass is different from StorageClass of the source", + "Selected volumes": "Selected volumes", "Selector": "Selector", "selector key": "selector key", "selector value": "selector value", @@ -1226,6 +1229,7 @@ "The Descheduler can be used to evict a running VirtualMachine so that the VirtualMachine can be rescheduled onto a more suitable Node via a live migration.": "The Descheduler can be used to evict a running VirtualMachine so that the VirtualMachine can be rescheduled onto a more suitable Node via a live migration.", "The disk must be attached to the VirtualMAchine as a SCSI LUN for this option to work. It should only be used for cluster-aware applications": "The disk must be attached to the VirtualMAchine as a SCSI LUN for this option to work. It should only be used for cluster-aware applications", "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "The entire VirtualMachine": "The entire VirtualMachine", "The following areas have pending changes that will be applied when this VirtualMachine is restarted.": "The following areas have pending changes that will be applied when this VirtualMachine is restarted.", "The following credentials for this operating system were created via cloud-init. If unsuccessful, cloud-init could be improperly configured. Contact the image provider for more information.": "The following credentials for this operating system were created via cloud-init. If unsuccessful, cloud-init could be improperly configured. Contact the image provider for more information.", "The following disk will not be included in the snapshot_one": "The following disk will not be included in the snapshot", diff --git a/src/views/virtualmachines/migrate/VirtualMachineMigration.tsx b/src/views/virtualmachines/migrate/VirtualMachineMigration.tsx index b723c6066..f684ca352 100644 --- a/src/views/virtualmachines/migrate/VirtualMachineMigration.tsx +++ b/src/views/virtualmachines/migrate/VirtualMachineMigration.tsx @@ -2,6 +2,7 @@ import React, { FC, useCallback, useMemo, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom-v5-compat'; import { VirtualMachineModel } from 'src/views/dashboard-extensions/utils'; +import { IoK8sApiCoreV1PersistentVolumeClaim } from '@kubevirt-ui/kubevirt-api/kubernetes'; import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; import Loading from '@kubevirt-utils/components/Loading/Loading'; import useDefaultStorageClass from '@kubevirt-utils/hooks/useDefaultStorage/useDefaultStorageClass'; @@ -21,8 +22,9 @@ import { } from '@patternfly/react-core'; import VirtualMachineMigrationDestinationTab from './tabs/VirtualMachineMigrationDestinationTab'; +import VirtualMachineMigrationDetails from './tabs/VirtualMachineMigrationDetails'; import VirtualMachineMigrationReviewTab from './tabs/VirtualMachineMigrationReviewTab'; -import { migrateVM } from './utils'; +import { entireVMSelected, migrateVM } from './utils'; const VirtualMachineMigrate: FC = () => { const { t } = useKubevirtTranslation(); @@ -31,6 +33,9 @@ const VirtualMachineMigrate: FC = () => { const [selectedStorageClass, setSelectedStorageClass] = useState(''); const [migrationError, setMigrationError] = useState(null); const [migrationLoading, setMigrationLoading] = useState(false); + const [selectedPVCs, setSelectedPVCs] = useState( + null, + ); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -73,7 +78,11 @@ const VirtualMachineMigrate: FC = () => { setMigrationLoading(true); setMigrationError(null); try { - await migrateVM(vm, pvcs, destinationStorageClass); + await migrateVM( + vm, + entireVMSelected(selectedPVCs) ? pvcs : selectedPVCs, + destinationStorageClass, + ); goBack(); } catch (apiError) { @@ -110,6 +119,18 @@ const VirtualMachineMigrate: FC = () => { onSave={onSubmit} title={t('Migrate VirtualMachine storage')} > + + + >; + vm: V1VirtualMachine; +}; + +const VirtualMachineMigrationDetails: FC = ({ + pvcs, + selectedPVCs, + setSelectedPVCs, + vm, +}) => { + const { t } = useKubevirtTranslation(); + + const allVolumes = entireVMSelected(selectedPVCs); + + return ( + + + {t('Migration details')} + + {t('Select the storage to migrate for')} + + + + + setSelectedPVCs(null)} + /> + setSelectedPVCs(pvcs)} + /> + + {!allVolumes && ( + + + + )} + + ); +}; + +export default VirtualMachineMigrationDetails; diff --git a/src/views/virtualmachines/migrate/tabs/components/SelectMigrationDisksTable.tsx b/src/views/virtualmachines/migrate/tabs/components/SelectMigrationDisksTable.tsx new file mode 100644 index 000000000..ff217e093 --- /dev/null +++ b/src/views/virtualmachines/migrate/tabs/components/SelectMigrationDisksTable.tsx @@ -0,0 +1,77 @@ +import React, { Dispatch, FC, SetStateAction } from 'react'; + +import { IoK8sApiCoreV1PersistentVolumeClaim } from '@kubevirt-ui/kubevirt-api/kubernetes'; +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { getName } from '@kubevirt-utils/resources/shared'; +import { getVolumes } from '@kubevirt-utils/resources/vm'; +import { readableSizeUnit } from '@kubevirt-utils/utils/units'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import { columnNames } from './constants'; +import { getTableDiskData } from './utils'; + +type SelectMigrationDisksTableProps = { + pvcs: IoK8sApiCoreV1PersistentVolumeClaim[]; + selectedPVCs: IoK8sApiCoreV1PersistentVolumeClaim[]; + setSelectedPVCs: Dispatch>; + vm: V1VirtualMachine; +}; + +const SelectMigrationDisksTable: FC = ({ + pvcs, + selectedPVCs, + setSelectedPVCs, + vm, +}) => { + const tableData = getVolumes(vm)?.map((volume) => getTableDiskData(vm, volume, pvcs)); + + const selectableData = tableData.filter((data) => data.isSelectable); + + return ( + + + + + + + + + + + {tableData.map((diskData, rowIndex) => ( + + + + + + + ))} + +
+ setSelectedPVCs(isSelecting ? selectableData.map((data) => data.pvc) : []), + }} + aria-label="Row select" + /> + {columnNames.name}{columnNames.drive}{columnNames.storageClass}{columnNames.size}
getName(pvc) === getName(diskData.pvc)), + ), + onSelect: (_event, isSelecting) => + setSelectedPVCs((selection) => + isSelecting + ? [...selection, diskData.pvc] + : selection.filter((pvc) => getName(pvc) !== getName(diskData.pvc)), + ), + rowIndex, + }} + /> + {diskData.name}{diskData.drive}{diskData.storageClass}{readableSizeUnit(diskData?.size)}
+ ); +}; + +export default SelectMigrationDisksTable; diff --git a/src/views/virtualmachines/migrate/tabs/components/constants.ts b/src/views/virtualmachines/migrate/tabs/components/constants.ts new file mode 100644 index 000000000..96afa6edc --- /dev/null +++ b/src/views/virtualmachines/migrate/tabs/components/constants.ts @@ -0,0 +1,6 @@ +export const columnNames = { + drive: 'Drive', + name: 'Name', + size: 'Size', + storageClass: 'Storage class', +}; diff --git a/src/views/virtualmachines/migrate/tabs/components/utils.ts b/src/views/virtualmachines/migrate/tabs/components/utils.ts new file mode 100644 index 000000000..053de5f5e --- /dev/null +++ b/src/views/virtualmachines/migrate/tabs/components/utils.ts @@ -0,0 +1,34 @@ +import { IoK8sApiCoreV1PersistentVolumeClaim } from '@kubevirt-ui/kubevirt-api/kubernetes'; +import { V1VirtualMachine, V1Volume } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { getName } from '@kubevirt-utils/resources/shared'; +import { getDisks } from '@kubevirt-utils/resources/vm'; +import { NO_DATA_DASH } from '@kubevirt-utils/resources/vm/utils/constants'; +import { getPrintableDiskDrive } from '@kubevirt-utils/resources/vm/utils/disk/selectors'; +import { convertToBaseValue, humanizeBinaryBytes } from '@kubevirt-utils/utils/humanize.js'; +import { isEmpty } from '@kubevirt-utils/utils/utils'; + +export const getTableDiskData = ( + vm: V1VirtualMachine, + volume: V1Volume, + pvcs: IoK8sApiCoreV1PersistentVolumeClaim[], +) => { + const volumeDisk = getDisks(vm)?.find((disk) => disk.name === volume.name); + const volumePVC = pvcs?.find( + (pvc) => + getName(pvc) === volume.dataVolume?.name || + getName(pvc) === volume.persistentVolumeClaim?.claimName, + ); + + const pvcSize = humanizeBinaryBytes( + convertToBaseValue(volumePVC?.spec?.resources?.requests?.storage), + ); + + return { + drive: getPrintableDiskDrive(volumeDisk), + isSelectable: !volumeDisk?.shareable && !isEmpty(volumePVC), + name: volume.name, + pvc: volumePVC, + size: pvcSize?.value === 0 ? NO_DATA_DASH : pvcSize?.string, + storageClass: volumePVC?.spec?.storageClassName, + }; +}; diff --git a/src/views/virtualmachines/migrate/utils.ts b/src/views/virtualmachines/migrate/utils.ts index 13bacabdf..7b1475cfd 100644 --- a/src/views/virtualmachines/migrate/utils.ts +++ b/src/views/virtualmachines/migrate/utils.ts @@ -48,11 +48,11 @@ const getBlankDataVolume = ( }; const createBlankDataVolumes = async ( - originPVCs: IoK8sApiCoreV1PersistentVolumeClaim[], + selectedPVCs: IoK8sApiCoreV1PersistentVolumeClaim[], destinationStorageClass: string, ): Promise> => { const dataVolumesCreationResults = await Promise.allSettled( - originPVCs + selectedPVCs .map((pvc) => { return getBlankDataVolume( getName(pvc), @@ -93,7 +93,7 @@ const createBlankDataVolumes = async ( const dataVolumesCreated = fulfilledRequests.map((result) => result.value); - return originPVCs.reduce((createdDataVolumeByPVCName, pvc, index) => { + return selectedPVCs.reduce((createdDataVolumeByPVCName, pvc, index) => { createdDataVolumeByPVCName.set(getName(pvc), dataVolumesCreated[index]); return createdDataVolumeByPVCName; }, new Map()); @@ -101,9 +101,15 @@ const createBlankDataVolumes = async ( const createPatchData = ( vm: V1VirtualMachine, + selectedPVCs: IoK8sApiCoreV1PersistentVolumeClaim[], createdDataVolumeByPVCName: Map, ) => getVolumes(vm).reduce((patchArray, volume, volumeIndex) => { + const pvcName = volume?.persistentVolumeClaim?.claimName || volume?.dataVolume?.name; + const pvc = selectedPVCs.find((selectedPVC) => getName(selectedPVC) === pvcName); + + if (isEmpty(pvc)) return patchArray; + if (volume?.persistentVolumeClaim?.claimName) { patchArray.push({ op: 'replace', @@ -138,15 +144,15 @@ const createPatchData = ( export const migrateVM = async ( vm: V1VirtualMachine, - originPVCs: IoK8sApiCoreV1PersistentVolumeClaim[], + selectedPVCs: IoK8sApiCoreV1PersistentVolumeClaim[], destinationStorageClass: string, ) => { const createdDataVolumeByPVCName = await createBlankDataVolumes( - originPVCs, + selectedPVCs, destinationStorageClass, ); - const patchData = createPatchData(vm, createdDataVolumeByPVCName); + const patchData = createPatchData(vm, selectedPVCs, createdDataVolumeByPVCName); patchData.push({ op: 'add', @@ -171,3 +177,6 @@ export const migrateVM = async ( throw error; } }; + +export const entireVMSelected = (selectedPVCs: IoK8sApiCoreV1PersistentVolumeClaim[]) => + selectedPVCs === null;