mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 20:59:16 +00:00
feat(forklift): Changed to Wizard
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
parent
7c8f172012
commit
e703f34879
@ -2,25 +2,20 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { SCHEMA, STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../../../types';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter, currentRoute } from '../../../../utils/router';
|
||||
import { HCI } from '../../types';
|
||||
|
||||
const schema = {
|
||||
id: HCI.FORKLIFT_NETWORK_MAP,
|
||||
type: SCHEMA,
|
||||
attributes: {
|
||||
kind: HCI.FORKLIFT_NETWORK_MAP,
|
||||
namespaced: true
|
||||
},
|
||||
metadata: { name: HCI.FORKLIFT_NETWORK_MAP },
|
||||
};
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
provider: { type: Object, default: null },
|
||||
selectedVms: { type: Array, default: () => [] },
|
||||
stepData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['ready']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
@ -36,11 +31,19 @@ const allNetworkMaps = ref([]);
|
||||
const allStorageMaps = ref([]);
|
||||
const selectedNetworkTemplate = ref(NO_TEMPLATE);
|
||||
const selectedStorageTemplate = ref(NO_TEMPLATE);
|
||||
const errors = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
// Restore from stepData
|
||||
if (props.stepData.networkEntries.length > 0) {
|
||||
networkEntries.value = props.stepData.networkEntries;
|
||||
}
|
||||
if (props.stepData.storageEntries.length > 0) {
|
||||
storageEntries.value = props.stepData.storageEntries;
|
||||
}
|
||||
selectedNetworkTemplate.value = props.stepData.selectedNetworkTemplate;
|
||||
selectedStorageTemplate.value = props.stepData.selectedStorageTemplate;
|
||||
|
||||
const NAMESPACE = 'forklift';
|
||||
const providerName = computed(() => currentRoute().query.provider || 'vsphere');
|
||||
|
||||
const harvesterNetworkOptions = computed(() => {
|
||||
const options = [
|
||||
@ -74,7 +77,7 @@ const storageClassOptions = computed(() => {
|
||||
|
||||
const networkTemplateOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: t('harvester.addons.forklift.configureMappings.noTemplate'), value: NO_TEMPLATE }
|
||||
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
|
||||
];
|
||||
|
||||
const currentIds = new Set(networkEntries.value.map((e) => e.id).filter(Boolean));
|
||||
@ -100,7 +103,7 @@ const networkTemplateOptions = computed(() => {
|
||||
|
||||
const storageTemplateOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: t('harvester.addons.forklift.configureMappings.noTemplate'), value: NO_TEMPLATE }
|
||||
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
|
||||
];
|
||||
|
||||
const currentIds = new Set(storageEntries.value.map((e) => e.id).filter(Boolean));
|
||||
@ -192,6 +195,29 @@ const canSave = computed(() => {
|
||||
allNetworksMapped.value && allStorageMapped.value;
|
||||
});
|
||||
|
||||
watch(canSave, (val) => {
|
||||
emit('ready', val);
|
||||
});
|
||||
|
||||
// After restore, check if ready
|
||||
if (canSave.value) {
|
||||
emit('ready', true);
|
||||
}
|
||||
|
||||
// Sync state back to stepData
|
||||
watch(networkEntries, (val) => {
|
||||
props.stepData.networkEntries = val;
|
||||
}, { deep: true });
|
||||
watch(storageEntries, (val) => {
|
||||
props.stepData.storageEntries = val;
|
||||
}, { deep: true });
|
||||
watch(selectedNetworkTemplate, (val) => {
|
||||
props.stepData.selectedNetworkTemplate = val;
|
||||
});
|
||||
watch(selectedStorageTemplate, (val) => {
|
||||
props.stepData.selectedStorageTemplate = val;
|
||||
});
|
||||
|
||||
const buildNetworkEntries = () => {
|
||||
const networkMap = {};
|
||||
|
||||
@ -239,13 +265,15 @@ const buildStorageEntries = () => {
|
||||
name: ds.name || 'Unknown',
|
||||
id: ds.id,
|
||||
type: ds.type || '',
|
||||
capacity: ds.capacity || 0,
|
||||
capacity: 0,
|
||||
target: '',
|
||||
usedBy: [],
|
||||
_key: `stor-${ ds.id }`,
|
||||
};
|
||||
}
|
||||
|
||||
datastoreMap[ds.id].capacity += disk.capacity || 0;
|
||||
|
||||
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
|
||||
datastoreMap[ds.id].usedBy.push(vmName);
|
||||
}
|
||||
@ -274,27 +302,17 @@ const formatStorageDetail = (entry) => {
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' \u2022 ');
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveMappings = async(buttonCb) => {
|
||||
const saveAndReturn = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
const providerRef = {
|
||||
source: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: providerName.value,
|
||||
name: props.providerName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
destination: {
|
||||
@ -305,107 +323,85 @@ const saveMappings = async(buttonCb) => {
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Create NetworkMap
|
||||
const networkMapSpec = {
|
||||
map: networkEntries.value.map((entry) => {
|
||||
if (entry.target === 'pod') {
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { type: 'pod' },
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.target === 'ignored') {
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { type: 'ignored' },
|
||||
};
|
||||
}
|
||||
|
||||
const parts = entry.target.split('/');
|
||||
const netName = parts.length > 1 ? parts[1] : parts[0];
|
||||
const netNamespace = parts.length > 1 ? parts[0] : NAMESPACE;
|
||||
|
||||
// Create NetworkMap
|
||||
const networkMapSpec = {
|
||||
map: networkEntries.value.map((entry) => {
|
||||
if (entry.target === 'pod') {
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: {
|
||||
type: 'multus',
|
||||
name: netName,
|
||||
namespace: netNamespace,
|
||||
},
|
||||
destination: { type: 'pod' },
|
||||
};
|
||||
}),
|
||||
provider: providerRef,
|
||||
};
|
||||
|
||||
// Fetch the Provider to build ownerReference
|
||||
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
|
||||
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
|
||||
|
||||
const providerOwnerRef = provider ? [{
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: provider.metadata.name,
|
||||
uid: provider.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
}] : [];
|
||||
|
||||
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: {
|
||||
name: `${ providerName.value }-network-map-${ Math.random().toString(36).substring(2, 7) }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: providerOwnerRef,
|
||||
},
|
||||
spec: networkMapSpec,
|
||||
});
|
||||
|
||||
await networkMap.save();
|
||||
|
||||
// Create StorageMap
|
||||
const storageMapSpec = {
|
||||
map: storageEntries.value.map((entry) => ({
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { storageClass: entry.target },
|
||||
})),
|
||||
provider: providerRef,
|
||||
};
|
||||
|
||||
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||
metadata: {
|
||||
name: `${ providerName.value }-storage-map-${ Math.random().toString(36).substring(2, 7) }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: providerOwnerRef,
|
||||
},
|
||||
spec: storageMapSpec,
|
||||
});
|
||||
|
||||
await storageMap.save();
|
||||
|
||||
// Navigate to review page
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: {
|
||||
provider: providerName.value,
|
||||
vms: currentRoute().query.vms,
|
||||
networkMap: networkMap.metadata.name,
|
||||
storageMap: storageMap.metadata.name,
|
||||
}
|
||||
});
|
||||
|
||||
buttonCb(true);
|
||||
} catch (err) {
|
||||
errors.value = [err.message || err];
|
||||
buttonCb(false);
|
||||
}
|
||||
if (entry.target === 'ignored') {
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { type: 'ignored' },
|
||||
};
|
||||
}
|
||||
|
||||
const parts = entry.target.split('/');
|
||||
const netName = parts.length > 1 ? parts[1] : parts[0];
|
||||
const netNamespace = parts.length > 1 ? parts[0] : NAMESPACE;
|
||||
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: {
|
||||
type: 'multus',
|
||||
name: netName,
|
||||
namespace: netNamespace,
|
||||
},
|
||||
};
|
||||
}),
|
||||
provider: providerRef,
|
||||
};
|
||||
|
||||
const providerOwnerRef = props.provider ? [{
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: props.provider.metadata.name,
|
||||
uid: props.provider.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
}] : [];
|
||||
|
||||
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: {
|
||||
name: `${ props.providerName }-network-map-${ Math.random().toString(36).substring(2, 7) }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: providerOwnerRef,
|
||||
},
|
||||
spec: networkMapSpec,
|
||||
});
|
||||
|
||||
await networkMap.save();
|
||||
|
||||
// Create StorageMap
|
||||
const storageMapSpec = {
|
||||
map: storageEntries.value.map((entry) => ({
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { storageClass: entry.target },
|
||||
})),
|
||||
provider: providerRef,
|
||||
};
|
||||
|
||||
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||
metadata: {
|
||||
name: `${ props.providerName }-storage-map-${ Math.random().toString(36).substring(2, 7) }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: providerOwnerRef,
|
||||
},
|
||||
spec: storageMapSpec,
|
||||
});
|
||||
|
||||
await storageMap.save();
|
||||
|
||||
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
|
||||
};
|
||||
|
||||
defineExpose({ saveMappings: saveAndReturn });
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
@ -433,28 +429,17 @@ const init = async() => {
|
||||
allStorageMaps.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||
} catch (e) {
|
||||
// Provider may already be in store from previous step
|
||||
}
|
||||
vms.value = props.selectedVms;
|
||||
|
||||
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
|
||||
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
|
||||
|
||||
const vmsParam = currentRoute().query.vms;
|
||||
|
||||
if (vmsParam) {
|
||||
if (networkEntries.value.length === 0) {
|
||||
try {
|
||||
const vmIds = JSON.parse(vmsParam);
|
||||
const providerUid = provider?.metadata?.uid;
|
||||
const providerType = provider?.spec?.type || 'vsphere';
|
||||
const providerUid = props.provider?.metadata?.uid;
|
||||
const providerType = props.provider?.spec?.type || 'vsphere';
|
||||
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
|
||||
|
||||
const [allVms, networksData, datastoresData] = await Promise.all([
|
||||
fetch(`${ baseUrl }/vms`).then((r) => r.json()).catch(() => []),
|
||||
const [networksData, datastoresData] = await Promise.all([
|
||||
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
|
||||
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).catch(() => []),
|
||||
fetch(`${ baseUrl }/datastores?detail=1`).then((r) => r.json()).catch(() => []),
|
||||
]);
|
||||
|
||||
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
|
||||
@ -462,51 +447,48 @@ const init = async() => {
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
const datastoreNameMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
|
||||
map[d.id] = d.name;
|
||||
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
|
||||
map[d.id] = { name: d.name, type: d.type || '' };
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
const vmList = Array.isArray(allVms) ? allVms : (allVms?.data || []);
|
||||
vms.value = vms.value.map((vm) => {
|
||||
const resolved = { ...vm };
|
||||
|
||||
vms.value = vmIds.map((id) => {
|
||||
const found = vmList.find((vm) => vm.id === id);
|
||||
if (resolved.networks) {
|
||||
resolved.networks = resolved.networks.map((n) => ({
|
||||
...n,
|
||||
name: n.name || networkNameMap[n.id] || n.id,
|
||||
}));
|
||||
}
|
||||
|
||||
if (found) {
|
||||
// Resolve network names
|
||||
if (found.networks) {
|
||||
found.networks = found.networks.map((n) => ({
|
||||
...n,
|
||||
name: n.name || networkNameMap[n.id] || n.id,
|
||||
}));
|
||||
}
|
||||
if (resolved.disks) {
|
||||
resolved.disks = resolved.disks.map((d) => {
|
||||
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
|
||||
|
||||
// Resolve datastore names
|
||||
if (found.disks) {
|
||||
found.disks = found.disks.map((d) => ({
|
||||
return {
|
||||
...d,
|
||||
datastore: d.datastore ? {
|
||||
...d.datastore,
|
||||
name: d.datastore.name || datastoreNameMap[d.datastore.id] || d.datastore.id,
|
||||
name: d.datastore.name || dsInfo?.name || d.datastore.id,
|
||||
type: dsInfo?.type || '',
|
||||
} : d.datastore,
|
||||
}));
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id, name: id, networks: [], disks: []
|
||||
};
|
||||
return resolved;
|
||||
});
|
||||
} catch (e) {
|
||||
vms.value = [];
|
||||
// Name resolution failed — entries will use IDs as fallback
|
||||
}
|
||||
}
|
||||
|
||||
buildNetworkEntries();
|
||||
buildStorageEntries();
|
||||
buildNetworkEntries();
|
||||
}
|
||||
if (storageEntries.value.length === 0) {
|
||||
buildStorageEntries();
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
@ -519,34 +501,24 @@ init();
|
||||
v-else
|
||||
class="configure-mappings"
|
||||
>
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.configureMappings.title')"
|
||||
:is-creatable="false"
|
||||
>
|
||||
<template #subHeader>
|
||||
<p class="text-muted line-height-20 mmt-5">
|
||||
{{ t('harvester.addons.forklift.configureMappings.description') }}
|
||||
</p>
|
||||
</template>
|
||||
</Masthead>
|
||||
|
||||
<p class="text-muted line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
|
||||
</p>
|
||||
<div class="mappings-columns">
|
||||
<!-- Network Mapping -->
|
||||
<div class="mapping-column">
|
||||
<div>
|
||||
<h3 class="mapping-section-title">
|
||||
{{ t('harvester.addons.forklift.configureMappings.networkMapping.title') }}
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.title') }}
|
||||
</h3>
|
||||
<p class="text-muted line-height-20">
|
||||
{{ t('harvester.addons.forklift.configureMappings.networkMapping.description') }}
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="selectedNetworkTemplate"
|
||||
:label="t('harvester.addons.forklift.configureMappings.networkMapping.template')"
|
||||
:label="t('harvester.addons.vmMigration.configureMappings.networkMapping.template')"
|
||||
:options="networkTemplateOptions"
|
||||
:reduce="(opt) => opt.value"
|
||||
class="mb-10"
|
||||
@ -574,7 +546,7 @@ init();
|
||||
<LabeledSelect
|
||||
v-model:value="entry.target"
|
||||
:options="harvesterNetworkOptions"
|
||||
:placeholder="t('harvester.addons.forklift.configureMappings.networkMapping.placeholder')"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
|
||||
:searchable="true"
|
||||
/>
|
||||
</div>
|
||||
@ -593,16 +565,16 @@ init();
|
||||
<div class="mapping-column">
|
||||
<div>
|
||||
<h3 class="mapping-section-title">
|
||||
{{ t('harvester.addons.forklift.configureMappings.storageMapping.title') }}
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.title') }}
|
||||
</h3>
|
||||
<p class="text-muted">
|
||||
{{ t('harvester.addons.forklift.configureMappings.storageMapping.description') }}
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="selectedStorageTemplate"
|
||||
:label="t('harvester.addons.forklift.configureMappings.storageMapping.template')"
|
||||
:label="t('harvester.addons.vmMigration.configureMappings.storageMapping.template')"
|
||||
:options="storageTemplateOptions"
|
||||
:reduce="(opt) => opt.value"
|
||||
class="mb-10"
|
||||
@ -633,7 +605,7 @@ init();
|
||||
<LabeledSelect
|
||||
v-model:value="entry.target"
|
||||
:options="storageClassOptions"
|
||||
:placeholder="t('harvester.addons.forklift.configureMappings.storageMapping.placeholder')"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
|
||||
:searchable="true"
|
||||
/>
|
||||
</div>
|
||||
@ -648,30 +620,10 @@ init();
|
||||
</RcItemCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-footer">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<AsyncButton
|
||||
:disabled="!canSave"
|
||||
:action-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
:waiting-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
:success-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
:error-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
@click="saveMappings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.configure-mappings {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mappings-columns {
|
||||
display: grid;
|
||||
@ -738,13 +690,6 @@ init();
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
@ -754,4 +699,14 @@ init();
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.configure-mappings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -5,28 +5,19 @@ import { useStore } from 'vuex';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import { Checkbox } from '@components/Form/Checkbox';
|
||||
import { Banner } from '@components/Banner';
|
||||
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { SCHEMA, SECRET } from '@shell/config/types';
|
||||
import { SECRET } from '@shell/config/types';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../../../types';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter } from '../../../../utils/router';
|
||||
|
||||
const schema = {
|
||||
id: HCI.FORKLIFT_PROVIDER,
|
||||
type: SCHEMA,
|
||||
attributes: {
|
||||
kind: HCI.FORKLIFT_PROVIDER,
|
||||
namespaced: true
|
||||
},
|
||||
metadata: { name: HCI.FORKLIFT_PROVIDER },
|
||||
};
|
||||
import { HCI } from '../../types';
|
||||
|
||||
const CREATE_NEW = '__create_new__';
|
||||
|
||||
const props = defineProps({ stepData: { type: Object, required: true } });
|
||||
|
||||
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
@ -41,19 +32,67 @@ const skipTlsVerify = ref(false);
|
||||
const testResult = ref(null);
|
||||
const testError = ref(null);
|
||||
const errors = ref([]);
|
||||
const testBtnRef = ref(null);
|
||||
const createdProvider = ref(null);
|
||||
const createdSecret = ref(null);
|
||||
const loading = ref(true);
|
||||
const testPassed = ref(false);
|
||||
const testing = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
// Restore state from stepData on mount
|
||||
selectedProvider.value = props.stepData.selectedProvider;
|
||||
providerName.value = props.stepData.providerName;
|
||||
url.value = props.stepData.url;
|
||||
username.value = props.stepData.username;
|
||||
password.value = props.stepData.password;
|
||||
skipTlsVerify.value = props.stepData.skipTlsVerify;
|
||||
testPassed.value = props.stepData.testPassed;
|
||||
testResult.value = props.stepData.testResult;
|
||||
testError.value = props.stepData.testError;
|
||||
createdProvider.value = props.stepData.createdProvider;
|
||||
createdSecret.value = props.stepData.createdSecret;
|
||||
|
||||
// Sync state back to stepData
|
||||
watch(selectedProvider, (val) => {
|
||||
props.stepData.selectedProvider = val;
|
||||
});
|
||||
watch(providerName, (val) => {
|
||||
props.stepData.providerName = val;
|
||||
});
|
||||
watch(url, (val) => {
|
||||
props.stepData.url = val;
|
||||
});
|
||||
watch(username, (val) => {
|
||||
props.stepData.username = val;
|
||||
});
|
||||
watch(password, (val) => {
|
||||
props.stepData.password = val;
|
||||
});
|
||||
watch(skipTlsVerify, (val) => {
|
||||
props.stepData.skipTlsVerify = val;
|
||||
});
|
||||
watch(testPassed, (val) => {
|
||||
props.stepData.testPassed = val;
|
||||
});
|
||||
watch(testResult, (val) => {
|
||||
props.stepData.testResult = val;
|
||||
});
|
||||
watch(testError, (val) => {
|
||||
props.stepData.testError = val;
|
||||
});
|
||||
watch(createdProvider, (val) => {
|
||||
props.stepData.createdProvider = val;
|
||||
});
|
||||
watch(createdSecret, (val) => {
|
||||
props.stepData.createdSecret = val;
|
||||
});
|
||||
|
||||
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
|
||||
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
|
||||
|
||||
const providerOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: t('harvester.addons.forklift.configureProvider.createNew'), value: CREATE_NEW }
|
||||
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
|
||||
];
|
||||
|
||||
allProviders.value.forEach((p) => {
|
||||
@ -134,15 +173,22 @@ watch([providerName, url, username, password], () => {
|
||||
}
|
||||
});
|
||||
|
||||
const cancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
};
|
||||
// Emit ready/complete when testPassed changes
|
||||
watch(testPassed, (val) => {
|
||||
emit('ready', val);
|
||||
if (val) {
|
||||
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Emit form-valid when form validity changes
|
||||
watch(isFormValid, (val) => {
|
||||
emit('form-valid', val);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(testing, (val) => {
|
||||
emit('testing', val);
|
||||
}, { immediate: true });
|
||||
|
||||
const testConnection = async(buttonCb) => {
|
||||
testResult.value = null;
|
||||
@ -150,7 +196,7 @@ const testConnection = async(buttonCb) => {
|
||||
testing.value = true;
|
||||
|
||||
if (!providerName.value || !url.value || !username.value || !password.value) {
|
||||
testError.value = t('harvester.addons.forklift.configureProvider.testMissingFields');
|
||||
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
|
||||
@ -205,16 +251,16 @@ const testConnection = async(buttonCb) => {
|
||||
|
||||
if (connected) {
|
||||
testPassed.value = true;
|
||||
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess');
|
||||
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
|
||||
testing.value = false;
|
||||
buttonCb(true);
|
||||
} else {
|
||||
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout');
|
||||
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
} catch (err) {
|
||||
testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed');
|
||||
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
@ -329,7 +375,7 @@ const testConnection = async(buttonCb) => {
|
||||
|
||||
if (connected) {
|
||||
testPassed.value = true;
|
||||
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess');
|
||||
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
|
||||
testing.value = false;
|
||||
buttonCb(true);
|
||||
} else {
|
||||
@ -342,7 +388,7 @@ const testConnection = async(buttonCb) => {
|
||||
createdSecret.value = null;
|
||||
}
|
||||
|
||||
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout');
|
||||
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
@ -360,47 +406,12 @@ const testConnection = async(buttonCb) => {
|
||||
createdSecret.value = null;
|
||||
}
|
||||
|
||||
testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed');
|
||||
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProvider = async(buttonCb) => {
|
||||
errors.value = [];
|
||||
saving.value = true;
|
||||
|
||||
if (!testPassed.value) {
|
||||
// Test hasn't passed yet — run it first
|
||||
await testConnection((success) => {
|
||||
if (success) {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: { provider: providerName.value }
|
||||
});
|
||||
} else {
|
||||
saving.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: { provider: providerName.value }
|
||||
});
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
@ -414,171 +425,139 @@ const init = async() => {
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
const clickTestButton = () => {
|
||||
testBtnRef.value?.$el?.click();
|
||||
};
|
||||
|
||||
defineExpose({ testConnection, clickTestButton });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="configure-provider">
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.configureProvider.title')"
|
||||
:is-creatable="false"
|
||||
>
|
||||
<template #subHeader>
|
||||
<div class="mmt-5">
|
||||
<p class="text-muted">
|
||||
{{ t('harvester.addons.forklift.configureProvider.description') }}
|
||||
<div class="configure-provider-step">
|
||||
<p class="text-muted line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
|
||||
</p>
|
||||
|
||||
<div class="configure-provider-step-content">
|
||||
<h3 class="table-title m-0">
|
||||
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
|
||||
</h3>
|
||||
|
||||
<Banner
|
||||
class="requirements-banner m-0"
|
||||
color="info"
|
||||
>
|
||||
<div class="requirements-banner-content">
|
||||
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
|
||||
<br>
|
||||
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div class="configure-provider-step-form">
|
||||
<div>
|
||||
<LabeledSelect
|
||||
v-model:value="selectedProvider"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
|
||||
:options="providerOptions"
|
||||
:reduce="(opt) => opt.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isExistingProvider"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model:value="providerName"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.name')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LabeledInput
|
||||
v-model:value="url"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
<p class="text-muted mt-5">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Masthead>
|
||||
|
||||
<h3 class="mmt-3 mmb-3 table-title">
|
||||
<b>{{ t('harvester.addons.forklift.configureProvider.connectionDetails') }}</b>
|
||||
</h3>
|
||||
<div class="row">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="username"
|
||||
:label="t('harvester.addons.vmMigration.fields.username')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
:label="t('harvester.addons.vmMigration.fields.password')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
class="requirements-banner mt-0 mmb-3"
|
||||
color="info"
|
||||
>
|
||||
<div class="requirements-banner-content">
|
||||
<span class="requirements-banner-title">{{ t('harvester.addons.forklift.configureProvider.requirementsTitle') }}</span>
|
||||
<br>
|
||||
<span>{{ t('harvester.addons.forklift.configureProvider.requirementsText') }}</span>
|
||||
<div>
|
||||
<Checkbox
|
||||
v-model:value="skipTlsVerify"
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
|
||||
:disabled="isExistingProvider"
|
||||
/>
|
||||
<p class="text-muted ml-20">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<AsyncButton
|
||||
ref="testBtnRef"
|
||||
mode="test"
|
||||
:disabled="!isFormValid"
|
||||
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
|
||||
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
|
||||
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
|
||||
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
|
||||
:action-color="'role-secondary'"
|
||||
:waiting-color="'role-disabled'"
|
||||
@click="testConnection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
v-if="testResult"
|
||||
color="success"
|
||||
>
|
||||
{{ testResult }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-if="testError"
|
||||
color="error"
|
||||
>
|
||||
{{ testError }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
>
|
||||
{{ err }}
|
||||
</Banner>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div class="mb-20">
|
||||
<LabeledSelect
|
||||
v-model:value="selectedProvider"
|
||||
:label="t('harvester.addons.forklift.configureProvider.providerSelect')"
|
||||
:options="providerOptions"
|
||||
:reduce="(opt) => opt.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isExistingProvider"
|
||||
class="mb-20"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model:value="providerName"
|
||||
:label="t('harvester.addons.forklift.configureProvider.name')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-20">
|
||||
<LabeledInput
|
||||
v-model:value="url"
|
||||
:label="t('harvester.addons.forklift.configureProvider.urlLabel')"
|
||||
:placeholder="t('harvester.addons.forklift.configureProvider.urlPlaceholder')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
<p class="text-muted mt-5">
|
||||
{{ t('harvester.addons.forklift.configureProvider.urlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="username"
|
||||
:label="t('harvester.addons.forklift.fields.username')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
:label="t('harvester.addons.forklift.fields.password')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-20">
|
||||
<Checkbox
|
||||
v-model:value="skipTlsVerify"
|
||||
:label="t('harvester.addons.forklift.configureProvider.skipSsl')"
|
||||
:disabled="isExistingProvider"
|
||||
/>
|
||||
<p class="text-muted ml-20">
|
||||
{{ t('harvester.addons.forklift.configureProvider.skipSslHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-20">
|
||||
<button
|
||||
v-if="testPassed"
|
||||
class="btn role-secondary test-passed-btn"
|
||||
disabled
|
||||
>
|
||||
<i class="icon icon-checkmark mr-10" /> {{ t('harvester.addons.forklift.configureProvider.testConnection') }}
|
||||
</button>
|
||||
<AsyncButton
|
||||
v-else
|
||||
mode="test"
|
||||
:disabled="!isFormValid || testing || saving"
|
||||
:action-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
:waiting-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
:success-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
:error-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
class="btn role-secondary"
|
||||
@click="testConnection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
v-if="testResult"
|
||||
color="success"
|
||||
class="mb-10"
|
||||
>
|
||||
{{ testResult }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-if="testError"
|
||||
color="error"
|
||||
class="mb-10"
|
||||
>
|
||||
{{ testError }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
class="mb-10"
|
||||
>
|
||||
{{ err }}
|
||||
</Banner>
|
||||
|
||||
<div class="provider-actions">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<AsyncButton
|
||||
:disabled="!isFormValid || testing || saving"
|
||||
:action-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
:waiting-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
:success-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
:error-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
@click="saveProvider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.configure-provider {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.requirements-banner {
|
||||
font-weight: 400;
|
||||
|
||||
@ -587,10 +566,25 @@ init();
|
||||
}
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
.configure-provider-step {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.configure-provider-step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.configure-provider-step-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
510
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal file
510
pkg/harvester/components/vm-migration/ReviewMigrationStep.vue
Normal 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 • {{ vm.memGB }} • {{ vm.diskDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
<MappingsCell
|
||||
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
|
||||
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</RcItemCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cold Migration Warning -->
|
||||
<Banner
|
||||
color="warning"
|
||||
class="m-0"
|
||||
>
|
||||
<div>
|
||||
<span class="banner-title">{{ t('harvester.addons.vmMigration.reviewMigration.warningTitle') }}</span><br>
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }}
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<!-- Error banner -->
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-migration {
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.review-migration-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px 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>
|
||||
@ -1,31 +1,24 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import {
|
||||
ref, computed, watch, nextTick, onBeforeUnmount
|
||||
} from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||
import SortableTable from '@shell/components/SortableTable';
|
||||
import { BadgeState } from '@components/BadgeState';
|
||||
import { SCHEMA } from '@shell/config/types';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../../../types';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter, currentRoute } from '../../../../utils/router';
|
||||
|
||||
const schema = {
|
||||
id: HCI.FORKLIFT_PROVIDER,
|
||||
type: SCHEMA,
|
||||
attributes: {
|
||||
kind: HCI.FORKLIFT_PROVIDER,
|
||||
namespaced: true
|
||||
},
|
||||
metadata: { name: HCI.FORKLIFT_PROVIDER },
|
||||
};
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
provider: { type: Object, default: null },
|
||||
stepData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['complete', 'loading']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const allProviders = ref([]);
|
||||
const provider = ref(null);
|
||||
const discoveredVMs = ref([]);
|
||||
const selectedVMs = ref([]);
|
||||
const tableRows = ref([]);
|
||||
@ -37,7 +30,65 @@ const allVMsSelected = ref(false);
|
||||
const selectedVMIds = ref(new Set());
|
||||
let skipNextSelectionEvent = false;
|
||||
|
||||
const providerName = computed(() => provider.value?.metadata?.name || currentRoute().query.provider || '');
|
||||
const lastFetchedAt = ref(null);
|
||||
const now = ref(Date.now());
|
||||
|
||||
const formatElapsed = (ms) => {
|
||||
const totalMinutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return `${ hours } ${ hours === 1 ? 'hour' : 'hours' } and ${ minutes } ${ minutes === 1 ? 'minute' : 'minutes' }`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${ hours } ${ hours === 1 ? 'hour' : 'hours' }`;
|
||||
}
|
||||
|
||||
return `${ Math.max(1, minutes) } ${ minutes <= 1 ? 'minute' : 'minutes' }`;
|
||||
};
|
||||
|
||||
const lastSyncedTime = computed(() => {
|
||||
if (!lastFetchedAt.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formatElapsed(now.value - lastFetchedAt.value);
|
||||
});
|
||||
|
||||
const nowTimer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 30000);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(nowTimer);
|
||||
});
|
||||
|
||||
// Restore from stepData
|
||||
if (props.stepData.discoveredVMs.length > 0) {
|
||||
discoveredVMs.value = props.stepData.discoveredVMs;
|
||||
}
|
||||
if (props.stepData.selectedVMIds.size > 0) {
|
||||
selectedVMIds.value = props.stepData.selectedVMIds;
|
||||
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||
}
|
||||
if (props.stepData.tableRows.length > 0) {
|
||||
tableRows.value = props.stepData.tableRows;
|
||||
}
|
||||
|
||||
// Sync back to stepData
|
||||
watch(discoveredVMs, (val) => {
|
||||
props.stepData.discoveredVMs = val;
|
||||
}, { deep: true });
|
||||
watch(selectedVMIds, (val) => {
|
||||
props.stepData.selectedVMIds = val;
|
||||
}, { deep: true });
|
||||
watch(tableRows, (val) => {
|
||||
props.stepData.tableRows = val;
|
||||
}, { deep: true });
|
||||
|
||||
const vmCount = computed(() => discoveredVMs.value.length);
|
||||
const selectedCount = computed(() => selectedVMs.value.length);
|
||||
|
||||
@ -62,39 +113,39 @@ const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('
|
||||
const headers = [
|
||||
{
|
||||
name: 'vmName',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.vmName',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
|
||||
value: 'vmName',
|
||||
sort: ['vmName'],
|
||||
subLabel: 'Identifier',
|
||||
},
|
||||
{
|
||||
name: 'os',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.os',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
|
||||
value: 'os',
|
||||
sort: ['os'],
|
||||
},
|
||||
{
|
||||
name: 'resources',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.resources',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
|
||||
value: 'resources',
|
||||
sort: false,
|
||||
},
|
||||
{
|
||||
name: 'powerState',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.powerState',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
|
||||
value: 'powerState',
|
||||
sort: ['powerState'],
|
||||
},
|
||||
{
|
||||
name: 'network',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.network',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
|
||||
value: 'network',
|
||||
sort: ['network'],
|
||||
subLabel: 'Identifier',
|
||||
},
|
||||
{
|
||||
name: 'datastore',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.datastore',
|
||||
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
|
||||
value: 'datastore',
|
||||
sort: ['datastore'],
|
||||
subLabel: 'Identifier',
|
||||
@ -185,6 +236,8 @@ const onSelect = (rows) => {
|
||||
|
||||
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
|
||||
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||
|
||||
emit('complete', { selectedVMs: selectedVMs.value });
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
@ -230,112 +283,150 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const cancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
const refreshing = ref(false);
|
||||
|
||||
const isLoading = computed(() => loading.value || refreshing.value);
|
||||
|
||||
watch(isLoading, (val) => {
|
||||
emit('loading', val);
|
||||
}, { immediate: true });
|
||||
|
||||
const fetchVMs = async() => {
|
||||
if (!props.provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerUid = props.provider.metadata.uid;
|
||||
const providerType = props.provider.spec?.type || 'vsphere';
|
||||
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
|
||||
|
||||
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
|
||||
fetch(`${ baseUrl }/vms`).then((r) => r.json()),
|
||||
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
|
||||
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).catch(() => []),
|
||||
]);
|
||||
|
||||
if (Array.isArray(vmsResp)) {
|
||||
discoveredVMs.value = vmsResp;
|
||||
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
|
||||
discoveredVMs.value = vmsResp.data;
|
||||
}
|
||||
|
||||
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
|
||||
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
|
||||
|
||||
networkMap.value = networks.reduce((map, n) => {
|
||||
map[n.id] = n.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
datastoreMap.value = datastores.reduce((map, d) => {
|
||||
map[d.id] = d.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
lastFetchedAt.value = Date.now();
|
||||
tableRows.value = buildTableRows();
|
||||
};
|
||||
|
||||
const saveSelection = () => {
|
||||
const vmIds = selectedVMs.value.map((vm) => vm.id || vm.vmId || vm.metadata?.name);
|
||||
const refreshVMs = async() => {
|
||||
refreshing.value = true;
|
||||
skipNextSelectionEvent = true;
|
||||
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: {
|
||||
provider: providerName.value,
|
||||
vms: JSON.stringify(vmIds),
|
||||
const previousSelectedIds = new Set(selectedVMIds.value);
|
||||
|
||||
try {
|
||||
await fetchVMs();
|
||||
} catch (e) {
|
||||
discoveredVMs.value = [];
|
||||
tableRows.value = [];
|
||||
}
|
||||
|
||||
selectedVMIds.value = new Set(
|
||||
discoveredVMs.value
|
||||
.filter((vm) => previousSelectedIds.has(vm.id))
|
||||
.map((vm) => vm.id)
|
||||
);
|
||||
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
|
||||
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
|
||||
|
||||
props.stepData.discoveredVMs = discoveredVMs.value;
|
||||
props.stepData.selectedVMIds = selectedVMIds.value;
|
||||
props.stepData.tableRows = tableRows.value;
|
||||
emit('complete', { selectedVMs: selectedVMs.value });
|
||||
refreshing.value = false;
|
||||
|
||||
const table = sortableTableRef.value;
|
||||
|
||||
if (table) {
|
||||
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
|
||||
|
||||
if (rowsToReselect.length > 0) {
|
||||
table.update(rowsToReselect, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||
|
||||
const queryProvider = currentRoute().query.provider;
|
||||
|
||||
if (queryProvider) {
|
||||
provider.value = allProviders.value.find(
|
||||
(p) => p.metadata.name === queryProvider && p.metadata.namespace === 'forklift'
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.value) {
|
||||
try {
|
||||
const providerUid = provider.value.metadata.uid;
|
||||
const providerType = provider.value.spec?.type || 'vsphere';
|
||||
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
|
||||
|
||||
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
|
||||
fetch(`${ baseUrl }/vms`).then((r) => r.json()),
|
||||
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
|
||||
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).catch(() => []),
|
||||
]);
|
||||
|
||||
if (Array.isArray(vmsResp)) {
|
||||
discoveredVMs.value = vmsResp;
|
||||
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
|
||||
discoveredVMs.value = vmsResp.data;
|
||||
}
|
||||
|
||||
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
|
||||
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
|
||||
|
||||
networkMap.value = networks.reduce((map, n) => {
|
||||
map[n.id] = n.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
datastoreMap.value = datastores.reduce((map, d) => {
|
||||
map[d.id] = d.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
} catch (e) {
|
||||
discoveredVMs.value = [];
|
||||
if (discoveredVMs.value.length > 0) {
|
||||
if (!lastFetchedAt.value) {
|
||||
lastFetchedAt.value = Date.now();
|
||||
}
|
||||
tableRows.value = buildTableRows();
|
||||
loading.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchVMs();
|
||||
} catch (e) {
|
||||
discoveredVMs.value = [];
|
||||
}
|
||||
|
||||
tableRows.value = buildTableRows();
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// Emit initial complete if VMs are already selected (restored from stepData)
|
||||
if (selectedVMs.value.length > 0) {
|
||||
emit('complete', { selectedVMs: selectedVMs.value });
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="refreshing" />
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
class="select-vms-step"
|
||||
>
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.selectVms.title')"
|
||||
:is-creatable="false"
|
||||
>
|
||||
<template #subHeader>
|
||||
<p class="text-muted mmt-5">
|
||||
{{ t('harvester.addons.forklift.selectVms.discovered', { count: vmCount }) }}
|
||||
<a
|
||||
v-if="providerName"
|
||||
class="provider-link"
|
||||
>{{ providerName }}</a>
|
||||
<br>
|
||||
<span class="text-small">{{ t('harvester.addons.forklift.selectVms.lastSynced') }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</Masthead>
|
||||
|
||||
<p class="text-muted line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
|
||||
<router-link
|
||||
v-if="provider"
|
||||
class="provider-link"
|
||||
:to="provider._detailLocation"
|
||||
>
|
||||
{{ providerName }}
|
||||
</router-link>
|
||||
<br>
|
||||
<span
|
||||
v-if="lastSyncedTime"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
|
||||
<a
|
||||
role="button"
|
||||
class="text-bold"
|
||||
:class="{ disabled: refreshing }"
|
||||
@click.prevent="refreshVMs"
|
||||
>
|
||||
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
<!-- Discovered VMs table -->
|
||||
<SortableTable
|
||||
ref="sortableTableRef"
|
||||
@ -353,9 +444,9 @@ init();
|
||||
<template #header-left>
|
||||
<div class="vm-table-title">
|
||||
<h3 class="m-0">
|
||||
{{ t('harvester.addons.forklift.selectVms.availableVms') }}
|
||||
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||
</h3>
|
||||
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.forklift.selectVms.selected') }}</span>
|
||||
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:vmName="{ row }">
|
||||
@ -410,51 +501,30 @@ init();
|
||||
class="select-all-banner-cell"
|
||||
>
|
||||
<template v-if="allVMsSelected">
|
||||
<span>{{ t('harvester.addons.forklift.selectVms.selectAllBanner.allSelected') }}</span>
|
||||
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="clearSelection"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.clearSelection') }}
|
||||
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ t('harvester.addons.forklift.selectVms.selectAllBanner.pageOnly') }}</span>
|
||||
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="selectAllVMs"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
|
||||
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
|
||||
</a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</Teleport>
|
||||
|
||||
<div class="actions-footer">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn role-primary"
|
||||
:disabled="selectedCount === 0"
|
||||
@click="saveSelection"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.selectVms.saveSelection') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.provider-link {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vm-table-title {
|
||||
h4 {
|
||||
margin: 0;
|
||||
@ -480,6 +550,17 @@ init();
|
||||
}
|
||||
}
|
||||
|
||||
.select-vms-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.select-all-banner-row) {
|
||||
.select-all-banner-cell {
|
||||
text-align: center;
|
||||
@ -500,11 +581,4 @@ init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
@ -1329,7 +1329,7 @@ export function init($plugin, store) {
|
||||
// ===========================================================================
|
||||
// Forklift Addon UI Flow
|
||||
// ===========================================================================
|
||||
weightGroup('forklift', 0, false);
|
||||
weightGroup('vmMigration', 0, false);
|
||||
|
||||
// Provider
|
||||
headers(HCI.FORKLIFT_PROVIDER, [
|
||||
@ -1349,8 +1349,8 @@ export function init($plugin, store) {
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_PROVIDER,
|
||||
labelKey: 'harvester.addons.forklift.labels.provider',
|
||||
group: 'forklift::Advanced',
|
||||
labelKey: 'harvester.addons.vmMigration.labels.provider',
|
||||
group: 'vmMigration::Advanced',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
@ -1376,8 +1376,8 @@ export function init($plugin, store) {
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_NETWORK_MAP,
|
||||
labelKey: 'harvester.addons.forklift.labels.networkMap',
|
||||
group: 'forklift::Advanced',
|
||||
labelKey: 'harvester.addons.vmMigration.labels.networkMap',
|
||||
group: 'vmMigration::Advanced',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
@ -1403,8 +1403,8 @@ export function init($plugin, store) {
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_STORAGE_MAP,
|
||||
labelKey: 'harvester.addons.forklift.labels.storageMap',
|
||||
group: 'forklift::Advanced',
|
||||
labelKey: 'harvester.addons.vmMigration.labels.storageMap',
|
||||
group: 'vmMigration::Advanced',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
@ -1431,8 +1431,8 @@ export function init($plugin, store) {
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_PLAN,
|
||||
labelKey: 'harvester.addons.forklift.labels.plan',
|
||||
group: 'forklift::Advanced',
|
||||
labelKey: 'harvester.addons.vmMigration.labels.plan',
|
||||
group: 'vmMigration::Advanced',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
@ -1457,8 +1457,8 @@ export function init($plugin, store) {
|
||||
});
|
||||
virtualType({
|
||||
name: HCI.FORKLIFT_MIGRATION,
|
||||
labelKey: 'harvester.addons.forklift.labels.migration',
|
||||
group: 'forklift::Advanced',
|
||||
labelKey: 'harvester.addons.vmMigration.labels.migration',
|
||||
group: 'vmMigration::Advanced',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
@ -1468,11 +1468,11 @@ export function init($plugin, store) {
|
||||
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
|
||||
virtualType({
|
||||
name: 'forklift-create',
|
||||
labelKey: 'harvester.addons.forklift.labels.dashboard',
|
||||
group: 'forklift',
|
||||
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
|
||||
group: 'vmMigration',
|
||||
namespaced: true,
|
||||
route: {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||
params: {}
|
||||
}
|
||||
});
|
||||
@ -1481,7 +1481,7 @@ export function init($plugin, store) {
|
||||
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||
resourceType: HCI.ADD_ONS,
|
||||
navGroup: 'forklift',
|
||||
navGroup: 'vmMigration',
|
||||
types: [
|
||||
'forklift-create',
|
||||
]
|
||||
@ -1489,7 +1489,7 @@ export function init($plugin, store) {
|
||||
registerAddonSideNav(store, PRODUCT_NAME, {
|
||||
addonName: ADD_ONS.FORKLIFT_OPERATOR,
|
||||
resourceType: HCI.ADD_ONS,
|
||||
navGroup: 'forklift::Advanced',
|
||||
navGroup: 'vmMigration::Advanced',
|
||||
types: [
|
||||
HCI.FORKLIFT_PROVIDER,
|
||||
HCI.FORKLIFT_NETWORK_MAP,
|
||||
|
||||
@ -238,7 +238,7 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
|
||||
// Provider type column in forklift.konveyor.io.provider list page
|
||||
export const FORKLIFT_PROVIDER_TYPE = {
|
||||
name: 'providerType',
|
||||
labelKey: 'harvester.tableHeaders.forkliftProviderType',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationProviderType',
|
||||
value: 'spec.type',
|
||||
sort: 'spec.type',
|
||||
align: 'left',
|
||||
@ -247,7 +247,7 @@ export const FORKLIFT_PROVIDER_TYPE = {
|
||||
// Provider URL column in forklift.konveyor.io.provider list page
|
||||
export const FORKLIFT_PROVIDER_URL = {
|
||||
name: 'providerUrl',
|
||||
labelKey: 'harvester.tableHeaders.forkliftProviderUrl',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl',
|
||||
value: 'spec.url',
|
||||
sort: 'spec.url',
|
||||
align: 'left',
|
||||
@ -256,7 +256,7 @@ export const FORKLIFT_PROVIDER_URL = {
|
||||
// Source provider column in forklift network/storage map list page
|
||||
export const FORKLIFT_MAP_SOURCE_PROVIDER = {
|
||||
name: 'sourceProvider',
|
||||
labelKey: 'harvester.tableHeaders.forkliftMapSourceProvider',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider',
|
||||
value: 'spec.provider.source.name',
|
||||
sort: 'spec.provider.source.name',
|
||||
align: 'left',
|
||||
@ -265,7 +265,7 @@ export const FORKLIFT_MAP_SOURCE_PROVIDER = {
|
||||
// Destination provider column in forklift network/storage map list page
|
||||
export const FORKLIFT_MAP_DEST_PROVIDER = {
|
||||
name: 'destProvider',
|
||||
labelKey: 'harvester.tableHeaders.forkliftMapDestProvider',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider',
|
||||
value: 'spec.provider.destination.name',
|
||||
sort: 'spec.provider.destination.name',
|
||||
align: 'left',
|
||||
@ -274,7 +274,7 @@ export const FORKLIFT_MAP_DEST_PROVIDER = {
|
||||
// Target namespace column in forklift.konveyor.io.plan list page
|
||||
export const FORKLIFT_PLAN_TARGET_NS = {
|
||||
name: 'targetNamespace',
|
||||
labelKey: 'harvester.tableHeaders.forkliftPlanTargetNs',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs',
|
||||
value: 'spec.targetNamespace',
|
||||
sort: 'spec.targetNamespace',
|
||||
align: 'left',
|
||||
@ -283,7 +283,7 @@ export const FORKLIFT_PLAN_TARGET_NS = {
|
||||
// VM count column in forklift.konveyor.io.plan list page
|
||||
export const FORKLIFT_PLAN_VM_COUNT = {
|
||||
name: 'vmCount',
|
||||
labelKey: 'harvester.tableHeaders.forkliftPlanVmCount',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
|
||||
value: 'spec.vms.length',
|
||||
sort: 'spec.vms.length',
|
||||
align: 'left',
|
||||
@ -292,7 +292,7 @@ export const FORKLIFT_PLAN_VM_COUNT = {
|
||||
// Plan reference column in forklift.konveyor.io.migration list page
|
||||
export const FORKLIFT_MIGRATION_PLAN = {
|
||||
name: 'plan',
|
||||
labelKey: 'harvester.tableHeaders.forkliftMigrationPlan',
|
||||
labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan',
|
||||
value: 'spec.plan.name',
|
||||
sort: 'spec.plan.name',
|
||||
align: 'left',
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
wizard:
|
||||
create: Create Migration
|
||||
next: Proceed
|
||||
previous: Back
|
||||
|
||||
generic:
|
||||
tip: Tip
|
||||
resourceExternalLinkTips: 'External Link'
|
||||
@ -22,7 +27,7 @@ nav:
|
||||
Logging: Logging
|
||||
'Monitoring and Logging': Monitoring and Logging
|
||||
vmimport: Virtual Machine Imports
|
||||
forklift: Forklift Migration
|
||||
vmMigration: VM Migration
|
||||
|
||||
resourceTable:
|
||||
groupBy:
|
||||
@ -353,13 +358,13 @@ harvester:
|
||||
v4ip: V4 IP
|
||||
v6ip: V6 IP
|
||||
eipName: EIP Name
|
||||
forkliftProviderType: Type
|
||||
forkliftProviderUrl: URL
|
||||
forkliftMapSourceProvider: Source Provider
|
||||
forkliftMapDestProvider: Destination Provider
|
||||
forkliftPlanTargetNs: Target Namespace
|
||||
forkliftPlanVmCount: VMs
|
||||
forkliftMigrationPlan: Plan
|
||||
vmMigrationProviderType: Type
|
||||
vmMigrationProviderUrl: URL
|
||||
vmMigrationMapSourceProvider: Source Provider
|
||||
vmMigrationMapDestProvider: Destination Provider
|
||||
vmMigrationPlanTargetNs: Target Namespace
|
||||
vmMigrationPlanVmCount: VMs
|
||||
vmMigrationMigrationPlan: Plan
|
||||
tab:
|
||||
volume: Volumes
|
||||
network: Networks
|
||||
@ -1807,7 +1812,7 @@ harvester:
|
||||
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices.
|
||||
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
|
||||
|
||||
forklift:
|
||||
vmMigration:
|
||||
labels:
|
||||
dashboard: Migrations
|
||||
provider: Providers
|
||||
@ -1815,6 +1820,21 @@ harvester:
|
||||
storageMap: Storage Maps
|
||||
plan: Migration Plans
|
||||
migration: Migrations
|
||||
wizard:
|
||||
title: VM Migration
|
||||
steps:
|
||||
configureProvider:
|
||||
label: Provider
|
||||
description: Configure Provider
|
||||
selectVms:
|
||||
label: VMs
|
||||
description: Select VMs
|
||||
configureMappings:
|
||||
label: Mappings
|
||||
description: Define Mappings
|
||||
reviewMigration:
|
||||
label: Migration Plan
|
||||
description: Review Migration Plan
|
||||
fields:
|
||||
username: Username
|
||||
password: Password
|
||||
@ -1832,7 +1852,11 @@ harvester:
|
||||
urlHint: Enter the full URL including https://
|
||||
skipSsl: Skip SSL certificate verification
|
||||
skipSslHint: Not recommended for production environments
|
||||
testConnection: Test Connection
|
||||
testConnection:
|
||||
action: Test Connection
|
||||
success: Connection successful
|
||||
error: Connection failed
|
||||
waiting: Testing connection...
|
||||
testSuccess: Connection test passed
|
||||
testFailed: Connection test failed
|
||||
testMissingFields: Please fill in all required fields before testing
|
||||
@ -1841,8 +1865,10 @@ harvester:
|
||||
saveExisting: Check Provider and Continue
|
||||
selectVms:
|
||||
title: Select Virtual Machines
|
||||
description: Select the virtual machines you want to migrate from the source provider
|
||||
discovered: "{count} VMs discovered from"
|
||||
lastSynced: Last synced 5 minutes ago
|
||||
lastSynced: "Last synced {time} ago • "
|
||||
refreshNow: Refresh now
|
||||
availableVms: Available Virtual Machines
|
||||
selected: selected
|
||||
saveSelection: Save Selection and Continue
|
||||
|
||||
@ -5,7 +5,7 @@ import { PRODUCT_NAME } from '../config/harvester';
|
||||
export default class ForkliftPlan extends HarvesterResource {
|
||||
get listLocation() {
|
||||
return {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||
params: {
|
||||
product: this.$rootGetters['productId'],
|
||||
cluster: this.$rootGetters['clusterId'],
|
||||
|
||||
@ -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 • {{ vm.memGB }} • {{ vm.diskDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
<MappingsCell
|
||||
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
|
||||
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</RcItemCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cold Migration Warning -->
|
||||
<Banner
|
||||
color="warning"
|
||||
class="m-0"
|
||||
>
|
||||
<div>
|
||||
<span class="banner-title">{{ t('harvester.addons.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>
|
||||
@ -127,7 +127,7 @@ const rows = computed(() => {
|
||||
});
|
||||
|
||||
const createLocation = computed(() => ({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
@ -135,21 +135,21 @@ const createLocation = computed(() => ({
|
||||
}));
|
||||
|
||||
const headers = [
|
||||
{ ...STATE, labelKey: 'harvester.addons.forklift.dashboard.columns.status' },
|
||||
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
|
||||
{
|
||||
...NAME_COL,
|
||||
labelKey: 'harvester.addons.forklift.dashboard.columns.plan',
|
||||
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
|
||||
},
|
||||
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
|
||||
{
|
||||
name: 'progress',
|
||||
labelKey: 'harvester.addons.forklift.dashboard.columns.progress',
|
||||
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
|
||||
value: 'progress',
|
||||
width: 500,
|
||||
},
|
||||
{
|
||||
name: 'mappings',
|
||||
labelKey: 'harvester.addons.forklift.dashboard.columns.mappings',
|
||||
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
|
||||
value: 'mappingsDisplay',
|
||||
},
|
||||
{ ...AGE },
|
||||
@ -174,12 +174,12 @@ init();
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.dashboard.title')"
|
||||
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
|
||||
>
|
||||
<template #subHeader>
|
||||
<div class="mmt-5">
|
||||
<p class="text-muted">
|
||||
{{ t('harvester.addons.forklift.dashboard.description') }}
|
||||
{{ t('harvester.addons.vmMigration.dashboard.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@ -188,7 +188,7 @@ init();
|
||||
:to="createLocation"
|
||||
class="btn role-primary"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.dashboard.createPlan') }}
|
||||
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Masthead>
|
||||
@ -200,13 +200,11 @@ init();
|
||||
:groupable="false"
|
||||
:table-actions="false"
|
||||
:search="false"
|
||||
default-sort-by="state"
|
||||
:default-sort-descending="true"
|
||||
key-field="_key"
|
||||
>
|
||||
<template #header-left>
|
||||
<h3 class="table-title m-0">
|
||||
{{ t('harvester.addons.forklift.dashboard.tableTitle') }}
|
||||
{{ t('harvester.addons.vmMigration.dashboard.tableTitle') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #cell:name="{ row }">
|
||||
@ -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>
|
||||
@ -16,11 +16,8 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
|
||||
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
|
||||
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
|
||||
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
|
||||
import ForkliftDashboard from '../pages/c/_cluster/forklift/index.vue';
|
||||
import ForkliftConfigureProvider from '../pages/c/_cluster/forklift/configure-provider.vue';
|
||||
import ForkliftSelectVms from '../pages/c/_cluster/forklift/select-vms.vue';
|
||||
import ForkliftConfigureMappings from '../pages/c/_cluster/forklift/configure-mappings.vue';
|
||||
import ForkliftReviewMigration from '../pages/c/_cluster/forklift/review-migration.vue';
|
||||
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
|
||||
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@ -89,25 +86,13 @@ const routes = [
|
||||
path: `/:product/c/:cluster/projectsnamespaces`,
|
||||
component: ProjectNamespaces,
|
||||
}, {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
path: `/:product/c/:cluster/forklift`,
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||
path: `/:product/c/:cluster/vm-migration`,
|
||||
component: ForkliftDashboard,
|
||||
}, {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
|
||||
path: `/:product/c/:cluster/forklift/configure-provider`,
|
||||
component: ForkliftConfigureProvider,
|
||||
}, {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
|
||||
path: `/:product/c/:cluster/forklift/select-vms`,
|
||||
component: ForkliftSelectVms,
|
||||
}, {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
|
||||
path: `/:product/c/:cluster/forklift/configure-mappings`,
|
||||
component: ForkliftConfigureMappings,
|
||||
}, {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
|
||||
path: `/:product/c/:cluster/forklift/review-migration`,
|
||||
component: ForkliftReviewMigration,
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||
path: `/:product/c/:cluster/vm-migration/wizard`,
|
||||
component: ForkliftVmMigrationWizard,
|
||||
}, {
|
||||
name: `${ PRODUCT_NAME }-c-cluster-resource`,
|
||||
path: `/:product/c/:cluster/:resource`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user