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 }); +}; @@ -87,6 +101,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 +210,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/SelectVmsStep.vue b/pkg/harvester/components/vm-migration/SelectVmsStep.vue index 6ec93f03..33be7e1d 100644 --- a/pkg/harvester/components/vm-migration/SelectVmsStep.vue +++ b/pkg/harvester/components/vm-migration/SelectVmsStep.vue @@ -25,8 +25,9 @@ const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData); const selectedVMs = ref([]); const loading = ref(true); -const networkMap = ref({}); -const datastoreMap = ref({}); +// Restored from stepData so friendly names survive step navigation / remount. +const networkMap = ref(props.stepData.networkMap || {}); +const datastoreMap = ref(props.stepData.datastoreMap || {}); const sortableTableRef = ref(null); const allVMsSelected = ref(false); const errors = ref([]); @@ -94,7 +95,7 @@ const showSelectAllBanner = computed(() => { const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead')); -const headers = [ +const headers = ref([ { name: 'vmName', labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName', @@ -139,7 +140,7 @@ const headers = [ subLabel: t('harvester.addons.vmMigration.generic.identifier'), width: 200, }, -]; +]); const buildTableRows = () => { return discoveredVMs.value.map((vm) => { @@ -190,6 +191,7 @@ const buildTableRows = () => { return { _original: vm, _key: vm.id || vm.vmId || vm.metadata?.name, + selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1, vmName: vm.name || vm.metadata?.name || '-', vmId: vm.id || vm.vmId || vm.metadata?.name || '-', os: vm.guestName || vm.guestOS || vm.os || '-', @@ -239,6 +241,10 @@ const clearSelection = () => { if (table) { table.clearSelection(); + + if (typeof table.setPage === 'function') { + table.setPage(1); + } } }); }; @@ -249,6 +255,49 @@ const selectAllVMs = () => { selectedVMs.value = discoveredVMs.value.slice(); }; +// Moves currently-selected VMs to the first page(s) and re-checks them. +const sortSelectedToFront = () => { + if (selectedVMIds.value.size === 0) { + return; + } + + tableRows.value.forEach((row) => { + row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1; + }); + + headers.value = headers.value.map((h) => ( + h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h + )); + + skipNextSelectionEvent = true; + + nextTick(() => { + const table = sortableTableRef.value; + + if (!table) { + skipNextSelectionEvent = false; + + return; + } + + if (typeof table.changeSort === 'function') { + table.changeSort('vmName', false); + } else if (typeof table.setPage === 'function') { + table.setPage(1); + } + + nextTick(() => { + const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id)); + + if (rowsToReselect.length > 0) { + table.update(rowsToReselect, []); + } + + skipNextSelectionEvent = false; + }); + }); +}; + watch( () => sortableTableRef.value?.page, () => { @@ -318,6 +367,9 @@ const fetchVMs = async() => { return map; }, {}); + props.stepData.networkMap = networkMap.value; + props.stepData.datastoreMap = datastoreMap.value; + lastFetchedAt.value = Date.now(); tableRows.value = buildTableRows(); }; @@ -374,6 +426,10 @@ const init = async() => { tableRows.value = buildTableRows(); loading.value = false; + // Returning to the step with an existing selection: bring selected VMs forward and re-check them. + await nextTick(); + sortSelectedToFront(); + return; } @@ -441,7 +497,6 @@ init(); :row-actions="false" :groupable="false" :paging="true" - :rows-per-page="20" key-field="_key" @selection="onSelect" > @@ -450,7 +505,20 @@ init(); {{ t('harvester.addons.vmMigration.selectVms.availableVms') }} - {{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }} + + + {{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }} + + + | + + {{ t('harvester.addons.vmMigration.selectVms.clearAll') }} + + + @@ -466,7 +534,7 @@ init(); @@ -533,6 +601,23 @@ init(); .text-deemphasized { font-size: 13px; } + + .selected-actions { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + + a { + cursor: pointer; + + &.disabled { + color: var(--muted); + cursor: default; + pointer-events: none; + } + } + } } .vm-name-cell { diff --git a/pkg/harvester/components/vm-migration/StorageDefaultsModal.vue b/pkg/harvester/components/vm-migration/StorageDefaultsModal.vue new file mode 100644 index 00000000..3226834c --- /dev/null +++ b/pkg/harvester/components/vm-migration/StorageDefaultsModal.vue @@ -0,0 +1,175 @@ + + + + + + + + {{ 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..f4cdd073 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -1922,6 +1922,7 @@ harvester: refreshNow: Refresh now availableVms: Available Virtual Machines selected: selected + clearAll: Clear All saveSelection: Save Selection and Continue selectAllBanner: pageOnly: "Your current selection includes only VMs currently shown on the page." @@ -1982,6 +1983,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/dynamic-nav.js b/pkg/harvester/utils/dynamic-nav.js index 5c23968c..4909e3c9 100644 --- a/pkg/harvester/utils/dynamic-nav.js +++ b/pkg/harvester/utils/dynamic-nav.js @@ -24,13 +24,14 @@ export function registerAddonSideNav(store, productName, { const kickSideNav = () => { const TRIGGER = 'ui.refresh.trigger'; - store.dispatch('type-map/addFavorite', TRIGGER); - - // SideNav component seem to ignore rapid state changes. - // Wait 600ms to ensure the toggle event triggers a re-render. - setTimeout(() => { - store.dispatch('type-map/removeFavorite', TRIGGER); - }, 600); + // Toggle the trigger a few times so an early kick (fired before the SideNav + // has mounted on first login) is retried once the component is listening. + [0, 600, 1500].forEach((delay) => { + setTimeout(() => { + store.dispatch('type-map/addFavorite', TRIGGER); + setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300); + }, delay); + }); }; const hasAccessibleSchema = (t) => { @@ -92,16 +93,25 @@ export function registerAddonSideNav(store, productName, { // Store is ready. Stop polling. clearInterval(waitForStore); - // Watch the specific addon resource for changes to its enabled status. + // Watch the addon's enabled status together with the schema availability + // of the gated types. Schemas (e.g. forklift CRDs) can load after the + // addon is already enabled, so the watcher must also re-run when they + // become accessible; otherwise the menu never updates until a refresh. store.watch( (state, getters) => { const addons = getters[`${ productName }/all`](resourceType); const addon = addons.find((a) => a.metadata.name === addonName); + const isEnabled = addon?.spec?.enabled === true; - return addon?.spec?.enabled === true; + const schemaReady = requireSchema ? types.every(hasAccessibleSchema) : true; + + return `${ isEnabled }:${ schemaReady }`; }, - (isEnabled) => { - setMenuVisibility(isEnabled); + () => { + const addons = store.getters[`${ productName }/all`](resourceType); + const addon = addons.find((a) => a.metadata.name === addonName); + + setMenuVisibility(addon?.spec?.enabled === true); }, { immediate: true, deep: true } ); 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 }) }} + +