From 091f8ad3a7d75e5c7ee14b740f49811a40b2603d Mon Sep 17 00:00:00 2001 From: Ugo Palatucci Date: Thu, 17 Oct 2024 13:59:23 +0200 Subject: [PATCH] CNV-36070: Create initial migration interface --- locales/en/plugin__kubevirt-plugin.json | 10 +- .../ActionDropdownItem/ActionDropdownItem.tsx | 31 +++- .../action-dropdown-item.scss | 4 + .../ActionsDropdown/ActionsDropdown.tsx | 53 +++--- .../components/ActionsDropdown/constants.ts | 5 + .../actions/VirtualMachineActionFactory.tsx | 43 ++++- .../VirtualMachineActions.tsx | 4 +- .../hooks/useVirtualMachineActionsProvider.ts | 26 ++- .../useVirtualMachineActionsProvider.test.tsx | 13 +- src/views/virtualmachines/extensions.ts | 11 ++ .../migrate/VirtualMachineMigration.tsx | 139 ++++++++++++++ .../VirtualMachineMigrationDestinationTab.tsx | 58 ++++++ .../tabs/VirtualMachineMigrationReviewTab.tsx | 70 +++++++ src/views/virtualmachines/migrate/utils.ts | 173 ++++++++++++++++++ 14 files changed, 590 insertions(+), 50 deletions(-) create mode 100644 src/utils/components/ActionsDropdown/constants.ts create mode 100644 src/views/virtualmachines/migrate/VirtualMachineMigration.tsx create mode 100644 src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationDestinationTab.tsx create mode 100644 src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationReviewTab.tsx create mode 100644 src/views/virtualmachines/migrate/utils.ts diff --git a/locales/en/plugin__kubevirt-plugin.json b/locales/en/plugin__kubevirt-plugin.json index 762a784bd..3c8749ee9 100644 --- a/locales/en/plugin__kubevirt-plugin.json +++ b/locales/en/plugin__kubevirt-plugin.json @@ -263,6 +263,7 @@ "Clone template": "Clone template", "Clone volume": "Clone volume", "Cloning": "Cloning", + "Close header": "Close header", "Closing it will cause the upload to fail. You may still navigate the console.": "Closing it will cause the upload to fail. You may still navigate the console.", "Cloud-init": "Cloud-init", "Cloud-init and SSH key configurations will be applied to the VirtualMachine only at the first boot.": "Cloud-init and SSH key configurations will be applied to the VirtualMachine only at the first boot.", @@ -277,6 +278,7 @@ "Completion time": "Completion time", "Completion timeout": "Completion timeout", "CompletionTimeoutPerGiB is the maximum number of seconds per GiB a migration is allowed to take. If a live-migration takes longer to migrate than this value multiplied by the size of the VMI, the migration will be cancelled, unless AllowPostCopy is true. Defaults to 800. ": "CompletionTimeoutPerGiB is the maximum number of seconds per GiB a migration is allowed to take. If a live-migration takes longer to migrate than this value multiplied by the size of the VMI, the migration will be cancelled, unless AllowPostCopy is true. Defaults to 800. ", + "Compute": "Compute", "Compute-intensive applications": "Compute-intensive applications", "Condition": "Condition", "Conditions": "Conditions", @@ -411,6 +413,7 @@ "Desktop viewer": "Desktop viewer", "Destination details": "Destination details", "Destination project": "Destination project", + "Destination StorageClass": "Destination StorageClass", "Detach": "Detach", "Detach disk?": "Detach disk?", "Detach sysprep": "Detach sysprep", @@ -720,10 +723,12 @@ "Metrics": "Metrics", "Metrics are collected by the OpenShift Monitoring Operator.": "Metrics are collected by the OpenShift Monitoring Operator.", "Migratable": "Migratable", - "Migrate": "Migrate", "Migrate a VirtualMachine to a different Node or change the selected time range.": "Migrate a VirtualMachine to a different Node or change the selected time range.", "Migrate multiple virtual machine workloads to OpenShift Virtualization. ": "Migrate multiple virtual machine workloads to OpenShift Virtualization. ", "Migrate to a different Node": "Migrate to a different Node", + "Migrate VirtualMachine storage": "Migrate VirtualMachine storage", + "Migrate VirtualMachine storage to a different StorageClass": "Migrate VirtualMachine storage to a different StorageClass", + "Migrate VirtualMachine storage to a different StorageClass.": "Migrate VirtualMachine storage to a different StorageClass.", "Migration": "Migration", "Migration chart": "Migration chart", "Migration metrics": "Migration metrics", @@ -1003,6 +1008,7 @@ "Restore the VirtualMachine to this snapshot`s state": "Restore the VirtualMachine to this snapshot`s state", "Restore VirtualMachine from snapshot": "Restore VirtualMachine from snapshot", "Retain revisions": "Retain revisions", + "Review": "Review", "RHEL download page ": "RHEL download page ", "Rules with \"Preferred\" condition will stack with an \"AND\" relation between them.": "Rules with \"Preferred\" condition will stack with an \"AND\" relation between them.", "Rules with \"Required\" condition will stack with an \"OR\" relation between them.": "Rules with \"Required\" condition will stack with an \"OR\" relation between them.", @@ -1071,6 +1077,7 @@ "Select storage class": "Select storage class", "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 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", @@ -1340,6 +1347,7 @@ "vCPU": "vCPU", "vCPU wait": "vCPU wait", "Vendor": "Vendor", + "Verify the details and click <1>Migrate VirtualMachine storage to start the migration": "Verify the details and click <1>Migrate VirtualMachine storage to start the migration", "view {{qualifiedNodesCount}} matching nodes": "view {{qualifiedNodesCount}} matching nodes", "View alert": "View alert", "View alerts": "View alerts", diff --git a/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx b/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx index d1fb00d96..e15fb1c03 100644 --- a/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx +++ b/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx @@ -2,19 +2,21 @@ import React, { Dispatch, FC, SetStateAction } from 'react'; import classNames from 'classnames'; import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; -import { Action, useAccessReview } from '@openshift-console/dynamic-plugin-sdk'; -import { DropdownItem, TooltipPosition } from '@patternfly/react-core'; +import { useAccessReview } from '@openshift-console/dynamic-plugin-sdk'; +import { Menu, MenuContent, MenuItem, MenuList, TooltipPosition } from '@patternfly/react-core'; + +import { ActionDropdownItemType } from '../ActionsDropdown/constants'; import './action-dropdown-item.scss'; type ActionDropdownItemProps = { - action: Action; + action: ActionDropdownItemType; setIsOpen: Dispatch>; }; const ActionDropdownItem: FC = ({ action, setIsOpen }) => { const { t } = useKubevirtTranslation(); - const [actionAllowed] = useAccessReview(action?.accessReview); + const [actionAllowed] = useAccessReview(action?.accessReview || {}); const isCloneDisabled = !actionAllowed && action?.id === 'vm-action-clone'; const handleClick = () => { @@ -25,7 +27,7 @@ const ActionDropdownItem: FC = ({ action, setIsOpen }) }; return ( - = ({ action, setIsOpen }) position: TooltipPosition.left, }, })} - className={classNames({ ActionDropdownItem__disabled: isCloneDisabled })} + className={classNames('ActionDropdownItem', { + ActionDropdownItem__disabled: isCloneDisabled, + })} + flyoutMenu={ + action?.options && ( + + + + {action?.options?.map((option) => ( + + ))} + + + + ) + } > {action?.label} {action?.icon && ( @@ -46,7 +63,7 @@ const ActionDropdownItem: FC = ({ action, setIsOpen }) {action.icon} )} - + ); }; diff --git a/src/utils/components/ActionDropdownItem/action-dropdown-item.scss b/src/utils/components/ActionDropdownItem/action-dropdown-item.scss index b25e0f954..cce59a44e 100644 --- a/src/utils/components/ActionDropdownItem/action-dropdown-item.scss +++ b/src/utils/components/ActionDropdownItem/action-dropdown-item.scss @@ -3,4 +3,8 @@ cursor: unset; color: var(--pf-global--disabled-color--100); } + + .pf-v5-c-menu.pf-m-flyout { + --pf-v5-c-menu--Width: 200px; + } } diff --git a/src/utils/components/ActionsDropdown/ActionsDropdown.tsx b/src/utils/components/ActionsDropdown/ActionsDropdown.tsx index 3c0f307bb..6b2113248 100644 --- a/src/utils/components/ActionsDropdown/ActionsDropdown.tsx +++ b/src/utils/components/ActionsDropdown/ActionsDropdown.tsx @@ -1,14 +1,16 @@ -import React, { FC, memo, useState } from 'react'; +import React, { FC, memo, useRef, useState } from 'react'; -import ActionDropdownItem from '@kubevirt-utils/components/ActionDropdownItem/ActionDropdownItem'; import DropdownToggle from '@kubevirt-utils/components/toggles/DropdownToggle'; import KebabToggle from '@kubevirt-utils/components/toggles/KebabToggle'; import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; -import { Action } from '@openshift-console/dynamic-plugin-sdk'; -import { Dropdown, DropdownList } from '@patternfly/react-core'; +import { Menu, MenuContent, MenuList, Popper } from '@patternfly/react-core'; + +import ActionDropdownItem from '../ActionDropdownItem/ActionDropdownItem'; + +import { ActionDropdownItemType } from './constants'; type ActionsDropdownProps = { - actions: Action[]; + actions: ActionDropdownItemType[]; className?: string; id?: string; isKebabToggle?: boolean; @@ -17,13 +19,14 @@ type ActionsDropdownProps = { const ActionsDropdown: FC = ({ actions = [], - className, - id, isKebabToggle, onLazyClick, }) => { const { t } = useKubevirtTranslation(); const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + const toggleRef = useRef(null); + const containerRef = useRef(null); const onToggle = () => { setIsOpen((prevIsOpen) => { @@ -41,21 +44,29 @@ const ActionsDropdown: FC = ({ onClick: onToggle, }); + const menu = ( + + + + {actions?.map((action) => ( + + ))} + + + + ); + return ( - setIsOpen(open)} - popperProps={{ enableFlip: true, position: 'right' }} - toggle={Toggle} - > - - {actions?.map((action) => ( - - ))} - - +
+ {Toggle(toggleRef)} + +
); }; diff --git a/src/utils/components/ActionsDropdown/constants.ts b/src/utils/components/ActionsDropdown/constants.ts new file mode 100644 index 000000000..b99b63e6b --- /dev/null +++ b/src/utils/components/ActionsDropdown/constants.ts @@ -0,0 +1,5 @@ +import { Action } from '@openshift-console/dynamic-plugin-sdk'; + +type Optional = Pick, K> & Omit; + +export type ActionDropdownItemType = Optional & { options?: Action[] }; diff --git a/src/views/virtualmachines/actions/VirtualMachineActionFactory.tsx b/src/views/virtualmachines/actions/VirtualMachineActionFactory.tsx index 8e84def84..deebce571 100644 --- a/src/views/virtualmachines/actions/VirtualMachineActionFactory.tsx +++ b/src/views/virtualmachines/actions/VirtualMachineActionFactory.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Location, NavigateFunction } from 'react-router-dom-v5-compat'; import VirtualMachineCloneModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineCloneModel'; import VirtualMachineInstanceMigrationModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineInstanceMigrationModel'; @@ -8,13 +9,14 @@ import { V1VirtualMachine, V1VirtualMachineInstanceMigration, } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { ActionDropdownItemType } from '@kubevirt-utils/components/ActionsDropdown/constants'; import { AnnotationsModal } from '@kubevirt-utils/components/AnnotationsModal/AnnotationsModal'; import CloneVMModal from '@kubevirt-utils/components/CloneVMModal/CloneVMModal'; import { LabelsModal } from '@kubevirt-utils/components/LabelsModal/LabelsModal'; import { ModalComponent } from '@kubevirt-utils/components/ModalProvider/ModalProvider'; import SnapshotModal from '@kubevirt-utils/components/SnapshotModal/SnapshotModal'; import { t } from '@kubevirt-utils/hooks/useKubevirtTranslation'; -import { asAccessReview } from '@kubevirt-utils/resources/shared'; +import { asAccessReview, getNamespace, getResourceUrl } from '@kubevirt-utils/resources/shared'; import { getVMSSHSecretName } from '@kubevirt-utils/resources/vm'; import { isEmpty } from '@kubevirt-utils/utils/utils'; import { Action, k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; @@ -46,7 +48,7 @@ const { } = printableVMStatus; export const VirtualMachineActionFactory = { - cancelMigration: ( + cancelMigrationCompute: ( vm: V1VirtualMachine, vmim: V1VirtualMachineInstanceMigration, isSingleNodeCluster: boolean, @@ -99,7 +101,6 @@ export const VirtualMachineActionFactory = { label: t('Delete'), }; }, - editAnnotations: (vm: V1VirtualMachine, createModal: (modal: ModalComponent) => void): Action => { return { accessReview: asAccessReview(VirtualMachineModel, vm, 'patch'), @@ -129,6 +130,7 @@ export const VirtualMachineActionFactory = { label: t('Edit annotations'), }; }, + editLabels: (vm: V1VirtualMachine, createModal: (modal: ModalComponent) => void): Action => { return { accessReview: asAccessReview(VirtualMachineModel, vm, 'patch'), @@ -170,7 +172,7 @@ export const VirtualMachineActionFactory = { label: t('Force stop'), }; }, - migrate: (vm: V1VirtualMachine, isSingleNodeCluster: boolean): Action => { + migrateCompute: (vm: V1VirtualMachine, isSingleNodeCluster: boolean): Action => { return { accessReview: { group: VirtualMachineInstanceMigrationModel.apiGroup, @@ -182,9 +184,40 @@ export const VirtualMachineActionFactory = { description: t('Migrate to a different Node'), disabled: !isLiveMigratable(vm, isSingleNodeCluster), id: 'vm-action-migrate', - label: t('Migrate'), + label: t('Compute'), + }; + }, + migrateStorage: ( + vm: V1VirtualMachine, + navigate: NavigateFunction, + location: Location, + ): Action => { + return { + accessReview: { + group: VirtualMachineModel.apiGroup, + namespace: getNamespace(vm), + resource: VirtualMachineModel.plural, + verb: 'patch', + }, + cta: () => + navigate( + `${getResourceUrl({ + model: VirtualMachineModel, + resource: vm, + })}/migratestorage?fromURL=${encodeURIComponent( + `${location.pathname}${location.search}`, + )}`, + ), + description: t('Migrate VirtualMachine storage to a different StorageClass'), + id: 'vm-migrate-storage', + label: t('Storage'), }; }, + migrationActions: (...migrationActions): ActionDropdownItemType => ({ + id: 'migration-menu', + label: 'Migration', + options: migrationActions, + }), pause: (vm: V1VirtualMachine): Action => { return { accessReview: asAccessReview(VirtualMachineModel, vm, 'patch'), diff --git a/src/views/virtualmachines/actions/components/VirtualMachineActions/VirtualMachineActions.tsx b/src/views/virtualmachines/actions/components/VirtualMachineActions/VirtualMachineActions.tsx index a0b6e8ab6..eb245ced3 100644 --- a/src/views/virtualmachines/actions/components/VirtualMachineActions/VirtualMachineActions.tsx +++ b/src/views/virtualmachines/actions/components/VirtualMachineActions/VirtualMachineActions.tsx @@ -1,12 +1,12 @@ import React, { FC, memo } from 'react'; import ActionsDropdown from '@kubevirt-utils/components/ActionsDropdown/ActionsDropdown'; -import { Action } from '@openshift-console/dynamic-plugin-sdk'; +import { ActionDropdownItemType } from '@kubevirt-utils/components/ActionsDropdown/constants'; import './VirtualMachineActions.scss'; type VirtualMachinesInstanceActionsProps = { - actions: Action[]; + actions: ActionDropdownItemType[]; isKebabToggle?: boolean; }; diff --git a/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts b/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts index 5df94030a..bb1ddb3a8 100644 --- a/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts +++ b/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts @@ -1,14 +1,16 @@ -import * as React from 'react'; +import { useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; import { V1VirtualMachine, V1VirtualMachineInstanceMigration, } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { ActionDropdownItemType } from '@kubevirt-utils/components/ActionsDropdown/constants'; import { useModal } from '@kubevirt-utils/components/ModalProvider/ModalProvider'; import { getConsoleVirtctlCommand } from '@kubevirt-utils/components/SSHAccess/utils'; import { VirtualMachineModelRef } from '@kubevirt-utils/models'; import { vmimStatuses } from '@kubevirt-utils/resources/vmim/statuses'; -import { Action, useK8sModel } from '@openshift-console/dynamic-plugin-sdk'; +import { useK8sModel } from '@openshift-console/dynamic-plugin-sdk'; import { printableVMStatus } from '../../utils'; import { VirtualMachineActionFactory } from '../VirtualMachineActionFactory'; @@ -17,7 +19,7 @@ type UseVirtualMachineActionsProvider = ( vm: V1VirtualMachine, vmim?: V1VirtualMachineInstanceMigration, isSingleNodeCluster?: boolean, -) => [Action[], boolean, any]; +) => [ActionDropdownItemType[], boolean, any]; const useVirtualMachineActionsProvider: UseVirtualMachineActionsProvider = ( vm, @@ -25,11 +27,13 @@ const useVirtualMachineActionsProvider: UseVirtualMachineActionsProvider = ( isSingleNodeCluster, ) => { const { createModal } = useModal(); + const navigate = useNavigate(); + const location = useLocation(); const virtctlCommand = getConsoleVirtctlCommand(vm); const [, inFlight] = useK8sModel(VirtualMachineModelRef); - const actions: Action[] = React.useMemo(() => { + const actions: ActionDropdownItemType[] = useMemo(() => { const printableStatus = vm?.status?.printableStatus; const { Migrating, Paused } = printableVMStatus; @@ -43,11 +47,13 @@ const useVirtualMachineActionsProvider: UseVirtualMachineActionsProvider = ( return map[printableStatusMachine] || map.default; })(printableStatus); - const migrateOrCancelMigration = + const migrateOrCancelMigrationCompute = printableStatus === Migrating || (vmim && ![vmimStatuses.Failed, vmimStatuses.Succeeded].includes(vmim?.status?.phase)) - ? VirtualMachineActionFactory.cancelMigration(vm, vmim, isSingleNodeCluster) - : VirtualMachineActionFactory.migrate(vm, isSingleNodeCluster); + ? VirtualMachineActionFactory.cancelMigrationCompute(vm, vmim, isSingleNodeCluster) + : VirtualMachineActionFactory.migrateCompute(vm, isSingleNodeCluster); + + const migrateStorage = VirtualMachineActionFactory.migrateStorage(vm, navigate, location); const pauseOrUnpause = printableStatus === Paused @@ -60,16 +66,16 @@ const useVirtualMachineActionsProvider: UseVirtualMachineActionsProvider = ( pauseOrUnpause, VirtualMachineActionFactory.clone(vm, createModal), VirtualMachineActionFactory.snapshot(vm, createModal), - migrateOrCancelMigration, + VirtualMachineActionFactory.migrationActions(migrateOrCancelMigrationCompute, migrateStorage), // VirtualMachineActionFactory.openConsole(vm), VirtualMachineActionFactory.copySSHCommand(vm, virtctlCommand), VirtualMachineActionFactory.editLabels(vm, createModal), VirtualMachineActionFactory.editAnnotations(vm, createModal), VirtualMachineActionFactory.delete(vm, createModal), ]; - }, [vm, vmim, isSingleNodeCluster, createModal, virtctlCommand]); + }, [vm, vmim, isSingleNodeCluster, navigate, location, createModal, virtctlCommand]); - return React.useMemo(() => [actions, !inFlight, undefined], [actions, inFlight]); + return useMemo(() => [actions, !inFlight, undefined], [actions, inFlight]); }; export default useVirtualMachineActionsProvider; diff --git a/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx b/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx index 54f713166..9b6666368 100644 --- a/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx +++ b/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx @@ -29,7 +29,7 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-pause', 'vm-action-clone', 'vm-action-snapshot', - 'vm-action-migrate', + 'migration-menu', 'vm-action-copy-ssh', 'vm-action-edit-labels', 'vm-action-edit-annotations', @@ -54,7 +54,7 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-pause', 'vm-action-clone', 'vm-action-snapshot', - 'vm-action-migrate', + 'migration-menu', 'vm-action-copy-ssh', 'vm-action-edit-labels', 'vm-action-edit-annotations', @@ -79,7 +79,7 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-unpause', 'vm-action-clone', 'vm-action-snapshot', - 'vm-action-migrate', + 'migration-menu', 'vm-action-copy-ssh', 'vm-action-edit-labels', 'vm-action-edit-annotations', @@ -97,6 +97,9 @@ describe('useVirtualMachineActionsProvider tests', () => { const [actions] = result.current; const migratingVMActions = actions.map((action) => action.id); + const migratingSubmenu = actions.find((action) => action.id === 'migration-menu'); + const migratingSubmenuIds = migratingSubmenu.options.map((action) => action.id); + // Migrating vm should have stop, restart, pause, migrate and delete actions expect(migratingVMActions).toEqual([ 'vm-action-stop', @@ -104,11 +107,13 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-pause', 'vm-action-clone', 'vm-action-snapshot', - 'vm-action-cancel-migrate', + 'migration-menu', 'vm-action-copy-ssh', 'vm-action-edit-labels', 'vm-action-edit-annotations', 'vm-action-delete', ]); + + expect(migratingSubmenuIds).toEqual(['vm-action-cancel-migrate', 'vm-migrate-storage']); }); }); diff --git a/src/views/virtualmachines/extensions.ts b/src/views/virtualmachines/extensions.ts index e6fa70194..eeffb9ba4 100644 --- a/src/views/virtualmachines/extensions.ts +++ b/src/views/virtualmachines/extensions.ts @@ -3,6 +3,7 @@ import { ResourceActionProvider, ResourceDetailsPage, ResourceListPage, + RoutePage, StandaloneRoutePage, } from '@openshift-console/dynamic-plugin-sdk'; import type { ConsolePluginBuildMetadata } from '@openshift-console/dynamic-plugin-sdk-webpack'; @@ -12,6 +13,7 @@ export const exposedModules: ConsolePluginBuildMetadata['exposedModules'] = { './views/virtualmachines/details/tabs/diagnostic/VirtualMachineLogViewer/VirtualMachineLogViewerStandAlone/VirtualMachineLogViewerStandAlone.tsx', useVirtualMachineActionsProvider: './views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts', + VirtualMachineMigration: './views/virtualmachines/migrate/VirtualMachineMigration.tsx', VirtualMachineNavPage: './views/virtualmachines/details/VirtualMachineNavPage.tsx', VirtualMachinesList: './views/virtualmachines/list/VirtualMachinesList.tsx', }; @@ -67,4 +69,13 @@ export const extensions: EncodedExtension[] = [ }, type: 'console.page/resource/list', } as EncodedExtension, + { + properties: { + component: { + $codeRef: 'VirtualMachineMigration', + }, + path: ['/k8s/ns/:namespace/:kind/:name/migratestorage'], + }, + type: 'console.page/route', + } as EncodedExtension, ]; diff --git a/src/views/virtualmachines/migrate/VirtualMachineMigration.tsx b/src/views/virtualmachines/migrate/VirtualMachineMigration.tsx new file mode 100644 index 000000000..b723c6066 --- /dev/null +++ b/src/views/virtualmachines/migrate/VirtualMachineMigration.tsx @@ -0,0 +1,139 @@ +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 { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import Loading from '@kubevirt-utils/components/Loading/Loading'; +import useDefaultStorageClass from '@kubevirt-utils/hooks/useDefaultStorage/useDefaultStorageClass'; +import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; +import { VirtualMachineModelGroupVersionKind } from '@kubevirt-utils/models'; +import { getName, getResourceUrl } from '@kubevirt-utils/resources/shared'; +import useDisksSources from '@kubevirt-utils/resources/vm/hooks/disk/useDisksSources'; +import { isEmpty } from '@kubevirt-utils/utils/utils'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { + Alert, + AlertVariant, + Bullseye, + Wizard, + WizardHeader, + WizardStep, +} from '@patternfly/react-core'; + +import VirtualMachineMigrationDestinationTab from './tabs/VirtualMachineMigrationDestinationTab'; +import VirtualMachineMigrationReviewTab from './tabs/VirtualMachineMigrationReviewTab'; +import { migrateVM } from './utils'; + +const VirtualMachineMigrate: FC = () => { + const { t } = useKubevirtTranslation(); + + const { name, namespace } = useParams<{ name: string; namespace: string }>(); + const [selectedStorageClass, setSelectedStorageClass] = useState(''); + const [migrationError, setMigrationError] = useState(null); + const [migrationLoading, setMigrationLoading] = useState(false); + + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const [vm, loaded, error] = useK8sWatchResource( + name + ? { + groupVersionKind: VirtualMachineModelGroupVersionKind, + name, + namespace, + } + : null, + ); + + const { pvcs } = useDisksSources(vm); + + const [{ clusterDefaultStorageClass, sortedStorageClasses }, scLoaded] = useDefaultStorageClass(); + + const defaultStorageClassName = useMemo( + () => getName(clusterDefaultStorageClass), + [clusterDefaultStorageClass], + ); + + const destinationStorageClass = useMemo( + () => selectedStorageClass || defaultStorageClassName, + [defaultStorageClassName, selectedStorageClass], + ); + + const goBack = useCallback(() => { + const fromUrl = searchParams.get('fromURL'); + if (isEmpty(fromUrl)) { + navigate(`${getResourceUrl({ model: VirtualMachineModel, resource: vm })}`); + return; + } + + navigate(fromUrl); + }, [searchParams, navigate, vm]); + + const onSubmit = async () => { + setMigrationLoading(true); + setMigrationError(null); + try { + await migrateVM(vm, pvcs, destinationStorageClass); + + goBack(); + } catch (apiError) { + setMigrationError(apiError); + } + + setMigrationLoading(false); + }; + + if (!loaded && !scLoaded) + return ( + + + + ); + + if (error) + return ( + + {error?.message} + + ); + + return ( + + } + onClose={goBack} + onSave={onSubmit} + title={t('Migrate VirtualMachine storage')} + > + + + + + + + + ); +}; + +export default VirtualMachineMigrate; diff --git a/src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationDestinationTab.tsx b/src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationDestinationTab.tsx new file mode 100644 index 000000000..caff3b6ef --- /dev/null +++ b/src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationDestinationTab.tsx @@ -0,0 +1,58 @@ +import React, { Dispatch, FC, SetStateAction } from 'react'; + +import InlineFilterSelect from '@kubevirt-utils/components/FilterSelect/InlineFilterSelect'; +import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; +import { modelToGroupVersionKind, StorageClassModel } from '@kubevirt-utils/models'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { Stack, StackItem, Text, TextVariants, Title } from '@patternfly/react-core'; + +type VirtualMachineMigrationDestinationTabProps = { + defaultStorageClassName: string; + destinationStorageClass: string; + setSelectedStorageClass: Dispatch>; + sortedStorageClasses: string[]; +}; + +const StorageClassModelGroupVersionKind = modelToGroupVersionKind(StorageClassModel); + +const VirtualMachineMigrationDestinationTab: FC = ({ + defaultStorageClassName, + destinationStorageClass, + setSelectedStorageClass, + sortedStorageClasses, +}) => { + const { t } = useKubevirtTranslation(); + return ( + + + {t('Destination StorageClass')} + + {t('Select the destination storage for the VirtualMachine storage migration.')} + + + + ({ + children: ( + <> + + {defaultStorageClassName === storageClass ? t('(default)') : ''} + + ), + value: storageClass, + }))} + selected={destinationStorageClass} + setSelected={setSelectedStorageClass} + toggleProps={{ isFullWidth: true, placeholder: t('Select StorageClass') }} + /> + + + ); +}; + +export default VirtualMachineMigrationDestinationTab; diff --git a/src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationReviewTab.tsx b/src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationReviewTab.tsx new file mode 100644 index 000000000..dde5100d1 --- /dev/null +++ b/src/views/virtualmachines/migrate/tabs/VirtualMachineMigrationReviewTab.tsx @@ -0,0 +1,70 @@ +import React, { FC } from 'react'; +import { Trans } from 'react-i18next'; + +import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; +import { + Alert, + AlertVariant, + Stack, + StackItem, + Text, + TextVariants, + Title, +} from '@patternfly/react-core'; +import { Table, Tbody, Td, Tr } from '@patternfly/react-table'; + +type VirtualMachineMigrationReviewTabProps = { + defaultStorageClassName: string; + destinationStorageClass: string; + migrationError: Error; +}; + +const VirtualMachineMigrationReviewTab: FC = ({ + defaultStorageClassName, + destinationStorageClass, + migrationError, +}) => { + const { t } = useKubevirtTranslation(); + return ( + + + {t('Review')} + + + Verify the details and click Migrate VirtualMachine storage to start + the migration + + + + + + + + + + + + + + + +
+ Migration type + VirtualMachine storage
+ Destination StorageClass + + {destinationStorageClass}{' '} + {defaultStorageClassName === destinationStorageClass ? '(default)' : ''} +
+ + {migrationError && ( + + {migrationError?.message} + + )} +
+
+ ); +}; + +export default VirtualMachineMigrationReviewTab; diff --git a/src/views/virtualmachines/migrate/utils.ts b/src/views/virtualmachines/migrate/utils.ts new file mode 100644 index 000000000..13bacabdf --- /dev/null +++ b/src/views/virtualmachines/migrate/utils.ts @@ -0,0 +1,173 @@ +import { VirtualMachineModel } from 'src/views/dashboard-extensions/utils'; + +import DataVolumeModel from '@kubevirt-ui/kubevirt-api/console/models/DataVolumeModel'; +import { + V1beta1DataVolume, + V1beta1StorageSpecVolumeModeEnum, +} from '@kubevirt-ui/kubevirt-api/containerized-data-importer/models'; +import { IoK8sApiCoreV1PersistentVolumeClaim } from '@kubevirt-ui/kubevirt-api/kubernetes'; +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { MAX_NAME_LENGTH } from '@kubevirt-utils/components/SSHSecretModal/utils/constants'; +import { getName, getNamespace } from '@kubevirt-utils/resources/shared'; +import { getDataVolumeTemplates, getVolumes } from '@kubevirt-utils/resources/vm'; +import { getRandomChars, isEmpty } from '@kubevirt-utils/utils/utils'; +import { k8sCreate, k8sDelete, k8sPatch, Patch } from '@openshift-console/dynamic-plugin-sdk'; + +const getBlankDataVolume = ( + originName: string, + namespace: string, + storageClassName: string, + storage: string, +): V1beta1DataVolume => { + const randomChars = getRandomChars(); + const namePrefix = `clone-${originName}`.substring(0, MAX_NAME_LENGTH - 6 - randomChars.length); + + return { + apiVersion: `${DataVolumeModel.apiGroup}/${DataVolumeModel.apiVersion}`, + kind: DataVolumeModel.kind, + metadata: { + name: `${namePrefix}-${randomChars}`, + namespace, + }, + spec: { + source: { + blank: {}, + }, + storage: { + accessModes: ['ReadWriteMany'], + resources: { + requests: { + storage, + }, + }, + storageClassName, + volumeMode: V1beta1StorageSpecVolumeModeEnum.Block, + }, + }, + }; +}; + +const createBlankDataVolumes = async ( + originPVCs: IoK8sApiCoreV1PersistentVolumeClaim[], + destinationStorageClass: string, +): Promise> => { + const dataVolumesCreationResults = await Promise.allSettled( + originPVCs + .map((pvc) => { + return getBlankDataVolume( + getName(pvc), + getNamespace(pvc), + destinationStorageClass, + pvc?.spec?.resources?.requests?.storage, + ); + }) + .map((dataVolume) => + k8sCreate({ data: dataVolume, model: DataVolumeModel, ns: getNamespace(dataVolume) }), + ), + ); + + const { fulfilledRequests, rejectedRequests } = dataVolumesCreationResults.reduce( + (acc, result) => { + result.status === 'rejected' + ? acc.rejectedRequests.push(result) + : acc.fulfilledRequests.push(result); + + return acc; + }, + { + fulfilledRequests: [] as PromiseFulfilledResult[], + rejectedRequests: [] as PromiseRejectedResult[], + }, + ); + + if (!isEmpty(rejectedRequests)) { + fulfilledRequests.map((result) => + k8sDelete({ + model: DataVolumeModel, + resource: result.value, + }), + ); + + throw new Error(rejectedRequests?.[0].reason); + } + + const dataVolumesCreated = fulfilledRequests.map((result) => result.value); + + return originPVCs.reduce((createdDataVolumeByPVCName, pvc, index) => { + createdDataVolumeByPVCName.set(getName(pvc), dataVolumesCreated[index]); + return createdDataVolumeByPVCName; + }, new Map()); +}; + +const createPatchData = ( + vm: V1VirtualMachine, + createdDataVolumeByPVCName: Map, +) => + getVolumes(vm).reduce((patchArray, volume, volumeIndex) => { + if (volume?.persistentVolumeClaim?.claimName) { + patchArray.push({ + op: 'replace', + path: `/spec/template/spec/volumes/${volumeIndex}/persistentVolumeClaim/claimName`, + value: getName(createdDataVolumeByPVCName.get(volume?.persistentVolumeClaim?.claimName)), + }); + } + + if (volume?.dataVolume?.name) { + const destinationDataVolumeName = getName( + createdDataVolumeByPVCName.get(volume?.dataVolume?.name), + ); + + const dataVolumeIndex = getDataVolumeTemplates(vm)?.findIndex( + (dataVolumeTemplate) => getName(dataVolumeTemplate) === volume?.dataVolume?.name, + ); + + patchArray.push({ + op: 'replace', + path: `/spec/template/spec/volumes/${volumeIndex}/dataVolume/name`, + value: destinationDataVolumeName, + }); + + patchArray.push({ + op: 'replace', + path: `/spec/dataVolumeTemplates/${dataVolumeIndex}/metadata/name`, + value: destinationDataVolumeName, + }); + } + return patchArray; + }, [] as Patch[]); + +export const migrateVM = async ( + vm: V1VirtualMachine, + originPVCs: IoK8sApiCoreV1PersistentVolumeClaim[], + destinationStorageClass: string, +) => { + const createdDataVolumeByPVCName = await createBlankDataVolumes( + originPVCs, + destinationStorageClass, + ); + + const patchData = createPatchData(vm, createdDataVolumeByPVCName); + + patchData.push({ + op: 'add', + path: '/spec/updateVolumesStrategy', + value: 'migration', + }); + + try { + return k8sPatch({ + data: patchData, + model: VirtualMachineModel, + resource: vm, + }); + } catch (error) { + createdDataVolumeByPVCName.forEach((dataVolume) => + k8sDelete({ + model: DataVolumeModel, + resource: dataVolume, + }), + ); + + throw error; + } +};