feat(forklift): added the provider wizard

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-05-21 18:13:43 +02:00
parent e703f34879
commit de7598b7af
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
11 changed files with 640 additions and 217 deletions

View File

@ -9,10 +9,13 @@ 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: () => [] },
stepData: { type: Object, required: true },
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
stepData: { type: Object, required: true },
useAllProviderData: { type: Boolean, default: false },
existingNetworkMap: { type: Object, default: null },
existingStorageMap: { type: Object, default: null },
});
const emit = defineEmits(['ready']);
@ -20,8 +23,6 @@ const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const NO_TEMPLATE = '__none__';
const vms = ref([]);
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
@ -29,8 +30,6 @@ const networkEntries = ref([]);
const storageEntries = ref([]);
const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const selectedNetworkTemplate = ref(NO_TEMPLATE);
const selectedStorageTemplate = ref(NO_TEMPLATE);
const loading = ref(true);
// Restore from stepData
@ -40,8 +39,6 @@ if (props.stepData.networkEntries.length > 0) {
if (props.stepData.storageEntries.length > 0) {
storageEntries.value = props.stepData.storageEntries;
}
selectedNetworkTemplate.value = props.stepData.selectedNetworkTemplate;
selectedStorageTemplate.value = props.stepData.selectedStorageTemplate;
const NAMESPACE = 'forklift';
@ -75,75 +72,13 @@ const storageClassOptions = computed(() => {
return options;
});
const networkTemplateOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
];
const currentIds = new Set(networkEntries.value.map((e) => e.id).filter(Boolean));
const sorted = [...allNetworkMaps.value].sort(
(a, b) => new Date(b.metadata.creationTimestamp) - new Date(a.metadata.creationTimestamp)
);
sorted.forEach((nm) => {
const mapSources = (nm.spec?.map || []).map((m) => m.source?.id).filter(Boolean);
const hasOverlap = mapSources.some((id) => currentIds.has(id));
if (hasOverlap) {
options.push({
label: nm.metadata.name,
value: nm.metadata.name,
});
}
});
return options;
});
const storageTemplateOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
];
const currentIds = new Set(storageEntries.value.map((e) => e.id).filter(Boolean));
const sorted = [...allStorageMaps.value].sort(
(a, b) => new Date(b.metadata.creationTimestamp) - new Date(a.metadata.creationTimestamp)
);
sorted.forEach((sm) => {
const mapSources = (sm.spec?.map || []).map((m) => m.source?.id).filter(Boolean);
const hasOverlap = mapSources.some((id) => currentIds.has(id));
if (hasOverlap) {
options.push({
label: sm.metadata.name,
value: sm.metadata.name,
});
}
});
return options;
});
watch(selectedNetworkTemplate, (val) => {
if (val === NO_TEMPLATE) {
networkEntries.value.forEach((e) => {
e.target = '';
});
return;
}
const template = allNetworkMaps.value.find((nm) => nm.metadata.name === val);
if (!template?.spec?.map) {
const applyNetworkMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
networkEntries.value.forEach((entry) => {
const match = template.spec.map.find(
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
@ -159,25 +94,15 @@ watch(selectedNetworkTemplate, (val) => {
}
}
});
});
};
watch(selectedStorageTemplate, (val) => {
if (val === NO_TEMPLATE) {
storageEntries.value.forEach((e) => {
e.target = '';
});
return;
}
const template = allStorageMaps.value.find((sm) => sm.metadata.name === val);
if (!template?.spec?.map) {
const applyStorageMapTargets = (mapSpec) => {
if (!mapSpec) {
return;
}
storageEntries.value.forEach((entry) => {
const match = template.spec.map.find(
const match = mapSpec.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
@ -185,12 +110,32 @@ watch(selectedStorageTemplate, (val) => {
entry.target = match.destination.storageClass;
}
});
});
};
const applyDefaultNetworkMap = () => {
const defaultMap = allNetworkMaps.value.find(
(nm) => nm.metadata.name === `${ props.providerName }-network-map-default`
);
applyNetworkMapTargets(defaultMap?.spec?.map);
};
const applyDefaultStorageMap = () => {
const defaultMap = allStorageMaps.value.find(
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
);
applyStorageMapTargets(defaultMap?.spec?.map);
};
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
const canSave = computed(() => {
if (props.useAllProviderData) {
return true;
}
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
allNetworksMapped.value && allStorageMapped.value;
});
@ -211,13 +156,6 @@ watch(networkEntries, (val) => {
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 = {};
@ -285,6 +223,29 @@ const buildStorageEntries = () => {
storageEntries.value = Object.values(datastoreMap);
};
const buildNetworkEntriesFromProvider = (networksData) => {
networkEntries.value = (Array.isArray(networksData) ? networksData : []).map((net) => ({
name: net.name || net.id,
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ net.id || net.name }`,
}));
};
const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
}));
};
const formatStorageDetail = (entry) => {
const parts = [];
@ -305,26 +266,8 @@ const formatStorageDetail = (entry) => {
return parts.join(' • ');
};
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
// Create NetworkMap
const networkMapSpec = {
const buildNetworkMapSpec = (providerRef) => {
return {
map: networkEntries.value.map((entry) => {
if (entry.target === 'pod') {
return {
@ -355,6 +298,49 @@ const saveAndReturn = async() => {
}),
provider: providerRef,
};
};
const buildStorageMapSpec = (providerRef) => {
return {
map: storageEntries.value.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
})),
provider: providerRef,
};
};
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
// Update existing maps in place (edit mode)
if (props.existingNetworkMap && props.existingStorageMap) {
props.existingNetworkMap.spec = buildNetworkMapSpec(providerRef);
await props.existingNetworkMap.save();
props.existingStorageMap.spec = buildStorageMapSpec(providerRef);
await props.existingStorageMap.save();
return {
networkMapName: props.existingNetworkMap.metadata.name,
storageMapName: props.existingStorageMap.metadata.name,
};
}
const providerOwnerRef = props.provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
@ -367,32 +353,23 @@ const saveAndReturn = async() => {
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) }`,
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: networkMapSpec,
spec: buildNetworkMapSpec(providerRef),
});
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) }`,
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: storageMapSpec,
spec: buildStorageMapSpec(providerRef),
});
await storageMap.save();
@ -429,9 +406,7 @@ const init = async() => {
allStorageMaps.value = [];
}
vms.value = props.selectedVms;
if (networkEntries.value.length === 0) {
if (networkEntries.value.length === 0 || storageEntries.value.length === 0) {
try {
const providerUid = props.provider?.metadata?.uid;
const providerType = props.provider?.spec?.type || 'vsphere';
@ -442,53 +417,79 @@ const init = async() => {
fetch(`${ baseUrl }/datastores?detail=1`).then((r) => r.json()).catch(() => []),
]);
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
if (props.useAllProviderData) {
if (networkEntries.value.length === 0) {
buildNetworkEntriesFromProvider(networksData);
}
if (resolved.disks) {
resolved.disks = resolved.disks.map((d) => {
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
};
});
if (storageEntries.value.length === 0) {
buildStorageEntriesFromProvider(datastoresData);
}
} else {
vms.value = props.selectedVms;
return resolved;
});
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
}
if (resolved.disks) {
resolved.disks = resolved.disks.map((d) => {
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
};
});
}
return resolved;
});
if (networkEntries.value.length === 0) {
buildNetworkEntries();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
}
}
} catch (e) {
// Name resolution failed entries will use IDs as fallback
// Data resolution failed entries will use IDs as fallback
}
}
buildNetworkEntries();
if (props.existingNetworkMap?.spec?.map) {
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
} else {
applyDefaultNetworkMap();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map);
} else {
applyDefaultStorageMap();
}
loading.value = false;
};
@ -516,14 +517,6 @@ init();
</p>
</div>
<LabeledSelect
v-model:value="selectedNetworkTemplate"
:label="t('harvester.addons.vmMigration.configureMappings.networkMapping.template')"
:options="networkTemplateOptions"
:reduce="(opt) => opt.value"
class="mb-10"
/>
<RcItemCard
v-for="entry in networkEntries"
:id="entry._key"
@ -551,7 +544,7 @@ init();
/>
</div>
</div>
<div>
<div v-if="!useAllProviderData && entry.usedBy.length">
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
@ -572,14 +565,6 @@ init();
</p>
</div>
<LabeledSelect
v-model:value="selectedStorageTemplate"
:label="t('harvester.addons.vmMigration.configureMappings.storageMapping.template')"
:options="storageTemplateOptions"
:reduce="(opt) => opt.value"
class="mb-10"
/>
<RcItemCard
v-for="entry in storageEntries"
:id="entry._key"
@ -610,7 +595,7 @@ init();
/>
</div>
</div>
<div>
<div v-if="!useAllProviderData && entry.usedBy.length">
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
@ -626,9 +611,22 @@ init();
<style lang="scss" scoped>
.mappings-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 64px;
display: flex;
flex-direction: column;
gap: 32px;
@media only screen and (min-width: map-get($breakpoints, '--viewport-7')) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-9')) {
gap: 48px;
}
@media only screen and (min-width: map-get($breakpoints, '--viewport-12')) {
gap: 64px;
}
}
.mapping-column {
@ -650,7 +648,7 @@ init();
.card-content-row {
display: flex;
align-items: center;
gap: 40px;
gap: 16px;
width: 100%;
}
@ -664,13 +662,23 @@ init();
.mapping-source {
display: flex;
flex-direction: column;
min-width: 160px;
flex: 1;
min-width: 100px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.used-by {
@ -686,7 +694,8 @@ init();
}
.mapping-target {
flex: 1;
flex: 3;
min-width: 0;
overflow: hidden;
}

View File

@ -14,7 +14,11 @@ import { HCI } from '../../types';
const CREATE_NEW = '__create_new__';
const props = defineProps({ stepData: { type: Object, required: true } });
const props = defineProps({
stepData: { type: Object, required: true },
createOnly: { type: Boolean, default: false },
editMode: { type: Boolean, default: false },
});
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
@ -205,6 +209,85 @@ const testConnection = async(buttonCb) => {
const inStore = store.getters['currentProduct'].inStore;
// Edit mode: update existing provider URL + secret, then poll
if (props.editMode && createdProvider.value) {
try {
const namespace = 'forklift';
createdProvider.value.spec.url = url.value;
await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret;
if (secretRef && createdSecret.value) {
createdSecret.value.data = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await createdSecret.value.save();
}
const maxAttempts = 15;
let attempts = 0;
let connected = false;
let errorMsg = '';
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempts++;
const refreshed = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: `${ namespace }/${ providerName.value }`,
opt: { force: true }
});
const conditions = refreshed?.status?.conditions || [];
const readyCondition = conditions.find((c) => c.type === 'Ready');
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
if (connectionCondition) {
if (connectionCondition.status === 'True') {
connected = true;
break;
} else {
errorMsg = connectionCondition.message || 'Connection failed';
break;
}
}
if (readyCondition) {
if (readyCondition.status === 'True') {
connected = true;
break;
} else if (readyCondition.status === 'False') {
errorMsg = readyCondition.message || 'Provider not ready';
break;
}
}
}
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
if (isExistingProvider.value) {
try {
@ -456,7 +539,7 @@ defineExpose({ testConnection, clickTestButton });
</Banner>
<div class="configure-provider-step-form">
<div>
<div v-if="!createOnly && !editMode">
<LabeledSelect
v-model:value="selectedProvider"
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
@ -466,11 +549,12 @@ defineExpose({ testConnection, clickTestButton });
</div>
<div
v-if="!isExistingProvider"
v-if="!isExistingProvider || editMode"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.vmMigration.configureProvider.name')"
:disabled="editMode"
required
/>
</div>
@ -480,7 +564,7 @@ defineExpose({ testConnection, clickTestButton });
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-muted mt-5">
@ -493,7 +577,7 @@ defineExpose({ testConnection, clickTestButton });
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
@ -502,7 +586,7 @@ defineExpose({ testConnection, clickTestButton });
v-model:value="password"
type="password"
:label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider"
:disabled="isExistingProvider && !editMode"
required
/>
</div>
@ -512,7 +596,7 @@ defineExpose({ testConnection, clickTestButton });
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider"
:disabled="isExistingProvider && !editMode"
/>
<p class="text-muted ml-20">
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}

View File

@ -395,8 +395,7 @@ defineExpose({ startMigration: startMigrationAction });
display: grid;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px 128px;
width: 747px;
gap: 12px clamp(64px, 8vw, 128px);
line-height: 20px;
}

View File

@ -1350,8 +1350,9 @@ export function init($plugin, store) {
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.vmMigration.labels.provider',
group: 'vmMigration::Advanced',
group: 'vmMigration',
namespaced: true,
weight: 100,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
@ -1471,6 +1472,7 @@ export function init($plugin, store) {
labelKey: 'harvester.addons.vmMigration.labels.dashboard',
group: 'vmMigration',
namespaced: true,
weight: 200,
route: {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
params: {}
@ -1484,6 +1486,7 @@ export function init($plugin, store) {
navGroup: 'vmMigration',
types: [
'forklift-create',
HCI.FORKLIFT_PROVIDER,
]
});
registerAddonSideNav(store, PRODUCT_NAME, {
@ -1491,7 +1494,6 @@ export function init($plugin, store) {
resourceType: HCI.ADD_ONS,
navGroup: 'vmMigration::Advanced',
types: [
HCI.FORKLIFT_PROVIDER,
HCI.FORKLIFT_NETWORK_MAP,
HCI.FORKLIFT_STORAGE_MAP,
HCI.FORKLIFT_PLAN,

View File

@ -0,0 +1,24 @@
<script>
import { h } from 'vue';
import { PRODUCT_NAME } from '../config/harvester';
export default {
name: 'ForkliftProviderEdit',
created() {
const { namespace, id } = this.$route.params;
const query = namespace && id ? { providerId: `${ namespace }/${ id }` } : {};
this.$router.replace({
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$route.params.product,
cluster: this.$route.params.cluster,
},
query,
});
},
render: () => h('div'),
};
</script>

View File

@ -1,5 +1,5 @@
wizard:
create: Create Migration
create: Create
next: Proceed
previous: Back
@ -1820,6 +1820,9 @@ harvester:
storageMap: Storage Maps
plan: Migration Plans
migration: Migrations
providerWizard:
title: Create Provider
editTitle: Edit Provider
wizard:
title: VM Migration
steps:

View File

@ -128,7 +128,7 @@ export default class ForkliftPlan extends HarvesterResource {
get isForkliftDashboard() {
const route = this.currentRouter()?.currentRoute?.value;
return route?.name?.endsWith('-forklift');
return route?.name?.endsWith('-vm-migration');
}
get _availableActions() {

View File

@ -1,6 +1,28 @@
import HarvesterResource from './harvester';
import { PRODUCT_NAME } from '../config/harvester';
export default class ForkliftProvider extends HarvesterResource {
get _createLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
}
};
}
get _editLocation() {
return {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
params: {
product: this.$rootGetters['productId'],
cluster: this.$rootGetters['clusterId'],
},
query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` },
};
}
/**
* Deleting a Provider cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.

View File

@ -0,0 +1,281 @@
<script setup>
import { reactive, ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
import CruResource from '@shell/components/CruResource';
import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import ConfigureProviderStep from '../../../../components/vm-migration/ConfigureProviderStep';
import ConfigureMappingsStep from '../../../../components/vm-migration/ConfigureMappingsStep';
import { PRODUCT_NAME } from '../../../../config/harvester';
import { currentRouter } from '../../../../utils/router';
import { HCI } from '../../../../types';
const store = useStore();
const route = useRoute();
const { t } = useI18n(store);
const cruRef = ref(null);
const providerStepRef = ref(null);
const mappingsStepRef = ref(null);
const providerName = ref('');
const provider = ref(null);
const errors = ref([]);
const providerReady = ref(false);
const providerFormValid = ref(false);
const providerTesting = ref(false);
const mappingsReady = ref(false);
const existingNetworkMap = ref(null);
const existingStorageMap = ref(null);
const initialLoading = ref(true);
const dummyResource = ref({ save: () => Promise.resolve() });
const providerId = route.query?.providerId || null;
const isEditMode = !!providerId;
const stepData = reactive({
provider: {
selectedProvider: isEditMode ? '' : '__create_new__',
providerName: '',
url: '',
username: '',
password: '',
skipTlsVerify: false,
testPassed: false,
testResult: null,
testError: null,
createdProvider: null,
createdSecret: null,
},
mappings: {
networkEntries: [],
storageEntries: [],
},
});
const steps = reactive([
{
name: 'configure-provider',
label: '',
subtext: '',
ready: false,
},
{
name: 'configure-mappings',
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.configureMappings.label');
steps[1].subtext = t('harvester.addons.vmMigration.wizard.steps.configureMappings.description');
watch([providerFormValid, providerTesting], () => {
steps[0].ready = providerFormValid.value && !providerTesting.value;
}, { immediate: true });
watch(mappingsReady, (val) => {
steps[1].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 wizardTitle = computed(() => {
return isEditMode ? t('harvester.addons.vmMigration.providerWizard.editTitle') : t('harvester.addons.vmMigration.providerWizard.title');
});
const onProviderComplete = (data) => {
providerName.value = data.providerName;
provider.value = data.provider;
providerReady.value = true;
};
const onProviderFormValid = (valid) => {
providerFormValid.value = valid;
};
const onProviderTesting = (testing) => {
providerTesting.value = testing;
};
const onProviderReady = (ready) => {
providerReady.value = ready;
};
const onMappingsReady = (ready) => {
mappingsReady.value = ready;
};
// Clear mappings when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
mappingsReady.value = false;
}
});
const providerListLocation = {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: {
product: store.getters['productId'],
cluster: store.getters['clusterId'],
resource: HCI.FORKLIFT_PROVIDER,
}
};
const onFinish = async(buttonCb) => {
try {
await mappingsStepRef.value.saveMappings();
buttonCb(true);
currentRouter().push(providerListLocation);
} catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)];
buttonCb(false);
}
};
const onCancel = () => {
currentRouter().push(providerListLocation);
};
const init = async() => {
if (!isEditMode) {
initialLoading.value = false;
return;
}
const inStore = store.getters['currentProduct'].inStore;
try {
const fetchedProvider = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: providerId,
opt: { force: true },
});
provider.value = fetchedProvider;
providerName.value = fetchedProvider.metadata.name;
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret;
if (secretRef) {
const allSecrets = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` },
});
const secret = allSecrets.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
stepData.provider.username = atob(secret.data.user || '');
stepData.provider.password = atob(secret.data.password || '');
stepData.provider.skipTlsVerify = atob(secret.data.insecureSkipVerify || '') === 'true';
stepData.provider.createdSecret = secret;
}
}
// Fetch existing default maps for this provider
try {
const allNetworkMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
const allStorageMaps = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
existingNetworkMap.value = allNetworkMaps.find(
(nm) => nm.metadata.name === `${ fetchedProvider.metadata.name }-network-map-default`
) || null;
existingStorageMap.value = allStorageMaps.find(
(sm) => sm.metadata.name === `${ fetchedProvider.metadata.name }-storage-map-default`
) || null;
} catch (e) {
// Maps may not exist yet
}
} catch (err) {
errors.value = [`Failed to load provider: ${ err.message || err }`];
}
initialLoading.value = false;
};
init();
</script>
<template>
<div v-if="initialLoading" />
<CruResource
v-else
ref="cruRef"
:resource="dummyResource"
:mode="isEditMode ? 'edit' : 'create'"
:steps="steps"
:errors="errors"
:validation-passed="true"
:can-yaml="false"
:cancel-event="true"
:title="wizardTitle"
finish-mode="finish"
class="wizard"
@cancel="onCancel"
@finish="onFinish"
>
<template #configure-provider>
<ConfigureProviderStep
ref="providerStepRef"
:step-data="stepData.provider"
:create-only="true"
:edit-mode="isEditMode"
@complete="onProviderComplete"
@ready="onProviderReady"
@form-valid="onProviderFormValid"
@testing="onProviderTesting"
/>
</template>
<template #configure-mappings>
<ConfigureMappingsStep
ref="mappingsStepRef"
:provider-name="providerName"
:provider="provider"
:step-data="stepData.mappings"
:use-all-provider-data="true"
:existing-network-map="existingNetworkMap"
:existing-storage-map="existingStorageMap"
@ready="onMappingsReady"
/>
</template>
</CruResource>
</template>

View File

@ -55,10 +55,8 @@ const stepData = reactive({
tableRows: [],
},
mappings: {
networkEntries: [],
storageEntries: [],
selectedNetworkTemplate: '__none__',
selectedStorageTemplate: '__none__',
networkEntries: [],
storageEntries: [],
},
review: { planName: '' },
});
@ -181,8 +179,6 @@ watch(() => stepData.provider.providerName, (newVal, oldVal) => {
stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = [];
stepData.mappings.selectedNetworkTemplate = '__none__';
stepData.mappings.selectedStorageTemplate = '__none__';
mappingsReady.value = false;
stepData.review.planName = '';
@ -195,8 +191,6 @@ 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 = '';

View File

@ -18,6 +18,7 @@ import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfi
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue';
import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue';
import ForkliftProviderWizard from '../pages/c/_cluster/vm-migration/provider-wizard.vue';
const routes = [
{
@ -93,6 +94,10 @@ const routes = [
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
path: `/:product/c/:cluster/vm-migration/wizard`,
component: ForkliftVmMigrationWizard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`,
path: `/:product/c/:cluster/vm-migration/provider-wizard`,
component: ForkliftProviderWizard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
path: `/:product/c/:cluster/:resource`,