mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
feat: add access/ volume mode model
Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
parent
74ef7b3772
commit
7db6c81c5c
@ -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"
|
||||
>
|
||||
<template #source-detail="{ entry }">
|
||||
<span
|
||||
@ -520,6 +583,19 @@ init();
|
||||
</template>
|
||||
</MappingColumn>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
||||
@ -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 });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -87,6 +100,29 @@ const optionsFor = (entry) => {
|
||||
/>
|
||||
</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">
|
||||
<span class="used-by">
|
||||
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
|
||||
@ -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;
|
||||
|
||||
173
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal file
173
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal 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>
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user