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

View File

@ -5,28 +5,19 @@ import { useStore } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox'; import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import Masthead from '@shell/components/ResourceList/Masthead';
import AsyncButton from '@shell/components/AsyncButton'; import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect'; 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 { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types'; 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 },
};
const CREATE_NEW = '__create_new__'; 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 store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
@ -41,19 +32,67 @@ const skipTlsVerify = ref(false);
const testResult = ref(null); const testResult = ref(null);
const testError = ref(null); const testError = ref(null);
const errors = ref([]); const errors = ref([]);
const testBtnRef = ref(null);
const createdProvider = ref(null); const createdProvider = ref(null);
const createdSecret = ref(null); const createdSecret = ref(null);
const loading = ref(true); const loading = ref(true);
const testPassed = ref(false); const testPassed = ref(false);
const testing = 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 isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value); const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const providerOptions = computed(() => { const providerOptions = computed(() => {
const options = [ 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) => { allProviders.value.forEach((p) => {
@ -134,15 +173,22 @@ watch([providerName, url, username, password], () => {
} }
}); });
const cancel = () => { // Emit ready/complete when testPassed changes
currentRouter().push({ watch(testPassed, (val) => {
name: `${ PRODUCT_NAME }-c-cluster-forklift`, emit('ready', val);
params: { if (val) {
product: store.getters['productId'], emit('complete', { providerName: providerName.value, provider: createdProvider.value });
cluster: store.getters['clusterId'],
} }
}); }, { 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) => { const testConnection = async(buttonCb) => {
testResult.value = null; testResult.value = null;
@ -150,7 +196,7 @@ const testConnection = async(buttonCb) => {
testing.value = true; testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) { 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; testing.value = false;
buttonCb(false); buttonCb(false);
@ -205,16 +251,16 @@ const testConnection = async(buttonCb) => {
if (connected) { if (connected) {
testPassed.value = true; testPassed.value = true;
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess'); testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false; testing.value = false;
buttonCb(true); buttonCb(true);
} else { } else {
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout'); testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false; testing.value = false;
buttonCb(false); buttonCb(false);
} }
} catch (err) { } 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; testing.value = false;
buttonCb(false); buttonCb(false);
} }
@ -329,7 +375,7 @@ const testConnection = async(buttonCb) => {
if (connected) { if (connected) {
testPassed.value = true; testPassed.value = true;
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess'); testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false; testing.value = false;
buttonCb(true); buttonCb(true);
} else { } else {
@ -342,7 +388,7 @@ const testConnection = async(buttonCb) => {
createdSecret.value = null; createdSecret.value = null;
} }
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout'); testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false; testing.value = false;
buttonCb(false); buttonCb(false);
} }
@ -360,47 +406,12 @@ const testConnection = async(buttonCb) => {
createdSecret.value = null; 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; testing.value = false;
buttonCb(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 init = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
@ -414,44 +425,41 @@ const init = async() => {
}; };
init(); init();
const clickTestButton = () => {
testBtnRef.value?.$el?.click();
};
defineExpose({ testConnection, clickTestButton });
</script> </script>
<template> <template>
<div class="configure-provider"> <div class="configure-provider-step">
<Masthead <p class="text-muted line-height-20">
:schema="schema" {{ t('harvester.addons.vmMigration.configureProvider.description') }}
: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') }}
</p> </p>
</div>
</template>
</Masthead>
<h3 class="mmt-3 mmb-3 table-title"> <div class="configure-provider-step-content">
<b>{{ t('harvester.addons.forklift.configureProvider.connectionDetails') }}</b> <h3 class="table-title m-0">
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
</h3> </h3>
<Banner <Banner
class="requirements-banner mt-0 mmb-3" class="requirements-banner m-0"
color="info" color="info"
> >
<div class="requirements-banner-content"> <div class="requirements-banner-content">
<span class="requirements-banner-title">{{ t('harvester.addons.forklift.configureProvider.requirementsTitle') }}</span> <span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
<br> <br>
<span>{{ t('harvester.addons.forklift.configureProvider.requirementsText') }}</span> <span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
</div> </div>
</Banner> </Banner>
<div class="mb-20"> <div class="configure-provider-step-form">
<div>
<LabeledSelect <LabeledSelect
v-model:value="selectedProvider" v-model:value="selectedProvider"
:label="t('harvester.addons.forklift.configureProvider.providerSelect')" :label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions" :options="providerOptions"
:reduce="(opt) => opt.value" :reduce="(opt) => opt.value"
/> />
@ -459,33 +467,32 @@ init();
<div <div
v-if="!isExistingProvider" v-if="!isExistingProvider"
class="mb-20"
> >
<LabeledInput <LabeledInput
v-model:value="providerName" v-model:value="providerName"
:label="t('harvester.addons.forklift.configureProvider.name')" :label="t('harvester.addons.vmMigration.configureProvider.name')"
required required
/> />
</div> </div>
<div class="mb-20"> <div>
<LabeledInput <LabeledInput
v-model:value="url" v-model:value="url"
:label="t('harvester.addons.forklift.configureProvider.urlLabel')" :label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.forklift.configureProvider.urlPlaceholder')" :placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider" :disabled="isExistingProvider"
required required
/> />
<p class="text-muted mt-5"> <p class="text-muted mt-5">
{{ t('harvester.addons.forklift.configureProvider.urlHint') }} {{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p> </p>
</div> </div>
<div class="row mb-20"> <div class="row">
<div class="col span-6"> <div class="col span-6">
<LabeledInput <LabeledInput
v-model:value="username" v-model:value="username"
:label="t('harvester.addons.forklift.fields.username')" :label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider" :disabled="isExistingProvider"
required required
/> />
@ -494,40 +501,34 @@ init();
<LabeledInput <LabeledInput
v-model:value="password" v-model:value="password"
type="password" type="password"
:label="t('harvester.addons.forklift.fields.password')" :label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider" :disabled="isExistingProvider"
required required
/> />
</div> </div>
</div> </div>
<div class="mb-20"> <div>
<Checkbox <Checkbox
v-model:value="skipTlsVerify" v-model:value="skipTlsVerify"
:label="t('harvester.addons.forklift.configureProvider.skipSsl')" :label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider" :disabled="isExistingProvider"
/> />
<p class="text-muted ml-20"> <p class="text-muted ml-20">
{{ t('harvester.addons.forklift.configureProvider.skipSslHint') }} {{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
</p> </p>
</div> </div>
<div class="mb-20"> <div>
<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 <AsyncButton
v-else ref="testBtnRef"
mode="test" mode="test"
:disabled="!isFormValid || testing || saving" :disabled="!isFormValid"
:action-label="t('harvester.addons.forklift.configureProvider.testConnection')" :action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
:waiting-label="t('harvester.addons.forklift.configureProvider.testConnection')" :waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
:success-label="t('harvester.addons.forklift.configureProvider.testConnection')" :success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
:error-label="t('harvester.addons.forklift.configureProvider.testConnection')" :error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
class="btn role-secondary" :action-color="'role-secondary'"
:waiting-color="'role-disabled'"
@click="testConnection" @click="testConnection"
/> />
</div> </div>
@ -535,14 +536,12 @@ init();
<Banner <Banner
v-if="testResult" v-if="testResult"
color="success" color="success"
class="mb-10"
> >
{{ testResult }} {{ testResult }}
</Banner> </Banner>
<Banner <Banner
v-if="testError" v-if="testError"
color="error" color="error"
class="mb-10"
> >
{{ testError }} {{ testError }}
</Banner> </Banner>
@ -550,35 +549,15 @@ init();
v-for="(err, i) in errors" v-for="(err, i) in errors"
:key="i" :key="i"
color="error" color="error"
class="mb-10"
> >
{{ err }} {{ err }}
</Banner> </Banner>
</div>
<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>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.configure-provider {
padding: 20px;
}
.requirements-banner { .requirements-banner {
font-weight: 400; font-weight: 400;
@ -587,10 +566,25 @@ init();
} }
} }
.provider-actions { .configure-provider-step {
display: flex; display: flex;
justify-content: flex-end; flex-direction: column;
gap: 10px; gap: 24px;
margin-top: 30px;
.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> </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> <script setup>
import { ref, computed, watch, nextTick } from 'vue'; import {
ref, computed, watch, nextTick, onBeforeUnmount
} from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import SortableTable from '@shell/components/SortableTable'; import SortableTable from '@shell/components/SortableTable';
import { BadgeState } from '@components/BadgeState'; import { BadgeState } from '@components/BadgeState';
import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { currentRouter, currentRoute } from '../../../../utils/router';
const schema = { const props = defineProps({
id: HCI.FORKLIFT_PROVIDER, providerName: { type: String, default: '' },
type: SCHEMA, provider: { type: Object, default: null },
attributes: { stepData: { type: Object, required: true },
kind: HCI.FORKLIFT_PROVIDER, });
namespaced: true
}, const emit = defineEmits(['complete', 'loading']);
metadata: { name: HCI.FORKLIFT_PROVIDER },
};
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
const allProviders = ref([]);
const provider = ref(null);
const discoveredVMs = ref([]); const discoveredVMs = ref([]);
const selectedVMs = ref([]); const selectedVMs = ref([]);
const tableRows = ref([]); const tableRows = ref([]);
@ -37,7 +30,65 @@ const allVMsSelected = ref(false);
const selectedVMIds = ref(new Set()); const selectedVMIds = ref(new Set());
let skipNextSelectionEvent = false; 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 vmCount = computed(() => discoveredVMs.value.length);
const selectedCount = computed(() => selectedVMs.value.length); const selectedCount = computed(() => selectedVMs.value.length);
@ -62,39 +113,39 @@ const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('
const headers = [ const headers = [
{ {
name: 'vmName', name: 'vmName',
labelKey: 'harvester.addons.forklift.selectVms.columns.vmName', labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName', value: 'vmName',
sort: ['vmName'], sort: ['vmName'],
subLabel: 'Identifier', subLabel: 'Identifier',
}, },
{ {
name: 'os', name: 'os',
labelKey: 'harvester.addons.forklift.selectVms.columns.os', labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os', value: 'os',
sort: ['os'], sort: ['os'],
}, },
{ {
name: 'resources', name: 'resources',
labelKey: 'harvester.addons.forklift.selectVms.columns.resources', labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources', value: 'resources',
sort: false, sort: false,
}, },
{ {
name: 'powerState', name: 'powerState',
labelKey: 'harvester.addons.forklift.selectVms.columns.powerState', labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState', value: 'powerState',
sort: ['powerState'], sort: ['powerState'],
}, },
{ {
name: 'network', name: 'network',
labelKey: 'harvester.addons.forklift.selectVms.columns.network', labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network', value: 'network',
sort: ['network'], sort: ['network'],
subLabel: 'Identifier', subLabel: 'Identifier',
}, },
{ {
name: 'datastore', name: 'datastore',
labelKey: 'harvester.addons.forklift.selectVms.columns.datastore', labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore', value: 'datastore',
sort: ['datastore'], sort: ['datastore'],
subLabel: 'Identifier', subLabel: 'Identifier',
@ -185,6 +236,8 @@ const onSelect = (rows) => {
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length; allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id)); selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
emit('complete', { selectedVMs: selectedVMs.value });
}; };
const clearSelection = () => { const clearSelection = () => {
@ -230,49 +283,21 @@ watch(
} }
); );
const cancel = () => { const refreshing = ref(false);
currentRouter().push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
}
});
};
const saveSelection = () => { const isLoading = computed(() => loading.value || refreshing.value);
const vmIds = selectedVMs.value.map((vm) => vm.id || vm.vmId || vm.metadata?.name);
currentRouter().push({ watch(isLoading, (val) => {
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`, emit('loading', val);
params: { }, { immediate: true });
product: store.getters['productId'],
cluster: store.getters['clusterId'],
},
query: {
provider: providerName.value,
vms: JSON.stringify(vmIds),
}
});
};
const init = async() => { const fetchVMs = async() => {
const inStore = store.getters['currentProduct'].inStore; if (!props.provider) {
return;
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) { const providerUid = props.provider.metadata.uid;
try { const providerType = props.provider.spec?.type || 'vsphere';
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 baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
const [vmsResp, networksResp, datastoresResp] = await Promise.all([ const [vmsResp, networksResp, datastoresResp] = await Promise.all([
@ -300,42 +325,108 @@ const init = async() => {
return map; return map;
}, {}); }, {});
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
const refreshVMs = async() => {
refreshing.value = true;
skipNextSelectionEvent = true;
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() => {
if (discoveredVMs.value.length > 0) {
if (!lastFetchedAt.value) {
lastFetchedAt.value = Date.now();
}
tableRows.value = buildTableRows();
loading.value = false;
return;
}
try {
await fetchVMs();
} catch (e) { } catch (e) {
discoveredVMs.value = []; discoveredVMs.value = [];
} }
}
tableRows.value = buildTableRows();
loading.value = false; 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(); init();
</script> </script>
<template> <template>
<Loading v-if="refreshing" />
<Loading v-if="loading" /> <Loading v-if="loading" />
<div <div
v-else v-else
class="select-vms-step"
> >
<Masthead <p class="text-muted line-height-20">
:schema="schema" {{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
:resource="schema.id" <router-link
:type-display="t('harvester.addons.forklift.selectVms.title')" v-if="provider"
: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" class="provider-link"
>{{ providerName }}</a> :to="provider._detailLocation"
>
{{ providerName }}
</router-link>
<br> <br>
<span class="text-small">{{ t('harvester.addons.forklift.selectVms.lastSynced') }}</span> <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> </p>
</template>
</Masthead>
<!-- Discovered VMs table --> <!-- Discovered VMs table -->
<SortableTable <SortableTable
ref="sortableTableRef" ref="sortableTableRef"
@ -353,9 +444,9 @@ init();
<template #header-left> <template #header-left>
<div class="vm-table-title"> <div class="vm-table-title">
<h3 class="m-0"> <h3 class="m-0">
{{ t('harvester.addons.forklift.selectVms.availableVms') }} {{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3> </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> </div>
</template> </template>
<template #cell:vmName="{ row }"> <template #cell:vmName="{ row }">
@ -410,51 +501,30 @@ init();
class="select-all-banner-cell" class="select-all-banner-cell"
> >
<template v-if="allVMsSelected"> <template v-if="allVMsSelected">
<span>{{ t('harvester.addons.forklift.selectVms.selectAllBanner.allSelected') }}</span> <span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
<a <a
role="button" role="button"
@click.prevent="clearSelection" @click.prevent="clearSelection"
> >
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.clearSelection') }} {{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
</a> </a>
</template> </template>
<template v-else> <template v-else>
<span>{{ t('harvester.addons.forklift.selectVms.selectAllBanner.pageOnly') }}</span> <span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
<a <a
role="button" role="button"
@click.prevent="selectAllVMs" @click.prevent="selectAllVMs"
> >
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.selectAll', { count: vmCount }) }} {{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
</a> </a>
</template> </template>
</td> </td>
</tr> </tr>
</Teleport> </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> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.provider-link {
color: var(--primary);
font-weight: 600;
}
.vm-table-title { .vm-table-title {
h4 { h4 {
margin: 0; 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) { :deep(.select-all-banner-row) {
.select-all-banner-cell { .select-all-banner-cell {
text-align: center; text-align: center;
@ -500,11 +581,4 @@ init();
} }
} }
} }
.actions-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
</style> </style>

View File

@ -1329,7 +1329,7 @@ export function init($plugin, store) {
// =========================================================================== // ===========================================================================
// Forklift Addon UI Flow // Forklift Addon UI Flow
// =========================================================================== // ===========================================================================
weightGroup('forklift', 0, false); weightGroup('vmMigration', 0, false);
// Provider // Provider
headers(HCI.FORKLIFT_PROVIDER, [ headers(HCI.FORKLIFT_PROVIDER, [
@ -1349,8 +1349,8 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
name: HCI.FORKLIFT_PROVIDER, name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.forklift.labels.provider', labelKey: 'harvester.addons.vmMigration.labels.provider',
group: 'forklift::Advanced', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1376,8 +1376,8 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
name: HCI.FORKLIFT_NETWORK_MAP, name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.forklift.labels.networkMap', labelKey: 'harvester.addons.vmMigration.labels.networkMap',
group: 'forklift::Advanced', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1403,8 +1403,8 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
name: HCI.FORKLIFT_STORAGE_MAP, name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.forklift.labels.storageMap', labelKey: 'harvester.addons.vmMigration.labels.storageMap',
group: 'forklift::Advanced', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1431,8 +1431,8 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
name: HCI.FORKLIFT_PLAN, name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.forklift.labels.plan', labelKey: 'harvester.addons.vmMigration.labels.plan',
group: 'forklift::Advanced', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1457,8 +1457,8 @@ export function init($plugin, store) {
}); });
virtualType({ virtualType({
name: HCI.FORKLIFT_MIGRATION, name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.forklift.labels.migration', labelKey: 'harvester.addons.vmMigration.labels.migration',
group: 'forklift::Advanced', group: 'vmMigration::Advanced',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1468,11 +1468,11 @@ export function init($plugin, store) {
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] }); configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
virtualType({ virtualType({
name: 'forklift-create', name: 'forklift-create',
labelKey: 'harvester.addons.forklift.labels.dashboard', labelKey: 'harvester.addons.vmMigration.labels.dashboard',
group: 'forklift', group: 'vmMigration',
namespaced: true, namespaced: true,
route: { route: {
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {} params: {}
} }
}); });
@ -1481,7 +1481,7 @@ export function init($plugin, store) {
registerAddonSideNav(store, PRODUCT_NAME, { registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR, addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS, resourceType: HCI.ADD_ONS,
navGroup: 'forklift', navGroup: 'vmMigration',
types: [ types: [
'forklift-create', 'forklift-create',
] ]
@ -1489,7 +1489,7 @@ export function init($plugin, store) {
registerAddonSideNav(store, PRODUCT_NAME, { registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR, addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS, resourceType: HCI.ADD_ONS,
navGroup: 'forklift::Advanced', navGroup: 'vmMigration::Advanced',
types: [ types: [
HCI.FORKLIFT_PROVIDER, HCI.FORKLIFT_PROVIDER,
HCI.FORKLIFT_NETWORK_MAP, 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 // Provider type column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_TYPE = { export const FORKLIFT_PROVIDER_TYPE = {
name: 'providerType', name: 'providerType',
labelKey: 'harvester.tableHeaders.forkliftProviderType', labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
value: 'spec.type', value: 'spec.type',
sort: 'spec.type', sort: 'spec.type',
align: 'left', align: 'left',
@ -247,7 +247,7 @@ export const FORKLIFT_PROVIDER_TYPE = {
// Provider URL column in forklift.konveyor.io.provider list page // Provider URL column in forklift.konveyor.io.provider list page
export const FORKLIFT_PROVIDER_URL = { export const FORKLIFT_PROVIDER_URL = {
name: 'providerUrl', name: 'providerUrl',
labelKey: 'harvester.tableHeaders.forkliftProviderUrl', labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
value: 'spec.url', value: 'spec.url',
sort: 'spec.url', sort: 'spec.url',
align: 'left', align: 'left',
@ -256,7 +256,7 @@ export const FORKLIFT_PROVIDER_URL = {
// Source provider column in forklift network/storage map list page // Source provider column in forklift network/storage map list page
export const FORKLIFT_MAP_SOURCE_PROVIDER = { export const FORKLIFT_MAP_SOURCE_PROVIDER = {
name: 'sourceProvider', name: 'sourceProvider',
labelKey: 'harvester.tableHeaders.forkliftMapSourceProvider', labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
value: 'spec.provider.source.name', value: 'spec.provider.source.name',
sort: 'spec.provider.source.name', sort: 'spec.provider.source.name',
align: 'left', align: 'left',
@ -265,7 +265,7 @@ export const FORKLIFT_MAP_SOURCE_PROVIDER = {
// Destination provider column in forklift network/storage map list page // Destination provider column in forklift network/storage map list page
export const FORKLIFT_MAP_DEST_PROVIDER = { export const FORKLIFT_MAP_DEST_PROVIDER = {
name: 'destProvider', name: 'destProvider',
labelKey: 'harvester.tableHeaders.forkliftMapDestProvider', labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
value: 'spec.provider.destination.name', value: 'spec.provider.destination.name',
sort: 'spec.provider.destination.name', sort: 'spec.provider.destination.name',
align: 'left', align: 'left',
@ -274,7 +274,7 @@ export const FORKLIFT_MAP_DEST_PROVIDER = {
// Target namespace column in forklift.konveyor.io.plan list page // Target namespace column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_TARGET_NS = { export const FORKLIFT_PLAN_TARGET_NS = {
name: 'targetNamespace', name: 'targetNamespace',
labelKey: 'harvester.tableHeaders.forkliftPlanTargetNs', labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
value: 'spec.targetNamespace', value: 'spec.targetNamespace',
sort: 'spec.targetNamespace', sort: 'spec.targetNamespace',
align: 'left', align: 'left',
@ -283,7 +283,7 @@ export const FORKLIFT_PLAN_TARGET_NS = {
// VM count column in forklift.konveyor.io.plan list page // VM count column in forklift.konveyor.io.plan list page
export const FORKLIFT_PLAN_VM_COUNT = { export const FORKLIFT_PLAN_VM_COUNT = {
name: 'vmCount', name: 'vmCount',
labelKey: 'harvester.tableHeaders.forkliftPlanVmCount', labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length', value: 'spec.vms.length',
sort: 'spec.vms.length', sort: 'spec.vms.length',
align: 'left', align: 'left',
@ -292,7 +292,7 @@ export const FORKLIFT_PLAN_VM_COUNT = {
// Plan reference column in forklift.konveyor.io.migration list page // Plan reference column in forklift.konveyor.io.migration list page
export const FORKLIFT_MIGRATION_PLAN = { export const FORKLIFT_MIGRATION_PLAN = {
name: 'plan', name: 'plan',
labelKey: 'harvester.tableHeaders.forkliftMigrationPlan', labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
value: 'spec.plan.name', value: 'spec.plan.name',
sort: 'spec.plan.name', sort: 'spec.plan.name',
align: 'left', align: 'left',

View File

@ -1,3 +1,8 @@
wizard:
create: Create Migration
next: Proceed
previous: Back
generic: generic:
tip: Tip tip: Tip
resourceExternalLinkTips: 'External Link' resourceExternalLinkTips: 'External Link'
@ -22,7 +27,7 @@ nav:
Logging: Logging Logging: Logging
'Monitoring and Logging': Monitoring and Logging 'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports vmimport: Virtual Machine Imports
forklift: Forklift Migration vmMigration: VM Migration
resourceTable: resourceTable:
groupBy: groupBy:
@ -353,13 +358,13 @@ harvester:
v4ip: V4 IP v4ip: V4 IP
v6ip: V6 IP v6ip: V6 IP
eipName: EIP Name eipName: EIP Name
forkliftProviderType: Type vmMigrationProviderType: Type
forkliftProviderUrl: URL vmMigrationProviderUrl: URL
forkliftMapSourceProvider: Source Provider vmMigrationMapSourceProvider: Source Provider
forkliftMapDestProvider: Destination Provider vmMigrationMapDestProvider: Destination Provider
forkliftPlanTargetNs: Target Namespace vmMigrationPlanTargetNs: Target Namespace
forkliftPlanVmCount: VMs vmMigrationPlanVmCount: VMs
forkliftMigrationPlan: Plan vmMigrationMigrationPlan: Plan
tab: tab:
volume: Volumes volume: Volumes
network: Networks 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. 'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.' 'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
forklift: vmMigration:
labels: labels:
dashboard: Migrations dashboard: Migrations
provider: Providers provider: Providers
@ -1815,6 +1820,21 @@ harvester:
storageMap: Storage Maps storageMap: Storage Maps
plan: Migration Plans plan: Migration Plans
migration: Migrations 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: fields:
username: Username username: Username
password: Password password: Password
@ -1832,7 +1852,11 @@ harvester:
urlHint: Enter the full URL including https:// urlHint: Enter the full URL including https://
skipSsl: Skip SSL certificate verification skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments 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 testSuccess: Connection test passed
testFailed: Connection test failed testFailed: Connection test failed
testMissingFields: Please fill in all required fields before testing testMissingFields: Please fill in all required fields before testing
@ -1841,8 +1865,10 @@ harvester:
saveExisting: Check Provider and Continue saveExisting: Check Provider and Continue
selectVms: selectVms:
title: Select Virtual Machines title: Select Virtual Machines
description: Select the virtual machines you want to migrate from the source provider
discovered: "{count} VMs discovered from" discovered: "{count} VMs discovered from"
lastSynced: Last synced 5 minutes ago lastSynced: "Last synced {time} ago • "
refreshNow: Refresh now
availableVms: Available Virtual Machines availableVms: Available Virtual Machines
selected: selected selected: selected
saveSelection: Save Selection and Continue saveSelection: Save Selection and Continue

View File

@ -5,7 +5,7 @@ import { PRODUCT_NAME } from '../config/harvester';
export default class ForkliftPlan extends HarvesterResource { export default class ForkliftPlan extends HarvesterResource {
get listLocation() { get listLocation() {
return { return {
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: { params: {
product: this.$rootGetters['productId'], product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'], 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(() => ({ const createLocation = computed(() => ({
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`, name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
params: { params: {
product: store.getters['productId'], product: store.getters['productId'],
cluster: store.getters['clusterId'], cluster: store.getters['clusterId'],
@ -135,21 +135,21 @@ const createLocation = computed(() => ({
})); }));
const headers = [ const headers = [
{ ...STATE, labelKey: 'harvester.addons.forklift.dashboard.columns.status' }, { ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
{ {
...NAME_COL, ...NAME_COL,
labelKey: 'harvester.addons.forklift.dashboard.columns.plan', labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
}, },
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 }, { ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
{ {
name: 'progress', name: 'progress',
labelKey: 'harvester.addons.forklift.dashboard.columns.progress', labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
value: 'progress', value: 'progress',
width: 500, width: 500,
}, },
{ {
name: 'mappings', name: 'mappings',
labelKey: 'harvester.addons.forklift.dashboard.columns.mappings', labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
value: 'mappingsDisplay', value: 'mappingsDisplay',
}, },
{ ...AGE }, { ...AGE },
@ -174,12 +174,12 @@ init();
<Masthead <Masthead
:schema="schema" :schema="schema"
:resource="schema.id" :resource="schema.id"
:type-display="t('harvester.addons.forklift.dashboard.title')" :type-display="t('harvester.addons.vmMigration.dashboard.title')"
> >
<template #subHeader> <template #subHeader>
<div class="mmt-5"> <div class="mmt-5">
<p class="text-muted"> <p class="text-muted">
{{ t('harvester.addons.forklift.dashboard.description') }} {{ t('harvester.addons.vmMigration.dashboard.description') }}
</p> </p>
</div> </div>
</template> </template>
@ -188,7 +188,7 @@ init();
:to="createLocation" :to="createLocation"
class="btn role-primary" class="btn role-primary"
> >
{{ t('harvester.addons.forklift.dashboard.createPlan') }} {{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
</router-link> </router-link>
</template> </template>
</Masthead> </Masthead>
@ -200,13 +200,11 @@ init();
:groupable="false" :groupable="false"
:table-actions="false" :table-actions="false"
:search="false" :search="false"
default-sort-by="state"
:default-sort-descending="true"
key-field="_key" key-field="_key"
> >
<template #header-left> <template #header-left>
<h3 class="table-title m-0"> <h3 class="table-title m-0">
{{ t('harvester.addons.forklift.dashboard.tableTitle') }} {{ t('harvester.addons.vmMigration.dashboard.tableTitle') }}
</h3> </h3>
</template> </template>
<template #cell:name="{ row }"> <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 ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue'; import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue'; import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
import ForkliftDashboard from '../pages/c/_cluster/forklift/index.vue'; import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
import ForkliftConfigureProvider from '../pages/c/_cluster/forklift/configure-provider.vue'; import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.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';
const routes = [ const routes = [
{ {
@ -89,25 +86,13 @@ const routes = [
path: `/:product/c/:cluster/projectsnamespaces`, path: `/:product/c/:cluster/projectsnamespaces`,
component: ProjectNamespaces, component: ProjectNamespaces,
}, { }, {
name: `${ PRODUCT_NAME }-c-cluster-forklift`, name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
path: `/:product/c/:cluster/forklift`, path: `/:product/c/:cluster/vm-migration`,
component: ForkliftDashboard, component: ForkliftDashboard,
}, { }, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`, name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
path: `/:product/c/:cluster/forklift/configure-provider`, path: `/:product/c/:cluster/vm-migration/wizard`,
component: ForkliftConfigureProvider, component: ForkliftVmMigrationWizard,
}, {
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-resource`, name: `${ PRODUCT_NAME }-c-cluster-resource`,
path: `/:product/c/:cluster/:resource`, path: `/:product/c/:cluster/:resource`,