feat(forklift): Changed to Wizard

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-05-21 10:53:28 +02:00
parent 7c8f172012
commit e703f34879
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
12 changed files with 1491 additions and 1309 deletions

View File

@ -2,25 +2,20 @@
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { SCHEMA, STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { currentRouter, currentRoute } from '../../../../utils/router';
import { HCI } from '../../types';
const schema = {
id: HCI.FORKLIFT_NETWORK_MAP,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_NETWORK_MAP,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_NETWORK_MAP },
};
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
@ -36,11 +31,19 @@ const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const selectedNetworkTemplate = ref(NO_TEMPLATE);
const selectedStorageTemplate = ref(NO_TEMPLATE);
const errors = ref([]);
const loading = ref(true);
// Restore from stepData
if (props.stepData.networkEntries.length > 0) {
networkEntries.value = props.stepData.networkEntries;
}
if (props.stepData.storageEntries.length > 0) {
storageEntries.value = props.stepData.storageEntries;
}
selectedNetworkTemplate.value = props.stepData.selectedNetworkTemplate;
selectedStorageTemplate.value = props.stepData.selectedStorageTemplate;
const NAMESPACE = 'forklift';
const providerName = computed(() => currentRoute().query.provider || 'vsphere');
const harvesterNetworkOptions = computed(() => {
const options = [
@ -74,7 +77,7 @@ const storageClassOptions = computed(() => {
const networkTemplateOptions = computed(() => {
const options = [
{ label: t('harvester.addons.forklift.configureMappings.noTemplate'), value: NO_TEMPLATE }
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
];
const currentIds = new Set(networkEntries.value.map((e) => e.id).filter(Boolean));
@ -100,7 +103,7 @@ const networkTemplateOptions = computed(() => {
const storageTemplateOptions = computed(() => {
const options = [
{ label: t('harvester.addons.forklift.configureMappings.noTemplate'), value: NO_TEMPLATE }
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
];
const currentIds = new Set(storageEntries.value.map((e) => e.id).filter(Boolean));
@ -192,6 +195,29 @@ const canSave = computed(() => {
allNetworksMapped.value && allStorageMapped.value;
});
watch(canSave, (val) => {
emit('ready', val);
});
// After restore, check if ready
if (canSave.value) {
emit('ready', true);
}
// Sync state back to stepData
watch(networkEntries, (val) => {
props.stepData.networkEntries = val;
}, { deep: true });
watch(storageEntries, (val) => {
props.stepData.storageEntries = val;
}, { deep: true });
watch(selectedNetworkTemplate, (val) => {
props.stepData.selectedNetworkTemplate = val;
});
watch(selectedStorageTemplate, (val) => {
props.stepData.selectedStorageTemplate = val;
});
const buildNetworkEntries = () => {
const networkMap = {};
@ -239,13 +265,15 @@ const buildStorageEntries = () => {
name: ds.name || 'Unknown',
id: ds.id,
type: ds.type || '',
capacity: ds.capacity || 0,
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);
}
@ -274,27 +302,17 @@ const formatStorageDetail = (entry) => {
}
}
return parts.join(' \u2022 ');
return parts.join(' ');
};
const cancel = () => {
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
};
const saveMappings = async(buttonCb) => {
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: providerName.value,
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
@ -305,107 +323,85 @@ const saveMappings = async(buttonCb) => {
},
};
try {
// Create NetworkMap
const networkMapSpec = {
map: networkEntries.value.map((entry) => {
if (entry.target === 'pod') {
return {
source: { name: entry.name, id: entry.id },
destination: { type: 'pod' },
};
}
if (entry.target === 'ignored') {
return {
source: { name: entry.name, id: entry.id },
destination: { type: 'ignored' },
};
}
const parts = entry.target.split('/');
const netName = parts.length > 1 ? parts[1] : parts[0];
const netNamespace = parts.length > 1 ? parts[0] : NAMESPACE;
// Create NetworkMap
const networkMapSpec = {
map: networkEntries.value.map((entry) => {
if (entry.target === 'pod') {
return {
source: { name: entry.name, id: entry.id },
destination: {
type: 'multus',
name: netName,
namespace: netNamespace,
},
destination: { type: 'pod' },
};
}),
provider: providerRef,
};
// Fetch the Provider to build ownerReference
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
const providerOwnerRef = provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: provider.metadata.name,
uid: provider.metadata.uid,
blockOwnerDeletion: true,
}] : [];
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: `${ providerName.value }-network-map-${ Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: networkMapSpec,
});
await networkMap.save();
// Create StorageMap
const storageMapSpec = {
map: storageEntries.value.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
})),
provider: providerRef,
};
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ providerName.value }-storage-map-${ Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: storageMapSpec,
});
await storageMap.save();
// Navigate to review page
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
},
query: {
provider: providerName.value,
vms: currentRoute().query.vms,
networkMap: networkMap.metadata.name,
storageMap: storageMap.metadata.name,
}
});
buttonCb(true);
} catch (err) {
errors.value = [err.message || err];
buttonCb(false);
}
if (entry.target === 'ignored') {
return {
source: { name: entry.name, id: entry.id },
destination: { type: 'ignored' },
};
}
const parts = entry.target.split('/');
const netName = parts.length > 1 ? parts[1] : parts[0];
const netNamespace = parts.length > 1 ? parts[0] : NAMESPACE;
return {
source: { name: entry.name, id: entry.id },
destination: {
type: 'multus',
name: netName,
namespace: netNamespace,
},
};
}),
provider: providerRef,
};
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-${ Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: networkMapSpec,
});
await networkMap.save();
// Create StorageMap
const storageMapSpec = {
map: storageEntries.value.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
})),
provider: providerRef,
};
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ props.providerName }-storage-map-${ Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: storageMapSpec,
});
await storageMap.save();
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
};
defineExpose({ saveMappings: saveAndReturn });
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
@ -433,28 +429,17 @@ const init = async() => {
allStorageMaps.value = [];
}
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {
// Provider may already be in store from previous step
}
vms.value = props.selectedVms;
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
const vmsParam = currentRoute().query.vms;
if (vmsParam) {
if (networkEntries.value.length === 0) {
try {
const vmIds = JSON.parse(vmsParam);
const providerUid = provider?.metadata?.uid;
const providerType = provider?.spec?.type || 'vsphere';
const providerUid = props.provider?.metadata?.uid;
const providerType = props.provider?.spec?.type || 'vsphere';
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
const [allVms, networksData, datastoresData] = await Promise.all([
fetch(`${ baseUrl }/vms`).then((r) => r.json()).catch(() => []),
const [networksData, datastoresData] = await Promise.all([
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).catch(() => []),
fetch(`${ baseUrl }/datastores?detail=1`).then((r) => r.json()).catch(() => []),
]);
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
@ -462,51 +447,48 @@ const init = async() => {
return map;
}, {});
const datastoreNameMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = d.name;
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
const vmList = Array.isArray(allVms) ? allVms : (allVms?.data || []);
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
vms.value = vmIds.map((id) => {
const found = vmList.find((vm) => vm.id === id);
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
}
if (found) {
// Resolve network names
if (found.networks) {
found.networks = found.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;
// Resolve datastore names
if (found.disks) {
found.disks = found.disks.map((d) => ({
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || datastoreNameMap[d.datastore.id] || d.datastore.id,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
}));
}
return found;
};
});
}
return {
id, name: id, networks: [], disks: []
};
return resolved;
});
} catch (e) {
vms.value = [];
// Name resolution failed entries will use IDs as fallback
}
}
buildNetworkEntries();
buildStorageEntries();
buildNetworkEntries();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
}
loading.value = false;
};
@ -519,34 +501,24 @@ init();
v-else
class="configure-mappings"
>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.forklift.configureMappings.title')"
:is-creatable="false"
>
<template #subHeader>
<p class="text-muted line-height-20 mmt-5">
{{ t('harvester.addons.forklift.configureMappings.description') }}
</p>
</template>
</Masthead>
<p class="text-muted line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>
<div class="mappings-columns">
<!-- Network Mapping -->
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ t('harvester.addons.forklift.configureMappings.networkMapping.title') }}
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.title') }}
</h3>
<p class="text-muted line-height-20">
{{ t('harvester.addons.forklift.configureMappings.networkMapping.description') }}
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.description') }}
</p>
</div>
<LabeledSelect
v-model:value="selectedNetworkTemplate"
:label="t('harvester.addons.forklift.configureMappings.networkMapping.template')"
:label="t('harvester.addons.vmMigration.configureMappings.networkMapping.template')"
:options="networkTemplateOptions"
:reduce="(opt) => opt.value"
class="mb-10"
@ -574,7 +546,7 @@ init();
<LabeledSelect
v-model:value="entry.target"
:options="harvesterNetworkOptions"
:placeholder="t('harvester.addons.forklift.configureMappings.networkMapping.placeholder')"
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
:searchable="true"
/>
</div>
@ -593,16 +565,16 @@ init();
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ t('harvester.addons.forklift.configureMappings.storageMapping.title') }}
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.title') }}
</h3>
<p class="text-muted">
{{ t('harvester.addons.forklift.configureMappings.storageMapping.description') }}
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.description') }}
</p>
</div>
<LabeledSelect
v-model:value="selectedStorageTemplate"
:label="t('harvester.addons.forklift.configureMappings.storageMapping.template')"
:label="t('harvester.addons.vmMigration.configureMappings.storageMapping.template')"
:options="storageTemplateOptions"
:reduce="(opt) => opt.value"
class="mb-10"
@ -633,7 +605,7 @@ init();
<LabeledSelect
v-model:value="entry.target"
:options="storageClassOptions"
:placeholder="t('harvester.addons.forklift.configureMappings.storageMapping.placeholder')"
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:searchable="true"
/>
</div>
@ -648,30 +620,10 @@ init();
</RcItemCard>
</div>
</div>
<div class="actions-footer">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
:disabled="!canSave"
:action-label="t('harvester.addons.forklift.configureMappings.save')"
:waiting-label="t('harvester.addons.forklift.configureMappings.save')"
:success-label="t('harvester.addons.forklift.configureMappings.save')"
:error-label="t('harvester.addons.forklift.configureMappings.save')"
@click="saveMappings"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.configure-mappings {
padding: 20px;
}
.mappings-columns {
display: grid;
@ -738,13 +690,6 @@ init();
overflow: hidden;
}
.actions-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
.line-height-20 {
line-height: 20px;
}
@ -754,4 +699,14 @@ init();
border: 0;
}
.configure-mappings {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
}
</style>

View File

@ -5,28 +5,19 @@ import { useStore } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import Masthead from '@shell/components/ResourceList/Masthead';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { SCHEMA, SECRET } from '@shell/config/types';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { currentRouter } from '../../../../utils/router';
const schema = {
id: HCI.FORKLIFT_PROVIDER,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PROVIDER,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PROVIDER },
};
import { HCI } from '../../types';
const CREATE_NEW = '__create_new__';
const props = defineProps({ stepData: { type: Object, required: true } });
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
const store = useStore();
const { t } = useI18n(store);
@ -41,19 +32,67 @@ const skipTlsVerify = ref(false);
const testResult = ref(null);
const testError = ref(null);
const errors = ref([]);
const testBtnRef = ref(null);
const createdProvider = ref(null);
const createdSecret = ref(null);
const loading = ref(true);
const testPassed = ref(false);
const testing = ref(false);
const saving = ref(false);
// Restore state from stepData on mount
selectedProvider.value = props.stepData.selectedProvider;
providerName.value = props.stepData.providerName;
url.value = props.stepData.url;
username.value = props.stepData.username;
password.value = props.stepData.password;
skipTlsVerify.value = props.stepData.skipTlsVerify;
testPassed.value = props.stepData.testPassed;
testResult.value = props.stepData.testResult;
testError.value = props.stepData.testError;
createdProvider.value = props.stepData.createdProvider;
createdSecret.value = props.stepData.createdSecret;
// Sync state back to stepData
watch(selectedProvider, (val) => {
props.stepData.selectedProvider = val;
});
watch(providerName, (val) => {
props.stepData.providerName = val;
});
watch(url, (val) => {
props.stepData.url = val;
});
watch(username, (val) => {
props.stepData.username = val;
});
watch(password, (val) => {
props.stepData.password = val;
});
watch(skipTlsVerify, (val) => {
props.stepData.skipTlsVerify = val;
});
watch(testPassed, (val) => {
props.stepData.testPassed = val;
});
watch(testResult, (val) => {
props.stepData.testResult = val;
});
watch(testError, (val) => {
props.stepData.testError = val;
});
watch(createdProvider, (val) => {
props.stepData.createdProvider = val;
});
watch(createdSecret, (val) => {
props.stepData.createdSecret = val;
});
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.forklift.configureProvider.createNew'), value: CREATE_NEW }
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
];
allProviders.value.forEach((p) => {
@ -134,15 +173,22 @@ watch([providerName, url, username, password], () => {
}
});
const cancel = () => {
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
};
// 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 testConnection = async(buttonCb) => {
testResult.value = null;
@ -150,7 +196,7 @@ const testConnection = async(buttonCb) => {
testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.forklift.configureProvider.testMissingFields');
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
testing.value = false;
buttonCb(false);
@ -205,16 +251,16 @@ const testConnection = async(buttonCb) => {
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess');
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout');
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
} catch (err) {
testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed');
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
@ -329,7 +375,7 @@ const testConnection = async(buttonCb) => {
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess');
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
@ -342,7 +388,7 @@ const testConnection = async(buttonCb) => {
createdSecret.value = null;
}
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout');
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
@ -360,47 +406,12 @@ const testConnection = async(buttonCb) => {
createdSecret.value = null;
}
testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed');
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
};
const saveProvider = async(buttonCb) => {
errors.value = [];
saving.value = true;
if (!testPassed.value) {
// Test hasn't passed yet run it first
await testConnection((success) => {
if (success) {
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
},
query: { provider: providerName.value }
});
} else {
saving.value = false;
buttonCb(false);
}
});
return;
}
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
},
query: { provider: providerName.value }
});
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
@ -414,171 +425,139 @@ const init = async() => {
};
init();
const clickTestButton = () => {
testBtnRef.value?.$el?.click();
};
defineExpose({ testConnection, clickTestButton });
</script>
<template>
<div class="configure-provider">
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.forklift.configureProvider.title')"
:is-creatable="false"
>
<template #subHeader>
<div class="mmt-5">
<p class="text-muted">
{{ t('harvester.addons.forklift.configureProvider.description') }}
<div class="configure-provider-step">
<p class="text-muted 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>
<LabeledSelect
v-model:value="selectedProvider"
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions"
:reduce="(opt) => opt.value"
/>
</div>
<div
v-if="!isExistingProvider"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.vmMigration.configureProvider.name')"
required
/>
</div>
<div>
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider"
required
/>
<p class="text-muted mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
</template>
</Masthead>
<h3 class="mmt-3 mmb-3 table-title">
<b>{{ t('harvester.addons.forklift.configureProvider.connectionDetails') }}</b>
</h3>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider"
required
/>
</div>
</div>
<Banner
class="requirements-banner mt-0 mmb-3"
color="info"
>
<div class="requirements-banner-content">
<span class="requirements-banner-title">{{ t('harvester.addons.forklift.configureProvider.requirementsTitle') }}</span>
<br>
<span>{{ t('harvester.addons.forklift.configureProvider.requirementsText') }}</span>
<div>
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider"
/>
<p class="text-muted 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>
</Banner>
<div class="mb-20">
<LabeledSelect
v-model:value="selectedProvider"
:label="t('harvester.addons.forklift.configureProvider.providerSelect')"
:options="providerOptions"
:reduce="(opt) => opt.value"
/>
</div>
<div
v-if="!isExistingProvider"
class="mb-20"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.forklift.configureProvider.name')"
required
/>
</div>
<div class="mb-20">
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.forklift.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.forklift.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider"
required
/>
<p class="text-muted mt-5">
{{ t('harvester.addons.forklift.configureProvider.urlHint') }}
</p>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.forklift.fields.username')"
:disabled="isExistingProvider"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.forklift.fields.password')"
:disabled="isExistingProvider"
required
/>
</div>
</div>
<div class="mb-20">
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.forklift.configureProvider.skipSsl')"
:disabled="isExistingProvider"
/>
<p class="text-muted ml-20">
{{ t('harvester.addons.forklift.configureProvider.skipSslHint') }}
</p>
</div>
<div class="mb-20">
<button
v-if="testPassed"
class="btn role-secondary test-passed-btn"
disabled
>
<i class="icon icon-checkmark mr-10" /> {{ t('harvester.addons.forklift.configureProvider.testConnection') }}
</button>
<AsyncButton
v-else
mode="test"
:disabled="!isFormValid || testing || saving"
:action-label="t('harvester.addons.forklift.configureProvider.testConnection')"
:waiting-label="t('harvester.addons.forklift.configureProvider.testConnection')"
:success-label="t('harvester.addons.forklift.configureProvider.testConnection')"
:error-label="t('harvester.addons.forklift.configureProvider.testConnection')"
class="btn role-secondary"
@click="testConnection"
/>
</div>
<Banner
v-if="testResult"
color="success"
class="mb-10"
>
{{ testResult }}
</Banner>
<Banner
v-if="testError"
color="error"
class="mb-10"
>
{{ testError }}
</Banner>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
class="mb-10"
>
{{ err }}
</Banner>
<div class="provider-actions">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
:disabled="!isFormValid || testing || saving"
:action-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
:waiting-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
:success-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
:error-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
@click="saveProvider"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.configure-provider {
padding: 20px;
}
.requirements-banner {
font-weight: 400;
@ -587,10 +566,25 @@ init();
}
}
.provider-actions {
.configure-provider-step {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
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;
}
}
}
</style>

View File

@ -0,0 +1,510 @@
<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';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
networkMapName: { type: String, default: '' },
storageMapName: { type: String, 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';
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 Math.round(totalMB / 1024);
});
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 Math.round(totalBytes / (1024 * 1024 * 1024));
});
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 ? `${ Math.round(memMB / 1024) } GB` : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-';
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 buildNetworkMapSpec = () => {
const entries = props.mappingEntries?.networkEntries || [];
return entries.map((entry) => {
if (entry.target === 'pod') {
return { source: { name: entry.name, id: entry.id }, destination: { type: 'pod' } };
}
if (entry.target === 'ignored') {
return { source: { name: entry.name, id: entry.id }, destination: { type: 'ignored' } };
}
const parts = entry.target.split('/');
const netName = parts.length > 1 ? parts[1] : parts[0];
const netNamespace = parts.length > 1 ? parts[0] : NAMESPACE;
return {
source: { name: entry.name, id: entry.id },
destination: {
type: 'multus', name: netName, namespace: netNamespace
},
};
});
};
const buildStorageMapSpec = () => {
const entries = props.mappingEntries?.storageEntries || [];
return entries.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
}));
};
const startMigrationAction = 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,
},
};
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: buildNetworkMapSpec(), provider: providerRef },
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NAMESPACE },
spec: { map: buildStorageMapSpec(), 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.konveyor.io/v1beta1',
kind: 'NetworkMap',
name: networkMapName,
namespace: NAMESPACE,
},
storage: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'StorageMap',
name: storageMapName,
namespace: NAMESPACE,
},
},
targetNamespace: 'default',
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
});
await plan.save();
const planOwnerRef = {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
const migration = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ planName.value }-migration-${ Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: [planOwnerRef],
},
spec: {
plan: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: planName.value,
namespace: NAMESPACE,
},
},
});
await migration.save();
networkMap.metadata.ownerReferences = [planOwnerRef];
await networkMap.save();
storageMap.metadata.ownerReferences = [planOwnerRef];
await storageMap.save();
};
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">{{ totalMemoryGB }} GB</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ totalStorageGB }} GB</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 }, statuses: [{ icon: 'icon-notify-tick', color: 'text-success' }] }"
>
<template #item-card-content>
<div class="vm-card-content">
<div class="vm-card-specs">
<span class="vm-os text-muted">{{ vm.os }}</span>
<span class="vm-resources">
<i class="icon icon-disk" />
{{ vm.cpus }} vCPU &bull; {{ vm.memGB }} &bull; {{ 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 128px;
width: 747px;
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;
}
}
.vm-card-mappings {
display: flex;
flex-direction: column;
gap: 4px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.mapping-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
.icon {
color: var(--muted);
font-size: 14px;
}
}
.small-text {
font-size: 12px;
line-height: 16px;
color: #973C00;
}
.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>

View File

@ -1,31 +1,24 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import {
ref, computed, watch, nextTick, onBeforeUnmount
} from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import SortableTable from '@shell/components/SortableTable';
import { BadgeState } from '@components/BadgeState';
import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { currentRouter, currentRoute } from '../../../../utils/router';
const schema = {
id: HCI.FORKLIFT_PROVIDER,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PROVIDER,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PROVIDER },
};
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 allProviders = ref([]);
const provider = ref(null);
const discoveredVMs = ref([]);
const selectedVMs = ref([]);
const tableRows = ref([]);
@ -37,7 +30,65 @@ const allVMsSelected = ref(false);
const selectedVMIds = ref(new Set());
let skipNextSelectionEvent = false;
const providerName = computed(() => provider.value?.metadata?.name || currentRoute().query.provider || '');
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 `${ hours } ${ hours === 1 ? 'hour' : 'hours' } and ${ minutes } ${ minutes === 1 ? 'minute' : 'minutes' }`;
}
if (hours > 0) {
return `${ hours } ${ hours === 1 ? 'hour' : 'hours' }`;
}
return `${ Math.max(1, minutes) } ${ minutes <= 1 ? 'minute' : '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 from stepData
if (props.stepData.discoveredVMs.length > 0) {
discoveredVMs.value = props.stepData.discoveredVMs;
}
if (props.stepData.selectedVMIds.size > 0) {
selectedVMIds.value = props.stepData.selectedVMIds;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
}
if (props.stepData.tableRows.length > 0) {
tableRows.value = props.stepData.tableRows;
}
// Sync back to stepData
watch(discoveredVMs, (val) => {
props.stepData.discoveredVMs = val;
}, { deep: true });
watch(selectedVMIds, (val) => {
props.stepData.selectedVMIds = val;
}, { deep: true });
watch(tableRows, (val) => {
props.stepData.tableRows = val;
}, { deep: true });
const vmCount = computed(() => discoveredVMs.value.length);
const selectedCount = computed(() => selectedVMs.value.length);
@ -62,39 +113,39 @@ const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('
const headers = [
{
name: 'vmName',
labelKey: 'harvester.addons.forklift.selectVms.columns.vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName',
sort: ['vmName'],
subLabel: 'Identifier',
},
{
name: 'os',
labelKey: 'harvester.addons.forklift.selectVms.columns.os',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os',
sort: ['os'],
},
{
name: 'resources',
labelKey: 'harvester.addons.forklift.selectVms.columns.resources',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources',
sort: false,
},
{
name: 'powerState',
labelKey: 'harvester.addons.forklift.selectVms.columns.powerState',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState',
sort: ['powerState'],
},
{
name: 'network',
labelKey: 'harvester.addons.forklift.selectVms.columns.network',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network',
sort: ['network'],
subLabel: 'Identifier',
},
{
name: 'datastore',
labelKey: 'harvester.addons.forklift.selectVms.columns.datastore',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore',
sort: ['datastore'],
subLabel: 'Identifier',
@ -185,6 +236,8 @@ const onSelect = (rows) => {
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 = () => {
@ -230,112 +283,150 @@ watch(
}
);
const cancel = () => {
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
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 providerUid = props.provider.metadata.uid;
const providerType = props.provider.spec?.type || 'vsphere';
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
fetch(`${ baseUrl }/vms`).then((r) => r.json()),
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).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 saveSelection = () => {
const vmIds = selectedVMs.value.map((vm) => vm.id || vm.vmId || vm.metadata?.name);
const refreshVMs = async() => {
refreshing.value = true;
skipNextSelectionEvent = true;
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
},
query: {
provider: providerName.value,
vms: JSON.stringify(vmIds),
const previousSelectedIds = new Set(selectedVMIds.value);
try {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
tableRows.value = [];
}
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;
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, []);
}
});
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
const queryProvider = currentRoute().query.provider;
if (queryProvider) {
provider.value = allProviders.value.find(
(p) => p.metadata.name === queryProvider && p.metadata.namespace === 'forklift'
);
}
if (provider.value) {
try {
const providerUid = provider.value.metadata.uid;
const providerType = provider.value.spec?.type || 'vsphere';
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
fetch(`${ baseUrl }/vms`).then((r) => r.json()),
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).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;
}, {});
} catch (e) {
discoveredVMs.value = [];
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 = [];
}
tableRows.value = buildTableRows();
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="refreshing" />
<Loading v-if="loading" />
<div
v-else
class="select-vms-step"
>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.forklift.selectVms.title')"
:is-creatable="false"
>
<template #subHeader>
<p class="text-muted mmt-5">
{{ t('harvester.addons.forklift.selectVms.discovered', { count: vmCount }) }}
<a
v-if="providerName"
class="provider-link"
>{{ providerName }}</a>
<br>
<span class="text-small">{{ t('harvester.addons.forklift.selectVms.lastSynced') }}</span>
</p>
</template>
</Masthead>
<p class="text-muted 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"
@ -353,9 +444,9 @@ init();
<template #header-left>
<div class="vm-table-title">
<h3 class="m-0">
{{ t('harvester.addons.forklift.selectVms.availableVms') }}
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.forklift.selectVms.selected') }}</span>
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
</div>
</template>
<template #cell:vmName="{ row }">
@ -410,51 +501,30 @@ init();
class="select-all-banner-cell"
>
<template v-if="allVMsSelected">
<span>{{ t('harvester.addons.forklift.selectVms.selectAllBanner.allSelected') }}</span>
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.clearSelection') }}
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
</a>
</template>
<template v-else>
<span>{{ t('harvester.addons.forklift.selectVms.selectAllBanner.pageOnly') }}</span>
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
<a
role="button"
@click.prevent="selectAllVMs"
>
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
</a>
</template>
</td>
</tr>
</Teleport>
<div class="actions-footer">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('generic.cancel') }}
</button>
<button
class="btn role-primary"
:disabled="selectedCount === 0"
@click="saveSelection"
>
{{ t('harvester.addons.forklift.selectVms.saveSelection') }}
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.provider-link {
color: var(--primary);
font-weight: 600;
}
.vm-table-title {
h4 {
margin: 0;
@ -480,6 +550,17 @@ init();
}
}
.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;
@ -500,11 +581,4 @@ init();
}
}
}
.actions-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
</style>

View File

@ -1329,7 +1329,7 @@ export function init($plugin, store) {
// ===========================================================================
// Forklift Addon UI Flow
// ===========================================================================
weightGroup('forklift', 0, false);
weightGroup('vmMigration', 0, false);
// Provider
headers(HCI.FORKLIFT_PROVIDER, [
@ -1349,8 +1349,8 @@ export function init($plugin, store) {
});
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.forklift.labels.provider',
group: 'forklift::Advanced',
labelKey: 'harvester.addons.vmMigration.labels.provider',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1376,8 +1376,8 @@ export function init($plugin, store) {
});
virtualType({
name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.forklift.labels.networkMap',
group: 'forklift::Advanced',
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1403,8 +1403,8 @@ export function init($plugin, store) {
});
virtualType({
name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.forklift.labels.storageMap',
group: 'forklift::Advanced',
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1431,8 +1431,8 @@ export function init($plugin, store) {
});
virtualType({
name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.forklift.labels.plan',
group: 'forklift::Advanced',
labelKey: 'harvester.addons.vmMigration.labels.plan',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1457,8 +1457,8 @@ export function init($plugin, store) {
});
virtualType({
name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.forklift.labels.migration',
group: 'forklift::Advanced',
labelKey: 'harvester.addons.vmMigration.labels.migration',
group: 'vmMigration::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1468,11 +1468,11 @@ export function init($plugin, store) {
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
virtualType({
name: 'forklift-create',
labelKey: 'harvester.addons.forklift.labels.dashboard',
group: 'forklift',
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
group: 'vmMigration',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {}
}
});
@ -1481,7 +1481,7 @@ export function init($plugin, store) {
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'forklift',
navGroup: 'vmMigration',
types: [
'forklift-create',
]
@ -1489,7 +1489,7 @@ export function init($plugin, store) {
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'forklift::Advanced',
navGroup: 'vmMigration::Advanced',
types: [
HCI.FORKLIFT_PROVIDER,
HCI.FORKLIFT_NETWORK_MAP,

View File

@ -238,7 +238,7 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
// Provider type column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_TYPE = {
name: 'providerType',
labelKey: 'harvester.tableHeaders.forkliftProviderType',
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
value: 'spec.type',
sort: 'spec.type',
align: 'left',
@ -247,7 +247,7 @@ export const FORKLIFT_PROVIDER_TYPE = {
// Provider URL column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_URL = {
name: 'providerUrl',
labelKey: 'harvester.tableHeaders.forkliftProviderUrl',
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
value: 'spec.url',
sort: 'spec.url',
align: 'left',
@ -256,7 +256,7 @@ export const FORKLIFT_PROVIDER_URL = {
// Source provider column in forklift network/storage map list page
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
name: 'sourceProvider',
labelKey: 'harvester.tableHeaders.forkliftMapSourceProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
value: 'spec.provider.source.name',
sort: 'spec.provider.source.name',
align: 'left',
@ -265,7 +265,7 @@ export const FORKLIFT_MAP_SOURCE_PROVIDER = {
// Destination provider column in forklift network/storage map list page
export const FORKLIFT_MAP_DEST_PROVIDER = {
name: 'destProvider',
labelKey: 'harvester.tableHeaders.forkliftMapDestProvider',
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
value: 'spec.provider.destination.name',
sort: 'spec.provider.destination.name',
align: 'left',
@ -274,7 +274,7 @@ export const FORKLIFT_MAP_DEST_PROVIDER = {
// Target namespace column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_TARGET_NS = {
name: 'targetNamespace',
labelKey: 'harvester.tableHeaders.forkliftPlanTargetNs',
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
value: 'spec.targetNamespace',
sort: 'spec.targetNamespace',
align: 'left',
@ -283,7 +283,7 @@ export const FORKLIFT_PLAN_TARGET_NS = {
// VM count column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_VM_COUNT = {
name: 'vmCount',
labelKey: 'harvester.tableHeaders.forkliftPlanVmCount',
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'left',
@ -292,7 +292,7 @@ export const FORKLIFT_PLAN_VM_COUNT = {
// Plan reference column in forklift.konveyor.io.migration list page
export const FORKLIFT_MIGRATION_PLAN = {
name: 'plan',
labelKey: 'harvester.tableHeaders.forkliftMigrationPlan',
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
value: 'spec.plan.name',
sort: 'spec.plan.name',
align: 'left',

View File

@ -1,3 +1,8 @@
wizard:
create: Create Migration
next: Proceed
previous: Back
generic:
tip: Tip
resourceExternalLinkTips: 'External Link'
@ -22,7 +27,7 @@ nav:
Logging: Logging
'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports
forklift: Forklift Migration
vmMigration: VM Migration
resourceTable:
groupBy:
@ -353,13 +358,13 @@ harvester:
v4ip: V4 IP
v6ip: V6 IP
eipName: EIP Name
forkliftProviderType: Type
forkliftProviderUrl: URL
forkliftMapSourceProvider: Source Provider
forkliftMapDestProvider: Destination Provider
forkliftPlanTargetNs: Target Namespace
forkliftPlanVmCount: VMs
forkliftMigrationPlan: Plan
vmMigrationProviderType: Type
vmMigrationProviderUrl: URL
vmMigrationMapSourceProvider: Source Provider
vmMigrationMapDestProvider: Destination Provider
vmMigrationPlanTargetNs: Target Namespace
vmMigrationPlanVmCount: VMs
vmMigrationMigrationPlan: Plan
tab:
volume: Volumes
network: Networks
@ -1807,7 +1812,7 @@ 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.'
forklift:
vmMigration:
labels:
dashboard: Migrations
provider: Providers
@ -1815,6 +1820,21 @@ harvester:
storageMap: Storage Maps
plan: Migration Plans
migration: Migrations
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
@ -1832,7 +1852,11 @@ harvester:
urlHint: Enter the full URL including https://
skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments
testConnection: Test Connection
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
@ -1841,8 +1865,10 @@ harvester:
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 5 minutes ago
lastSynced: "Last synced {time} ago • "
refreshNow: Refresh now
availableVms: Available Virtual Machines
selected: selected
saveSelection: Save Selection and Continue

View File

@ -5,7 +5,7 @@ import { PRODUCT_NAME } from '../config/harvester';
export default class ForkliftPlan extends HarvesterResource {
get listLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],

View File

@ -1,654 +0,0 @@
<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 AsyncButton from '@shell/components/AsyncButton';
import { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard';
import { LabeledInput } from '@components/Form/LabeledInput';
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 { currentRouter, currentRoute } from '../../../../utils/router';
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 vms = ref([]);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const errors = ref([]);
const loading = ref(true);
const NAMESPACE = 'forklift';
const TARGET_NAMESPACE = 'default';
const providerName = computed(() => currentRoute().query.provider || 'vsphere');
const networkMapName = computed(() => currentRoute().query.networkMap || '');
const storageMapName = computed(() => currentRoute().query.storageMap || '');
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 Math.round(totalMB / 1024);
});
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 Math.round(totalBytes / (1024 * 1024 * 1024));
});
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 ? `${ Math.round(memMB / 1024) } GB` : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-';
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 cancel = () => {
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
};
const startMigration = async(buttonCb) => {
const inStore = store.getters['currentProduct'].inStore;
try {
// Rename NetworkMap and StorageMap to use the plan name
const allNetworkMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [];
const allStorageMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [];
const currentNetworkMap = allNetworkMaps.find((nm) => nm.metadata.name === networkMapName.value && nm.metadata.namespace === NAMESPACE);
const currentStorageMap = allStorageMaps.find((sm) => sm.metadata.name === storageMapName.value && sm.metadata.namespace === NAMESPACE);
const newNetworkMapName = `${ planName.value }-network-map`;
const newStorageMapName = `${ planName.value }-storage-map`;
// Recreate NetworkMap with new name
let finalNetworkMapName = networkMapName.value;
if (currentNetworkMap) {
const networkMapData = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: newNetworkMapName,
namespace: NAMESPACE,
},
spec: currentNetworkMap.spec,
});
await networkMapData.save();
await currentNetworkMap.remove();
finalNetworkMapName = newNetworkMapName;
}
// Recreate StorageMap with new name
let finalStorageMapName = storageMapName.value;
if (currentStorageMap) {
const storageMapData = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: newStorageMapName,
namespace: NAMESPACE,
},
spec: currentStorageMap.spec,
});
await storageMapData.save();
await currentStorageMap.remove();
finalStorageMapName = newStorageMapName;
}
const planSpec = {
provider: {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: providerName.value,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
},
map: {
network: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'NetworkMap',
name: finalNetworkMapName,
namespace: NAMESPACE,
},
storage: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'StorageMap',
name: finalStorageMapName,
namespace: NAMESPACE,
},
},
targetNamespace: 'default',
vms: vms.value.map((vm) => ({
id: vm.id,
name: vm.name || vm.id,
})),
warm: false,
};
const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN,
metadata: {
name: planName.value,
namespace: NAMESPACE,
},
spec: planSpec,
});
await plan.save();
// Build ownerReference pointing to the Plan
const planOwnerRef = {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
// Create Migration owned by Plan
const migrationName = `${ planName.value }-migration-${ Math.random().toString(36).substring(2, 7) }`;
const migration = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: migrationName,
namespace: NAMESPACE,
ownerReferences: [planOwnerRef],
},
spec: {
plan: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: planName.value,
namespace: NAMESPACE,
},
},
});
await migration.save();
// Set Plan as owner of the newly created NetworkMap and StorageMap
const updatedNetworkMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [];
const updatedStorageMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [];
const newNetworkMap = updatedNetworkMaps.find((nm) => nm.metadata.name === finalNetworkMapName && nm.metadata.namespace === NAMESPACE);
const newStorageMap = updatedStorageMaps.find((sm) => sm.metadata.name === finalStorageMapName && sm.metadata.namespace === NAMESPACE);
if (newNetworkMap) {
newNetworkMap.metadata.ownerReferences = [planOwnerRef];
await newNetworkMap.save();
}
if (newStorageMap) {
newStorageMap.metadata.ownerReferences = [planOwnerRef];
await newStorageMap.save();
}
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
buttonCb(true);
} catch (err) {
errors.value = [err.message || err];
buttonCb(false);
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER }).catch(() => {});
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }).catch(() => {});
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }).catch(() => {});
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
const vmsParam = currentRoute().query.vms;
if (vmsParam) {
try {
const vmIds = JSON.parse(vmsParam);
const providerUid = provider?.metadata?.uid;
const providerType = provider?.spec?.type || 'vsphere';
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
const allVms = await fetch(`${ baseUrl }/vms`).then((r) => r.json()).catch(() => []);
const vmList = Array.isArray(allVms) ? allVms : (allVms?.data || []);
vms.value = vmIds.map((id) => {
const found = vmList.find((vm) => vm.id === id);
return found || {
id, name: id, networks: [], disks: [], cpuCount: 0, memoryMB: 0, guestName: ''
};
});
} catch (e) {
vms.value = [];
}
}
// Build mapping display data from the actual NetworkMap/StorageMap resources
const allNetworkMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [];
const allStorageMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [];
const networkMap = allNetworkMaps.find((nm) => nm.metadata.name === networkMapName.value && nm.metadata.namespace === NAMESPACE);
const storageMap = allStorageMaps.find((sm) => sm.metadata.name === storageMapName.value && sm.metadata.namespace === NAMESPACE);
if (networkMap?.spec?.map) {
networkMappings.value = networkMap.spec.map.map((m) => {
const source = m.source?.name || m.source?.id || 'Unknown';
let target = '';
if (m.destination?.type === 'pod') {
target = 'Pod Networking';
} else if (m.destination?.type === 'ignored') {
target = 'Ignored';
} else if (m.destination?.type === 'multus' && m.destination?.name) {
target = m.destination.namespace ? `${ m.destination.namespace }/${ m.destination.name }` : m.destination.name;
}
// Find which VMs use this network
const sourceId = m.source?.id;
const usedBy = vms.value
.filter((vm) => vm.networks?.some((n) => n.id === sourceId || n.name === m.source?.name))
.map((vm) => vm.name || vm.id);
return {
source, target, usedBy
};
});
}
if (storageMap?.spec?.map) {
storageMappings.value = storageMap.spec.map.map((m) => {
const source = m.source?.name || m.source?.id || 'Unknown';
const target = m.destination?.storageClass || '';
// Find which VMs use this datastore
const sourceId = m.source?.id;
const usedBy = vms.value
.filter((vm) => vm.disks?.some((d) => d.datastore?.id === sourceId || d.datastore?.name === m.source?.name))
.map((vm) => vm.name || vm.id);
return {
source, target, usedBy
};
});
}
loading.value = false;
};
init();
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="review-migration"
>
<div
class="review-migration-content"
>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.forklift.reviewMigration.title')"
:is-creatable="false"
class="line-height-32"
>
<template #subHeader>
<p class="text-muted mt-5">
{{ t('harvester.addons.forklift.reviewMigration.description') }}
</p>
</template>
</Masthead>
<!-- Migration Details Summary -->
<div class="migration-details">
<h3 class="section-title m-0">
{{ t('harvester.addons.forklift.reviewMigration.migrationDetails') }}
</h3>
<div class="span-9">
<LabeledInput
v-model:value="planName"
:label="t('harvester.addons.forklift.reviewMigration.planName')"
:placeholder="t('harvester.addons.forklift.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.forklift.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.forklift.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.forklift.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.forklift.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.forklift.reviewMigration.memory') }}</span>
<span class="detail-value">{{ totalMemoryGB }} GB</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.forklift.reviewMigration.storage') }}</span>
<span class="detail-value">{{ totalStorageGB }} GB</span>
</div>
</div>
<div class="detail-column grid-column-2">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.forklift.reviewMigration.migrationMode') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">
{{ t('harvester.addons.forklift.reviewMigration.coldMigration') }}
</span>
</div>
</div>
</div>
</div>
<!-- Virtual Machines -->
<div class="vm-section">
<h3 class="section-title m-0">
{{ t('harvester.addons.forklift.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 }, statuses: [{ icon: 'icon-notify-tick', color: 'text-success' }] }"
>
<template #item-card-content>
<div class="vm-card-content">
<div class="vm-card-specs">
<span class="vm-os text-muted">{{ vm.os }}</span>
<span class="vm-resources">
<i class="icon icon-disk" />
{{ vm.cpus }} vCPU &bull; {{ vm.memGB }} &bull; {{ 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.forklift.reviewMigration.warningTitle') }}</span><br>
{{ t('harvester.addons.forklift.reviewMigration.warningMessage') }}
</div>
</Banner>
<!-- Error banner -->
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</div>
<!-- Actions -->
<div class="actions-footer">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
:disabled="!planName"
:action-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
:waiting-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
:success-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
:error-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
@click="startMigration"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-migration {
padding: 20px;
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 128px;
width: 747px;
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;
}
}
.vm-card-mappings {
display: flex;
flex-direction: column;
gap: 4px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.mapping-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
.icon {
color: var(--muted);
font-size: 14px;
}
}
.small-text {
font-size: 12px;
line-height: 16px;
color: #973C00;
}
.actions-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.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;
}
.line-height-32 {
line-height: 32px;
}
</style>

View File

@ -127,7 +127,7 @@ const rows = computed(() => {
});
const createLocation = computed(() => ({
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
@ -135,21 +135,21 @@ const createLocation = computed(() => ({
}));
const headers = [
{ ...STATE, labelKey: 'harvester.addons.forklift.dashboard.columns.status' },
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
{
...NAME_COL,
labelKey: 'harvester.addons.forklift.dashboard.columns.plan',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
},
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
{
name: 'progress',
labelKey: 'harvester.addons.forklift.dashboard.columns.progress',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
value: 'progress',
width: 500,
},
{
name: 'mappings',
labelKey: 'harvester.addons.forklift.dashboard.columns.mappings',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
value: 'mappingsDisplay',
},
{ ...AGE },
@ -174,12 +174,12 @@ init();
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.forklift.dashboard.title')"
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
>
<template #subHeader>
<div class="mmt-5">
<p class="text-muted">
{{ t('harvester.addons.forklift.dashboard.description') }}
{{ t('harvester.addons.vmMigration.dashboard.description') }}
</p>
</div>
</template>
@ -188,7 +188,7 @@ init();
:to="createLocation"
class="btn role-primary"
>
{{ t('harvester.addons.forklift.dashboard.createPlan') }}
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
</router-link>
</template>
</Masthead>
@ -200,13 +200,11 @@ init();
:groupable="false"
:table-actions="false"
:search="false"
default-sort-by="state"
:default-sort-descending="true"
key-field="_key"
>
<template #header-left>
<h3 class="table-title m-0">
{{ t('harvester.addons.forklift.dashboard.tableTitle') }}
{{ t('harvester.addons.vmMigration.dashboard.tableTitle') }}
</h3>
</template>
<template #cell:name="{ row }">

View File

@ -0,0 +1,294 @@
<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 '../../../../components/vm-migration/ConfigureProviderStep';
import SelectVmsStep from '../../../../components/vm-migration/SelectVmsStep';
import ConfigureMappingsStep from '../../../../components/vm-migration/ConfigureMappingsStep';
import ReviewMigrationStep from '../../../../components/vm-migration/ReviewMigrationStep';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { currentRouter } from '../../../../utils/router';
const store = useStore();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const mappingsStepRef = ref(null);
const reviewStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const selectedVMs = ref([]);
const networkMapName = ref('');
const storageMapName = 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: [],
selectedNetworkTemplate: '__none__',
selectedStorageTemplate: '__none__',
},
review: { planName: '' },
});
const steps = reactive([
{
name: 'configure-provider',
label: '',
subtext: '',
ready: false,
},
{
name: 'select-vms',
label: '',
subtext: '',
ready: false,
},
{
name: 'configure-mappings',
label: '',
subtext: '',
ready: false,
},
{
name: 'review-migration',
label: '',
subtext: '',
ready: false,
},
]);
steps[0].label = t('harvester.addons.vmMigration.wizard.steps.configureProvider.label');
steps[0].subtext = t('harvester.addons.vmMigration.wizard.steps.configureProvider.description');
steps[1].label = t('harvester.addons.vmMigration.wizard.steps.selectVms.label');
steps[1].subtext = t('harvester.addons.vmMigration.wizard.steps.selectVms.description');
steps[2].label = t('harvester.addons.vmMigration.wizard.steps.configureMappings.label');
steps[2].subtext = t('harvester.addons.vmMigration.wizard.steps.configureMappings.description');
steps[3].label = t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label');
steps[3].subtext = t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description');
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;
});
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false);
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 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 = [];
stepData.mappings.selectedNetworkTemplate = '__none__';
stepData.mappings.selectedStorageTemplate = '__none__';
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
// Clear mappings and review when VM selection changes
watch(selectedVMs, (newVal, oldVal) => {
if (oldVal.length > 0 && JSON.stringify(newVal.map((v) => v.id).sort()) !== JSON.stringify(oldVal.map((v) => v.id).sort())) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
stepData.mappings.selectedNetworkTemplate = '__none__';
stepData.mappings.selectedStorageTemplate = '__none__';
mappingsReady.value = false;
stepData.review.planName = '';
reviewReady.value = false;
}
});
const onFinish = async(buttonCb) => {
try {
await reviewStepRef.value.startMigration();
buttonCb(true);
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
} catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)];
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
};
</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"
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
ref="mappingsStepRef"
: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"
:network-map-name="networkMapName"
:storage-map-name="storageMapName"
:mapping-entries="stepData.mappings"
:step-data="stepData.review"
@ready="onReviewReady"
/>
</template>
</CruResource>
</template>

View File

@ -16,11 +16,8 @@ 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/forklift/index.vue';
import ForkliftConfigureProvider from '../pages/c/_cluster/forklift/configure-provider.vue';
import ForkliftSelectVms from '../pages/c/_cluster/forklift/select-vms.vue';
import ForkliftConfigureMappings from '../pages/c/_cluster/forklift/configure-mappings.vue';
import ForkliftReviewMigration from '../pages/c/_cluster/forklift/review-migration.vue';
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
const routes = [
{
@ -89,25 +86,13 @@ const routes = [
path: `/:product/c/:cluster/projectsnamespaces`,
component: ProjectNamespaces,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
path: `/:product/c/:cluster/forklift`,
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
path: `/:product/c/:cluster/vm-migration`,
component: ForkliftDashboard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
path: `/:product/c/:cluster/forklift/configure-provider`,
component: ForkliftConfigureProvider,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
path: `/:product/c/:cluster/forklift/select-vms`,
component: ForkliftSelectVms,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
path: `/:product/c/:cluster/forklift/configure-mappings`,
component: ForkliftConfigureMappings,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
path: `/:product/c/:cluster/forklift/review-migration`,
component: ForkliftReviewMigration,
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
path: `/:product/c/:cluster/vm-migration/wizard`,
component: ForkliftVmMigrationWizard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
path: `/:product/c/:cluster/:resource`,