From 394918c95ba1022dd3e650848b4fc13674a44ab0 Mon Sep 17 00:00:00 2001 From: Elad Haan Date: Mon, 27 Jul 2026 10:31:16 +0300 Subject: [PATCH] MTV-5812 | Add Resume Conversion UI for failed migrations with copied disks When a migration fails after disk copy completes but before guest conversion succeeds, users can now resume just the conversion step without re-copying disks. - Add "Resume conversion" dropdown action on plans with the ConversionResumable condition - Show "Migrated disks available" badge on failed resumable plans - Display info alert during resume-conversion execution - Create Migration CR with resumeConversion: true on confirm Resolves: MTV-5812 Signed-off-by: Elad Haan --- .../en/plugin__forklift-console-plugin.json | 7 + .../es/plugin__forklift-console-plugin.json | 8 + .../fr/plugin__forklift-console-plugin.json | 8 + .../ja/plugin__forklift-console-plugin.json | 7 + .../ko/plugin__forklift-console-plugin.json | 7 + .../zh/plugin__forklift-console-plugin.json | 7 + .../actions/PlanActionsDropdownItems.tsx | 27 ++- .../PlanResumeConversionModal.tsx | 86 ++++++++++ .../PlanResumeConversionModal.test.tsx | 158 ++++++++++++++++++ .../components/PlanStatus/utils/constants.ts | 1 + .../components/PlanStatus/utils/utils.ts | 11 ++ .../PlanRowFields/PlanStatus/PlanStatus.tsx | 21 ++- .../__tests__/PlanStatusResumeButton.test.tsx | 123 ++++++++++++++ 13 files changed, 466 insertions(+), 5 deletions(-) create mode 100644 src/plans/actions/components/ResumeConversionModal/PlanResumeConversionModal.tsx create mode 100644 src/plans/actions/components/ResumeConversionModal/__tests__/PlanResumeConversionModal.test.tsx create mode 100644 src/plans/list/components/PlanRowFields/PlanStatus/__tests__/PlanStatusResumeButton.test.tsx diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index 0b37ff4592..314e4da2c7 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -418,6 +418,7 @@ "Disabled": "Disabled", "Disabling shared disk migration requires VDDK to be configured on the VMware provider. Without VDDK, the migration will stall. Either enable VDDK on the provider or keep shared disk migration enabled.": "Disabling shared disk migration requires VDDK to be configured on the VMware provider. Without VDDK, the migration will stall. Either enable VDDK on the provider or keep shared disk migration enabled.", "Disk allocation": "Disk allocation", + "Disk copy will be skipped": "Disk copy will be skipped", "Disk counter": "Disk counter", "Disk decryption": "Disk decryption", "Disk decryption passphrases": "Disk decryption passphrases", @@ -1125,6 +1126,7 @@ "Raw copy mode": "Raw copy mode", "Raw Copy Mode (Fallback)": "Raw Copy Mode (Fallback)", "Raw copy mode performs a block-level, byte-for-byte copy of the source disk. It is useful for complex disk layouts or when standard methods fail. Use this with caution as it bypasses filesystem optimizations and should only be used if you fully understand its implications.": "Raw copy mode performs a block-level, byte-for-byte copy of the source disk. It is useful for complex disk layouts or when standard methods fail. Use this with caution as it bypasses filesystem optimizations and should only be used if you fully understand its implications.", + "Re-run conversion using previously copied disks": "Re-run conversion using previously copied disks", "ReadOnlyMany (ROX)": "ReadOnlyMany (ROX)", "ReadWriteMany (RWX)": "ReadWriteMany (RWX)", "ReadWriteOnce (RWO)": "ReadWriteOnce (RWO)", @@ -1153,6 +1155,10 @@ "Resource": "Resource", "Restart": "Restart", "Restore default columns": "Restore default columns", + "Resume": "Resume", + "Resume conversion": "Resume conversion", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._one": "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed.", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._other": "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed.", "Retain source VM power state": "Retain source VM power state", "Reveal values": "Reveal values", "Review all the configured settings for your migration plan.": "Review all the configured settings for your migration plan.", @@ -1463,6 +1469,7 @@ "This secret must be created in your project beforehand, typically using details provided by your storage administrator.": "This secret must be created in your project beforehand, typically using details provided by your storage administrator.", "This significantly speeds up the migration process and frees up network and host resources by avoiding the need to pull data through the source host.": "This significantly speeds up the migration process and frees up network and host resources by avoiding the need to pull data through the source host.", "This VM is already being inspected.": "This VM is already being inspected.", + "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.": "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.", "Tips and tricks": "Tips and tricks", "To create a network mapping:": "To create a network mapping:", "To create a storage mapping:": "To create a storage mapping:", diff --git a/locales/es/plugin__forklift-console-plugin.json b/locales/es/plugin__forklift-console-plugin.json index e3ae464543..4c4eb41043 100644 --- a/locales/es/plugin__forklift-console-plugin.json +++ b/locales/es/plugin__forklift-console-plugin.json @@ -426,6 +426,7 @@ "Disabled": "Desactivado", "Disabling shared disk migration requires VDDK to be configured on the VMware provider. Without VDDK, the migration will stall. Either enable VDDK on the provider or keep shared disk migration enabled.": "Para deshabilitar la migración de discos compartidos, es necesario configurar VDDK en el proveedor de VMware. Sin VDDK, la migración se detendrá. Habilite VDDK en el proveedor o mantenga habilitada la migración de discos compartidos.", "Disk allocation": "Disk allocation", + "Disk copy will be skipped": "", "Disk counter": "Contador de discos", "Disk decryption": "Descifrado de disco", "Disk decryption passphrases": "Frases de paso para descifrado de disco", @@ -1135,6 +1136,7 @@ "Raw copy mode": "Modo de copia sin procesar", "Raw Copy Mode (Fallback)": "Modo de copia sin procesar (alternativo)", "Raw copy mode performs a block-level, byte-for-byte copy of the source disk. It is useful for complex disk layouts or when standard methods fail. Use this with caution as it bypasses filesystem optimizations and should only be used if you fully understand its implications.": "El modo de copia sin procesar realiza una copia a nivel de bloque, byte a byte, del disco de origen. Es útil para diseños de disco complejos o cuando fallan los métodos estándar. Úselo con precaución, ya que omite las optimizaciones del sistema de archivos y solo debe usarse si comprende completamente sus implicaciones.", + "Re-run conversion using previously copied disks": "", "ReadOnlyMany (ROX)": "ReadOnlyMany (ROX)", "ReadWriteMany (RWX)": "ReadWriteMany (RWX)", "ReadWriteOnce (RWO)": "ReadWriteOnce (RWO)", @@ -1163,6 +1165,11 @@ "Resource": "Recurso", "Restart": "Reiniciar", "Restore default columns": "Restaurar columnas predeterminadas", + "Resume": "", + "Resume conversion": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._one": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._many": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._other": "", "Retain source VM power state": "Conservar el estado de energía de la máquina virtual de origen", "Reveal values": "Mostrar valores", "Review all the configured settings for your migration plan.": "Revise todas las configuraciones para su plan de migración.", @@ -1473,6 +1480,7 @@ "This secret must be created in your project beforehand, typically using details provided by your storage administrator.": "Este secreto debe crearse en su proyecto de antemano, generalmente utilizando detalles proporcionados por su administrador de almacenamiento.", "This significantly speeds up the migration process and frees up network and host resources by avoiding the need to pull data through the source host.": "Esto acelera significativamente el proceso de migración y libera recursos de red y de host, ya que alivia la necesidad de extraer datos a través del host de origen.", "This VM is already being inspected.": "This VM is already being inspected.", + "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.": "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.", "Tips and tricks": "Consejos y trucos", "To create a network mapping:": "Para crear una asociación de red:", "To create a storage mapping:": "Para crear una asociación de almacenamiento:", diff --git a/locales/fr/plugin__forklift-console-plugin.json b/locales/fr/plugin__forklift-console-plugin.json index f0e2ea88c0..8c703f12e5 100644 --- a/locales/fr/plugin__forklift-console-plugin.json +++ b/locales/fr/plugin__forklift-console-plugin.json @@ -426,6 +426,7 @@ "Disabled": "Désactivé", "Disabling shared disk migration requires VDDK to be configured on the VMware provider. Without VDDK, the migration will stall. Either enable VDDK on the provider or keep shared disk migration enabled.": "La désactivation de la migration des disques partagés nécessite la configuration de VDDK sur le fournisseur VMware. Sans VDDK, la migration sera interrompue. Activez VDDK sur le fournisseur ou laissez la migration des disques partagés activée.", "Disk allocation": "Disk allocation", + "Disk copy will be skipped": "", "Disk counter": "Compteur de disque", "Disk decryption": "Déchiffrement du disque", "Disk decryption passphrases": "Phrases de passe de décryptage de disque", @@ -1135,6 +1136,7 @@ "Raw copy mode": "Mode de copie brute", "Raw Copy Mode (Fallback)": "Mode de copie brute (selon les besoins)", "Raw copy mode performs a block-level, byte-for-byte copy of the source disk. It is useful for complex disk layouts or when standard methods fail. Use this with caution as it bypasses filesystem optimizations and should only be used if you fully understand its implications.": "Le mode de copie brute effectue une copie bloc par bloc, octet par octet, du disque source. Il est utile pour les configurations de disque complexes ou lorsque les méthodes standard échouent. Utilisez-le avec précaution car il désactive les optimisations du système de fichiers et ne doit être utilisé que si vous en comprenez parfaitement les conséquences.", + "Re-run conversion using previously copied disks": "", "ReadOnlyMany (ROX)": "ReadOnlyMany (ROX)", "ReadWriteMany (RWX)": "ReadWriteMany (RWX)", "ReadWriteOnce (RWO)": "ReadWriteOnce (RWO)", @@ -1163,6 +1165,11 @@ "Resource": "Ressource", "Restart": "Redémarrage", "Restore default columns": "Restaurer les colonnes par défaut", + "Resume": "", + "Resume conversion": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._one": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._many": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._other": "", "Retain source VM power state": "Conserver l'état d'alimentation de la machine virtuelle source", "Reveal values": "Afficher les valeurs", "Review all the configured settings for your migration plan.": "Vérifiez tous les paramètres configurés pour votre plan de migration.", @@ -1473,6 +1480,7 @@ "This secret must be created in your project beforehand, typically using details provided by your storage administrator.": "Ce secret doit être créé au préalable dans votre projet, généralement à l'aide des informations fournies par votre administrateur de stockage.", "This significantly speeds up the migration process and frees up network and host resources by avoiding the need to pull data through the source host.": "Cela accélère considérablement le processus de migration et libère des ressources réseau et hôte en évitant la nécessité de faire transiter les données par l'hôte source.", "This VM is already being inspected.": "This VM is already being inspected.", + "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.": "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.", "Tips and tricks": "Conseils et astuces", "To create a network mapping:": "Pour créer un mappage réseau :", "To create a storage mapping:": "Pour créer un mappage de stockage :", diff --git a/locales/ja/plugin__forklift-console-plugin.json b/locales/ja/plugin__forklift-console-plugin.json index 633046c33c..f6f395a6de 100644 --- a/locales/ja/plugin__forklift-console-plugin.json +++ b/locales/ja/plugin__forklift-console-plugin.json @@ -415,6 +415,7 @@ "Disabled": "無効", "Disabling shared disk migration requires VDDK to be configured on the VMware provider. Without VDDK, the migration will stall. Either enable VDDK on the provider or keep shared disk migration enabled.": "共有ディスクの移行を無効にするには、VMware プロバイダーで VDDK が設定されている必要があります。VDDK がない場合、移行が停止します。プロバイダーで VDDK を有効にするか、共有ディスクの移行を有効のままにしてください。", "Disk allocation": "Disk allocation", + "Disk copy will be skipped": "", "Disk counter": "ディスクカウンター", "Disk decryption": "ディスク復号", "Disk decryption passphrases": "ディスク復号パスフレーズ", @@ -1122,6 +1123,7 @@ "Raw copy mode": "Raw コピーモード", "Raw Copy Mode (Fallback)": "Raw コピーモード (フォールバック)", "Raw copy mode performs a block-level, byte-for-byte copy of the source disk. It is useful for complex disk layouts or when standard methods fail. Use this with caution as it bypasses filesystem optimizations and should only be used if you fully understand its implications.": "Raw コピーモードは、ソースディスクのブロックレベルでバイト単位のコピーを実行します。複雑なディスクレイアウトの場合や、標準的な方法がうまくいかない場合に便利です。このモードはファイルシステムの最適化を省略するため、その影響を十分に理解している場合にのみ慎重に使用してください。", + "Re-run conversion using previously copied disks": "", "ReadOnlyMany (ROX)": "ReadOnlyMany (ROX)", "ReadWriteMany (RWX)": "ReadWriteMany (RWX)", "ReadWriteOnce (RWO)": "ReadWriteOnce (RWO)", @@ -1150,6 +1152,10 @@ "Resource": "リソース", "Restart": "再起動", "Restore default columns": "デフォルト列の復元", + "Resume": "", + "Resume conversion": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._one": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._other": "", "Retain source VM power state": "ソース仮想マシンの電源状態を保持する", "Reveal values": "値を表示する", "Review all the configured settings for your migration plan.": "移行計画に指定したすべての設定を確認してください。", @@ -1460,6 +1466,7 @@ "This secret must be created in your project beforehand, typically using details provided by your storage administrator.": "このシークレットは、通常はストレージ管理者から提供された詳細情報を使用して、事前にプロジェクト内に作成する必要があります。", "This significantly speeds up the migration process and frees up network and host resources by avoiding the need to pull data through the source host.": "これにより、ソースホストを介してデータを取得する必要がなくなるため、移行プロセスが大幅に高速化され、ネットワークとホストのリソースが解放されます。", "This VM is already being inspected.": "This VM is already being inspected.", + "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.": "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.", "Tips and tricks": "ヒントとコツ", "To create a network mapping:": "ネットワークマッピングを作成するには:", "To create a storage mapping:": "ストレージマッピングを作成するには:", diff --git a/locales/ko/plugin__forklift-console-plugin.json b/locales/ko/plugin__forklift-console-plugin.json index 3d7cc28063..cf679c5e87 100644 --- a/locales/ko/plugin__forklift-console-plugin.json +++ b/locales/ko/plugin__forklift-console-plugin.json @@ -415,6 +415,7 @@ "Disabled": "비활성화됨", "Disabling shared disk migration requires VDDK to be configured on the VMware provider. Without VDDK, the migration will stall. Either enable VDDK on the provider or keep shared disk migration enabled.": "공유 디스크 마이그레이션을 비활성화하려면 VMware 공급자에 VDDK를 구성해야 합니다. VDDK가 없으면 마이그레이션이 중단됩니다. 공급자에서 VDDK를 활성화하거나 공유 디스크 마이그레이션을 활성화합니다.", "Disk allocation": "Disk allocation", + "Disk copy will be skipped": "", "Disk counter": "디스크 수", "Disk decryption": "디스크 복호화", "Disk decryption passphrases": "디스크 복호화 암호문", @@ -1122,6 +1123,7 @@ "Raw copy mode": "원시 복사 모드", "Raw Copy Mode (Fallback)": "원본 복사 모드(대체 모드)", "Raw copy mode performs a block-level, byte-for-byte copy of the source disk. It is useful for complex disk layouts or when standard methods fail. Use this with caution as it bypasses filesystem optimizations and should only be used if you fully understand its implications.": "원시 복사 모드는 소스 디스크를 블록 단위로 바이트 단위로 복사합니다. 복잡한 디스크 구조나 표준 복사 방식이 실패할 때 유용합니다. 하지만 파일 시스템 최적화를 무시하므로 사용 시 주의해야 하며, 그 의미를 완전히 이해한 경우에만 사용해야 합니다.", + "Re-run conversion using previously copied disks": "", "ReadOnlyMany (ROX)": "ReadOnlyMany (ROX)", "ReadWriteMany (RWX)": "ReadWriteMany (RWX)", "ReadWriteOnce (RWO)": "ReadWriteOnce (RWO)", @@ -1150,6 +1152,10 @@ "Resource": "리소스", "Restart": "재시작", "Restore default columns": "기본 열 복원", + "Resume": "", + "Resume conversion": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._one": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._other": "", "Retain source VM power state": "소스 VM 전원 상태 유지", "Reveal values": "값 표시", "Review all the configured settings for your migration plan.": "마이그레이션 계획에 대해 구성된 모든 설정을 검토하십시오.", @@ -1460,6 +1466,7 @@ "This secret must be created in your project beforehand, typically using details provided by your storage administrator.": "이 시크릿은 일반적으로 스토리지 관리자가 제공하는 정보를 사용하여 프로젝트에서 미리 생성해야 합니다.", "This significantly speeds up the migration process and frees up network and host resources by avoiding the need to pull data through the source host.": "이로 인해 마이그레이션 프로세스 속도가 크게 향상되고 소스 호스트를 통해 데이터를 가져올 필요가 없어 네트워크 및 호스트 리소스를 확보할 수 있습니다.", "This VM is already being inspected.": "This VM is already being inspected.", + "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.": "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.", "Tips and tricks": "팁 및 요령", "To create a network mapping:": "네트워크 매핑을 생성하려면 다음 단계를 따르세요:", "To create a storage mapping:": "스토리지 매핑을 생성하려면 다음 단계를 따르세요.", diff --git a/locales/zh/plugin__forklift-console-plugin.json b/locales/zh/plugin__forklift-console-plugin.json index de16ae52c1..8a982ea173 100644 --- a/locales/zh/plugin__forklift-console-plugin.json +++ b/locales/zh/plugin__forklift-console-plugin.json @@ -415,6 +415,7 @@ "Disabled": "禁用", "Disabling shared disk migration requires VDDK to be configured on the VMware provider. Without VDDK, the migration will stall. Either enable VDDK on the provider or keep shared disk migration enabled.": "禁用共享磁盘迁移需要在 VMware 供应商上配置 VDDK。如果没有 VDDK,迁移将停止。在供应商上启用 VDDK,或者启用共享磁盘迁移。", "Disk allocation": "Disk allocation", + "Disk copy will be skipped": "", "Disk counter": "磁盘计数器", "Disk decryption": "磁盘解密", "Disk decryption passphrases": "磁盘解密密码短语", @@ -1122,6 +1123,7 @@ "Raw copy mode": "原始复制模式", "Raw Copy Mode (Fallback)": "原始复制模式 (Fallback)", "Raw copy mode performs a block-level, byte-for-byte copy of the source disk. It is useful for complex disk layouts or when standard methods fail. Use this with caution as it bypasses filesystem optimizations and should only be used if you fully understand its implications.": "原始复制(Raw Copy)模式执行源磁盘的块级的、每个字节的副本。这在复杂磁盘布局,或使用标准方法失败时很有用。请谨慎使用这个选项,因为它会绕过文件系统优化,请仅在您完全了解其影响时才应使用。", + "Re-run conversion using previously copied disks": "", "ReadOnlyMany (ROX)": "ReadOnlyMany (ROX)", "ReadWriteMany (RWX)": "ReadWriteMany (RWX)", "ReadWriteOnce (RWO)": "ReadWriteOnce (RWO)", @@ -1150,6 +1152,10 @@ "Resource": "资源", "Restart": "重启", "Restore default columns": "恢复默认列", + "Resume": "", + "Resume conversion": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._one": "", + "Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed._other": "", "Retain source VM power state": "保留源虚拟机电源状态", "Reveal values": "显示值", "Review all the configured settings for your migration plan.": "查看迁移计划的所有配置设置。", @@ -1460,6 +1466,7 @@ "This secret must be created in your project beforehand, typically using details provided by your storage administrator.": "此 secret 必须事先在项目中创建,通常使用您的存储管理员提供的详细信息。", "This significantly speeds up the migration process and frees up network and host resources by avoiding the need to pull data through the source host.": "这将不需要通过源主机拉取数据,因此可以显著地加快迁移、释放网络以及主机资源的速度。", "This VM is already being inspected.": "This VM is already being inspected.", + "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.": "This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.", "Tips and tricks": "提示和技巧", "To create a network mapping:": "创建网络映射:", "To create a storage mapping:": "创建存储映射:", diff --git a/src/plans/actions/PlanActionsDropdownItems.tsx b/src/plans/actions/PlanActionsDropdownItems.tsx index 0f22bba2e2..72e20273aa 100644 --- a/src/plans/actions/PlanActionsDropdownItems.tsx +++ b/src/plans/actions/PlanActionsDropdownItems.tsx @@ -14,6 +14,7 @@ import { isEmpty } from '@utils/helpers'; import { PlanStatuses } from '../details/components/PlanStatus/utils/types'; import { canPlanReStart, + canPlanResumeConversion, canPlanStart, getPlanStatus, isPlanArchived, @@ -25,6 +26,9 @@ import ArchiveModal from './components/ArchiveModal'; import PlanCutoverMigrationModal from './components/CutoverModal/PlanCutoverMigrationModal'; import DuplicateModal from './components/DuplicateModal/DuplicateModal'; import PlanDeleteModal from './components/PlanDeleteModal'; +import PlanResumeConversionModal, { + type PlanResumeConversionModalProps, +} from './components/ResumeConversionModal/PlanResumeConversionModal'; import PlanStartMigrationModal, { type PlanStartMigrationModalProps, } from './components/StartPlanModal/PlanStartMigrationModal'; @@ -51,13 +55,14 @@ const PlanActionsDropdownItems: FC = ({ isDetails const canStart = canPlanStart(plan); const canReStart = canPlanReStart(plan); + const canResume = canPlanResumeConversion(plan); const isWarmAndExecuting = getPlanIsWarm(plan) && isPlanExecuting(plan); const isArchived = isPlanArchived(plan); const buttonStartLabel = canReStart ? t('Restart') : t('Start'); const canScheduleCutover = isWarmAndExecuting && !isArchived && planStatus !== PlanStatuses.Pending; - const [activeMigration] = usePlanMigration(plan); + const [activeMigration, migrationLoaded] = usePlanMigration(plan); const hasCutover = canScheduleCutover && Boolean(activeMigration?.spec?.cutover); const onClickPlanStart = () => { @@ -67,6 +72,10 @@ const PlanActionsDropdownItems: FC = ({ isDetails }); }; + const onClickResumeConversion = () => { + launcher(PlanResumeConversionModal, { plan }); + }; + const onClickPlanCutover = () => { launcher(PlanCutoverMigrationModal, { plan }); }; @@ -120,6 +129,16 @@ const PlanActionsDropdownItems: FC = ({ isDetails + {t('Resume conversion')} + + = ({ isDetails {hasCutover ? t('Edit cutover') : t('Schedule cutover')} = ({ isDetails {t('Duplicate')} {t('Archive')} - + {t('Delete')} diff --git a/src/plans/actions/components/ResumeConversionModal/PlanResumeConversionModal.tsx b/src/plans/actions/components/ResumeConversionModal/PlanResumeConversionModal.tsx new file mode 100644 index 0000000000..ab68684d37 --- /dev/null +++ b/src/plans/actions/components/ResumeConversionModal/PlanResumeConversionModal.tsx @@ -0,0 +1,86 @@ +import { useCallback } from 'react'; +import { useForkliftTranslation } from 'src/utils/i18n'; + +import ModalForm from '@components/ModalForm/ModalForm'; +import { MigrationModel, type V1beta1Plan } from '@forklift-ui/types'; +import { k8sCreate } from '@openshift-console/dynamic-plugin-sdk'; +import type { ModalComponent } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider'; +import { + Alert, + AlertVariant, + Content, + ContentVariants, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { getPlanVirtualMachinesMigrationStatus } from '@utils/crds/plans/selectors'; +import { getObjectRef } from '@utils/helpers/getObjectRef'; + +export type PlanResumeConversionModalProps = { + plan: V1beta1Plan; +}; + +const PlanResumeConversionModal: ModalComponent = ({ + plan, + ...rest +}) => { + const { t } = useForkliftTranslation(); + const { name, namespace, uid } = getObjectRef(plan); + + // TODO: Remove cast once `disksCopied` is added to @forklift-ui/types (upstream CRD type gap). + const resumableVMs = getPlanVirtualMachinesMigrationStatus(plan).filter( + (vm) => (vm as { disksCopied?: boolean }).disksCopied === true, + ); + + const onConfirm = useCallback(async () => { + const migration = { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Migration', + metadata: { + generateName: `${name}-resume-`, + namespace, + ownerReferences: [{ apiVersion: plan.apiVersion, kind: plan.kind, name: name!, uid: uid! }], + }, + spec: { + plan: { name, namespace, uid }, + // TODO: Remove cast once `resumeConversion` is added to @forklift-ui/types. + resumeConversion: true, + }, + }; + await k8sCreate({ data: migration, model: MigrationModel }); + }, [plan, name, namespace, uid]); + + return ( + + + + {t( + 'Resume conversion for plan {{name}}? {{count}} VM with copied disks will be processed.', + { count: resumableVMs.length, name }, + )} + + + + + {t( + 'This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.', + )} + + + + + + ); +}; + +export default PlanResumeConversionModal; diff --git a/src/plans/actions/components/ResumeConversionModal/__tests__/PlanResumeConversionModal.test.tsx b/src/plans/actions/components/ResumeConversionModal/__tests__/PlanResumeConversionModal.test.tsx new file mode 100644 index 0000000000..1751fff967 --- /dev/null +++ b/src/plans/actions/components/ResumeConversionModal/__tests__/PlanResumeConversionModal.test.tsx @@ -0,0 +1,158 @@ +import type { K8sResourceCommon, V1beta1Plan } from '@forklift-ui/types'; +import { mockI18n } from '@test-utils/mockI18n'; + +mockI18n(); + +const mockK8sCreate = jest.fn().mockResolvedValue(undefined); +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + k8sCreate: jest.fn((args: { data: K8sResourceCommon }) => mockK8sCreate(args)), +})); + +jest.mock('@utils/helpers/getObjectRef', () => ({ + getObjectRef: jest.fn( + (obj: { apiVersion: string; kind: string; metadata?: Record }) => ({ + apiVersion: obj?.apiVersion, + kind: obj?.kind, + name: obj?.metadata?.name, + namespace: obj?.metadata?.namespace, + uid: obj?.metadata?.uid, + }), + ), +})); + +// eslint-disable-next-line import/first +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import PlanResumeConversionModal from '../PlanResumeConversionModal'; + +const makePlan = (disksCopiedVMs: string[] = ['vm-1']): V1beta1Plan => + ({ + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Plan', + metadata: { name: 'test-plan', namespace: 'test-ns', uid: 'plan-uid-123' }, + status: { + migration: { + vms: disksCopiedVMs.map((name) => ({ + name, + disksCopied: true, + })), + }, + }, + }) as unknown as V1beta1Plan; + +const closeModal = jest.fn(); + +describe('PlanResumeConversionModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the modal with the correct title and confirm label', () => { + render(); + + expect(screen.getByText('Resume conversion')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /resume/i })).toBeInTheDocument(); + }); + + it('shows the info alert and safety warning', () => { + render(); + + expect(screen.getByText('Disk copy will be skipped')).toBeInTheDocument(); + expect( + screen.getByText( + 'This will re-run only the guest conversion step, reusing the disks that were already copied. The source VM must not have been powered on since the original migration.', + ), + ).toBeInTheDocument(); + }); + + it('displays the interpolated VM count in the confirmation message', () => { + render(); + + expect( + screen.getByText( + 'Resume conversion for plan test-plan? 2 VM with copied disks will be processed.', + ), + ).toBeInTheDocument(); + }); + + it('displays the correct count for a single VM', () => { + render(); + + expect( + screen.getByText( + 'Resume conversion for plan test-plan? 1 VM with copied disks will be processed.', + ), + ).toBeInTheDocument(); + }); + + it('creates a Migration with resumeConversion: true on confirm', async () => { + const user = userEvent.setup(); + render(); + + const confirmButton = screen.getByRole('button', { name: /resume/i }); + await user.click(confirmButton); + + expect(mockK8sCreate).toHaveBeenCalledTimes(1); + const [[{ data }]] = mockK8sCreate.mock.calls; + + expect(data.spec.resumeConversion).toBe(true); + expect(data.spec.plan).toEqual({ + name: 'test-plan', + namespace: 'test-ns', + uid: 'plan-uid-123', + }); + }); + + it('sets the correct ownerReference on the Migration', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /resume/i })); + + const [[{ data }]] = mockK8sCreate.mock.calls; + const [ownerRef] = data.metadata.ownerReferences; + + expect(ownerRef.apiVersion).toBe('forklift.konveyor.io/v1beta1'); + expect(ownerRef.kind).toBe('Plan'); + expect(ownerRef.name).toBe('test-plan'); + expect(ownerRef.uid).toBe('plan-uid-123'); + }); + + it('sets generateName with plan name prefix', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /resume/i })); + + const [[{ data }]] = mockK8sCreate.mock.calls; + expect(data.metadata.generateName).toBe('test-plan-resume-'); + expect(data.metadata.namespace).toBe('test-ns'); + }); + + it('filters only VMs with disksCopied === true', () => { + const plan = { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Plan', + metadata: { name: 'mixed-plan', namespace: 'test-ns', uid: 'plan-uid-123' }, + status: { + migration: { + vms: [ + { name: 'vm-copied', disksCopied: true }, + { name: 'vm-not-copied', disksCopied: false }, + { name: 'vm-no-field' }, + ], + }, + }, + } as unknown as V1beta1Plan; + + render(); + + expect( + screen.getByText( + 'Resume conversion for plan mixed-plan? 1 VM with copied disks will be processed.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/src/plans/details/components/PlanStatus/utils/constants.ts b/src/plans/details/components/PlanStatus/utils/constants.ts index bbe1e66810..838231f6a4 100644 --- a/src/plans/details/components/PlanStatus/utils/constants.ts +++ b/src/plans/details/components/PlanStatus/utils/constants.ts @@ -2,3 +2,4 @@ export const STATUS_POPOVER_VMS_COUNT_THRESHOLD = 5; export const PLAN_CONDITION_VALIDATING_VDDK = 'ValidatingVDDK'; export const PLAN_CONDITION_VDDK_INIT_IMAGE_NOT_READY = 'VDDKInitImageNotReady'; +export const PLAN_CONDITION_CONVERSION_RESUMABLE = 'ConversionResumable'; diff --git a/src/plans/details/components/PlanStatus/utils/utils.ts b/src/plans/details/components/PlanStatus/utils/utils.ts index 15088b59c0..09d34d6636 100644 --- a/src/plans/details/components/PlanStatus/utils/utils.ts +++ b/src/plans/details/components/PlanStatus/utils/utils.ts @@ -13,6 +13,7 @@ import { t } from '@utils/i18n'; import type { TargetPowerStateValue } from '@utils/plans/constants'; import { + PLAN_CONDITION_CONVERSION_RESUMABLE, PLAN_CONDITION_VALIDATING_VDDK, PLAN_CONDITION_VDDK_INIT_IMAGE_NOT_READY, STATUS_POPOVER_VMS_COUNT_THRESHOLD, @@ -239,6 +240,16 @@ export const canPlanReStart = (plan: V1beta1Plan) => { ); }; +export const canPlanResumeConversion = (plan: V1beta1Plan): boolean => { + return ( + plan?.status?.conditions?.some( + (condition) => + condition.type === PLAN_CONDITION_CONVERSION_RESUMABLE && + condition.status === CONDITION_STATUS.TRUE, + ) ?? false + ); +}; + export const isPlanSucceeded = (plan: V1beta1Plan) => { const conditions = getConditions(plan); diff --git a/src/plans/list/components/PlanRowFields/PlanStatus/PlanStatus.tsx b/src/plans/list/components/PlanRowFields/PlanStatus/PlanStatus.tsx index f30b7f23c6..d9d11c6451 100644 --- a/src/plans/list/components/PlanRowFields/PlanStatus/PlanStatus.tsx +++ b/src/plans/list/components/PlanRowFields/PlanStatus/PlanStatus.tsx @@ -1,10 +1,14 @@ import type { FC } from 'react'; +import PlanResumeConversionModal, { + type PlanResumeConversionModalProps, +} from 'src/plans/actions/components/ResumeConversionModal/PlanResumeConversionModal'; import PlanStartMigrationModal, { type PlanStartMigrationModalProps, } from 'src/plans/actions/components/StartPlanModal/PlanStartMigrationModal'; import PlanStatusLabel from 'src/plans/details/components/PlanStatus/PlanStatusLabel'; import { PlanStatuses } from 'src/plans/details/components/PlanStatus/utils/types'; import { + canPlanResumeConversion, getCantStartVMStatusCount, getMigrationVMsStatusCounts, getPlanStatus, @@ -17,7 +21,7 @@ import { useForkliftTranslation } from 'src/utils/i18n'; import { useModal } from '@openshift-console/dynamic-plugin-sdk'; import { Button, ButtonVariant, Flex, FlexItem, Spinner, Split } from '@patternfly/react-core'; -import { PlayIcon as StartIcon } from '@patternfly/react-icons'; +import { PlayIcon as StartIcon, RedoIcon } from '@patternfly/react-icons'; import { getPlanVirtualMachines, getPlanVirtualMachinesMigrationStatus, @@ -93,6 +97,21 @@ const PlanStatus: FC = ({ plan }) => { )} + {canPlanResumeConversion(plan) && loaded && !hasActiveMigration && ( + + + + )} diff --git a/src/plans/list/components/PlanRowFields/PlanStatus/__tests__/PlanStatusResumeButton.test.tsx b/src/plans/list/components/PlanRowFields/PlanStatus/__tests__/PlanStatusResumeButton.test.tsx new file mode 100644 index 0000000000..83dd5d9918 --- /dev/null +++ b/src/plans/list/components/PlanRowFields/PlanStatus/__tests__/PlanStatusResumeButton.test.tsx @@ -0,0 +1,123 @@ +import type { V1beta1Plan } from '@forklift-ui/types'; +import { mockI18n } from '@test-utils/mockI18n'; + +mockI18n(); + +const mockLauncher = jest.fn(); +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + useModal: () => mockLauncher, +})); + +const mockUsePlanMigration = jest.fn(); +jest.mock('src/plans/hooks/usePlanMigration', () => ({ + usePlanMigration: (...args: unknown[]) => mockUsePlanMigration(...args), +})); + +const mockCanPlanResumeConversion = jest.fn(); +const mockGetPlanStatus = jest.fn(); +jest.mock('src/plans/details/components/PlanStatus/utils/utils', () => ({ + canPlanResumeConversion: (...args: unknown[]) => mockCanPlanResumeConversion(...args), + getCantStartVMStatusCount: jest.fn(() => ({})), + getMigrationVMsStatusCounts: jest.fn(() => ({})), + getPlanStatus: (...args: unknown[]) => mockGetPlanStatus(...args), + isPlanArchived: jest.fn(() => false), + isPlanExecuting: jest.fn(() => false), +})); + +jest.mock('src/plans/details/components/PlanStatus/PlanStatusLabel', () => ({ + __esModule: true, + default: () => StatusLabel, +})); + +jest.mock('src/plans/details/components/PlanStatus/VMStatusIconsRow', () => ({ + __esModule: true, + default: () => VMIcons, +})); + +jest.mock('../hooks/usePipelineTaskProgress', () => ({ + __esModule: true, + default: () => 0, +})); + +jest.mock('@utils/crds/plans/selectors', () => ({ + getPlanVirtualMachines: jest.fn(() => []), + getPlanVirtualMachinesMigrationStatus: jest.fn(() => []), +})); + +jest.mock('@utils/helpers', () => ({ + isEmpty: jest.fn((val: unknown) => !val), +})); + +// eslint-disable-next-line import/first +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import PlanStatus from '../PlanStatus'; + +const makePlan = (): V1beta1Plan => + ({ + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Plan', + metadata: { name: 'test-plan', namespace: 'test-ns', uid: 'uid-123' }, + status: { + conditions: [{ type: 'ConversionResumable', status: 'True' }], + migration: { vms: [{ name: 'vm-1', disksCopied: true }] }, + }, + }) as unknown as V1beta1Plan; + +describe('PlanStatus Resume button', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetPlanStatus.mockReturnValue('Failed'); + mockUsePlanMigration.mockReturnValue([undefined, true]); + }); + + it('shows Resume button when plan is resumable, loaded, and no active migration', () => { + mockCanPlanResumeConversion.mockReturnValue(true); + + render(); + + expect(screen.getByTestId('plan-resume-button-status')).toBeInTheDocument(); + expect(screen.getByText('Resume')).toBeInTheDocument(); + }); + + it('hides Resume button when plan is not resumable', () => { + mockCanPlanResumeConversion.mockReturnValue(false); + + render(); + + expect(screen.queryByTestId('plan-resume-button-status')).not.toBeInTheDocument(); + }); + + it('hides Resume button when migration data is not loaded', () => { + mockCanPlanResumeConversion.mockReturnValue(true); + mockUsePlanMigration.mockReturnValue([undefined, false]); + + render(); + + expect(screen.queryByTestId('plan-resume-button-status')).not.toBeInTheDocument(); + }); + + it('hides Resume button when there is an active migration', () => { + mockCanPlanResumeConversion.mockReturnValue(true); + mockUsePlanMigration.mockReturnValue([{ metadata: { name: 'mig-1' } }, true]); + + render(); + + expect(screen.queryByTestId('plan-resume-button-status')).not.toBeInTheDocument(); + }); + + it('launches PlanResumeConversionModal on click', async () => { + mockCanPlanResumeConversion.mockReturnValue(true); + const plan = makePlan(); + + render(); + + const user = userEvent.setup(); + await user.click(screen.getByTestId('plan-resume-button-status')); + + expect(mockLauncher).toHaveBeenCalledTimes(1); + expect(mockLauncher).toHaveBeenCalledWith(expect.any(Function), { plan }); + }); +});