mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
feat: forklift UI (#891)
* feat: forklift poc first commit Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: add migration and plan detail pages with progress tracking Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): UI based on the FIGMA Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fixed config based on new figma changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): New changes added based on new figma Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Add the succeded state Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * Merge the new APIs Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Applied fix to select the proper data on network. Added condition to show critical Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Added icons, critical state, fix grids, action button Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Change to use the proper provider instead of a hardcoded Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Added pagination UI Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): FIxed the routing for the extension Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): route and router doesnt work properly on extensions Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): fixed to fix the icon on extension Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Added overflow hidden to mapping target list Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Added better handling on the saving and testing Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Remove test flags Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Changed based on review Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): fixed title and button Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Design changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Changed to Wizard Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): added the provider wizard Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fixed some UI changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Removed wrong configs used for testing Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): update size Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes after Copilot Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes after Copilot Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): reverted settings.json changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes after Copilot Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fix the way it is registered Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fixed the type for VMware Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small changes before rebase Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: Added new API for the vms Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: Fix some labels Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: Added some final fixes to structure Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: added loading to provider steps Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: added comments from copilot review Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> --------- Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
parent
c3bb0ea4be
commit
560643d299
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_OVA_URL,
|
||||
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';
|
||||
import { ADD_ONS } from './harvester-map';
|
||||
import { registerAddonSideNav } from '../utils/dynamic-nav';
|
||||
@ -1319,4 +1326,182 @@ export function init($plugin, store) {
|
||||
exact: false,
|
||||
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',
|
||||
LVM_DRIVER: 'lvm.driver.harvesterhci.io',
|
||||
KUBEOVN_OPERATOR: 'kubeovn-operator',
|
||||
FORKLIFT_OPERATOR: 'forklift-operator',
|
||||
};
|
||||
|
||||
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'
|
||||
};
|
||||
|
||||
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
|
||||
export const HARVESTER_CRD_MAP = {
|
||||
node: HCI.HOST,
|
||||
|
||||
@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
|
||||
sort: 'status.status',
|
||||
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:
|
||||
tip: Tip
|
||||
resourceExternalLinkTips: 'External Link'
|
||||
@ -31,6 +36,7 @@ nav:
|
||||
Logging: Logging
|
||||
'Monitoring and Logging': Monitoring and Logging
|
||||
vmimport: Virtual Machine Imports
|
||||
vmMigration: VM Migration
|
||||
|
||||
resourceTable:
|
||||
groupBy:
|
||||
@ -44,6 +50,8 @@ members:
|
||||
projectMembership: Project Membership
|
||||
|
||||
asyncButton:
|
||||
createAndStart:
|
||||
action: Create and Start
|
||||
restart:
|
||||
action: Save and Restart
|
||||
success: Restarted
|
||||
@ -365,6 +373,13 @@ harvester:
|
||||
v4ip: V4 IP
|
||||
v6ip: V6 IP
|
||||
eipName: EIP Name
|
||||
vmMigrationProviderType: Type
|
||||
vmMigrationProviderUrl: URL
|
||||
vmMigrationMapSourceProvider: Source Provider
|
||||
vmMigrationMapDestProvider: Destination Provider
|
||||
vmMigrationPlanTargetNs: Target Namespace
|
||||
vmMigrationPlanVmCount: VMs
|
||||
vmMigrationMigrationPlan: Plan
|
||||
tab:
|
||||
volume: Volumes
|
||||
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.
|
||||
'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:
|
||||
titles:
|
||||
basic: Basic
|
||||
@ -2449,3 +2636,28 @@ typeLabel:
|
||||
one { Virtual Machine Import }
|
||||
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 HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.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 = [
|
||||
{
|
||||
@ -83,6 +86,18 @@ const routes = [
|
||||
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
|
||||
path: `/:product/c/:cluster/projectsnamespaces`,
|
||||
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`,
|
||||
path: `/:product/c/:cluster/:resource`,
|
||||
|
||||
@ -75,7 +75,12 @@ export const HCI = {
|
||||
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
|
||||
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
|
||||
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';
|
||||
|
||||
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