From d4562a91d11a1b55065def3523d2935266963e7c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:33:26 +0800 Subject: [PATCH] feat: add access/volume mode support and improvements for VM migration (#1074) (#1081) * fix: VM migration tab not appear when first login * feat: add access/ volume mode model * feat: add selected all | clear all actions for selected VM * fix: bugs in VM selection table * refactor: copilot review --------- (cherry picked from commit afe632b723bf6efc6dfdaec983413e25fa481a6c) Signed-off-by: Andy Lee Co-authored-by: Andy Lee --- .../vm-migration/ConfigureMappingsStep.vue | 122 ++++++++++-- .../components/vm-migration/MappingColumn.vue | 86 ++++++++- .../components/vm-migration/SelectVmsStep.vue | 99 +++++++++- .../vm-migration/StorageDefaultsModal.vue | 175 ++++++++++++++++++ pkg/harvester/l10n/en-us.yaml | 14 ++ .../_cluster/vm-migration/provider-wizard.vue | 5 +- pkg/harvester/utils/dynamic-nav.js | 32 ++-- pkg/harvester/utils/forklift.js | 21 ++- 8 files changed, 506 insertions(+), 48 deletions(-) create mode 100644 pkg/harvester/components/vm-migration/StorageDefaultsModal.vue diff --git a/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue b/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue index 0cb5370b..561e4dd0 100644 --- a/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue +++ b/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue @@ -7,10 +7,15 @@ import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types'; import { useI18n } from '@shell/composables/useI18n'; import { randomStr } from '@shell/utils/string'; import { HCI } from '../../types'; +import { VOLUME_MODE, ACCESS_MODE } from '../../config/types'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift'; import { isInternalStorageClass } from '../../utils/storage-class'; import MappingColumn from './MappingColumn.vue'; +import StorageDefaultsModal from './StorageDefaultsModal.vue'; + +const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM; +const DEFAULT_ACCESS_MODES = [ACCESS_MODE.READ_WRITE_MANY]; const props = defineProps({ providerName: { type: String, default: '' }, @@ -35,6 +40,10 @@ const allStorageMaps = ref([]); const errors = ref([]); const loading = ref(true); +// Storage defaults edit modal state. +const showStorageDefaultsModal = ref(false); +const editingStorageEntry = ref(null); + const { networkEntries, storageEntries } = toRefs(props.stepData); const NAMESPACE = FORKLIFT_NAMESPACE; @@ -105,7 +114,7 @@ const applyNetworkMapTargets = (mapSpec) => { }); }; -const applyStorageMapTargets = (mapSpec) => { +const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => { if (!mapSpec) { return; } @@ -117,6 +126,30 @@ const applyStorageMapTargets = (mapSpec) => { if (match?.destination?.storageClass) { entry.target = match.destination.storageClass; + + if (captureInherited) { + entry.inheritedFromProvider = true; + } + + if (match.destination.volumeMode) { + entry.volumeMode = match.destination.volumeMode; + + if (captureInherited) { + entry.inheritedVolumeMode = match.destination.volumeMode; + } + } + + if (match.destination.accessMode) { + entry.accessModes = [match.destination.accessMode]; + + if (captureInherited) { + entry.inheritedAccessModes = [match.destination.accessMode]; + } + } + + if (markOverridden) { + entry.overridden = true; + } } }); }; @@ -134,12 +167,39 @@ const applyDefaultStorageMap = () => { (sm) => sm.metadata.name === `${ props.providerName }-storage-map-default` ); - applyStorageMapTargets(defaultMap?.spec?.map); + applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true }); }; const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target)); const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target)); +// The "Inherited from provider" hint only appears in the migration plan wizard +// (where mappings inherit from the provider default), not on the provider creation page. +const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName)); + +const openStorageDefaults = (entry) => { + editingStorageEntry.value = entry; + showStorageDefaultsModal.value = true; +}; + +const closeStorageDefaults = () => { + showStorageDefaultsModal.value = false; + editingStorageEntry.value = null; +}; + +const applyStorageDefaults = ({ volumeMode, accessModes }) => { + if (editingStorageEntry.value) { + editingStorageEntry.value.volumeMode = volumeMode; + editingStorageEntry.value.accessModes = accessModes; + + const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode; + const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0]; + const selectedAccessMode = accessModes?.[0]; + + editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode; + } +}; + const canSave = computed(() => { if (props.useAllProviderData) { return true; @@ -202,13 +262,19 @@ const buildStorageEntries = () => { if (ds && ds.id) { if (!datastoreMap[ds.id]) { datastoreMap[ds.id] = { - name: ds.name || t('harvester.addons.vmMigration.generic.unknown'), - id: ds.id, - type: ds.type || '', - capacity: 0, - target: '', - usedBy: [], - _key: `stor-${ ds.id }`, + name: ds.name || t('harvester.addons.vmMigration.generic.unknown'), + id: ds.id, + type: ds.type || '', + capacity: 0, + target: '', + volumeMode: DEFAULT_VOLUME_MODE, + accessModes: [...DEFAULT_ACCESS_MODES], + inheritedVolumeMode: DEFAULT_VOLUME_MODE, + inheritedAccessModes: [...DEFAULT_ACCESS_MODES], + inheritedFromProvider: false, + overridden: false, + usedBy: [], + _key: `stor-${ ds.id }`, }; } @@ -238,13 +304,19 @@ const buildNetworkEntriesFromProvider = (networksData) => { const buildStorageEntriesFromProvider = (datastoresData) => { storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({ - name: ds.name || ds.id, - id: ds.id || '', - type: ds.type || '', - capacity: ds.capacity || 0, - target: '', - usedBy: [], - _key: `stor-${ ds.id || ds.name }`, + name: ds.name || ds.id, + id: ds.id || '', + type: ds.type || '', + capacity: ds.capacity || 0, + target: '', + volumeMode: DEFAULT_VOLUME_MODE, + accessModes: [...DEFAULT_ACCESS_MODES], + inheritedVolumeMode: DEFAULT_VOLUME_MODE, + inheritedAccessModes: [...DEFAULT_ACCESS_MODES], + inheritedFromProvider: false, + overridden: false, + usedBy: [], + _key: `stor-${ ds.id || ds.name }`, })); }; @@ -461,7 +533,7 @@ const init = async() => { if (!hasExistingStorageTargets) { if (props.existingStorageMap?.spec?.map) { - applyStorageMapTargets(props.existingStorageMap.spec.map); + applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true }); } else { applyDefaultStorageMap(); } @@ -511,6 +583,9 @@ init(); :placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')" :show-used-by="!useAllProviderData" :clearable="useAllProviderData" + :show-volume-settings="true" + :inherited-provider-name="inheritedProviderName" + @edit-defaults="openStorageDefaults" > diff --git a/pkg/harvester/components/vm-migration/MappingColumn.vue b/pkg/harvester/components/vm-migration/MappingColumn.vue index 3264b757..1e3d7176 100644 --- a/pkg/harvester/components/vm-migration/MappingColumn.vue +++ b/pkg/harvester/components/vm-migration/MappingColumn.vue @@ -3,20 +3,27 @@ import { useStore } from 'vuex'; import LabeledSelect from '@shell/components/form/LabeledSelect'; import { RcItemCard } from '@components/RcItemCard'; import { useI18n } from '@shell/composables/useI18n'; +import { VOLUME_MODE } from '../../config/types'; const store = useStore(); const { t } = useI18n(store); const props = defineProps({ - title: { type: String, required: true }, - description: { type: String, default: '' }, - entries: { type: Array, default: () => [] }, - options: { type: Array, default: () => [] }, - placeholder: { type: String, default: '' }, - showUsedBy: { type: Boolean, default: false }, - clearable: { type: Boolean, default: false }, + title: { type: String, required: true }, + description: { type: String, default: '' }, + entries: { type: Array, default: () => [] }, + options: { type: Array, default: () => [] }, + placeholder: { type: String, default: '' }, + showUsedBy: { type: Boolean, default: false }, + clearable: { type: Boolean, default: false }, + // Storage-specific: show the volume/access mode defaults row with an Edit action. + showVolumeSettings: { type: Boolean, default: false }, + // When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only). + inheritedProviderName: { type: String, default: '' }, }); +const emit = defineEmits(['edit-defaults']); + // Only offer "Remove Map" for entries that already have a target selected; // entries without a selection just show the regular options. const optionsFor = (entry) => { @@ -38,6 +45,13 @@ const optionsFor = (entry) => { ...props.options, ]; }; + +const formatModes = (entry) => { + const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM; + const accessModes = (entry.accessModes || []).join(', '); + + return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes }); +};