diff --git a/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue b/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue index 0cb5370b..75d7ab5c 100644 --- a/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue +++ b/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue @@ -11,6 +11,10 @@ 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 = 'Filesystem'; +const DEFAULT_ACCESS_MODES = ['ReadWriteMany']; const props = defineProps({ providerName: { type: String, default: '' }, @@ -35,6 +39,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 +113,7 @@ const applyNetworkMapTargets = (mapSpec) => { }); }; -const applyStorageMapTargets = (mapSpec) => { +const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => { if (!mapSpec) { return; } @@ -117,6 +125,26 @@ const applyStorageMapTargets = (mapSpec) => { if (match?.destination?.storageClass) { entry.target = match.destination.storageClass; + + 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 +162,34 @@ 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; + editingStorageEntry.value.overridden = true; + } +}; + const canSave = computed(() => { if (props.useAllProviderData) { return true; @@ -202,13 +252,18 @@ 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], + overridden: false, + usedBy: [], + _key: `stor-${ ds.id }`, }; } @@ -238,13 +293,18 @@ 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], + overridden: false, + usedBy: [], + _key: `stor-${ ds.id || ds.name }`, })); }; @@ -461,7 +521,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 +571,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..f234121d 100644 --- a/pkg/harvester/components/vm-migration/MappingColumn.vue +++ b/pkg/harvester/components/vm-migration/MappingColumn.vue @@ -8,15 +8,21 @@ 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 +44,13 @@ const optionsFor = (entry) => { ...props.options, ]; }; + +const formatModes = (entry) => { + const volumeMode = entry.volumeMode || 'Filesystem'; + const accessModes = (entry.accessModes || []).join(', '); + + return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes }); +}; @@ -87,6 +100,29 @@ const optionsFor = (entry) => { /> + + + + {{ formatModes(entry) }} + + {{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }} + + + + {{ t('harvester.addons.vmMigration.storageDefaults.edit') }} + + + {{ t('harvester.addons.vmMigration.generic.usedBy') }} {{ entry.usedBy.join(', ') }} @@ -173,6 +209,41 @@ const optionsFor = (entry) => { line-height: 20px; } + .storage-defaults-row { + width: 100%; + } + + .storage-defaults { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-radius: 4px; + background-color: var(--body-bg); + + .storage-defaults-info { + display: flex; + flex-direction: column; + min-width: 0; + } + + .storage-defaults-summary { + font-size: 13px; + line-height: 20px; + } + + .storage-defaults-inherited { + font-size: 12px; + line-height: 18px; + } + + .storage-defaults-edit { + flex-shrink: 0; + cursor: pointer; + } + } + .bg-light-gray { background-color: var(--category-active) !important; border: 0; diff --git a/pkg/harvester/components/vm-migration/StorageDefaultsModal.vue b/pkg/harvester/components/vm-migration/StorageDefaultsModal.vue new file mode 100644 index 00000000..0777e5d0 --- /dev/null +++ b/pkg/harvester/components/vm-migration/StorageDefaultsModal.vue @@ -0,0 +1,173 @@ + + + + + + + + {{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }} + + + + + + + {{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }} + + + {{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }} + + + + + + + + + + + + + + {{ t('harvester.addons.vmMigration.storageDefaults.cancel') }} + + + + {{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }} + + + {{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }} + + + + + + + + + diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml index 5e2e4417..caeca190 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -1982,6 +1982,19 @@ harvester: description: Map VMware datastores to Harvester storage classes placeholder: Choose a Harvester Storage Class template: Use existing storage mapping as template + storageDefaults: + title: 'Storage defaults — {name}' + description: 'Set the volume mode and access modes applied to every datastore in this storage map that maps to {name}.' + descriptionInherited: 'Applies to every datastore in this storage map that maps to {name}. The provider default for {provider} stays unchanged.' + summary: 'Volume mode : {volumeMode} · Access mode : {accessModes}' + inherited: 'Inherited from provider {provider}' + volumeMode: Volume Mode + accessMode: Access Mode + edit: Edit + cancel: Cancel + apply: Apply + resetToProviderDefault: Reset to Provider Default + applyOverride: Apply Override reviewMigration: title: Review Migration Plan description: Confirm your migration settings before starting the transfer of VMs to the target cluster. diff --git a/pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue b/pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue index 196453c4..2790424e 100644 --- a/pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue +++ b/pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue @@ -5,6 +5,7 @@ import CruResource from '@shell/components/CruResource'; import Loading from '@shell/components/Loading'; import { SECRET } from '@shell/config/types'; import { useI18n } from '@shell/composables/useI18n'; +import { exceptionToErrorsArray, stringify } from '@shell/utils/error'; import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue'; import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue'; import { PRODUCT_NAME } from '@pkg/harvester/config/harvester'; @@ -163,7 +164,7 @@ const onFinish = async(buttonCb) => { buttonCb(true); currentRouter().push(providerListLocation); } catch (err) { - errors.value = [err instanceof Error ? err.message : String(err)]; + errors.value = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e))); buttonCb(false); } }; @@ -233,7 +234,7 @@ const init = async() => { // Maps may not exist yet } } catch (err) { - errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })]; + errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || stringify(err) })]; } initialLoading.value = false; diff --git a/pkg/harvester/utils/forklift.js b/pkg/harvester/utils/forklift.js index 8387c5ec..502d74c1 100644 --- a/pkg/harvester/utils/forklift.js +++ b/pkg/harvester/utils/forklift.js @@ -73,8 +73,21 @@ export function buildNetworkMapEntries(entries = [], defaultNamespace) { export function buildStorageMapEntries(entries = []) { return entries .filter((entry) => !!entry.target) - .map((entry) => ({ - source: { name: entry.name, id: entry.id }, - destination: { storageClass: entry.target }, - })); + .map((entry) => { + const destination = { storageClass: entry.target }; + + if (entry.volumeMode) { + destination.volumeMode = entry.volumeMode; + } + + // Forklift StorageMap destination expects a single `accessMode` value. + if (Array.isArray(entry.accessModes) && entry.accessModes.length) { + destination.accessMode = entry.accessModes[0]; + } + + return { + source: { name: entry.name, id: entry.id }, + destination, + }; + }); }
+ + {{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }} + + + {{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }} + +