diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml index 0555078c..ba5ae29d 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -1892,7 +1892,6 @@ harvester: targetNamespace: Target Namespace migrationMode: Migration Mode coldMigration: Cold Migration - vmsWillShutDown: VMs will shut down virtualMachines: Virtual Machines warningTitle: "Cold Migration Warning" warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders" diff --git a/pkg/harvester/models/forklift.konveyor.io.plan.js b/pkg/harvester/models/forklift.konveyor.io.plan.js index d97cf01a..0f2a14a1 100644 --- a/pkg/harvester/models/forklift.konveyor.io.plan.js +++ b/pkg/harvester/models/forklift.konveyor.io.plan.js @@ -1,6 +1,161 @@ import HarvesterResource from './harvester'; +import { HCI } from '../types'; export default class ForkliftPlan extends HarvesterResource { + get planFailed() { + const conditions = this.status?.conditions || []; + + return conditions.some((c) => c.type === 'Failed' && c.status === 'True'); + } + + get isMigrating() { + const migration = this.status?.migration; + + return !!migration?.started && !migration?.completed; + } + + get planCanceled() { + const history = this.status?.migration?.history || []; + + if (history.length === 0) { + return false; + } + + const latest = history[history.length - 1]; + const conditions = latest.conditions || []; + + return conditions.some((c) => c.type === 'Canceled' && c.status === 'True'); + } + + get stateDisplay() { + if (this.planFailed) { + return 'Error'; + } + + if (this.planCanceled) { + return 'Canceled'; + } + + if (this.isMigrating) { + return 'In Progress'; + } + + return 'Active'; + } + + get stateBackground() { + if (this.planFailed) { + return 'bg-error'; + } + + if (this.planCanceled) { + return 'bg-warning'; + } + + if (this.isMigrating) { + return 'bg-info'; + } + + return 'bg-success'; + } + + get _availableActions() { + const canStop = this.isMigrating && !this.planCanceled; + const canStart = !this.isMigrating || this.planFailed || this.planCanceled; + + const out = super._availableActions; + + if (canStop) { + out.unshift({ + action: 'stopMigration', + enabled: true, + icon: 'icon icon-pause', + label: 'Stop', + }); + } + + if (canStart) { + out.unshift({ + action: 'startMigration', + enabled: true, + icon: 'icon icon-play', + label: 'Start', + }); + } + + return out; + } + + async stopMigration() { + const history = this.status?.migration?.history || []; + + if (history.length === 0) { + return; + } + + const latest = history[history.length - 1]; + const migrationName = latest.migration?.name; + + if (migrationName) { + const selfUrl = this.linkFor('self'); + const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`); + + await this.$dispatch('request', { url, method: 'DELETE' }); + } + } + + async startMigration() { + const namespace = this.metadata.namespace; + const history = this.status?.migration?.history || []; + + // Delete previous migrations before starting a new one + for (const entry of history) { + const name = entry.migration?.name; + + if (name) { + const selfUrl = this.linkFor('self'); + const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`); + + try { + await this.$dispatch('request', { url, method: 'DELETE' }); + } catch (e) { + // Migration may already be gone — ignore 404s + } + } + } + + const migration = await this.$dispatch('create', { + type: HCI.FORKLIFT_MIGRATION, + metadata: { + name: `${ this.metadata.name }-migration-${ Math.random().toString(36).substring(2, 7) }`, + namespace, + ownerReferences: [ + { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Plan', + name: this.metadata.name, + uid: this.metadata.uid, + blockOwnerDeletion: true, + }, + ], + }, + spec: { + plan: { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Plan', + name: this.metadata.name, + namespace, + }, + }, + }); + + await migration.save(); + } + + async deletePlan() { + await this.remove(); + } + /** * Deleting a Plan cascades via ownerReferences set at creation time. * Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider (→ Secret). @@ -9,7 +164,7 @@ export default class ForkliftPlan extends HarvesterResource { remove() { const opt = { ...arguments }; - opt.params = { propagationPolicy: 'Foreground' }; + opt.params = { propagationPolicy: 'Background' }; return this._remove(opt); } diff --git a/pkg/harvester/pages/c/_cluster/forklift/configure-provider.vue b/pkg/harvester/pages/c/_cluster/forklift/configure-provider.vue index 4793f475..3e3e42f8 100644 --- a/pkg/harvester/pages/c/_cluster/forklift/configure-provider.vue +++ b/pkg/harvester/pages/c/_cluster/forklift/configure-provider.vue @@ -41,6 +41,7 @@ const createdProvider = ref(null); const createdSecret = ref(null); const loading = ref(true); const testPassed = ref(false); +const testing = ref(false); const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value); @@ -71,6 +72,7 @@ const cancel = async() => { const testConnection = async(buttonCb) => { testResult.value = null; testError.value = null; + testing.value = true; if (!providerName.value || !url.value || !username.value || !password.value) { testError.value = t('harvester.addons.forklift.configureProvider.testMissingFields'); @@ -191,6 +193,7 @@ const testConnection = async(buttonCb) => { if (connected) { testPassed.value = true; testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess'); + testing.value = false; buttonCb(true); } else { if (createdProvider.value) { @@ -203,6 +206,7 @@ const testConnection = async(buttonCb) => { } testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout'); + testing.value = false; buttonCb(false); } } catch (err) { @@ -220,6 +224,7 @@ const testConnection = async(buttonCb) => { } testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed'); + testing.value = false; buttonCb(false); } }; @@ -399,7 +404,7 @@ init(); {{ t('generic.cancel') }} { plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.type === 'pod' ? 'Pod Network' : (e.destination?.name || '-') }`); plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.storageClass || '-' }`); - plan.networkDisplay = plan.networkEntries.join(', ') || '-'; - plan.storageDisplay = plan.storageEntries.join(', ') || '-'; plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-'; @@ -76,13 +74,31 @@ const rows = computed(() => { currentStep = step.name || `Step ${ idx + 1 }`; } - if (step.error) { - errorMsg = (step.error.reasons || []).join('; ') || 'Error'; + if (step.error && !errorMsg) { + const reasons = (step.error.reasons || []).join('; ') || 'Error'; + + errorMsg = `${ step.name || `Step ${ idx + 1 }` }: ${ reasons }`; + } + + if (step.phase === 'Failed' && !errorMsg) { + errorMsg = `${ step.name || `Step ${ idx + 1 }` }: Failed`; } } }); - overallProgress = Math.round(overallProgress); + // Fallback to VM-level error if no step-level error found + if (!errorMsg && vm.error) { + const reasons = (vm.error.reasons || []).join('; ') || 'Failed'; + + errorMsg = `${ currentStep || vm.error.phase || 'Migration' }: ${ reasons }`; + } + + overallProgress = Math.round(overallProgress * 10) / 10; + + // If no step-level error but the plan itself is failed, surface it + if (!errorMsg && plan.planFailed) { + errorMsg = `${ currentStep || 'Migration' }: Failed`; + } return { vmName: vm.name || vm.id || 'Unknown', @@ -90,10 +106,11 @@ const rows = computed(() => { progress: overallProgress, currentStep, errorMsg, + canceled: plan.planCanceled, }; }); - plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length) : 0; + plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0; return plan; }); @@ -209,16 +226,18 @@ init(); {{ vm.vmName }} id: {{ vm.vmId }} - {{ vm.progress }}% - +
+ + {{ vm.progress }}% +
Finished Successfully @@ -229,6 +248,12 @@ init(); > {{ vm.errorMsg }}
+
+ Canceled +
.plan-name-cell { .plan-name { - font-weight: 600; + font-weight: 500; line-height: 20px; } @@ -266,14 +291,7 @@ init(); .progress-cells { display: flex; flex-direction: column; - gap: 10px; - } - - .vm-progress { - &:not(:last-child) { - padding-bottom: 8px; - border-bottom: 1px solid var(--border); - } + gap: 16px; } .vm-progress-header { @@ -284,8 +302,7 @@ init(); } .vm-name { - font-weight: 600; - font-size: 12px; + font-size: 14px; } .vm-name-block { @@ -299,8 +316,16 @@ init(); } .vm-pct { - font-size: 12px; - font-weight: 600; + font-size: 14px; + min-width: 40px; + text-align: left; + } + + .vm-pct-block { + display: flex; + align-items: center; + gap: 14px; + justify-content: space-between; } .vm-bar { @@ -308,13 +333,12 @@ init(); } .step-label { - font-size: 12px; + font-size: 13px; margin-top: 4px; line-height: 20px; &.text-error { color: var(--error); - font-weight: 600; } }