mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 12:49:14 +00:00
* feat: forklift poc first commit * feat: add migration and plan detail pages with progress tracking * feat(forklift): UI based on the FIGMA * feat(forklift): Fixed config based on new figma changes * feat(forklift): New changes added based on new figma * feat(forklift): Add the succeded state * Merge the new APIs * feat(forklift): Applied fix to select the proper data on network. Added condition to show critical * feat(forklift): Added icons, critical state, fix grids, action button * feat(forklift): Change to use the proper provider instead of a hardcoded * feat(forklift): Added pagination UI * feat(forklift): FIxed the routing for the extension * feat(forklift): route and router doesnt work properly on extensions * feat(forklift): fixed to fix the icon on extension * feat(forklift): Added overflow hidden to mapping target list * feat(forklift): Added better handling on the saving and testing * feat(forklift): Remove test flags * feat(forklift): Changed based on review * feat(forklift): fixed title and button * feat(forklift): Design changes * feat(forklift): Changed to Wizard * feat(forklift): added the provider wizard * feat(forklift): Fixed some UI changes * feat(forklift): Removed wrong configs used for testing * feat(forklift): update size * feat(forklift): small fixes * feat(forklift): small fixes after Copilot * feat(forklift): small fixes after Copilot * feat(forklift): reverted settings.json changes * feat(forklift): small fixes after Copilot * feat(forklift): Fix the way it is registered * feat(forklift): Fixed the type for VMware * feat(forklift): small changes before rebase * feat: Added new API for the vms * feat: Fix some labels * feat: Added some final fixes to structure * feat: added loading to provider steps * feat: added comments from copilot review --------- (cherry picked from commit 560643d299216e679942d05803af9d1ce0f74f6a) Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> Co-authored-by: marcelofukumoto <marceloyfukumoto@gmail.com>
This commit is contained in:
parent
73126b953c
commit
d3b77e2e41
59
pkg/harvester/components/MappingsCell.vue
Normal file
59
pkg/harvester/components/MappingsCell.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
networkEntries: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
storageEntries: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mappings-cell">
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in networkEntries"
|
||||||
|
:key="'net-' + idx"
|
||||||
|
class="mapping-entry"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon icon-network"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(entry, idx) in storageEntries"
|
||||||
|
:key="'stor-' + idx"
|
||||||
|
class="mapping-entry"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon icon-datastore"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mappings-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
548
pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue
Normal file
548
pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, toRefs } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
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 { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
|
||||||
|
import MappingColumn from './MappingColumn.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
selectedVms: { type: Array, default: () => [] },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
useAllProviderData: { type: Boolean, default: false },
|
||||||
|
existingNetworkMap: { type: Object, default: null },
|
||||||
|
existingStorageMap: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['ready']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const vms = ref([]);
|
||||||
|
const harvesterNetworks = ref([]);
|
||||||
|
const storageClasses = ref([]);
|
||||||
|
const allNetworkMaps = ref([]);
|
||||||
|
const allStorageMaps = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
const { networkEntries, storageEntries } = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||||
|
|
||||||
|
const harvesterNetworkOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
|
||||||
|
];
|
||||||
|
|
||||||
|
harvesterNetworks.value.forEach((net) => {
|
||||||
|
const ns = net.metadata?.namespace || '';
|
||||||
|
const name = net.metadata?.name || net.id;
|
||||||
|
const fullName = ns ? `${ ns }/${ name }` : name;
|
||||||
|
|
||||||
|
options.push({ label: fullName, value: fullName });
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageClassOptions = computed(() => {
|
||||||
|
const options = storageClasses.value.map((sc) => ({
|
||||||
|
label: sc.metadata?.name || sc.id,
|
||||||
|
value: sc.metadata?.name || sc.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!options.find((o) => o.value === 'harvester-longhorn')) {
|
||||||
|
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyNetworkMapTargets = (mapSpec) => {
|
||||||
|
if (!mapSpec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
networkEntries.value.forEach((entry) => {
|
||||||
|
const match = mapSpec.find(
|
||||||
|
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match?.destination) {
|
||||||
|
const dest = match.destination;
|
||||||
|
|
||||||
|
if (dest.type === 'pod') {
|
||||||
|
entry.target = 'pod';
|
||||||
|
} else if (dest.type === 'ignored') {
|
||||||
|
entry.target = 'ignored';
|
||||||
|
} else if (dest.type === 'multus' && dest.name) {
|
||||||
|
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStorageMapTargets = (mapSpec) => {
|
||||||
|
if (!mapSpec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storageEntries.value.forEach((entry) => {
|
||||||
|
const match = mapSpec.find(
|
||||||
|
(m) => m.source?.id === entry.id || m.source?.name === entry.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match?.destination?.storageClass) {
|
||||||
|
entry.target = match.destination.storageClass;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDefaultNetworkMap = () => {
|
||||||
|
const defaultMap = allNetworkMaps.value.find(
|
||||||
|
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
|
||||||
|
);
|
||||||
|
|
||||||
|
applyNetworkMapTargets(defaultMap?.spec?.map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDefaultStorageMap = () => {
|
||||||
|
const defaultMap = allStorageMaps.value.find(
|
||||||
|
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
|
||||||
|
);
|
||||||
|
|
||||||
|
applyStorageMapTargets(defaultMap?.spec?.map);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 canSave = computed(() => {
|
||||||
|
if (props.useAllProviderData) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
|
||||||
|
allNetworksMapped.value && allStorageMapped.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(canSave, (val) => {
|
||||||
|
emit('ready', val);
|
||||||
|
});
|
||||||
|
|
||||||
|
// After restore, check if ready
|
||||||
|
if (canSave.value) {
|
||||||
|
emit('ready', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildNetworkEntries = () => {
|
||||||
|
const networkMap = {};
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
const vmName = vm.name || vm.id;
|
||||||
|
|
||||||
|
if (vm.networks && vm.networks.length > 0) {
|
||||||
|
vm.networks.forEach((net) => {
|
||||||
|
const netKey = net.id || net.name || 'default';
|
||||||
|
|
||||||
|
if (!networkMap[netKey]) {
|
||||||
|
networkMap[netKey] = {
|
||||||
|
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
id: net.id || '',
|
||||||
|
vlanId: net.vlanId || '',
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `net-${ netKey }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!networkMap[netKey].usedBy.includes(vmName)) {
|
||||||
|
networkMap[netKey].usedBy.push(vmName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
networkEntries.value = Object.values(networkMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStorageEntries = () => {
|
||||||
|
const datastoreMap = {};
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
const vmName = vm.name || vm.id;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
vm.disks.forEach((disk) => {
|
||||||
|
const ds = disk.datastore;
|
||||||
|
|
||||||
|
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 }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
datastoreMap[ds.id].capacity += disk.capacity || 0;
|
||||||
|
|
||||||
|
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
|
||||||
|
datastoreMap[ds.id].usedBy.push(vmName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
storageEntries.value = Object.values(datastoreMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNetworkEntriesFromProvider = (networksData) => {
|
||||||
|
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
|
||||||
|
name: net.name || net.id,
|
||||||
|
id: net.id || '',
|
||||||
|
vlanId: net.vlanId || '',
|
||||||
|
target: '',
|
||||||
|
usedBy: [],
|
||||||
|
_key: `net-${ net.id || net.name }`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }`,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStorageDetail = (entry) => {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (entry.type) {
|
||||||
|
parts.push(entry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.capacity) {
|
||||||
|
const gb = entry.capacity / (1024 * 1024 * 1024);
|
||||||
|
|
||||||
|
if (gb >= 1024) {
|
||||||
|
parts.push(t('harvester.addons.vmMigration.generic.memoryTb', { value: (gb / 1024).toFixed(1) }));
|
||||||
|
} else {
|
||||||
|
parts.push(t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(gb) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' • ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNetworkMapSpec = (providerRef) => ({
|
||||||
|
map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
|
||||||
|
provider: providerRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildStorageMapSpec = (providerRef) => ({
|
||||||
|
map: buildStorageMapEntries(storageEntries.value),
|
||||||
|
provider: providerRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveAndReturn = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
const providerRef = {
|
||||||
|
source: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.providerName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: 'host',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update existing maps in place (edit mode)
|
||||||
|
if (props.existingNetworkMap && props.existingStorageMap) {
|
||||||
|
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
|
||||||
|
await props.existingNetworkMap.save();
|
||||||
|
|
||||||
|
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
|
||||||
|
await props.existingStorageMap.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
networkMapName: props.existingNetworkMap.metadata.name,
|
||||||
|
storageMapName: props.existingStorageMap.metadata.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOwnerRef = props.provider ? [{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.provider.metadata.name,
|
||||||
|
uid: props.provider.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
}] : [];
|
||||||
|
|
||||||
|
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
metadata: {
|
||||||
|
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
ownerReferences: providerOwnerRef,
|
||||||
|
},
|
||||||
|
spec: buildNetworkMapSpec(providerRef),
|
||||||
|
});
|
||||||
|
|
||||||
|
await networkMap.save();
|
||||||
|
|
||||||
|
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
metadata: {
|
||||||
|
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
ownerReferences: providerOwnerRef,
|
||||||
|
},
|
||||||
|
spec: buildStorageMapSpec(providerRef),
|
||||||
|
});
|
||||||
|
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ saveMappings: saveAndReturn });
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
|
||||||
|
} catch (e) {
|
||||||
|
harvesterNetworks.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
|
||||||
|
} catch (e) {
|
||||||
|
storageClasses.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
|
||||||
|
} catch (e) {
|
||||||
|
allNetworkMaps.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
|
||||||
|
} catch (e) {
|
||||||
|
allStorageMaps.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
|
||||||
|
try {
|
||||||
|
const providerUid = props.provider?.metadata?.uid;
|
||||||
|
const providerType = props.provider?.spec?.type || 'vsphere';
|
||||||
|
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||||
|
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [networksData, datastoresData] = await Promise.all([
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores?detail=1` }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (props.useAllProviderData) {
|
||||||
|
if (networkEntries.value.length === 0) {
|
||||||
|
buildNetworkEntriesFromProvider(networksData);
|
||||||
|
}
|
||||||
|
if (storageEntries.value.length === 0) {
|
||||||
|
buildStorageEntriesFromProvider(datastoresData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vms.value = props.selectedVms;
|
||||||
|
|
||||||
|
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
|
||||||
|
map[n.id] = n.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
|
||||||
|
map[d.id] = { name: d.name, type: d.type || '' };
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
vms.value = vms.value.map((vm) => {
|
||||||
|
const resolved = { ...vm };
|
||||||
|
|
||||||
|
if (resolved.networks) {
|
||||||
|
resolved.networks = resolved.networks.map((n) => ({
|
||||||
|
...n,
|
||||||
|
name: n.name || networkNameMap[n.id] || n.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.disks) {
|
||||||
|
resolved.disks = resolved.disks.map((d) => {
|
||||||
|
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
datastore: d.datastore ? {
|
||||||
|
...d.datastore,
|
||||||
|
name: d.datastore.name || dsInfo?.name || d.datastore.id,
|
||||||
|
type: dsInfo?.type || '',
|
||||||
|
} : d.datastore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (networkEntries.value.length === 0) {
|
||||||
|
buildNetworkEntries();
|
||||||
|
}
|
||||||
|
if (storageEntries.value.length === 0) {
|
||||||
|
buildStorageEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
|
||||||
|
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
|
||||||
|
|
||||||
|
if (!hasExistingNetworkTargets) {
|
||||||
|
if (props.existingNetworkMap?.spec?.map) {
|
||||||
|
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
|
||||||
|
} else {
|
||||||
|
applyDefaultNetworkMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExistingStorageTargets) {
|
||||||
|
if (props.existingStorageMap?.spec?.map) {
|
||||||
|
applyStorageMapTargets(props.existingStorageMap.spec.map);
|
||||||
|
} else {
|
||||||
|
applyDefaultStorageMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="configure-mappings"
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
|
||||||
|
</p>
|
||||||
|
<div class="mappings-columns">
|
||||||
|
<MappingColumn
|
||||||
|
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
|
||||||
|
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
|
||||||
|
:entries="networkEntries"
|
||||||
|
:options="harvesterNetworkOptions"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
|
||||||
|
:show-used-by="!useAllProviderData"
|
||||||
|
:clearable="useAllProviderData"
|
||||||
|
>
|
||||||
|
<template #source-detail="{ entry }">
|
||||||
|
<span class="source-detail text-deemphasized">{{ t('harvester.addons.vmMigration.generic.vlan', { id: entry.vlanId || '0' }) }}</span>
|
||||||
|
</template>
|
||||||
|
</MappingColumn>
|
||||||
|
|
||||||
|
<MappingColumn
|
||||||
|
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
|
||||||
|
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
|
||||||
|
:entries="storageEntries"
|
||||||
|
:options="storageClassOptions"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
|
||||||
|
:show-used-by="!useAllProviderData"
|
||||||
|
:clearable="useAllProviderData"
|
||||||
|
>
|
||||||
|
<template #source-detail="{ entry }">
|
||||||
|
<span
|
||||||
|
v-if="entry.type || entry.capacity"
|
||||||
|
class="source-detail text-deemphasized"
|
||||||
|
>{{ formatStorageDetail(entry) }}</span>
|
||||||
|
</template>
|
||||||
|
</MappingColumn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mappings-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
|
||||||
|
gap: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-mappings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
538
pkg/harvester/components/vm-migration/ConfigureProviderStep.vue
Normal file
538
pkg/harvester/components/vm-migration/ConfigureProviderStep.vue
Normal file
@ -0,0 +1,538 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, toRefs } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import { Checkbox } from '@components/Form/Checkbox';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import AsyncButton from '@shell/components/AsyncButton';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { SECRET } from '@shell/config/types';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import { decodeSecretValue } from '../../utils/forklift';
|
||||||
|
|
||||||
|
const CREATE_NEW = '__create_new__';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
createOnly: { type: Boolean, default: false },
|
||||||
|
editMode: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const allProviders = ref([]);
|
||||||
|
const allSecrets = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
const testBtnRef = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const testing = ref(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedProvider,
|
||||||
|
providerName,
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
skipTlsVerify,
|
||||||
|
testPassed,
|
||||||
|
testResult,
|
||||||
|
testError,
|
||||||
|
createdProvider,
|
||||||
|
createdSecret,
|
||||||
|
} = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
|
||||||
|
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
|
||||||
|
|
||||||
|
const providerOptions = computed(() => {
|
||||||
|
const options = [
|
||||||
|
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
|
||||||
|
];
|
||||||
|
|
||||||
|
allProviders.value.forEach((p) => {
|
||||||
|
if (!p.spec?.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretRef = p.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const secret = allSecrets.value.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secret?.data?.user || !secret?.data?.password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
label: p.metadata.name,
|
||||||
|
value: p.metadata.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the provider selection changes, populate or clear the fields
|
||||||
|
watch(selectedProvider, (val) => {
|
||||||
|
testPassed.value = false;
|
||||||
|
testResult.value = null;
|
||||||
|
testError.value = null;
|
||||||
|
|
||||||
|
if (val === CREATE_NEW) {
|
||||||
|
providerName.value = '';
|
||||||
|
url.value = '';
|
||||||
|
username.value = '';
|
||||||
|
password.value = '';
|
||||||
|
skipTlsVerify.value = false;
|
||||||
|
createdProvider.value = null;
|
||||||
|
createdSecret.value = null;
|
||||||
|
} else {
|
||||||
|
const provider = allProviders.value.find(
|
||||||
|
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (provider) {
|
||||||
|
providerName.value = provider.metadata.name;
|
||||||
|
url.value = provider.spec?.url || '';
|
||||||
|
|
||||||
|
const secretRef = provider.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const secret = allSecrets.value.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret?.data) {
|
||||||
|
username.value = decodeSecretValue(secret.data.user);
|
||||||
|
password.value = decodeSecretValue(secret.data.password);
|
||||||
|
skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createdProvider.value = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset test state when any field changes (only for create new)
|
||||||
|
watch([providerName, url, username, password], () => {
|
||||||
|
if (!isExistingProvider.value) {
|
||||||
|
testPassed.value = false;
|
||||||
|
testResult.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit ready/complete when testPassed changes
|
||||||
|
watch(testPassed, (val) => {
|
||||||
|
emit('ready', val);
|
||||||
|
if (val) {
|
||||||
|
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Emit form-valid when form validity changes
|
||||||
|
watch(isFormValid, (val) => {
|
||||||
|
emit('form-valid', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(testing, (val) => {
|
||||||
|
emit('testing', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const pollProviderReady = async(name) => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
const namespace = FORKLIFT_NAMESPACE;
|
||||||
|
const maxAttempts = 15;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
const refreshed = await store.dispatch(`${ inStore }/find`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
id: `${ namespace }/${ name }`,
|
||||||
|
opt: { force: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const conditions = refreshed?.status?.conditions || [];
|
||||||
|
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
|
||||||
|
const readyCondition = conditions.find((c) => c.type === 'Ready');
|
||||||
|
|
||||||
|
if (connectionCondition) {
|
||||||
|
if (connectionCondition.status === 'True') {
|
||||||
|
return { connected: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: false, errorMsg: connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readyCondition) {
|
||||||
|
if (readyCondition.status === 'True') {
|
||||||
|
return { connected: true };
|
||||||
|
} else if (readyCondition.status === 'False') {
|
||||||
|
return { connected: false, errorMsg: readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: false, errorMsg: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePollResult = ({ connected, errorMsg }, buttonCb) => {
|
||||||
|
if (connected) {
|
||||||
|
testPassed.value = true;
|
||||||
|
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(true);
|
||||||
|
} else {
|
||||||
|
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupCreatedResources = async() => {
|
||||||
|
if (createdProvider.value) {
|
||||||
|
try {
|
||||||
|
await createdProvider.value.remove();
|
||||||
|
} catch (e) {}
|
||||||
|
createdProvider.value = null;
|
||||||
|
createdSecret.value = null;
|
||||||
|
} else if (createdSecret.value) {
|
||||||
|
try {
|
||||||
|
await createdSecret.value.remove();
|
||||||
|
} catch (e) {}
|
||||||
|
createdSecret.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async(buttonCb) => {
|
||||||
|
testResult.value = null;
|
||||||
|
testError.value = null;
|
||||||
|
testing.value = true;
|
||||||
|
|
||||||
|
if (!providerName.value || !url.value || !username.value || !password.value) {
|
||||||
|
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
// Edit mode: update existing provider URL + secret, then poll
|
||||||
|
if (props.editMode && createdProvider.value) {
|
||||||
|
try {
|
||||||
|
createdProvider.value.spec.url = url.value;
|
||||||
|
await createdProvider.value.save();
|
||||||
|
|
||||||
|
const secretRef = createdProvider.value.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef && createdSecret.value) {
|
||||||
|
createdSecret.value.data = {
|
||||||
|
user: btoa(username.value),
|
||||||
|
password: btoa(password.value),
|
||||||
|
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||||
|
url: btoa(url.value),
|
||||||
|
};
|
||||||
|
await createdSecret.value.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
|
||||||
|
if (isExistingProvider.value) {
|
||||||
|
try {
|
||||||
|
handlePollResult(await pollProviderReady(providerName.value), buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new providers, create provider + secret then poll
|
||||||
|
try {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
|
||||||
|
const namespace = FORKLIFT_NAMESPACE;
|
||||||
|
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
|
||||||
|
|
||||||
|
const provider = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
metadata: {
|
||||||
|
name: providerName.value,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
type: 'vsphere',
|
||||||
|
url: url.value,
|
||||||
|
secret: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.save();
|
||||||
|
createdProvider.value = provider;
|
||||||
|
|
||||||
|
const newSecret = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: SECRET,
|
||||||
|
metadata: {
|
||||||
|
name: secretName,
|
||||||
|
namespace,
|
||||||
|
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Provider',
|
||||||
|
name: provider.metadata.name,
|
||||||
|
uid: provider.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newSecret['_type'] = 'Opaque';
|
||||||
|
newSecret['data'] = {
|
||||||
|
user: btoa(username.value),
|
||||||
|
password: btoa(password.value),
|
||||||
|
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
|
||||||
|
url: btoa(url.value),
|
||||||
|
};
|
||||||
|
|
||||||
|
await newSecret.save();
|
||||||
|
createdSecret.value = newSecret;
|
||||||
|
|
||||||
|
const result = await pollProviderReady(providerName.value);
|
||||||
|
|
||||||
|
if (!result.connected) {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePollResult(result, buttonCb);
|
||||||
|
} catch (err) {
|
||||||
|
await cleanupCreatedResources();
|
||||||
|
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||||
|
testing.value = false;
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||||
|
|
||||||
|
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
|
||||||
|
type: SECRET,
|
||||||
|
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
const clickTestButton = () => {
|
||||||
|
testBtnRef.value?.$el?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ testConnection, clickTestButton });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="configure-provider-step"
|
||||||
|
>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="configure-provider-step-content">
|
||||||
|
<h3 class="table-title m-0">
|
||||||
|
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
class="requirements-banner m-0"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
|
<div class="requirements-banner-content">
|
||||||
|
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
|
||||||
|
<br>
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<div class="configure-provider-step-form">
|
||||||
|
<div v-if="!createOnly && !editMode">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="selectedProvider"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
|
||||||
|
:options="providerOptions"
|
||||||
|
:reduce="(opt) => opt.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isExistingProvider || editMode"
|
||||||
|
>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="providerName"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.name')"
|
||||||
|
:disabled="editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="url"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized mt-5">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="username"
|
||||||
|
:label="t('harvester.addons.vmMigration.fields.username')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
:label="t('harvester.addons.vmMigration.fields.password')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
v-model:value="skipTlsVerify"
|
||||||
|
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
|
||||||
|
:disabled="isExistingProvider && !editMode"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized ml-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<AsyncButton
|
||||||
|
ref="testBtnRef"
|
||||||
|
mode="test"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
|
||||||
|
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
|
||||||
|
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
|
||||||
|
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
|
||||||
|
:action-color="'role-secondary'"
|
||||||
|
:waiting-color="'role-disabled'"
|
||||||
|
@click="testConnection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Banner
|
||||||
|
v-if="testResult"
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
{{ testResult }}
|
||||||
|
</Banner>
|
||||||
|
<Banner
|
||||||
|
v-if="testError"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{{ testError }}
|
||||||
|
</Banner>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{{ err }}
|
||||||
|
</Banner>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.requirements-banner {
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.requirements-banner-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-provider-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configure-provider-step-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.configure-provider-step-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.checkbox-label) {
|
||||||
|
color: var(--body-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
184
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { RcItemCard } from '@components/RcItemCard';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectOptions = computed(() => {
|
||||||
|
if (!props.clearable) {
|
||||||
|
return props.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
|
||||||
|
value: null,
|
||||||
|
kind: 'highlighted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'divider',
|
||||||
|
disabled: true,
|
||||||
|
kind: 'divider',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${ props.placeholder }:`,
|
||||||
|
disabled: true,
|
||||||
|
kind: 'title',
|
||||||
|
},
|
||||||
|
...props.options,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mapping-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="mapping-section-title">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
class="text-deemphasized line-height-20"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RcItemCard
|
||||||
|
v-for="entry in entries"
|
||||||
|
:id="entry._key"
|
||||||
|
:key="entry._key"
|
||||||
|
:variant="'small'"
|
||||||
|
:header="{}"
|
||||||
|
class="bg-light-gray"
|
||||||
|
>
|
||||||
|
<template #item-card-content>
|
||||||
|
<div class="card-content-column">
|
||||||
|
<div class="card-content-row">
|
||||||
|
<div class="mapping-source">
|
||||||
|
<span class="source-name">{{ entry.name }}</span>
|
||||||
|
<slot
|
||||||
|
name="source-detail"
|
||||||
|
:entry="entry"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
|
||||||
|
<i
|
||||||
|
class="icon icon-right-arrow-alt"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-target">
|
||||||
|
<LabeledSelect
|
||||||
|
v-model:value="entry.target"
|
||||||
|
:options="selectOptions"
|
||||||
|
:placeholder="placeholder+'...'"
|
||||||
|
:searchable="true"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</RcItemCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mapping-column {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
:deep(.item-card-header) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-source {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 2;
|
||||||
|
min-width: 100px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
.source-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.used-by {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-arrow {
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-target {
|
||||||
|
flex: 3;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light-gray {
|
||||||
|
background-color: var(--category-active) !important;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
439
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal file
439
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { RcItemCard } from '@components/RcItemCard';
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import MappingsCell from '../MappingsCell';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||||
|
import {
|
||||||
|
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
|
||||||
|
} from '../../utils/forklift';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
selectedVms: { type: Array, default: () => [] },
|
||||||
|
mappingEntries: { type: Object, default: null },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['ready']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const vms = ref([]);
|
||||||
|
const networkMappings = ref([]);
|
||||||
|
const storageMappings = ref([]);
|
||||||
|
const planName = ref('');
|
||||||
|
const errors = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
// Restore persisted state
|
||||||
|
planName.value = props.stepData.planName;
|
||||||
|
|
||||||
|
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||||
|
const TARGET_NAMESPACE = 'default';
|
||||||
|
|
||||||
|
watch(planName, (val) => {
|
||||||
|
props.stepData.planName = val;
|
||||||
|
emit('ready', !!val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
|
||||||
|
|
||||||
|
const totalMemoryGB = computed(() => {
|
||||||
|
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
|
||||||
|
|
||||||
|
return mbToGB(totalMB);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalStorageGB = computed(() => {
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
vms.value.forEach((vm) => {
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bytesToGB(totalBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
const vmCards = computed(() => {
|
||||||
|
return vms.value.map((vm) => {
|
||||||
|
const cpus = vm.cpuCount || vm.numCPU || 0;
|
||||||
|
const memMB = vm.memoryMB || vm.memory || 0;
|
||||||
|
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||||
|
|
||||||
|
let totalDiskBytes = 0;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||||
|
const os = vm.guestName || vm.guestOS || vm.os || '-';
|
||||||
|
|
||||||
|
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||||
|
const vmStorageMappings = storageMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: vm.id,
|
||||||
|
name: vm.name || vm.id,
|
||||||
|
os,
|
||||||
|
cpus,
|
||||||
|
memGB,
|
||||||
|
diskDisplay,
|
||||||
|
networkMappings: vmNetworkMappings,
|
||||||
|
storageMappings: vmStorageMappings,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const startMigrationAction = async() => {
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
const providerRef = {
|
||||||
|
source: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Provider',
|
||||||
|
name: props.providerName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Provider',
|
||||||
|
name: 'host',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkMapName = `${ planName.value }-network-map`;
|
||||||
|
const storageMapName = `${ planName.value }-storage-map`;
|
||||||
|
|
||||||
|
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
metadata: { name: networkMapName, namespace: NAMESPACE },
|
||||||
|
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
|
||||||
|
});
|
||||||
|
|
||||||
|
await networkMap.save();
|
||||||
|
|
||||||
|
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
metadata: { name: storageMapName, namespace: NAMESPACE },
|
||||||
|
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
|
||||||
|
});
|
||||||
|
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
const plan = await store.dispatch(`${ inStore }/create`, {
|
||||||
|
type: HCI.FORKLIFT_PLAN,
|
||||||
|
metadata: { name: planName.value, namespace: NAMESPACE },
|
||||||
|
spec: {
|
||||||
|
provider: providerRef,
|
||||||
|
map: {
|
||||||
|
network: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'NetworkMap',
|
||||||
|
name: networkMapName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'StorageMap',
|
||||||
|
name: storageMapName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targetNamespace: TARGET_NAMESPACE,
|
||||||
|
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
|
||||||
|
warm: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await plan.save();
|
||||||
|
|
||||||
|
const planOwnerRef = {
|
||||||
|
apiVersion: FORKLIFT_API_VERSION,
|
||||||
|
kind: 'Plan',
|
||||||
|
name: plan.metadata.name,
|
||||||
|
uid: plan.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
networkMap.metadata.ownerReferences = [planOwnerRef];
|
||||||
|
await networkMap.save();
|
||||||
|
storageMap.metadata.ownerReferences = [planOwnerRef];
|
||||||
|
await storageMap.save();
|
||||||
|
|
||||||
|
// Kick off the first migration through the model so the Migration payload
|
||||||
|
// lives in a single place (also reused by the dashboard start/restart action).
|
||||||
|
await plan.startMigration();
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
vms.value = props.selectedVms;
|
||||||
|
|
||||||
|
if (props.mappingEntries) {
|
||||||
|
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
|
||||||
|
source: entry.name || entry.id,
|
||||||
|
target: entry.target || '',
|
||||||
|
usedBy: entry.usedBy || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
|
||||||
|
source: entry.name || entry.id,
|
||||||
|
target: entry.target || '',
|
||||||
|
usedBy: entry.usedBy || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
defineExpose({ startMigration: startMigrationAction });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="review-migration"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="review-migration-content"
|
||||||
|
>
|
||||||
|
<!-- Migration Details Summary -->
|
||||||
|
<div class="migration-details">
|
||||||
|
<h3 class="section-title m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
|
||||||
|
</h3>
|
||||||
|
<div class="span-9">
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="planName"
|
||||||
|
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
|
||||||
|
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="details-grid span-9">
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
|
||||||
|
<span class="detail-value detail-value-large">{{ vms.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-value">{{ providerName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-value">{{ TARGET_NAMESPACE }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
|
||||||
|
<span class="detail-value">{{ totalVCpu }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
|
||||||
|
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
|
||||||
|
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-column grid-column-2">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-value">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Virtual Machines -->
|
||||||
|
<div class="vm-section">
|
||||||
|
<h3 class="section-title m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="vm-cards-grid">
|
||||||
|
<RcItemCard
|
||||||
|
v-for="vm in vmCards"
|
||||||
|
:id="vm.id"
|
||||||
|
:key="vm.id"
|
||||||
|
:variant="'small'"
|
||||||
|
:header="{ title: { text: vm.name } }"
|
||||||
|
>
|
||||||
|
<template #item-card-content>
|
||||||
|
<div class="vm-card-content">
|
||||||
|
<div class="vm-card-specs">
|
||||||
|
<span class="vm-os text-deemphasized">{{ vm.os }}</span>
|
||||||
|
<span class="vm-resources">
|
||||||
|
<i
|
||||||
|
class="icon icon-disk"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} • {{ vm.memGB }} • {{ vm.diskDisplay }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MappingsCell
|
||||||
|
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
|
||||||
|
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</RcItemCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cold Migration Warning -->
|
||||||
|
<Banner
|
||||||
|
color="warning"
|
||||||
|
class="m-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br>
|
||||||
|
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }}
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<!-- Error banner -->
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.review-migration {
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-migration-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px clamp(64px, 8vw, 128px);
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&.grid-column-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&.detail-value-large {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-card-specs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.vm-os {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-resources {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
578
pkg/harvester/components/vm-migration/SelectVmsStep.vue
Normal file
578
pkg/harvester/components/vm-migration/SelectVmsStep.vue
Normal file
@ -0,0 +1,578 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref, computed, watch, nextTick, onBeforeUnmount, toRefs
|
||||||
|
} from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import SortableTable from '@shell/components/SortableTable';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import { BadgeState } from '@components/BadgeState';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { bytesToGB, mbToGB } from '../../utils/forklift';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
providerName: { type: String, default: '' },
|
||||||
|
provider: { type: Object, default: null },
|
||||||
|
stepData: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete', 'loading']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
|
||||||
|
|
||||||
|
const selectedVMs = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const networkMap = ref({});
|
||||||
|
const datastoreMap = ref({});
|
||||||
|
const sortableTableRef = ref(null);
|
||||||
|
const allVMsSelected = ref(false);
|
||||||
|
const errors = ref([]);
|
||||||
|
let skipNextSelectionEvent = false;
|
||||||
|
|
||||||
|
const lastFetchedAt = ref(null);
|
||||||
|
const now = ref(Date.now());
|
||||||
|
|
||||||
|
const formatElapsed = (ms) => {
|
||||||
|
const totalMinutes = Math.floor(ms / 60000);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (hours > 0 && minutes > 0) {
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.hoursAndMinutes', { hours, minutes });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.hours', { hours });
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('harvester.addons.vmMigration.generic.elapsed.minutes', { minutes: Math.max(1, minutes) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastSyncedTime = computed(() => {
|
||||||
|
if (!lastFetchedAt.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatElapsed(now.value - lastFetchedAt.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowTimer = setInterval(() => {
|
||||||
|
now.value = Date.now();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(nowTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore selection state from stepData
|
||||||
|
if (selectedVMIds.value.size > 0) {
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmCount = computed(() => discoveredVMs.value.length);
|
||||||
|
const selectedCount = computed(() => selectedVMs.value.length);
|
||||||
|
|
||||||
|
const showSelectAllBanner = computed(() => {
|
||||||
|
if (allVMsSelected.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagedRows = sortableTableRef.value?.pagedRows || [];
|
||||||
|
|
||||||
|
if (pagedRows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
return allPageSelected && tableRows.value.length > pagedRows.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
name: 'vmName',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
||||||
|
value: 'vmName',
|
||||||
|
sort: ['vmName'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'os',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
|
||||||
|
value: 'os',
|
||||||
|
sort: ['os'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resources',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
|
||||||
|
value: 'resources',
|
||||||
|
sort: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'powerState',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
|
||||||
|
value: 'powerState',
|
||||||
|
sort: ['powerState'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'network',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
|
||||||
|
value: 'network',
|
||||||
|
sort: ['network'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'datastore',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
|
||||||
|
value: 'datastore',
|
||||||
|
sort: ['datastore'],
|
||||||
|
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const buildTableRows = () => {
|
||||||
|
return discoveredVMs.value.map((vm) => {
|
||||||
|
const cpus = vm.cpuCount || vm.numCPU || '-';
|
||||||
|
const memMB = vm.memoryMB || vm.memory || 0;
|
||||||
|
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||||
|
|
||||||
|
let totalDiskBytes = 0;
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||||
|
const rawPowerState = vm.powerState || vm.status?.phase || '-';
|
||||||
|
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
let networks = [];
|
||||||
|
|
||||||
|
if (vm.networks && vm.networks.length > 0) {
|
||||||
|
networks = vm.networks.map((n) => ({
|
||||||
|
name: n.name || networkMap.value[n.id] || n.id || n,
|
||||||
|
id: n.id || '',
|
||||||
|
vlanId: n.vlanId || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const datastores = [];
|
||||||
|
|
||||||
|
if (vm.disks && vm.disks.length > 0) {
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
vm.disks.forEach((d) => {
|
||||||
|
const dsId = d.datastore?.id || d.datastore?.name;
|
||||||
|
|
||||||
|
if (dsId && !seen.has(dsId)) {
|
||||||
|
seen.add(dsId);
|
||||||
|
datastores.push({
|
||||||
|
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
|
||||||
|
id: d.datastore?.id || '',
|
||||||
|
type: d.datastore?.type || '',
|
||||||
|
capacity: d.capacity || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_original: vm,
|
||||||
|
_key: vm.id || vm.vmId || vm.metadata?.name,
|
||||||
|
vmName: vm.name || vm.metadata?.name || '-',
|
||||||
|
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
|
||||||
|
os: vm.guestName || vm.guestOS || vm.os || '-',
|
||||||
|
resourcesDisplay: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
|
||||||
|
resourcesSub: `${ memGB } • ${ diskDisplay }`,
|
||||||
|
powerState,
|
||||||
|
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
|
||||||
|
networks,
|
||||||
|
network: networks.map((n) => n.name).join(', ') || '-',
|
||||||
|
datastores,
|
||||||
|
datastore: datastores.map((d) => d.name).join(', ') || '-',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelect = (rows) => {
|
||||||
|
if (skipNextSelectionEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPageRows = sortableTableRef.value?.pagedRows || [];
|
||||||
|
const selectedKeys = new Set(rows.map((r) => r._key));
|
||||||
|
|
||||||
|
currentPageRows.forEach((row) => {
|
||||||
|
const vmId = row._original?.id;
|
||||||
|
|
||||||
|
if (selectedKeys.has(row._key)) {
|
||||||
|
selectedVMIds.value.add(vmId);
|
||||||
|
} else {
|
||||||
|
selectedVMIds.value.delete(vmId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
allVMsSelected.value = false;
|
||||||
|
selectedVMIds.value = new Set();
|
||||||
|
selectedVMs.value = [];
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
table.clearSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllVMs = () => {
|
||||||
|
allVMsSelected.value = true;
|
||||||
|
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
|
||||||
|
selectedVMs.value = discoveredVMs.value.slice();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => sortableTableRef.value?.page,
|
||||||
|
() => {
|
||||||
|
skipNextSelectionEvent = true;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
if (rowsToReselect.length > 0) {
|
||||||
|
table.update(rowsToReselect, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
const isLoading = computed(() => loading.value || refreshing.value);
|
||||||
|
|
||||||
|
watch(isLoading, (val) => {
|
||||||
|
emit('loading', val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const fetchVMs = async() => {
|
||||||
|
if (!props.provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
const providerUid = props.provider.metadata.uid;
|
||||||
|
const providerType = props.provider.spec?.type || 'vsphere';
|
||||||
|
const baseUrl = store.getters['harvester-common/getHarvesterClusterUrl'](
|
||||||
|
`v1/harvester/providers/${ providerType }/${ providerUid }`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/vms` }),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/networks` }).catch(() => []),
|
||||||
|
store.dispatch(`${ inStore }/request`, { url: `${ baseUrl }/datastores` }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (Array.isArray(vmsResp)) {
|
||||||
|
discoveredVMs.value = vmsResp;
|
||||||
|
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
|
||||||
|
discoveredVMs.value = vmsResp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
|
||||||
|
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
|
||||||
|
|
||||||
|
networkMap.value = networks.reduce((map, n) => {
|
||||||
|
map[n.id] = n.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
datastoreMap.value = datastores.reduce((map, d) => {
|
||||||
|
map[d.id] = d.name;
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
lastFetchedAt.value = Date.now();
|
||||||
|
tableRows.value = buildTableRows();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshVMs = async() => {
|
||||||
|
refreshing.value = true;
|
||||||
|
skipNextSelectionEvent = true;
|
||||||
|
|
||||||
|
const previousSelectedIds = new Set(selectedVMIds.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
errors.value = [];
|
||||||
|
await fetchVMs();
|
||||||
|
} catch (e) {
|
||||||
|
discoveredVMs.value = [];
|
||||||
|
tableRows.value = [];
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedVMIds.value = new Set(
|
||||||
|
discoveredVMs.value
|
||||||
|
.filter((vm) => previousSelectedIds.has(vm.id))
|
||||||
|
.map((vm) => vm.id)
|
||||||
|
);
|
||||||
|
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||||
|
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
|
||||||
|
|
||||||
|
props.stepData.discoveredVMs = discoveredVMs.value;
|
||||||
|
props.stepData.selectedVMIds = selectedVMIds.value;
|
||||||
|
props.stepData.tableRows = tableRows.value;
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
refreshing.value = false;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const table = sortableTableRef.value;
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||||
|
|
||||||
|
if (rowsToReselect.length > 0) {
|
||||||
|
table.update(rowsToReselect, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skipNextSelectionEvent = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
if (discoveredVMs.value.length > 0) {
|
||||||
|
if (!lastFetchedAt.value) {
|
||||||
|
lastFetchedAt.value = Date.now();
|
||||||
|
}
|
||||||
|
tableRows.value = buildTableRows();
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchVMs();
|
||||||
|
} catch (e) {
|
||||||
|
discoveredVMs.value = [];
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit initial complete if VMs are already selected (restored from stepData)
|
||||||
|
if (selectedVMs.value.length > 0) {
|
||||||
|
emit('complete', { selectedVMs: selectedVMs.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading || refreshing" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="select-vms-step"
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<p class="text-deemphasized line-height-20">
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
|
||||||
|
<router-link
|
||||||
|
v-if="provider"
|
||||||
|
class="provider-link"
|
||||||
|
:to="provider._detailLocation"
|
||||||
|
>
|
||||||
|
{{ providerName }}
|
||||||
|
</router-link>
|
||||||
|
<br>
|
||||||
|
<span
|
||||||
|
v-if="lastSyncedTime"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
class="text-bold"
|
||||||
|
:class="{ disabled: refreshing }"
|
||||||
|
@click.prevent="refreshVMs"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<!-- Discovered VMs table -->
|
||||||
|
<SortableTable
|
||||||
|
ref="sortableTableRef"
|
||||||
|
:rows="tableRows"
|
||||||
|
:headers="headers"
|
||||||
|
:search="true"
|
||||||
|
:table-actions="true"
|
||||||
|
:row-actions="false"
|
||||||
|
:groupable="false"
|
||||||
|
:paging="true"
|
||||||
|
:rows-per-page="20"
|
||||||
|
key-field="_key"
|
||||||
|
@selection="onSelect"
|
||||||
|
>
|
||||||
|
<template #header-left>
|
||||||
|
<div class="vm-table-title">
|
||||||
|
<h3 class="m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:vmName="{ row }">
|
||||||
|
<div class="vm-name-cell">
|
||||||
|
<span class="vm-name">{{ row.vmName }}</span>
|
||||||
|
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:resources="{ row }">
|
||||||
|
<span>{{ row.resourcesDisplay }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:powerState="{ row }">
|
||||||
|
<BadgeState
|
||||||
|
:label="row.powerState"
|
||||||
|
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell:network="{ row }">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(net, i) in row.networks"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'mt-4': i > 0 }"
|
||||||
|
>
|
||||||
|
<span>{{ net.name }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ net.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:datastore="{ row }">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(ds, i) in row.datastores"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'mt-4': i > 0 }"
|
||||||
|
>
|
||||||
|
<span>{{ ds.name }}</span><br>
|
||||||
|
<span class="text-deemphasized">{{ ds.id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SortableTable>
|
||||||
|
|
||||||
|
<Teleport
|
||||||
|
v-if="showSelectAllBanner && theadElement"
|
||||||
|
:to="theadElement"
|
||||||
|
>
|
||||||
|
<tr class="select-all-banner-row">
|
||||||
|
<td
|
||||||
|
:colspan="headers.length + 1"
|
||||||
|
class="select-all-banner-cell"
|
||||||
|
>
|
||||||
|
<template v-if="allVMsSelected">
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="clearSelection"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
@click.prevent="selectAllVMs"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.vm-table-title {
|
||||||
|
.text-deemphasized {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.vm-name {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-id {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-vms-step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.line-height-20 {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-all-banner-row) {
|
||||||
|
.select-all-banner-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: var(--sortable-table-header-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -47,6 +47,13 @@ import {
|
|||||||
VM_IMPORT_SOURCE_O_STATUS,
|
VM_IMPORT_SOURCE_O_STATUS,
|
||||||
VM_IMPORT_SOURCE_OVA_URL,
|
VM_IMPORT_SOURCE_OVA_URL,
|
||||||
VM_IMPORT_SOURCE_OVA_STATUS,
|
VM_IMPORT_SOURCE_OVA_STATUS,
|
||||||
|
FORKLIFT_PROVIDER_TYPE,
|
||||||
|
FORKLIFT_PROVIDER_URL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
FORKLIFT_PLAN_TARGET_NS,
|
||||||
|
FORKLIFT_PLAN_VM_COUNT,
|
||||||
|
FORKLIFT_MIGRATION_PLAN,
|
||||||
} from './table-headers';
|
} from './table-headers';
|
||||||
import { ADD_ONS } from './harvester-map';
|
import { ADD_ONS } from './harvester-map';
|
||||||
import { registerAddonSideNav } from '../utils/dynamic-nav';
|
import { registerAddonSideNav } from '../utils/dynamic-nav';
|
||||||
@ -1319,4 +1326,182 @@ export function init($plugin, store) {
|
|||||||
exact: false,
|
exact: false,
|
||||||
ifHaveType: HCI.HOST_NETWORK_CONFIG,
|
ifHaveType: HCI.HOST_NETWORK_CONFIG,
|
||||||
});
|
});
|
||||||
|
// ===========================================================================
|
||||||
|
// Forklift Addon UI Flow
|
||||||
|
// ===========================================================================
|
||||||
|
weightGroup('vmMigration', 0, false);
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
headers(HCI.FORKLIFT_PROVIDER, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_PROVIDER_TYPE,
|
||||||
|
FORKLIFT_PROVIDER_URL,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_PROVIDER, {
|
||||||
|
resource: HCI.FORKLIFT_PROVIDER,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_PROVIDER,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.provider',
|
||||||
|
group: 'vmMigration',
|
||||||
|
namespaced: true,
|
||||||
|
weight: 100,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PROVIDER }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NetworkMap
|
||||||
|
headers(HCI.FORKLIFT_NETWORK_MAP, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_NETWORK_MAP, {
|
||||||
|
resource: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
|
||||||
|
group: 'vmMigration::Advanced',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// StorageMap
|
||||||
|
headers(HCI.FORKLIFT_STORAGE_MAP, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_MAP_DEST_PROVIDER,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_STORAGE_MAP, {
|
||||||
|
resource: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
|
||||||
|
group: 'vmMigration::Advanced',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plan
|
||||||
|
headers(HCI.FORKLIFT_PLAN, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MAP_SOURCE_PROVIDER,
|
||||||
|
FORKLIFT_PLAN_TARGET_NS,
|
||||||
|
FORKLIFT_PLAN_VM_COUNT,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_PLAN, {
|
||||||
|
resource: HCI.FORKLIFT_PLAN,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PLAN }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_PLAN,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.plan',
|
||||||
|
group: 'vmMigration::Advanced',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_PLAN }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migration
|
||||||
|
headers(HCI.FORKLIFT_MIGRATION, [
|
||||||
|
STATE,
|
||||||
|
NAME_COL,
|
||||||
|
NAMESPACE_COL,
|
||||||
|
FORKLIFT_MIGRATION_PLAN,
|
||||||
|
AGE
|
||||||
|
]);
|
||||||
|
configureType(HCI.FORKLIFT_MIGRATION, {
|
||||||
|
resource: HCI.FORKLIFT_MIGRATION,
|
||||||
|
location: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
virtualType({
|
||||||
|
name: HCI.FORKLIFT_MIGRATION,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.migration',
|
||||||
|
group: 'vmMigration::Advanced',
|
||||||
|
namespaced: true,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: { resource: HCI.FORKLIFT_MIGRATION }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
|
||||||
|
virtualType({
|
||||||
|
name: 'forklift-create',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
|
||||||
|
group: 'vmMigration',
|
||||||
|
namespaced: true,
|
||||||
|
weight: 200,
|
||||||
|
route: {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the dashboard entry directly — it's a virtual type with no schema,
|
||||||
|
// so it cannot go through registerAddonSideNav (which filters by schema).
|
||||||
|
basicType(['forklift-create'], 'vmMigration');
|
||||||
|
|
||||||
|
// Enable SideNav based on Forklift Addon Status
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration',
|
||||||
|
types: [
|
||||||
|
HCI.FORKLIFT_PROVIDER,
|
||||||
|
]
|
||||||
|
});
|
||||||
|
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||||
|
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||||
|
resourceType: HCI.ADD_ONS,
|
||||||
|
navGroup: 'vmMigration::Advanced',
|
||||||
|
types: [
|
||||||
|
HCI.FORKLIFT_NETWORK_MAP,
|
||||||
|
HCI.FORKLIFT_STORAGE_MAP,
|
||||||
|
HCI.FORKLIFT_PLAN,
|
||||||
|
HCI.FORKLIFT_MIGRATION,
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// ===========================================================================
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export const ADD_ONS = {
|
|||||||
VM_IMPORT_CONTROLLER: 'vm-import-controller',
|
VM_IMPORT_CONTROLLER: 'vm-import-controller',
|
||||||
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
||||||
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
||||||
|
FORKLIFT_OPERATOR: 'forklift-operator',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CSI_SECRETS = {
|
export const CSI_SECRETS = {
|
||||||
@ -92,6 +93,9 @@ export const CSI_SECRETS = {
|
|||||||
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||||
|
export const FORKLIFT_NAMESPACE = 'forklift';
|
||||||
|
|
||||||
// Some harvester CRD type is not equal to model file name, define the mapping here
|
// Some harvester CRD type is not equal to model file name, define the mapping here
|
||||||
export const HARVESTER_CRD_MAP = {
|
export const HARVESTER_CRD_MAP = {
|
||||||
node: HCI.HOST,
|
node: HCI.HOST,
|
||||||
|
|||||||
@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
|
|||||||
sort: 'status.status',
|
sort: 'status.status',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Forklift table headers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Provider type column in forklift.konveyor.io.provider list page
|
||||||
|
export const FORKLIFT_PROVIDER_TYPE = {
|
||||||
|
name: 'providerType',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
|
||||||
|
value: 'spec.type',
|
||||||
|
sort: 'spec.type',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider URL column in forklift.konveyor.io.provider list page
|
||||||
|
export const FORKLIFT_PROVIDER_URL = {
|
||||||
|
name: 'providerUrl',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
|
||||||
|
value: 'spec.url',
|
||||||
|
sort: 'spec.url',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source provider column in forklift network/storage map list page
|
||||||
|
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
|
||||||
|
name: 'sourceProvider',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
|
||||||
|
value: 'spec.provider.source.name',
|
||||||
|
sort: 'spec.provider.source.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Destination provider column in forklift network/storage map list page
|
||||||
|
export const FORKLIFT_MAP_DEST_PROVIDER = {
|
||||||
|
name: 'destProvider',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
|
||||||
|
value: 'spec.provider.destination.name',
|
||||||
|
sort: 'spec.provider.destination.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Target namespace column in forklift.konveyor.io.plan list page
|
||||||
|
export const FORKLIFT_PLAN_TARGET_NS = {
|
||||||
|
name: 'targetNamespace',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
|
||||||
|
value: 'spec.targetNamespace',
|
||||||
|
sort: 'spec.targetNamespace',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// VM count column in forklift.konveyor.io.plan list page
|
||||||
|
export const FORKLIFT_PLAN_VM_COUNT = {
|
||||||
|
name: 'vmCount',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
|
||||||
|
value: 'spec.vms.length',
|
||||||
|
sort: 'spec.vms.length',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plan reference column in forklift.konveyor.io.migration list page
|
||||||
|
export const FORKLIFT_MIGRATION_PLAN = {
|
||||||
|
name: 'plan',
|
||||||
|
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
|
||||||
|
value: 'spec.plan.name',
|
||||||
|
sort: 'spec.plan.name',
|
||||||
|
align: 'left',
|
||||||
|
};
|
||||||
|
|||||||
24
pkg/harvester/edit/forklift.konveyor.io.provider.vue
Normal file
24
pkg/harvester/edit/forklift.konveyor.io.provider.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<script>
|
||||||
|
import { h } from 'vue';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ForkliftProviderEdit',
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const { namespace, id } = this.$route.params;
|
||||||
|
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
|
||||||
|
|
||||||
|
this.$router.replace({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$route.params.product,
|
||||||
|
cluster: this.$route.params.cluster,
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => h('div'),
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -1,3 +1,8 @@
|
|||||||
|
wizard:
|
||||||
|
create: Create
|
||||||
|
next: Proceed
|
||||||
|
previous: Back
|
||||||
|
|
||||||
generic:
|
generic:
|
||||||
tip: Tip
|
tip: Tip
|
||||||
resourceExternalLinkTips: 'External Link'
|
resourceExternalLinkTips: 'External Link'
|
||||||
@ -31,6 +36,7 @@ nav:
|
|||||||
Logging: Logging
|
Logging: Logging
|
||||||
'Monitoring and Logging': Monitoring and Logging
|
'Monitoring and Logging': Monitoring and Logging
|
||||||
vmimport: Virtual Machine Imports
|
vmimport: Virtual Machine Imports
|
||||||
|
vmMigration: VM Migration
|
||||||
|
|
||||||
resourceTable:
|
resourceTable:
|
||||||
groupBy:
|
groupBy:
|
||||||
@ -44,6 +50,8 @@ members:
|
|||||||
projectMembership: Project Membership
|
projectMembership: Project Membership
|
||||||
|
|
||||||
asyncButton:
|
asyncButton:
|
||||||
|
createAndStart:
|
||||||
|
action: Create and Start
|
||||||
restart:
|
restart:
|
||||||
action: Save and Restart
|
action: Save and Restart
|
||||||
success: Restarted
|
success: Restarted
|
||||||
@ -365,6 +373,13 @@ harvester:
|
|||||||
v4ip: V4 IP
|
v4ip: V4 IP
|
||||||
v6ip: V6 IP
|
v6ip: V6 IP
|
||||||
eipName: EIP Name
|
eipName: EIP Name
|
||||||
|
vmMigrationProviderType: Type
|
||||||
|
vmMigrationProviderUrl: URL
|
||||||
|
vmMigrationMapSourceProvider: Source Provider
|
||||||
|
vmMigrationMapDestProvider: Destination Provider
|
||||||
|
vmMigrationPlanTargetNs: Target Namespace
|
||||||
|
vmMigrationPlanVmCount: VMs
|
||||||
|
vmMigrationMigrationPlan: Plan
|
||||||
tab:
|
tab:
|
||||||
volume: Volumes
|
volume: Volumes
|
||||||
network: Networks
|
network: Networks
|
||||||
@ -1814,6 +1829,178 @@ harvester:
|
|||||||
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
|
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
|
||||||
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
|
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
|
||||||
|
|
||||||
|
vmMigration:
|
||||||
|
generic:
|
||||||
|
unknown: Unknown
|
||||||
|
identifier: Identifier
|
||||||
|
podNetwork: Pod Network
|
||||||
|
usedBy: "Used by:"
|
||||||
|
vlan: "VLAN {id}"
|
||||||
|
vCpu: "{count} vCPU"
|
||||||
|
memoryGb: "{value} GB"
|
||||||
|
memoryTb: "{value} TB"
|
||||||
|
elapsed:
|
||||||
|
hoursAndMinutes: "{hours} {hours, plural, one {hour} other {hours}} and {minutes} {minutes, plural, one {minute} other {minutes}}"
|
||||||
|
hours: "{hours} {hours, plural, one {hour} other {hours}}"
|
||||||
|
minutes: "{minutes} {minutes, plural, one {minute} other {minutes}}"
|
||||||
|
errors:
|
||||||
|
failedLoadPlans: Failed to load migration plans
|
||||||
|
failedLoadVms: Failed to load virtual machines
|
||||||
|
failedRefreshVms: Failed to refresh virtual machines
|
||||||
|
failedLoadProviders: Failed to load providers
|
||||||
|
failedLoadProvider: "Failed to load provider: {error}"
|
||||||
|
failedResolveDetails: Failed to resolve network and datastore details
|
||||||
|
failedDecodeCredentials: Failed to decode provider credentials
|
||||||
|
connectionFailed: Connection failed
|
||||||
|
providerNotReady: Provider not ready
|
||||||
|
labels:
|
||||||
|
dashboard: Migrations
|
||||||
|
provider: Providers
|
||||||
|
networkMap: Network Maps
|
||||||
|
storageMap: Storage Maps
|
||||||
|
plan: Migration Plans
|
||||||
|
migration: Migrations
|
||||||
|
providerWizard:
|
||||||
|
title: Create Provider
|
||||||
|
editTitle: Edit Provider
|
||||||
|
wizard:
|
||||||
|
title: VM Migration
|
||||||
|
steps:
|
||||||
|
configureProvider:
|
||||||
|
label: Provider
|
||||||
|
description: Configure Provider
|
||||||
|
selectVms:
|
||||||
|
label: VMs
|
||||||
|
description: Select VMs
|
||||||
|
configureMappings:
|
||||||
|
label: Mappings
|
||||||
|
description: Define Mappings
|
||||||
|
reviewMigration:
|
||||||
|
label: Migration Plan
|
||||||
|
description: Review Migration Plan
|
||||||
|
fields:
|
||||||
|
username: Username
|
||||||
|
password: Password
|
||||||
|
configureProvider:
|
||||||
|
title: Configure Provider
|
||||||
|
description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration
|
||||||
|
connectionDetails: Connection Details
|
||||||
|
providerSelect: Provider
|
||||||
|
createNew: Create new provider
|
||||||
|
requirementsTitle: Requirements
|
||||||
|
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher."
|
||||||
|
name: Name
|
||||||
|
urlLabel: vCenter/ESXi URL
|
||||||
|
urlPlaceholder: "https://vcenter.example.com/sdk"
|
||||||
|
urlHint: Enter the full URL including https://
|
||||||
|
skipSsl: Skip SSL certificate verification
|
||||||
|
skipSslHint: Not recommended for production environments
|
||||||
|
testConnection:
|
||||||
|
action: Test Connection
|
||||||
|
success: Connection successful
|
||||||
|
error: Connection failed
|
||||||
|
waiting: Testing connection...
|
||||||
|
testSuccess: Connection test passed
|
||||||
|
testFailed: Connection test failed
|
||||||
|
testMissingFields: Please fill in all required fields before testing
|
||||||
|
testTimeout: Connection test timed out — the provider did not report a status in time
|
||||||
|
save: Save Provider and Continue
|
||||||
|
saveExisting: Check Provider and Continue
|
||||||
|
selectVms:
|
||||||
|
title: Select Virtual Machines
|
||||||
|
description: Select the virtual machines you want to migrate from the source provider
|
||||||
|
discovered: "{count} VMs discovered from"
|
||||||
|
lastSynced: "Last synced {time} ago • "
|
||||||
|
refreshNow: Refresh now
|
||||||
|
availableVms: Available Virtual Machines
|
||||||
|
selected: selected
|
||||||
|
saveSelection: Save Selection and Continue
|
||||||
|
selectAllBanner:
|
||||||
|
pageOnly: "Your current selection includes only VMs currently shown on the page."
|
||||||
|
selectAll: "Select all {count} VMs from this provider"
|
||||||
|
allSelected: "Your current selection includes all VMs."
|
||||||
|
clearSelection: "Clear Selection"
|
||||||
|
manualEntry:
|
||||||
|
title: Add Virtual Machines
|
||||||
|
description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names.
|
||||||
|
vmId: VM ID
|
||||||
|
vmIdPlaceholder: "e.g. vm-60527"
|
||||||
|
add: Add
|
||||||
|
added: VMs added
|
||||||
|
columns:
|
||||||
|
vmName: VM Name
|
||||||
|
vmId: VM ID
|
||||||
|
os: Operating System
|
||||||
|
resources: Resources
|
||||||
|
powerState: Power State
|
||||||
|
network: Network
|
||||||
|
datastore: Datastore
|
||||||
|
dashboard:
|
||||||
|
title: VMware to Harvester Migration
|
||||||
|
description: Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester.
|
||||||
|
createPlan: Create Migration
|
||||||
|
tableTitle: Migrations
|
||||||
|
columns:
|
||||||
|
status: Status
|
||||||
|
plan: Migration Plan
|
||||||
|
progress: Progress
|
||||||
|
mappings: Mappings
|
||||||
|
progress:
|
||||||
|
failed: Failed
|
||||||
|
migration: Migration
|
||||||
|
initializingMigration: Initializing migration
|
||||||
|
finishedSuccessfully: Finished Successfully
|
||||||
|
step: "Step {index}"
|
||||||
|
vmCount: "{count} VMs"
|
||||||
|
vmId: "id: {id}"
|
||||||
|
configureMappings:
|
||||||
|
title: Set Mappings
|
||||||
|
description: Map VMware networks and datastores to Harvester and Longhorn target resources
|
||||||
|
save: Save Mappings and Continue
|
||||||
|
noTemplate: Start from scratch
|
||||||
|
removeMap: Remove Map
|
||||||
|
networkMapping:
|
||||||
|
title: Network Mapping
|
||||||
|
description: Map VMware port groups to Harvester networks
|
||||||
|
placeholder: Choose a Harvester network
|
||||||
|
template: Use existing network mapping as template
|
||||||
|
options:
|
||||||
|
podNetworking: Pod Networking
|
||||||
|
ignored: Ignored
|
||||||
|
storageMapping:
|
||||||
|
title: Storage Mapping
|
||||||
|
description: Map VMware datastores to Harvester storage classes
|
||||||
|
placeholder: Choose a Longhorn Storage Volume
|
||||||
|
template: Use existing storage mapping as template
|
||||||
|
reviewMigration:
|
||||||
|
title: Review Migration Plan
|
||||||
|
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
|
||||||
|
planName: Name
|
||||||
|
planNamePlaceholder: "e.g. my-migration-plan"
|
||||||
|
migrationDetails: Migration Details
|
||||||
|
totalVms: Total VMs
|
||||||
|
vcpu: vCPU
|
||||||
|
memory: Memory
|
||||||
|
storage: Storage
|
||||||
|
source: Source
|
||||||
|
targetNamespace: Target Namespace
|
||||||
|
migrationMode: Migration Mode
|
||||||
|
coldMigration: Cold Migration
|
||||||
|
virtualMachines: Virtual Machines
|
||||||
|
warningTitle: "Cold Migration Warning"
|
||||||
|
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
|
||||||
|
startMigration: Start Migration Plan
|
||||||
|
plan:
|
||||||
|
states:
|
||||||
|
error: Error
|
||||||
|
canceled: Canceled
|
||||||
|
inProgress: In Progress
|
||||||
|
active: Succeeded
|
||||||
|
actions:
|
||||||
|
start: Start
|
||||||
|
restart: Restart
|
||||||
|
stop: Stop
|
||||||
|
|
||||||
vmImport:
|
vmImport:
|
||||||
titles:
|
titles:
|
||||||
basic: Basic
|
basic: Basic
|
||||||
@ -2449,3 +2636,28 @@ typeLabel:
|
|||||||
one { Virtual Machine Import }
|
one { Virtual Machine Import }
|
||||||
other { Virtual Machine Imports }
|
other { Virtual Machine Imports }
|
||||||
}
|
}
|
||||||
|
forklift.konveyor.io.provider: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Provider }
|
||||||
|
other { Providers }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.networkmap: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Network Map }
|
||||||
|
other { Network Maps }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.storagemap: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Storage Map }
|
||||||
|
other { Storage Maps }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.plan: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Migration Plan }
|
||||||
|
other { Migration Plans }
|
||||||
|
}
|
||||||
|
forklift.konveyor.io.migration: |-
|
||||||
|
{count, plural,
|
||||||
|
one { Migration }
|
||||||
|
other { Migrations }
|
||||||
|
}
|
||||||
|
|||||||
258
pkg/harvester/models/forklift.konveyor.io.plan.js
Normal file
258
pkg/harvester/models/forklift.konveyor.io.plan.js
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import HarvesterResource from './harvester';
|
||||||
|
import { HCI } from '../types';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
import { randomStr } from '@shell/utils/string';
|
||||||
|
|
||||||
|
export default class ForkliftPlan extends HarvesterResource {
|
||||||
|
get listLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get planFailed() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.type === 'Failed' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get planCritical() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.category === 'Critical' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get criticalMessages() {
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
return conditions
|
||||||
|
.filter((c) => c.category === 'Critical' && c.status === 'True')
|
||||||
|
.map((c) => c.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateDescription() {
|
||||||
|
const messages = [];
|
||||||
|
const conditions = this.status?.conditions || [];
|
||||||
|
|
||||||
|
if (this.planFailed) {
|
||||||
|
const failedMsg = conditions.find((c) => c.type === 'Failed' && c.status === 'True')?.message;
|
||||||
|
|
||||||
|
if (failedMsg) {
|
||||||
|
messages.push(failedMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
messages.push(...this.criticalMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.length > 0 ? messages.join(' ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateObj() {
|
||||||
|
return {
|
||||||
|
error: this.planFailed || this.planCritical,
|
||||||
|
transitioning: this.isMigrating,
|
||||||
|
message: this.stateDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMigrating() {
|
||||||
|
const migration = this.status?.migration;
|
||||||
|
|
||||||
|
return !!migration?.started && !migration?.completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
get planCanceled() {
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = history[history.length - 1];
|
||||||
|
const conditions = latest.conditions || [];
|
||||||
|
|
||||||
|
return conditions.some((c) => c.type === 'Canceled' && c.status === 'True');
|
||||||
|
}
|
||||||
|
|
||||||
|
get planSucceeded() {
|
||||||
|
const migration = this.status?.migration;
|
||||||
|
|
||||||
|
return !!migration?.completed && !this.planFailed && !this.planCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateDisplay() {
|
||||||
|
if (this.planFailed) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCanceled) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.canceled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMigrating) {
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.t('harvester.addons.vmMigration.plan.states.active');
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateBackground() {
|
||||||
|
if (this.planFailed) {
|
||||||
|
return 'bg-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCritical) {
|
||||||
|
return 'bg-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.planCanceled) {
|
||||||
|
return 'bg-warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMigrating) {
|
||||||
|
return 'bg-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-success';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isForkliftDashboard() {
|
||||||
|
const route = this.currentRouter()?.currentRoute?.value;
|
||||||
|
|
||||||
|
return route?.name?.endsWith('-vm-migration');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _availableActions() {
|
||||||
|
const canStop = this.isMigrating && !this.planCanceled;
|
||||||
|
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
if (canStart) {
|
||||||
|
out.push({
|
||||||
|
action: 'startMigration',
|
||||||
|
enabled: true,
|
||||||
|
icon: 'icon icon-play',
|
||||||
|
label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canStop) {
|
||||||
|
out.push({
|
||||||
|
action: 'stopMigration',
|
||||||
|
enabled: true,
|
||||||
|
icon: 'icon icon-pause',
|
||||||
|
label: this.t('harvester.addons.vmMigration.plan.actions.stop'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isForkliftDashboard) {
|
||||||
|
out.push({
|
||||||
|
action: 'promptRemove',
|
||||||
|
altAction: 'remove',
|
||||||
|
label: this.t('action.remove'),
|
||||||
|
icon: 'icon icon-trash',
|
||||||
|
bulkable: true,
|
||||||
|
enabled: this.canDelete,
|
||||||
|
bulkAction: 'promptRemove',
|
||||||
|
weight: -10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
} else {
|
||||||
|
out.push(...super._availableActions);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopMigration() {
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = history[history.length - 1];
|
||||||
|
const migrationName = latest.migration?.name;
|
||||||
|
|
||||||
|
if (migrationName) {
|
||||||
|
const selfUrl = this.linkFor('self');
|
||||||
|
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`);
|
||||||
|
|
||||||
|
await this.$dispatch('request', { url, method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startMigration() {
|
||||||
|
const namespace = this.metadata.namespace;
|
||||||
|
const history = this.status?.migration?.history || [];
|
||||||
|
|
||||||
|
// Delete previous migrations before starting a new one
|
||||||
|
for (const entry of history) {
|
||||||
|
const name = entry.migration?.name;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const selfUrl = this.linkFor('self');
|
||||||
|
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.$dispatch('request', { url, method: 'DELETE' });
|
||||||
|
} catch (e) {
|
||||||
|
// Migration may already be gone — ignore 404s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const migration = await this.$dispatch('create', {
|
||||||
|
type: HCI.FORKLIFT_MIGRATION,
|
||||||
|
metadata: {
|
||||||
|
name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`,
|
||||||
|
namespace,
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Plan',
|
||||||
|
name: this.metadata.name,
|
||||||
|
uid: this.metadata.uid,
|
||||||
|
blockOwnerDeletion: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
plan: {
|
||||||
|
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||||
|
kind: 'Plan',
|
||||||
|
name: this.metadata.name,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await migration.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlan() {
|
||||||
|
await this.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a Plan cascades via ownerReferences set at creation time.
|
||||||
|
* Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider (→ Secret).
|
||||||
|
* Use foreground propagation to ensure children are deleted before the parent.
|
||||||
|
*/
|
||||||
|
remove(opt = {}) {
|
||||||
|
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
|
||||||
|
|
||||||
|
return this._remove(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
pkg/harvester/models/forklift.konveyor.io.provider.js
Normal file
36
pkg/harvester/models/forklift.konveyor.io.provider.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import HarvesterResource from './harvester';
|
||||||
|
import { PRODUCT_NAME } from '../config/harvester';
|
||||||
|
|
||||||
|
export default class ForkliftProvider extends HarvesterResource {
|
||||||
|
get _createLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get _editLocation() {
|
||||||
|
return {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
params: {
|
||||||
|
product: this.$rootGetters['productId'],
|
||||||
|
cluster: this.$rootGetters['clusterId'],
|
||||||
|
},
|
||||||
|
query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a Provider cascades via ownerReferences set at creation time.
|
||||||
|
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
|
||||||
|
* Use foreground propagation to ensure children are deleted before the parent.
|
||||||
|
*/
|
||||||
|
remove(opt = {}) {
|
||||||
|
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
|
||||||
|
|
||||||
|
return this._remove(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
366
pkg/harvester/pages/c/_cluster/vm-migration/index.vue
Normal file
366
pkg/harvester/pages/c/_cluster/vm-migration/index.vue
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import Loading from '@shell/components/Loading';
|
||||||
|
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||||
|
import ResourceTable from '@shell/components/ResourceTable';
|
||||||
|
import PercentageBar from '@shell/components/PercentageBar';
|
||||||
|
import { Banner } from '@components/Banner';
|
||||||
|
import MappingsCell from '../../../../components/MappingsCell';
|
||||||
|
import { SCHEMA } from '@shell/config/types';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { HCI } from '../../../../types';
|
||||||
|
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||||
|
import { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
|
||||||
|
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
id: HCI.FORKLIFT_PLAN,
|
||||||
|
type: SCHEMA,
|
||||||
|
attributes: {
|
||||||
|
kind: HCI.FORKLIFT_PLAN,
|
||||||
|
namespaced: true
|
||||||
|
},
|
||||||
|
metadata: { name: HCI.FORKLIFT_PLAN },
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const inStore = computed(() => store.getters['currentProduct'].inStore);
|
||||||
|
|
||||||
|
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
|
||||||
|
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
|
||||||
|
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
return allPlans.value.map((plan) => {
|
||||||
|
const netMapName = plan.spec?.map?.network?.name;
|
||||||
|
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
|
||||||
|
const storMapName = plan.spec?.map?.storage?.name;
|
||||||
|
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
|
||||||
|
|
||||||
|
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
|
||||||
|
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
|
||||||
|
|
||||||
|
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`);
|
||||||
|
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' } → ${ e.destination?.storageClass || '-' }`);
|
||||||
|
|
||||||
|
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
|
||||||
|
|
||||||
|
const vms = plan.status?.migration?.vms || [];
|
||||||
|
|
||||||
|
plan.vmProgress = vms.map((vm) => {
|
||||||
|
const pipeline = vm.pipeline || [];
|
||||||
|
const totalSteps = pipeline.length || 1;
|
||||||
|
let overallProgress = 0;
|
||||||
|
let currentStep = '';
|
||||||
|
let errorMsg = '';
|
||||||
|
|
||||||
|
pipeline.forEach((step, idx) => {
|
||||||
|
const stepWeight = 100 / totalSteps;
|
||||||
|
|
||||||
|
if (step.phase === 'Completed') {
|
||||||
|
overallProgress += stepWeight;
|
||||||
|
} else {
|
||||||
|
const stepPct = (step.progress?.completed && step.progress?.total) ? (step.progress.completed / step.progress.total) * 100 : 0;
|
||||||
|
|
||||||
|
overallProgress += (stepPct / 100) * stepWeight;
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
currentStep = step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.error && !errorMsg) {
|
||||||
|
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
|
||||||
|
|
||||||
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ reasons }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.phase === 'Failed' && !errorMsg) {
|
||||||
|
errorMsg = `${ step.name || t('harvester.addons.vmMigration.dashboard.progress.step', { index: idx + 1 }) }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to VM-level error if no step-level error found
|
||||||
|
if (!errorMsg && vm.error) {
|
||||||
|
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
|
||||||
|
|
||||||
|
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
overallProgress = Math.round(overallProgress * 10) / 10;
|
||||||
|
|
||||||
|
// If no step-level error but the plan itself is failed, surface it
|
||||||
|
if (!errorMsg && plan.planFailed) {
|
||||||
|
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
vmId: vm.id || '',
|
||||||
|
progress: overallProgress,
|
||||||
|
currentStep,
|
||||||
|
errorMsg,
|
||||||
|
canceled: plan.planCanceled,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no migration progress but we have VMs in the spec, show them at 0%
|
||||||
|
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
|
||||||
|
plan.vmProgress = plan.spec.vms.map((vm) => ({
|
||||||
|
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
|
||||||
|
vmId: vm.id || '',
|
||||||
|
progress: 0,
|
||||||
|
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
|
||||||
|
errorMsg: '',
|
||||||
|
canceled: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLocation = computed(() => ({
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
|
||||||
|
{
|
||||||
|
...NAME_COL,
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
|
||||||
|
},
|
||||||
|
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
|
||||||
|
{
|
||||||
|
name: 'progress',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
|
||||||
|
value: 'progress',
|
||||||
|
width: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mappings',
|
||||||
|
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
|
||||||
|
},
|
||||||
|
{ ...AGE },
|
||||||
|
];
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
|
||||||
|
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<div v-else>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<Masthead
|
||||||
|
:schema="schema"
|
||||||
|
:resource="schema.id"
|
||||||
|
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
|
||||||
|
>
|
||||||
|
<template #subHeader>
|
||||||
|
<div class="mmt-5">
|
||||||
|
<p class="text-muted">
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #createButton>
|
||||||
|
<router-link
|
||||||
|
:to="createLocation"
|
||||||
|
class="btn role-primary"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</Masthead>
|
||||||
|
|
||||||
|
<ResourceTable
|
||||||
|
:schema="schema"
|
||||||
|
:rows="rows"
|
||||||
|
:headers="headers"
|
||||||
|
:groupable="false"
|
||||||
|
:table-actions="false"
|
||||||
|
:search="false"
|
||||||
|
key-field="_key"
|
||||||
|
>
|
||||||
|
<template #header-left>
|
||||||
|
<h3 class="table-title m-0">
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.tableTitle') }}
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
|
<template #cell:name="{ row }">
|
||||||
|
<div class="plan-name-cell">
|
||||||
|
<div class="plan-name">
|
||||||
|
{{ row.metadata.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:vmCount="{ row }">
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.progress.vmCount', { count: (row.spec.vms || []).length }) }}
|
||||||
|
</template>
|
||||||
|
<template #cell:progress="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="row.vmProgress && row.vmProgress.length"
|
||||||
|
class="progress-cells"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="vm in row.vmProgress"
|
||||||
|
:key="vm.vmId"
|
||||||
|
class="vm-progress"
|
||||||
|
>
|
||||||
|
<div class="vm-progress-header">
|
||||||
|
<div class="vm-name-block">
|
||||||
|
<span class="vm-name">{{ vm.vmName }}</span>
|
||||||
|
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vm-pct-block">
|
||||||
|
<PercentageBar
|
||||||
|
:model-value="vm.progress"
|
||||||
|
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
|
||||||
|
preferred-direction="MORE"
|
||||||
|
class="vm-bar"
|
||||||
|
/>
|
||||||
|
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="vm.progress >= 100"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.dashboard.progress.finishedSuccessfully') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.errorMsg"
|
||||||
|
class="step-label text-error"
|
||||||
|
>
|
||||||
|
{{ vm.errorMsg }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.canceled"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ t('harvester.addons.vmMigration.plan.states.canceled') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="vm.currentStep"
|
||||||
|
class="step-label text-muted"
|
||||||
|
>
|
||||||
|
{{ vm.currentStep }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="!row.vmProgress.length">-</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:mappings="{ row }">
|
||||||
|
<MappingsCell
|
||||||
|
:network-entries="row.networkEntries"
|
||||||
|
:storage-entries="row.storageEntries"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ResourceTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.plan-name-cell {
|
||||||
|
.plan-name {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-vms {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-cells {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-name-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-id {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pct {
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pct-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-bar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
&.text-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
286
pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue
Normal file
286
pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
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 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';
|
||||||
|
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
|
||||||
|
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
|
||||||
|
import { HCI } from '@pkg/harvester/types';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const route = currentRoute();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const cruRef = ref(null);
|
||||||
|
const providerStepRef = ref(null);
|
||||||
|
const mappingsStepRef = ref(null);
|
||||||
|
|
||||||
|
const providerName = ref('');
|
||||||
|
const provider = ref(null);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const providerReady = ref(false);
|
||||||
|
const providerFormValid = ref(false);
|
||||||
|
const providerTesting = ref(false);
|
||||||
|
const mappingsReady = ref(false);
|
||||||
|
|
||||||
|
const existingNetworkMap = ref(null);
|
||||||
|
const existingStorageMap = ref(null);
|
||||||
|
const initialLoading = ref(true);
|
||||||
|
|
||||||
|
const dummyResource = ref({ save: () => Promise.resolve() });
|
||||||
|
|
||||||
|
const providerId = route.query?.providerId || null;
|
||||||
|
const isEditMode = !!providerId;
|
||||||
|
|
||||||
|
const stepData = reactive({
|
||||||
|
provider: {
|
||||||
|
selectedProvider: isEditMode ? '' : '__create_new__',
|
||||||
|
providerName: '',
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
skipTlsVerify: false,
|
||||||
|
testPassed: false,
|
||||||
|
testResult: null,
|
||||||
|
testError: null,
|
||||||
|
createdProvider: null,
|
||||||
|
createdSecret: null,
|
||||||
|
},
|
||||||
|
mappings: {
|
||||||
|
networkEntries: [],
|
||||||
|
storageEntries: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = reactive([
|
||||||
|
{
|
||||||
|
name: 'configure-provider',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'configure-mappings',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch([providerFormValid, providerTesting], () => {
|
||||||
|
steps[0].ready = providerFormValid.value && !providerTesting.value;
|
||||||
|
}, { immediate: true });
|
||||||
|
watch(mappingsReady, (val) => {
|
||||||
|
steps[1].ready = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
|
||||||
|
// "test connection before advancing" handshake, since the provider must be verified
|
||||||
|
// before the user can leave the first step.
|
||||||
|
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||||
|
|
||||||
|
const pendingProceed = ref(false);
|
||||||
|
|
||||||
|
// Handshake to gate step 1 -> 2 on a successful connection test:
|
||||||
|
// 1. When the user tries to advance off the provider step before it's ready,
|
||||||
|
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
|
||||||
|
// programmatically trigger the async "Test connection" button.
|
||||||
|
// 2. Once the test flips `providerReady`, mark the step ready and advance
|
||||||
|
// (goToStep(2)). If the test fails, the user simply stays on the provider step.
|
||||||
|
watch(
|
||||||
|
() => wizardComponent.value?.activeStepIndex,
|
||||||
|
(newIdx, oldIdx) => {
|
||||||
|
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
|
||||||
|
wizardComponent.value.goToStep(1);
|
||||||
|
pendingProceed.value = true;
|
||||||
|
providerStepRef.value.clickTestButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(providerReady, (val) => {
|
||||||
|
if (val && pendingProceed.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
steps[0].ready = true;
|
||||||
|
wizardComponent.value.goToStep(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const wizardTitle = computed(() => {
|
||||||
|
return isEditMode ? t('harvester.addons.vmMigration.providerWizard.editTitle') : t('harvester.addons.vmMigration.providerWizard.title');
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProviderComplete = (data) => {
|
||||||
|
providerName.value = data.providerName;
|
||||||
|
provider.value = data.provider;
|
||||||
|
providerReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderFormValid = (valid) => {
|
||||||
|
providerFormValid.value = valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderTesting = (testing) => {
|
||||||
|
providerTesting.value = testing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderReady = (ready) => {
|
||||||
|
providerReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMappingsReady = (ready) => {
|
||||||
|
mappingsReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear mappings when provider changes
|
||||||
|
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerListLocation = {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
resource: HCI.FORKLIFT_PROVIDER,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async(buttonCb) => {
|
||||||
|
try {
|
||||||
|
await mappingsStepRef.value.saveMappings();
|
||||||
|
buttonCb(true);
|
||||||
|
currentRouter().push(providerListLocation);
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = [err instanceof Error ? err.message : String(err)];
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
currentRouter().push(providerListLocation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async() => {
|
||||||
|
if (!isEditMode) {
|
||||||
|
initialLoading.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inStore = store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedProvider = await store.dispatch(`${ inStore }/find`, {
|
||||||
|
type: HCI.FORKLIFT_PROVIDER,
|
||||||
|
id: providerId,
|
||||||
|
opt: { force: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.value = fetchedProvider;
|
||||||
|
providerName.value = fetchedProvider.metadata.name;
|
||||||
|
|
||||||
|
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
|
||||||
|
stepData.provider.providerName = fetchedProvider.metadata.name;
|
||||||
|
stepData.provider.url = fetchedProvider.spec?.url || '';
|
||||||
|
stepData.provider.createdProvider = fetchedProvider;
|
||||||
|
|
||||||
|
const secretRef = fetchedProvider.spec?.secret;
|
||||||
|
|
||||||
|
if (secretRef) {
|
||||||
|
const allSecrets = await store.dispatch(`${ inStore }/findAll`, {
|
||||||
|
type: SECRET,
|
||||||
|
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = allSecrets.find(
|
||||||
|
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secret?.data) {
|
||||||
|
stepData.provider.username = decodeSecretValue(secret.data.user);
|
||||||
|
stepData.provider.password = decodeSecretValue(secret.data.password);
|
||||||
|
stepData.provider.skipTlsVerify = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
|
||||||
|
stepData.provider.createdSecret = secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing default maps for this provider
|
||||||
|
try {
|
||||||
|
const allNetworkMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
|
||||||
|
const allStorageMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
|
||||||
|
|
||||||
|
existingNetworkMap.value = allNetworkMaps.find(
|
||||||
|
(nm) => nm.metadata.name === `${ fetchedProvider.metadata.name }-network-map-default`
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
existingStorageMap.value = allStorageMaps.find(
|
||||||
|
(sm) => sm.metadata.name === `${ fetchedProvider.metadata.name }-storage-map-default`
|
||||||
|
) || null;
|
||||||
|
} catch (e) {
|
||||||
|
// Maps may not exist yet
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })];
|
||||||
|
}
|
||||||
|
|
||||||
|
initialLoading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Loading v-if="initialLoading" />
|
||||||
|
<CruResource
|
||||||
|
v-else
|
||||||
|
ref="cruRef"
|
||||||
|
:resource="dummyResource"
|
||||||
|
:mode="isEditMode ? 'edit' : 'create'"
|
||||||
|
:steps="steps"
|
||||||
|
:errors="errors"
|
||||||
|
:validation-passed="true"
|
||||||
|
:can-yaml="false"
|
||||||
|
:cancel-event="true"
|
||||||
|
:title="wizardTitle"
|
||||||
|
finish-mode="finish"
|
||||||
|
class="wizard"
|
||||||
|
@cancel="onCancel"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<template #configure-provider>
|
||||||
|
<ConfigureProviderStep
|
||||||
|
ref="providerStepRef"
|
||||||
|
:step-data="stepData.provider"
|
||||||
|
:create-only="true"
|
||||||
|
:edit-mode="isEditMode"
|
||||||
|
@complete="onProviderComplete"
|
||||||
|
@ready="onProviderReady"
|
||||||
|
@form-valid="onProviderFormValid"
|
||||||
|
@testing="onProviderTesting"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #configure-mappings>
|
||||||
|
<ConfigureMappingsStep
|
||||||
|
ref="mappingsStepRef"
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:step-data="stepData.mappings"
|
||||||
|
:use-all-provider-data="true"
|
||||||
|
:existing-network-map="existingNetworkMap"
|
||||||
|
:existing-storage-map="existingStorageMap"
|
||||||
|
@ready="onMappingsReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CruResource>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,289 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import CruResource from '@shell/components/CruResource';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
|
||||||
|
import SelectVmsStep from '@pkg/harvester/components/vm-migration/SelectVmsStep.vue';
|
||||||
|
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
|
||||||
|
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
|
||||||
|
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
|
||||||
|
import { currentRouter } from '@pkg/harvester/utils/router';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const cruRef = ref(null);
|
||||||
|
const providerStepRef = ref(null);
|
||||||
|
const reviewStepRef = ref(null);
|
||||||
|
|
||||||
|
const providerName = ref('');
|
||||||
|
const provider = ref(null);
|
||||||
|
const selectedVMs = ref([]);
|
||||||
|
const errors = ref([]);
|
||||||
|
|
||||||
|
const providerReady = ref(false);
|
||||||
|
const providerFormValid = ref(false);
|
||||||
|
const providerTesting = ref(false);
|
||||||
|
const vmsReady = ref(false);
|
||||||
|
const vmsLoading = ref(true);
|
||||||
|
const mappingsReady = ref(false);
|
||||||
|
const reviewReady = ref(false);
|
||||||
|
|
||||||
|
const dummyResource = ref({ save: () => Promise.resolve() });
|
||||||
|
|
||||||
|
const stepData = reactive({
|
||||||
|
provider: {
|
||||||
|
selectedProvider: '__create_new__',
|
||||||
|
providerName: '',
|
||||||
|
url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
skipTlsVerify: false,
|
||||||
|
testPassed: false,
|
||||||
|
testResult: null,
|
||||||
|
testError: null,
|
||||||
|
createdProvider: null,
|
||||||
|
createdSecret: null,
|
||||||
|
},
|
||||||
|
vms: {
|
||||||
|
discoveredVMs: [],
|
||||||
|
selectedVMIds: new Set(),
|
||||||
|
tableRows: [],
|
||||||
|
},
|
||||||
|
mappings: {
|
||||||
|
networkEntries: [],
|
||||||
|
storageEntries: [],
|
||||||
|
},
|
||||||
|
review: { planName: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = reactive([
|
||||||
|
{
|
||||||
|
name: 'configure-provider',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureProvider.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureProvider.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'select-vms',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.selectVms.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.selectVms.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'configure-mappings',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.configureMappings.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.configureMappings.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'review-migration',
|
||||||
|
label: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label'),
|
||||||
|
subtext: t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description'),
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch([providerFormValid, providerTesting], () => {
|
||||||
|
steps[0].ready = providerFormValid.value && !providerTesting.value;
|
||||||
|
}, { immediate: true });
|
||||||
|
watch([vmsReady, vmsLoading], () => {
|
||||||
|
steps[1].ready = vmsReady.value && !vmsLoading.value;
|
||||||
|
});
|
||||||
|
watch(mappingsReady, (val) => {
|
||||||
|
steps[2].ready = val;
|
||||||
|
});
|
||||||
|
watch(reviewReady, (val) => {
|
||||||
|
steps[3].ready = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
|
||||||
|
// "test connection before advancing" handshake, since the provider must be verified
|
||||||
|
// before the user can leave the first step.
|
||||||
|
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||||
|
|
||||||
|
const pendingProceed = ref(false);
|
||||||
|
|
||||||
|
// Handshake to gate step 1 -> 2 on a successful connection test:
|
||||||
|
// 1. When the user tries to advance off the provider step before it's ready,
|
||||||
|
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
|
||||||
|
// programmatically trigger the async "Test connection" button.
|
||||||
|
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
|
||||||
|
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
|
||||||
|
// watcher clears `pendingProceed` and the user stays put.
|
||||||
|
watch(
|
||||||
|
() => wizardComponent.value?.activeStepIndex,
|
||||||
|
(newIdx, oldIdx) => {
|
||||||
|
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
|
||||||
|
wizardComponent.value.goToStep(1);
|
||||||
|
pendingProceed.value = true;
|
||||||
|
providerStepRef.value.clickTestButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(providerReady, (val) => {
|
||||||
|
if (val && pendingProceed.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
steps[0].ready = true;
|
||||||
|
wizardComponent.value.goToStep(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(providerTesting, (testing) => {
|
||||||
|
if (!testing && pendingProceed.value && !providerReady.value) {
|
||||||
|
pendingProceed.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProviderComplete = (data) => {
|
||||||
|
providerName.value = data.providerName;
|
||||||
|
provider.value = data.provider;
|
||||||
|
providerReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderFormValid = (valid) => {
|
||||||
|
providerFormValid.value = valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderTesting = (testing) => {
|
||||||
|
providerTesting.value = testing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProviderReady = (ready) => {
|
||||||
|
providerReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVmsComplete = (data) => {
|
||||||
|
selectedVMs.value = data.selectedVMs;
|
||||||
|
vmsReady.value = data.selectedVMs.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVmsLoading = (val) => {
|
||||||
|
vmsLoading.value = val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMappingsReady = (ready) => {
|
||||||
|
mappingsReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReviewReady = (ready) => {
|
||||||
|
reviewReady.value = ready;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear downstream data when provider changes
|
||||||
|
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
|
||||||
|
if (oldVal && newVal !== oldVal) {
|
||||||
|
stepData.vms.discoveredVMs = [];
|
||||||
|
stepData.vms.selectedVMIds = new Set();
|
||||||
|
stepData.vms.tableRows = [];
|
||||||
|
selectedVMs.value = [];
|
||||||
|
vmsReady.value = false;
|
||||||
|
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
|
||||||
|
stepData.review.planName = '';
|
||||||
|
reviewReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear mappings and review when VM selection changes
|
||||||
|
watch(selectedVMs, (newVal, oldVal) => {
|
||||||
|
const newIds = new Set(newVal.map((v) => v.id));
|
||||||
|
const oldIds = new Set(oldVal.map((v) => v.id));
|
||||||
|
const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id));
|
||||||
|
|
||||||
|
if (oldVal.length > 0 && changed) {
|
||||||
|
stepData.mappings.networkEntries = [];
|
||||||
|
stepData.mappings.storageEntries = [];
|
||||||
|
mappingsReady.value = false;
|
||||||
|
|
||||||
|
stepData.review.planName = '';
|
||||||
|
reviewReady.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrationListLocation = {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
params: {
|
||||||
|
product: store.getters['productId'],
|
||||||
|
cluster: store.getters['clusterId'],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async(buttonCb) => {
|
||||||
|
try {
|
||||||
|
await reviewStepRef.value.startMigration();
|
||||||
|
buttonCb(true);
|
||||||
|
currentRouter().push(migrationListLocation);
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = [err instanceof Error ? err.message : String(err)];
|
||||||
|
buttonCb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
currentRouter().push(migrationListLocation);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CruResource
|
||||||
|
ref="cruRef"
|
||||||
|
:resource="dummyResource"
|
||||||
|
:mode="'create'"
|
||||||
|
:steps="steps"
|
||||||
|
:errors="errors"
|
||||||
|
:validation-passed="true"
|
||||||
|
:can-yaml="false"
|
||||||
|
:cancel-event="true"
|
||||||
|
finish-mode="finish"
|
||||||
|
finish-button-mode="createAndStart"
|
||||||
|
class="wizard"
|
||||||
|
@cancel="onCancel"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<template #configure-provider>
|
||||||
|
<ConfigureProviderStep
|
||||||
|
ref="providerStepRef"
|
||||||
|
:step-data="stepData.provider"
|
||||||
|
@complete="onProviderComplete"
|
||||||
|
@ready="onProviderReady"
|
||||||
|
@form-valid="onProviderFormValid"
|
||||||
|
@testing="onProviderTesting"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #select-vms>
|
||||||
|
<SelectVmsStep
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:step-data="stepData.vms"
|
||||||
|
@complete="onVmsComplete"
|
||||||
|
@loading="onVmsLoading"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #configure-mappings>
|
||||||
|
<ConfigureMappingsStep
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:selected-vms="selectedVMs"
|
||||||
|
:step-data="stepData.mappings"
|
||||||
|
@ready="onMappingsReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #review-migration>
|
||||||
|
<ReviewMigrationStep
|
||||||
|
ref="reviewStepRef"
|
||||||
|
:provider-name="providerName"
|
||||||
|
:provider="provider"
|
||||||
|
:selected-vms="selectedVMs"
|
||||||
|
:mapping-entries="stepData.mappings"
|
||||||
|
:step-data="stepData.review"
|
||||||
|
@ready="onReviewReady"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CruResource>
|
||||||
|
</template>
|
||||||
@ -16,6 +16,9 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
|
|||||||
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
||||||
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
|
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
|
||||||
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
|
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
|
||||||
|
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
|
||||||
|
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
|
||||||
|
import ForkliftProviderWizard from '../pages/c/_cluster/vm-migration/provider-wizard.vue';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@ -83,6 +86,18 @@ const routes = [
|
|||||||
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
||||||
path: `/:product/c/:cluster/projectsnamespaces`,
|
path: `/:product/c/:cluster/projectsnamespaces`,
|
||||||
component: ProjectNamespaces,
|
component: ProjectNamespaces,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration`,
|
||||||
|
component: ForkliftDashboard,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration/wizard`,
|
||||||
|
component: ForkliftVmMigrationWizard,
|
||||||
|
}, {
|
||||||
|
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
|
||||||
|
path: `/:product/c/:cluster/vm-migration/provider-wizard`,
|
||||||
|
component: ForkliftProviderWizard,
|
||||||
}, {
|
}, {
|
||||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||||
path: `/:product/c/:cluster/:resource`,
|
path: `/:product/c/:cluster/:resource`,
|
||||||
|
|||||||
@ -5,77 +5,82 @@
|
|||||||
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
|
// 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group.
|
||||||
|
|
||||||
export const HCI = {
|
export const HCI = {
|
||||||
VM: 'kubevirt.io.virtualmachine',
|
VM: 'kubevirt.io.virtualmachine',
|
||||||
VMI: 'kubevirt.io.virtualmachineinstance',
|
VMI: 'kubevirt.io.virtualmachineinstance',
|
||||||
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
|
VMIM: 'kubevirt.io.virtualmachineinstancemigration',
|
||||||
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
|
VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate',
|
||||||
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
|
VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion',
|
||||||
IMAGE: 'harvesterhci.io.virtualmachineimage',
|
IMAGE: 'harvesterhci.io.virtualmachineimage',
|
||||||
SSH: 'harvesterhci.io.keypair',
|
SSH: 'harvesterhci.io.keypair',
|
||||||
VOLUME: 'harvesterhci.io.volume',
|
VOLUME: 'harvesterhci.io.volume',
|
||||||
USER: 'harvesterhci.io.user',
|
USER: 'harvesterhci.io.user',
|
||||||
SETTING: 'harvesterhci.io.setting',
|
SETTING: 'harvesterhci.io.setting',
|
||||||
UPGRADE: 'harvesterhci.io.upgrade',
|
UPGRADE: 'harvesterhci.io.upgrade',
|
||||||
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
|
UPGRADE_LOG: 'harvesterhci.io.upgradelog',
|
||||||
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
|
SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup',
|
||||||
BACKUP: 'harvesterhci.io.virtualmachinebackup',
|
BACKUP: 'harvesterhci.io.virtualmachinebackup',
|
||||||
RESTORE: 'harvesterhci.io.virtualmachinerestore',
|
RESTORE: 'harvesterhci.io.virtualmachinerestore',
|
||||||
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
|
NODE_NETWORK: 'network.harvesterhci.io.nodenetwork',
|
||||||
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
|
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
|
||||||
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
|
HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig',
|
||||||
SUBNET: 'kubeovn.io.subnet',
|
SUBNET: 'kubeovn.io.subnet',
|
||||||
VPC: 'kubeovn.io.vpc',
|
VPC: 'kubeovn.io.vpc',
|
||||||
IP: 'kubeovn.io.ip',
|
IP: 'kubeovn.io.ip',
|
||||||
VLAN: 'kubeovn.io.vlan',
|
VLAN: 'kubeovn.io.vlan',
|
||||||
IPTABLES_EIP: 'kubeovn.io.iptableseip',
|
IPTABLES_EIP: 'kubeovn.io.iptableseip',
|
||||||
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
|
IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule',
|
||||||
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
|
IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule',
|
||||||
PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
|
PROVIDER_NETWORK: 'kubeovn.io.providernetwork',
|
||||||
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
|
VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway',
|
||||||
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
|
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
|
||||||
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
|
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
|
||||||
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
||||||
CLUSTER: 'harvesterhci.io.management.cluster',
|
CLUSTER: 'harvesterhci.io.management.cluster',
|
||||||
DASHBOARD: 'harvesterhci.io.dashboard',
|
DASHBOARD: 'harvesterhci.io.dashboard',
|
||||||
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
|
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
|
||||||
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
||||||
HOST: 'harvesterhci.io.host',
|
HOST: 'harvesterhci.io.host',
|
||||||
VERSION: 'harvesterhci.io.version',
|
VERSION: 'harvesterhci.io.version',
|
||||||
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
|
SNAPSHOT: 'harvesterhci.io.volumesnapshot',
|
||||||
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
|
VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot',
|
||||||
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
|
ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig',
|
||||||
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
|
CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow',
|
||||||
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
|
CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput',
|
||||||
FLOW: 'harvesterhci.io.logging.flow',
|
FLOW: 'harvesterhci.io.logging.flow',
|
||||||
OUTPUT: 'harvesterhci.io.logging.output',
|
OUTPUT: 'harvesterhci.io.logging.output',
|
||||||
STORAGE: 'harvesterhci.io.storage',
|
STORAGE: 'harvesterhci.io.storage',
|
||||||
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
|
RESOURCE_QUOTA: 'harvesterhci.io.resourcequota',
|
||||||
KSTUNED: 'node.harvesterhci.io.ksmtuned',
|
KSTUNED: 'node.harvesterhci.io.ksmtuned',
|
||||||
HUGEPAGES: 'node.harvesterhci.io.hugepage',
|
HUGEPAGES: 'node.harvesterhci.io.hugepage',
|
||||||
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
|
PCI_DEVICE: 'devices.harvesterhci.io.pcidevice',
|
||||||
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
|
PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim',
|
||||||
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
|
SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice',
|
||||||
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
|
VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice',
|
||||||
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
|
SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice',
|
||||||
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
|
USB_DEVICE: 'devices.harvesterhci.io.usbdevice',
|
||||||
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
|
USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim',
|
||||||
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
|
MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration',
|
||||||
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
|
VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig',
|
||||||
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
|
VLAN_STATUS: 'network.harvesterhci.io.vlanstatus',
|
||||||
ADD_ONS: 'harvesterhci.io.addon',
|
ADD_ONS: 'harvesterhci.io.addon',
|
||||||
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
|
LINK_MONITOR: 'network.harvesterhci.io.linkmonitor',
|
||||||
SECRET: 'harvesterhci.io.secret',
|
SECRET: 'harvesterhci.io.secret',
|
||||||
INVENTORY: 'metal.harvesterhci.io.inventory',
|
INVENTORY: 'metal.harvesterhci.io.inventory',
|
||||||
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
|
LB: 'loadbalancer.harvesterhci.io.loadbalancer',
|
||||||
IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
|
IP_POOL: 'loadbalancer.harvesterhci.io.ippool',
|
||||||
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
|
HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig',
|
||||||
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
|
LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup',
|
||||||
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
|
VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource',
|
||||||
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
|
VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource',
|
||||||
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
|
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
|
||||||
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
|
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
|
||||||
MIGRATION: 'migration.harvesterhci.io',
|
MIGRATION: 'migration.harvesterhci.io',
|
||||||
|
// Forklift CRDs
|
||||||
|
FORKLIFT_PROVIDER: 'forklift.konveyor.io.provider',
|
||||||
|
FORKLIFT_NETWORK_MAP: 'forklift.konveyor.io.networkmap',
|
||||||
|
FORKLIFT_STORAGE_MAP: 'forklift.konveyor.io.storagemap',
|
||||||
|
FORKLIFT_PLAN: 'forklift.konveyor.io.plan',
|
||||||
|
FORKLIFT_MIGRATION: 'forklift.konveyor.io.migration',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
|
export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';
|
||||||
|
|||||||
80
pkg/harvester/utils/forklift.js
Normal file
80
pkg/harvester/utils/forklift.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Shared helpers for the Forklift VM-migration feature.
|
||||||
|
// Keep the map-spec builders here so the wizard step and the review step
|
||||||
|
// (and any future edit flow) stay in sync instead of drifting apart.
|
||||||
|
|
||||||
|
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64 value stored in a k8s Secret, tolerating malformed input.
|
||||||
|
*/
|
||||||
|
export function decodeSecretValue(val) {
|
||||||
|
try {
|
||||||
|
return atob(val || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes → whole GiB (rounded). Returns 0 for falsy input.
|
||||||
|
*/
|
||||||
|
export function bytesToGB(bytes) {
|
||||||
|
return Math.round((bytes || 0) / (1024 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MiB → whole GiB (rounded). Returns 0 for falsy input.
|
||||||
|
*/
|
||||||
|
export function mbToGB(mb) {
|
||||||
|
return Math.round((mb || 0) / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `spec.map` entries for a Forklift NetworkMap from wizard entries.
|
||||||
|
* Entries without a chosen target are dropped so we never emit an invalid
|
||||||
|
* multus destination with an empty name.
|
||||||
|
*
|
||||||
|
* @param {Array} entries network mapping entries ({ name, id, target })
|
||||||
|
* @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix
|
||||||
|
*/
|
||||||
|
export function buildNetworkMapEntries(entries = [], defaultNamespace) {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => !!entry.target)
|
||||||
|
.map((entry) => {
|
||||||
|
const source = { name: entry.name, id: entry.id };
|
||||||
|
|
||||||
|
if (entry.target === 'pod') {
|
||||||
|
return { source, destination: { type: 'pod' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.target === 'ignored') {
|
||||||
|
return { source, destination: { type: 'ignored' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = entry.target.split('/');
|
||||||
|
const name = parts.length > 1 ? parts[1] : parts[0];
|
||||||
|
const namespace = parts.length > 1 ? parts[0] : defaultNamespace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
destination: {
|
||||||
|
type: 'multus', name, namespace
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `spec.map` entries for a Forklift StorageMap from wizard entries.
|
||||||
|
* Entries without a chosen target are dropped.
|
||||||
|
*
|
||||||
|
* @param {Array} entries storage mapping entries ({ name, id, target })
|
||||||
|
*/
|
||||||
|
export function buildStorageMapEntries(entries = []) {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => !!entry.target)
|
||||||
|
.map((entry) => ({
|
||||||
|
source: { name: entry.name, id: entry.id },
|
||||||
|
destination: { storageClass: entry.target },
|
||||||
|
}));
|
||||||
|
}
|
||||||
11
pkg/harvester/utils/router.js
Normal file
11
pkg/harvester/utils/router.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Mirrors Resource class methods from @shell/plugins/dashboard-store/resource-class.js
|
||||||
|
// useRouter()/useRoute() from vue-router don't work in extension mode,
|
||||||
|
// so we use the same global access pattern the shell uses internally.
|
||||||
|
|
||||||
|
export function currentRouter() {
|
||||||
|
return window.$globalApp.$router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentRoute() {
|
||||||
|
return window.$globalApp.$route;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user