mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
feat: add access/volume mode support and improvements for VM migration (#1074)
* fix: VM migration tab not appear when first login Signed-off-by: Andy Lee <andy.lee@suse.com> * feat: add access/ volume mode model Signed-off-by: Andy Lee <andy.lee@suse.com> * feat: add selected all | clear all actions for selected VM Signed-off-by: Andy Lee <andy.lee@suse.com> * fix: bugs in VM selection table Signed-off-by: Andy Lee <andy.lee@suse.com> * refactor: copilot review Signed-off-by: Andy Lee <andy.lee@suse.com> --------- Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
parent
d050f21f9a
commit
afe632b723
@ -7,10 +7,15 @@ import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
|||||||
import { useI18n } from '@shell/composables/useI18n';
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
import { randomStr } from '@shell/utils/string';
|
import { randomStr } from '@shell/utils/string';
|
||||||
import { HCI } from '../../types';
|
import { HCI } from '../../types';
|
||||||
|
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
|
||||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
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 = VOLUME_MODE.FILE_SYSTEM;
|
||||||
|
const DEFAULT_ACCESS_MODES = [ACCESS_MODE.READ_WRITE_MANY];
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
providerName: { type: String, default: '' },
|
providerName: { type: String, default: '' },
|
||||||
@ -35,6 +40,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 +114,7 @@ const applyNetworkMapTargets = (mapSpec) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyStorageMapTargets = (mapSpec) => {
|
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
|
||||||
if (!mapSpec) {
|
if (!mapSpec) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -117,6 +126,30 @@ const applyStorageMapTargets = (mapSpec) => {
|
|||||||
|
|
||||||
if (match?.destination?.storageClass) {
|
if (match?.destination?.storageClass) {
|
||||||
entry.target = 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`
|
(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;
|
||||||
|
|
||||||
|
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(() => {
|
const canSave = computed(() => {
|
||||||
if (props.useAllProviderData) {
|
if (props.useAllProviderData) {
|
||||||
return true;
|
return true;
|
||||||
@ -207,6 +267,12 @@ const buildStorageEntries = () => {
|
|||||||
type: ds.type || '',
|
type: ds.type || '',
|
||||||
capacity: 0,
|
capacity: 0,
|
||||||
target: '',
|
target: '',
|
||||||
|
volumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
accessModes: [...DEFAULT_ACCESS_MODES],
|
||||||
|
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
|
||||||
|
inheritedFromProvider: false,
|
||||||
|
overridden: false,
|
||||||
usedBy: [],
|
usedBy: [],
|
||||||
_key: `stor-${ ds.id }`,
|
_key: `stor-${ ds.id }`,
|
||||||
};
|
};
|
||||||
@ -243,6 +309,12 @@ const buildStorageEntriesFromProvider = (datastoresData) => {
|
|||||||
type: ds.type || '',
|
type: ds.type || '',
|
||||||
capacity: ds.capacity || 0,
|
capacity: ds.capacity || 0,
|
||||||
target: '',
|
target: '',
|
||||||
|
volumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
accessModes: [...DEFAULT_ACCESS_MODES],
|
||||||
|
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
|
||||||
|
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
|
||||||
|
inheritedFromProvider: false,
|
||||||
|
overridden: false,
|
||||||
usedBy: [],
|
usedBy: [],
|
||||||
_key: `stor-${ ds.id || ds.name }`,
|
_key: `stor-${ ds.id || ds.name }`,
|
||||||
}));
|
}));
|
||||||
@ -461,7 +533,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 +583,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 +595,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>
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useStore } from 'vuex';
|
|||||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
import { RcItemCard } from '@components/RcItemCard';
|
import { RcItemCard } from '@components/RcItemCard';
|
||||||
import { useI18n } from '@shell/composables/useI18n';
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { VOLUME_MODE } from '../../config/types';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n(store);
|
const { t } = useI18n(store);
|
||||||
@ -15,8 +16,14 @@ const props = defineProps({
|
|||||||
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 +45,13 @@ const optionsFor = (entry) => {
|
|||||||
...props.options,
|
...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 });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -87,6 +101,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.inheritedFromProvider && !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 +210,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;
|
||||||
|
|||||||
@ -25,8 +25,9 @@ const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
|
|||||||
|
|
||||||
const selectedVMs = ref([]);
|
const selectedVMs = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const networkMap = ref({});
|
// Restored from stepData so friendly names survive step navigation / remount.
|
||||||
const datastoreMap = ref({});
|
const networkMap = ref(props.stepData.networkMap || {});
|
||||||
|
const datastoreMap = ref(props.stepData.datastoreMap || {});
|
||||||
const sortableTableRef = ref(null);
|
const sortableTableRef = ref(null);
|
||||||
const allVMsSelected = ref(false);
|
const allVMsSelected = ref(false);
|
||||||
const errors = ref([]);
|
const errors = ref([]);
|
||||||
@ -94,7 +95,7 @@ const showSelectAllBanner = computed(() => {
|
|||||||
|
|
||||||
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
|
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
|
||||||
|
|
||||||
const headers = [
|
const headers = ref([
|
||||||
{
|
{
|
||||||
name: 'vmName',
|
name: 'vmName',
|
||||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
||||||
@ -139,7 +140,7 @@ const headers = [
|
|||||||
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
width: 200,
|
width: 200,
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
const buildTableRows = () => {
|
const buildTableRows = () => {
|
||||||
return discoveredVMs.value.map((vm) => {
|
return discoveredVMs.value.map((vm) => {
|
||||||
@ -190,6 +191,7 @@ const buildTableRows = () => {
|
|||||||
return {
|
return {
|
||||||
_original: vm,
|
_original: vm,
|
||||||
_key: vm.id || vm.vmId || vm.metadata?.name,
|
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||||
|
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
|
||||||
vmName: vm.name || vm.metadata?.name || '-',
|
vmName: vm.name || vm.metadata?.name || '-',
|
||||||
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
|
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||||
os: vm.guestName || vm.guestOS || vm.os || '-',
|
os: vm.guestName || vm.guestOS || vm.os || '-',
|
||||||
@ -239,6 +241,10 @@ const clearSelection = () => {
|
|||||||
|
|
||||||
if (table) {
|
if (table) {
|
||||||
table.clearSelection();
|
table.clearSelection();
|
||||||
|
|
||||||
|
if (typeof table.setPage === 'function') {
|
||||||
|
table.setPage(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -249,6 +255,49 @@ const selectAllVMs = () => {
|
|||||||
selectedVMs.value = discoveredVMs.value.slice();
|
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(
|
watch(
|
||||||
() => sortableTableRef.value?.page,
|
() => sortableTableRef.value?.page,
|
||||||
() => {
|
() => {
|
||||||
@ -318,6 +367,9 @@ const fetchVMs = async() => {
|
|||||||
return map;
|
return map;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
props.stepData.networkMap = networkMap.value;
|
||||||
|
props.stepData.datastoreMap = datastoreMap.value;
|
||||||
|
|
||||||
lastFetchedAt.value = Date.now();
|
lastFetchedAt.value = Date.now();
|
||||||
tableRows.value = buildTableRows();
|
tableRows.value = buildTableRows();
|
||||||
};
|
};
|
||||||
@ -374,6 +426,10 @@ const init = async() => {
|
|||||||
tableRows.value = buildTableRows();
|
tableRows.value = buildTableRows();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
|
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
|
||||||
|
await nextTick();
|
||||||
|
sortSelectedToFront();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -441,7 +497,6 @@ init();
|
|||||||
:row-actions="false"
|
:row-actions="false"
|
||||||
:groupable="false"
|
:groupable="false"
|
||||||
:paging="true"
|
:paging="true"
|
||||||
:rows-per-page="20"
|
|
||||||
key-field="_key"
|
key-field="_key"
|
||||||
@selection="onSelect"
|
@selection="onSelect"
|
||||||
>
|
>
|
||||||
@ -450,7 +505,20 @@ init();
|
|||||||
<h3 class="m-0">
|
<h3 class="m-0">
|
||||||
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||||
</h3>
|
</h3>
|
||||||
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
|
<span class="selected-actions">
|
||||||
|
<span class="text-deemphasized">
|
||||||
|
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
|
||||||
|
</span>
|
||||||
|
<template v-if="selectedCount > 0">
|
||||||
|
<span class="text-deemphasized">|</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="clearSelection"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell:vmName="{ row }">
|
<template #cell:vmName="{ row }">
|
||||||
@ -466,7 +534,7 @@ init();
|
|||||||
<template #cell:powerState="{ row }">
|
<template #cell:powerState="{ row }">
|
||||||
<BadgeState
|
<BadgeState
|
||||||
:label="row.powerState"
|
:label="row.powerState"
|
||||||
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
|
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell:network="{ row }">
|
<template #cell:network="{ row }">
|
||||||
@ -533,6 +601,23 @@ init();
|
|||||||
.text-deemphasized {
|
.text-deemphasized {
|
||||||
font-size: 13px;
|
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 {
|
.vm-name-cell {
|
||||||
|
|||||||
175
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal file
175
pkg/harvester/components/vm-migration/StorageDefaultsModal.vue
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
<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';
|
||||||
|
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
|
||||||
|
|
||||||
|
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
|
||||||
|
const ACCESS_MODE_OPTIONS = [ACCESS_MODE.READ_WRITE_ONCE, ACCESS_MODE.READ_WRITE_MANY, ACCESS_MODE.READ_ONLY_MANY];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
storageClassName: { type: String, default: '' },
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
showInherited: { type: Boolean, default: false },
|
||||||
|
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
|
||||||
|
accessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
|
||||||
|
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
|
||||||
|
inheritedAccessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['apply', 'close']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
|
||||||
|
const localAccessMode = ref(props.accessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY);
|
||||||
|
|
||||||
|
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 || VOLUME_MODE.FILE_SYSTEM;
|
||||||
|
localAccessMode.value = props.inheritedAccessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@ -1922,6 +1922,7 @@ harvester:
|
|||||||
refreshNow: Refresh now
|
refreshNow: Refresh now
|
||||||
availableVms: Available Virtual Machines
|
availableVms: Available Virtual Machines
|
||||||
selected: selected
|
selected: selected
|
||||||
|
clearAll: Clear All
|
||||||
saveSelection: Save Selection and Continue
|
saveSelection: Save Selection and Continue
|
||||||
selectAllBanner:
|
selectAllBanner:
|
||||||
pageOnly: "Your current selection includes only VMs currently shown on the page."
|
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
|
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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -24,13 +24,14 @@ export function registerAddonSideNav(store, productName, {
|
|||||||
const kickSideNav = () => {
|
const kickSideNav = () => {
|
||||||
const TRIGGER = 'ui.refresh.trigger';
|
const TRIGGER = 'ui.refresh.trigger';
|
||||||
|
|
||||||
store.dispatch('type-map/addFavorite', TRIGGER);
|
// 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.
|
||||||
// SideNav component seem to ignore rapid state changes.
|
[0, 600, 1500].forEach((delay) => {
|
||||||
// Wait 600ms to ensure the toggle event triggers a re-render.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
store.dispatch('type-map/removeFavorite', TRIGGER);
|
store.dispatch('type-map/addFavorite', TRIGGER);
|
||||||
}, 600);
|
setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300);
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAccessibleSchema = (t) => {
|
const hasAccessibleSchema = (t) => {
|
||||||
@ -92,16 +93,25 @@ export function registerAddonSideNav(store, productName, {
|
|||||||
// Store is ready. Stop polling.
|
// Store is ready. Stop polling.
|
||||||
clearInterval(waitForStore);
|
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(
|
store.watch(
|
||||||
(state, getters) => {
|
(state, getters) => {
|
||||||
const addons = getters[`${ productName }/all`](resourceType);
|
const addons = getters[`${ productName }/all`](resourceType);
|
||||||
const addon = addons.find((a) => a.metadata.name === addonName);
|
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 }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user