feat: add access/ volume mode model

Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Andy Lee 2026-07-31 13:52:19 +08:00
parent 74ef7b3772
commit 7db6c81c5c
No known key found for this signature in database
GPG Key ID: 39DC4436AE3564D5
6 changed files with 377 additions and 30 deletions

View File

@ -11,6 +11,10 @@ import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift'; import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import { isInternalStorageClass } from '../../utils/storage-class'; import { isInternalStorageClass } from '../../utils/storage-class';
import MappingColumn from './MappingColumn.vue'; import MappingColumn from './MappingColumn.vue';
import StorageDefaultsModal from './StorageDefaultsModal.vue';
const DEFAULT_VOLUME_MODE = 'Filesystem';
const DEFAULT_ACCESS_MODES = ['ReadWriteMany'];
const props = defineProps({ const props = defineProps({
providerName: { type: String, default: '' }, providerName: { type: String, default: '' },
@ -35,6 +39,10 @@ const allStorageMaps = ref([]);
const errors = ref([]); const errors = ref([]);
const loading = ref(true); const loading = ref(true);
// Storage defaults edit modal state.
const showStorageDefaultsModal = ref(false);
const editingStorageEntry = ref(null);
const { networkEntries, storageEntries } = toRefs(props.stepData); const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE; const NAMESPACE = FORKLIFT_NAMESPACE;
@ -105,7 +113,7 @@ const applyNetworkMapTargets = (mapSpec) => {
}); });
}; };
const applyStorageMapTargets = (mapSpec) => { const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
if (!mapSpec) { if (!mapSpec) {
return; return;
} }
@ -117,6 +125,26 @@ const applyStorageMapTargets = (mapSpec) => {
if (match?.destination?.storageClass) { if (match?.destination?.storageClass) {
entry.target = 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` (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 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)); 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(() => { const canSave = computed(() => {
if (props.useAllProviderData) { if (props.useAllProviderData) {
return true; return true;
@ -202,13 +252,18 @@ const buildStorageEntries = () => {
if (ds && ds.id) { if (ds && ds.id) {
if (!datastoreMap[ds.id]) { if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = { datastoreMap[ds.id] = {
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'), name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id, id: ds.id,
type: ds.type || '', type: ds.type || '',
capacity: 0, capacity: 0,
target: '', target: '',
usedBy: [], volumeMode: DEFAULT_VOLUME_MODE,
_key: `stor-${ ds.id }`, 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) => { const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({ storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id, name: ds.name || ds.id,
id: ds.id || '', id: ds.id || '',
type: ds.type || '', type: ds.type || '',
capacity: ds.capacity || 0, capacity: ds.capacity || 0,
target: '', target: '',
usedBy: [], volumeMode: DEFAULT_VOLUME_MODE,
_key: `stor-${ ds.id || ds.name }`, 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 (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) { if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map); applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
} else { } else {
applyDefaultStorageMap(); applyDefaultStorageMap();
} }
@ -511,6 +571,9 @@ init();
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')" :placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData" :show-used-by="!useAllProviderData"
:clearable="useAllProviderData" :clearable="useAllProviderData"
:show-volume-settings="true"
:inherited-provider-name="inheritedProviderName"
@edit-defaults="openStorageDefaults"
> >
<template #source-detail="{ entry }"> <template #source-detail="{ entry }">
<span <span
@ -520,6 +583,19 @@ init();
</template> </template>
</MappingColumn> </MappingColumn>
</div> </div>
<StorageDefaultsModal
v-if="showStorageDefaultsModal && editingStorageEntry"
:storage-class-name="editingStorageEntry.target"
:provider-name="providerName"
:show-inherited="!!inheritedProviderName"
:volume-mode="editingStorageEntry.volumeMode"
:access-modes="editingStorageEntry.accessModes"
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
@apply="applyStorageDefaults"
@close="closeStorageDefaults"
/>
</div> </div>
</template> </template>

View File

@ -8,15 +8,21 @@ const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
const props = defineProps({ const props = defineProps({
title: { type: String, required: true }, title: { type: String, required: true },
description: { type: String, default: '' }, description: { type: String, default: '' },
entries: { type: Array, default: () => [] }, entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false }, showUsedBy: { type: Boolean, default: false },
clearable: { 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; // Only offer "Remove Map" for entries that already have a target selected;
// entries without a selection just show the regular options. // entries without a selection just show the regular options.
const optionsFor = (entry) => { const optionsFor = (entry) => {
@ -38,6 +44,13 @@ const optionsFor = (entry) => {
...props.options, ...props.options,
]; ];
}; };
const formatModes = (entry) => {
const volumeMode = entry.volumeMode || 'Filesystem';
const accessModes = (entry.accessModes || []).join(', ');
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
};
</script> </script>
<template> <template>
@ -87,6 +100,29 @@ const optionsFor = (entry) => {
/> />
</div> </div>
</div> </div>
<div
v-if="showVolumeSettings && entry.target"
class="storage-defaults-row"
>
<div class="storage-defaults">
<div class="storage-defaults-info">
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
<span
v-if="inheritedProviderName && !entry.overridden"
class="text-deemphasized storage-defaults-inherited"
>
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
</span>
</div>
<a
role="button"
class="storage-defaults-edit"
@click.prevent="emit('edit-defaults', entry)"
>
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
</a>
</div>
</div>
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length"> <div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by"> <span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b> {{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
@ -173,6 +209,41 @@ const optionsFor = (entry) => {
line-height: 20px; 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 { .bg-light-gray {
background-color: var(--category-active) !important; background-color: var(--category-active) !important;
border: 0; border: 0;

View File

@ -0,0 +1,173 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'vuex';
import { Card } from '@components/Card';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import AppModal from '@shell/components/AppModal';
import { useI18n } from '@shell/composables/useI18n';
const VOLUME_MODE_OPTIONS = ['Filesystem', 'Block'];
const ACCESS_MODE_OPTIONS = ['ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'];
const props = defineProps({
storageClassName: { type: String, default: '' },
providerName: { type: String, default: '' },
showInherited: { type: Boolean, default: false },
volumeMode: { type: String, default: 'Filesystem' },
accessModes: { type: Array, default: () => ['ReadWriteMany'] },
inheritedVolumeMode: { type: String, default: 'Filesystem' },
inheritedAccessModes: { type: Array, default: () => ['ReadWriteMany'] },
});
const emit = defineEmits(['apply', 'close']);
const store = useStore();
const { t } = useI18n(store);
const localVolumeMode = ref(props.volumeMode || 'Filesystem');
const localAccessMode = ref(props.accessModes?.[0] || 'ReadWriteMany');
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
const apply = () => {
emit('apply', {
volumeMode: localVolumeMode.value,
accessModes: [localAccessMode.value],
});
emit('close');
};
// Repopulate the dropdowns with the provider default values; the user then applies.
const reset = () => {
localVolumeMode.value = props.inheritedVolumeMode || 'Filesystem';
localAccessMode.value = props.inheritedAccessModes?.[0] || 'ReadWriteMany';
};
const cancel = () => {
emit('close');
};
</script>
<template>
<app-modal
class="storage-defaults-modal"
name="storageDefaultsDialog"
:width="620"
height="auto"
:click-to-close="false"
@close="cancel"
>
<Card
class="storage-defaults-card"
:show-highlight-border="false"
>
<template #title>
<h4 class="text-default-text">
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
</h4>
</template>
<template #body>
<p class="text-deemphasized description">
<template v-if="showInherited">
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
</template>
<template v-else>
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
</template>
</p>
<div class="settings-box">
<LabeledSelect
v-model:value="localVolumeMode"
class="volume-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
:options="volumeModeOptions"
:searchable="false"
:clearable="false"
/>
<LabeledSelect
v-model:value="localAccessMode"
class="access-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
:options="accessModeOptions"
:searchable="false"
:clearable="false"
/>
</div>
</template>
<template #actions>
<div class="buttons">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
</button>
<div class="right-buttons">
<button
v-if="showInherited"
class="btn role-secondary mr-10"
@click="reset"
>
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
</button>
<button
class="btn role-primary"
:disabled="!localAccessMode"
@click="apply"
>
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
</button>
</div>
</div>
</template>
</Card>
</app-modal>
</template>
<style lang="scss" scoped>
.storage-defaults-modal {
z-index: 45;
}
.storage-defaults-card {
margin: 0;
}
.description {
margin-bottom: 16px;
line-height: 20px;
}
.settings-box {
display: flex;
flex-direction: column;
gap: 20px;
padding: 16px;
border-radius: 6px;
background-color: var(--category-active);
}
.volume-mode {
max-width: 280px;
}
.access-mode {
max-width: 280px;
}
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right-buttons {
display: flex;
}
}
</style>

View File

@ -1982,6 +1982,19 @@ harvester:
description: Map VMware datastores to Harvester storage classes description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Harvester Storage Class placeholder: Choose a Harvester Storage Class
template: Use existing storage mapping as template 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: reviewMigration:
title: Review Migration Plan title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster. description: Confirm your migration settings before starting the transfer of VMs to the target cluster.

View File

@ -5,6 +5,7 @@ import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types'; import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue'; import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue'; import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester'; import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
@ -163,7 +164,7 @@ const onFinish = async(buttonCb) => {
buttonCb(true); buttonCb(true);
currentRouter().push(providerListLocation); currentRouter().push(providerListLocation);
} catch (err) { } 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); buttonCb(false);
} }
}; };
@ -233,7 +234,7 @@ const init = async() => {
// Maps may not exist yet // Maps may not exist yet
} }
} catch (err) { } 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; initialLoading.value = false;

View File

@ -73,8 +73,21 @@ export function buildNetworkMapEntries(entries = [], defaultNamespace) {
export function buildStorageMapEntries(entries = []) { export function buildStorageMapEntries(entries = []) {
return entries return entries
.filter((entry) => !!entry.target) .filter((entry) => !!entry.target)
.map((entry) => ({ .map((entry) => {
source: { name: entry.name, id: entry.id }, const destination = { storageClass: entry.target };
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,
};
});
} }