fix: forklift mapping UX and migration progress fixes (#1049)

* refactor: modify relatedResources using ownerReferences uid

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: wordings

Signed-off-by: Andy Lee <andy.lee@suse.com>

* fix(vm-migration): keep completed VMs green and linkable in failed plans

When a plan fails overall, VMs that already reached 100% were marked with an error: their progress bar turned red while the label still read "Finished Successfully", and the migrated-VM detail link was hidden.

Skip the plan-level failure fallback for VMs at 100% and drop the !planFailed guard from the detail-link condition so successfully migrated VMs stay green and stay navigable.

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Andy Lee 2026-07-23 14:38:49 +08:00 committed by GitHub
parent eadda2a18b
commit 2d747e435a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 76 additions and 82 deletions

View File

@ -1,5 +1,4 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard'; import { RcItemCard } from '@components/RcItemCard';
@ -18,8 +17,10 @@ const props = defineProps({
clearable: { type: Boolean, default: false }, clearable: { type: Boolean, default: false },
}); });
const selectOptions = computed(() => { // Only offer "Remove Map" for entries that already have a target selected;
if (!props.clearable) { // entries without a selection just show the regular options.
const optionsFor = (entry) => {
if (!props.clearable || !entry.target) {
return props.options; return props.options;
} }
@ -34,14 +35,9 @@ const selectOptions = computed(() => {
disabled: true, disabled: true,
kind: 'divider', kind: 'divider',
}, },
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options, ...props.options,
]; ];
}); };
</script> </script>
<template> <template>
@ -85,7 +81,7 @@ const selectOptions = computed(() => {
<div class="mapping-target"> <div class="mapping-target">
<LabeledSelect <LabeledSelect
v-model:value="entry.target" v-model:value="entry.target"
:options="selectOptions" :options="optionsFor(entry)"
:placeholder="placeholder+'...'" :placeholder="placeholder+'...'"
:searchable="true" :searchable="true"
/> />

View File

@ -1893,7 +1893,7 @@ harvester:
providerSelect: Provider providerSelect: Provider
createNew: Create new provider createNew: Create new provider
requirementsTitle: Requirements requirementsTitle: Requirements
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher." requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Harvester Node."
name: Name name: Name
urlLabel: vCenter/ESXi URL urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk" urlPlaceholder: "https://vcenter.example.com/sdk"
@ -1965,7 +1965,7 @@ harvester:
description: Map VMware networks and datastores to Harvester and Longhorn target resources description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue save: Save Mappings and Continue
noTemplate: Start from scratch noTemplate: Start from scratch
removeMap: Remove Map removeMap: Remove mapping
networkMapping: networkMapping:
title: Network Mapping title: Network Mapping
description: Map VMware port groups to Harvester networks description: Map VMware port groups to Harvester networks
@ -1977,7 +1977,7 @@ harvester:
storageMapping: storageMapping:
title: Storage Mapping title: Storage Mapping
description: Map VMware datastores to Harvester storage classes description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Longhorn Storage Volume placeholder: Choose a Harvester Storage Class
template: Use existing storage mapping as template template: Use existing storage mapping as template
reviewMigration: reviewMigration:
title: Review Migration Plan title: Review Migration Plan

View File

@ -179,8 +179,10 @@ const rows = computed(() => {
overallProgress = Math.round(overallProgress * 10) / 10; overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it // If no step-level error but the plan itself is failed, surface it.
if (!errorMsg && plan.planFailed) { // Skip VMs that already reached 100% (they finished successfully before
// the plan failed) so their bar stays green instead of turning red.
if (!errorMsg && plan.planFailed && overallProgress < 100) {
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`; errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
} }
@ -190,7 +192,11 @@ const rows = computed(() => {
const cluster = routeParams.cluster || store.getters['clusterId']; const cluster = routeParams.cluster || store.getters['clusterId'];
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean); const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean);
const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace)); const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planFailed && !plan.planCanceled && !!targetVm && !!product && !!cluster; // A VM that individually reached 100% without error should be navigable
// even when the overall plan failed (other VMs in the plan may have
// failed); `overallProgress >= 100 && !errorMsg && targetVm` already
// guarantees this VM migrated successfully.
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planCanceled && !!targetVm && !!product && !!cluster;
const vmDetailLocation = canNavigateToVm ? { const vmDetailLocation = canNavigateToVm ? {
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`, name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
params: { params: {

View File

@ -68,40 +68,28 @@ export default defineComponent({
return (this.value || []).map((plan) => { return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-'; const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || ''; const namespace = plan?.metadata?.namespace || '';
const networkMap = plan?.spec?.map?.network;
const storageMap = plan?.spec?.map?.storage;
const history = plan?.status?.migration?.history || [];
const migrationsMap = new Map(); const migrations = existing[HCI.FORKLIFT_MIGRATION]
.filter((resource) => this.ownedByPlan(resource, plan))
.map((resource) => ({
name: resource?.metadata?.name,
namespace: resource?.metadata?.namespace || namespace,
}));
history const matchedNetworkMap = existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => this.ownedByPlan(resource, plan));
.map((entry) => ({ const matchedStorageMap = existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => this.ownedByPlan(resource, plan));
name: entry?.migration?.name,
namespace: entry?.migration?.namespace || namespace,
}))
.filter((entry) => !!entry.name)
.forEach((entry) => {
const matched = existing[HCI.FORKLIFT_MIGRATION].find((resource) => resource?.metadata?.name === entry.name && resource?.metadata?.namespace === entry.namespace);
if (matched) {
migrationsMap.set(`${ entry.namespace }/${ entry.name }`, entry);
}
});
const matchedNetworkMap = networkMap?.name && existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMap.name && resource?.metadata?.namespace === (networkMap.namespace || namespace));
const matchedStorageMap = storageMap?.name && existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMap.name && resource?.metadata?.namespace === (storageMap.namespace || namespace));
return { return {
planName, planName,
namespace, namespace,
migrations: [...migrationsMap.values()], migrations,
networkMap: matchedNetworkMap ? { networkMap: matchedNetworkMap ? {
namespace: networkMap.namespace || namespace, namespace: matchedNetworkMap?.metadata?.namespace || namespace,
name: networkMap.name, name: matchedNetworkMap?.metadata?.name,
} : null, } : null,
storageMap: matchedStorageMap ? { storageMap: matchedStorageMap ? {
namespace: storageMap.namespace || namespace, namespace: matchedStorageMap?.metadata?.namespace || namespace,
name: storageMap.name, name: matchedStorageMap?.metadata?.name,
} : null, } : null,
}; };
}); });
@ -125,53 +113,57 @@ export default defineComponent({
methods: { methods: {
resourceNames, resourceNames,
/**
* Determine whether a related resource (migration / network map / storage map)
* is owned by the given plan by inspecting its `metadata.ownerReferences`.
* Matches on `kind: Plan` and the plan name; when both sides expose a uid it
* must match too, to disambiguate same-named plans.
*/
ownedByPlan(resource, plan) {
const planName = plan?.metadata?.name;
const planUid = plan?.metadata?.uid;
const owners = resource?.metadata?.ownerReferences || [];
if (!planName) {
return false;
}
return owners.some((owner) => owner?.kind === 'Plan' &&
owner?.name === planName &&
(!planUid || !owner?.uid || owner.uid === planUid));
},
buildDeleteTargets() { buildDeleteTargets() {
const existing = this.existingRelatedResources;
const targets = new Map(); const targets = new Map();
const addTarget = (type, resource) => {
const name = resource?.metadata?.name;
const ns = resource?.metadata?.namespace;
if (!name) {
return;
}
const key = `${ type }|${ ns }|${ name }`;
targets.set(key, {
type, name, namespace: ns
});
};
for (const plan of this.value || []) { for (const plan of this.value || []) {
const namespace = plan?.metadata?.namespace || ''; existing[HCI.FORKLIFT_NETWORK_MAP]
const networkMap = plan?.spec?.map?.network; .filter((resource) => this.ownedByPlan(resource, plan))
const storageMap = plan?.spec?.map?.storage; .forEach((resource) => addTarget(HCI.FORKLIFT_NETWORK_MAP, resource));
const history = plan?.status?.migration?.history || [];
if (networkMap?.name) { existing[HCI.FORKLIFT_STORAGE_MAP]
const ns = networkMap.namespace || namespace; .filter((resource) => this.ownedByPlan(resource, plan))
const key = `${ HCI.FORKLIFT_NETWORK_MAP }|${ ns }|${ networkMap.name }`; .forEach((resource) => addTarget(HCI.FORKLIFT_STORAGE_MAP, resource));
targets.set(key, { existing[HCI.FORKLIFT_MIGRATION]
type: HCI.FORKLIFT_NETWORK_MAP, .filter((resource) => this.ownedByPlan(resource, plan))
name: networkMap.name, .forEach((resource) => addTarget(HCI.FORKLIFT_MIGRATION, resource));
namespace: ns,
});
}
if (storageMap?.name) {
const ns = storageMap.namespace || namespace;
const key = `${ HCI.FORKLIFT_STORAGE_MAP }|${ ns }|${ storageMap.name }`;
targets.set(key, {
type: HCI.FORKLIFT_STORAGE_MAP,
name: storageMap.name,
namespace: ns,
});
}
for (const entry of history) {
const migrationName = entry?.migration?.name;
if (!migrationName) {
continue;
}
const migrationNs = entry?.migration?.namespace || namespace;
const key = `${ HCI.FORKLIFT_MIGRATION }|${ migrationNs }|${ migrationName }`;
targets.set(key, {
type: HCI.FORKLIFT_MIGRATION,
name: migrationName,
namespace: migrationNs,
});
}
} }
return [...targets.values()]; return [...targets.values()];